//// CalendarMark2.jw /// /// A client-based implemenetation of a simple web-calendar in jwacs. import "/lib/prototype.js"; import "/lib/effects.js"; import "/lib/dragdrop.js"; import "/lib/jwacs-lib.jw"; // import "CalendarMark2Config.js"; // This must never be combined, so we manually put the tag // into the template file. JwacsLib.initHistory(); //======= main program ============================================================================= function main(args) { var date = new Date; if(!isNaN(args.month)) date.setMonth(args.month - 1); if(!isNaN(args.year)) date.setYear(args.year); var year = date.getFullYear(); var month = date.getMonth(); showCalendarScreen(year, month); } function showCalendarScreen(year, month) { document.title = monthNames[month] + " " + year; var contentDiv = document.createElement("DIV"); contentDiv.id = "contentDiv"; contentDiv.innerHTML = "

" + monthNames[month] + " " + year + "

"; contentDiv.appendChild(calcNavigationLinksElement(year, month)); contentDiv.appendChild(calcMonthElement(year, month)); contentDiv.appendChild(calcNavigationLinksElement(year, month)); contentDiv.appendChild(calcBottomControlsElement(year, month)); var oldDiv = $('contentDiv'); oldDiv.parentNode.replaceChild(contentDiv, oldDiv); // Figure out the start and end days of this month's rectangle var s = new Date(year, month, 1); while(s.getDay() != 0) s.setDate(s.getDate() - 1); var e = new Date(year, month, lastDay(year, month)); while(e.getDay() != 6) e.setDate(e.getDate() + 1); var events = readEvents(s, e); for(var i = 0; i < events.length; i++) showEvent(events[i], true); // Make all the day cells droppable var handlerSpec = { onDrop: eventDropped }; for(var d = new Date(s.getTime()); d.getTime() <= e.getTime(); d.setDate(d.getDate() + 1)) Droppables.add(dateToStr(d), handlerSpec); } // Unpacks a server response. If the server has returned an error, it will be // thrown as an exception. Otherwise the CSV response rows will be converted // into an Array of Objects. function unpackResponse(text) { if(text == null || text == undefined || text.length == 0 || text.match(/^[\s\r\n]*$/)) throw "Empty response from server"; var lines = text.split(/\r?\n/); // First line is the status line if(!lines || lines.length == 0) throw text; if(!lines[0].match(/\s*OK\s*/)) throw lines[0]; var result = new Array; if(lines.length > 2) { // First post-status line holds the field names var headings = lines[1].split(","); // Each subsequent row is an object var i; for(i = 2; i < lines.length; i++) { var line = lines[i]; var fields = line.split(","); if(fields && fields.length > 0) { var obj = new Object; for(var j = 0; j < fields.length; j++) { obj[unescape(headings[j])] = unescape(fields[j]); } result[result.length] = obj; } } } return result; } function pad(num, width) { var ret = new String(num); while(ret.length < width) ret = "0" + ret; return ret; } //// ======= Date handling ========================================================================= var dayNames = ["Sunday", "Monday", "Tuesday", "Wednesday", "Thursday", "Friday", "Saturday"]; var monthNames = ["January", "February", "March", "April", "May", "June", "July", "August", "September", "October", "November", "December"]; function strToDate(str) { try { var components = str.split("-"); var yyyy = new Number(components[0]); var mm = new Number(components[1]); var dd = new Number(components[2]); return new Date(yyyy, mm - 1, dd); } catch(e) { throw "error in strToDate(" + str + "):\n" + e; } } function dateToStr(date) { return date.getFullYear() + "-" + pad(date.getMonth() + 1, 2) + "-" + pad(date.getDate(), 2); } function equalDates(date1, date2) { return date1.getFullYear() == date2.getFullYear() && date1.getMonth() == date2.getMonth() && date1.getDate() == date2.getDate(); } /// Returns the last day of month `month` in year `year`. /// `month` should be 0-based. function lastDay(year, month) { var now = new Date; var yyyy = new Number(year); var mm = new Number(month); if(isNaN(yyyy) || yyyy < 1901) yyyy = now.getFullYear(); if(isNaN(mm) || mm < 1 || mm > 12) mm = now.getMonth() ; var dd = 28; var testDate = new Date(yyyy, mm, dd); while(testDate.getMonth() == mm) { dd++; testDate = new Date(yyyy, mm, dd); } return dd - 1; } // Number of rows required to display month function rowsRequired(year, month) { var first = new Date(year, month, 1); // February if(first.getMonth() == 1) { return 5; } var last = new Date(first.getTime()); last.setDate(31); // 31-day months if(last.getDate() == 31) { if(first.getDay() == 5 || first.getDay() == 6) return 6; else return 5; } else { if(first.getDay() == 6) return 6; else return 5; } } //// ======= Drawing =============================================================================== function calcMonthNavigationCell(date, isRight) { var cell = document.createElement("TD"); if(isRight) cell.align = "right"; var link = document.createElement("A"); cell.appendChild(link); link.className = "navLink"; link.href = "#year=" + date.getFullYear() + "&month=" + (date.getMonth() + 1); link.onclick = function(evt) { if(window.event) evt = window.event; Event.stop(evt); jump(date.getFullYear(), date.getMonth()); }; var text = monthNames[date.getMonth()] + " " + date.getFullYear(); if(isRight) text += "--->"; else text = "<--- " + text; link.innerHTML = text; return cell; } function calcNavigationLinksElement(year, month) { var table = document.createElement("TABLE"); //TODO Come on, shouldn't this be part of the CSS? table.width = '90%'; table.align = 'center'; table.border = '0px'; var tbody = document.createElement("TBODY"); table.appendChild(tbody); var row = document.createElement("TR"); tbody.appendChild(row); row.appendChild(calcMonthNavigationCell(new Date(year, month - 1, 1))); row.appendChild(calcMonthNavigationCell(new Date(year, month + 1, 1), true)); return table; } function calcBottomControlsElement(year, month) { var table = document.createElement("TABLE"); table.width = '90%'; table.align = 'center'; table.border = '0px'; var tbody = document.createElement("TBODY"); table.appendChild(tbody); var row = document.createElement("TR"); tbody.appendChild(row); var leftCell = document.createElement("TD"); row.appendChild(leftCell); var link = document.createElement("A"); leftCell.appendChild(link); link.className = "navLink"; link.onclick = function(evt) { if(window.event) evt = window.event; Event.stop(evt); eventEdit(this); }; link.href = "#op=addNewEvent&year=" + year + "&month=" + (month + 1) + "&day=1"; link.innerHTML = "Add new event"; var rightCell = document.createElement("TD"); row.appendChild(rightCell); rightCell.align = "right"; var form = document.createElement("FORM"); rightCell.appendChild(form); form.id = "jumpForm"; form.onsubmit = jumpFormSubmitted; //??? var select = document.createElement("SELECT"); form.appendChild(select); select.id = "jumpMonth"; select.onchange = jumpFormSubmitted; for(var m = 0; m < 12; m++) { var option = document.createElement("OPTION"); select.appendChild(option); option.value = m; if(m == month) option.selected = true; option.innerHTML = monthNames[m]; } var input = document.createElement("INPUT"); form.appendChild(input); input.id = "jumpYear"; input.size = 4; input.value = year; return table; } function calcMonthElement(year, month) { var table = document.createElement("TABLE"); table.align = "center"; table.id = "monthTable"; var tbody = document.createElement("TBODY"); table.appendChild(tbody); var row = 0; var col = 0; //// Headers var headerRow = document.createElement("TR"); tbody.appendChild(headerRow); for(col = 0; col < 7; col++) { var dayCell = document.createElement("TH"); dayCell.innerHTML = dayNames[col]; headerRow.appendChild(dayCell); } //// Cells // Back up from the first day of the month to a Sunday var d = new Date(year, month, 1); while(d.getDay() != 0) { d.setDate(d.getDate() - 1); } // Generate the actual cells var rowCount = rowsRequired(year, month); for(row=0; row < rowCount; row++) { var rowElm = document.createElement("TR"); rowElm.className = "dataRow"; tbody.appendChild(rowElm); for(col = 0; col < 7; col++) { rowElm.appendChild(calcDayElement(d, month)); d.setDate(d.getDate() + 1); } } return table; } function isMonthDay(elm) { return (elm.className == "sameMonthDay" || elm.className == "otherMonthDay" || elm.className == "dayHeader"); } function dayCellDoubleClicked(evt) { if(window.event) evt = window.event; // Only create a new event if the target element was actually a day cell, // not just some other random element within the day cell. if(isMonthDay(Event.element(evt))) inPlaceAddEvent(this); } // Calculate a TD element that represents a day cell in the month table function calcDayElement(date, currentMonth) { var cell = document.createElement("TD"); cell.id = dateToStr(date); if(currentMonth == date.getMonth()) cell.className = "sameMonthDay"; else cell.className = "otherMonthDay"; cell.ondblclick = dayCellDoubleClicked; var dayHeader = document.createElement("DIV"); cell.appendChild(dayHeader); dayHeader.className = "dayHeader"; if(equalDates(date, new Date)) { var box = document.createElement("SPAN"); dayHeader.appendChild(box); box.className = "todayDay"; box.innerHTML = date.getDate(); } else dayHeader.innerHTML = date.getDate(); return cell; } // Return true if `element` is displaced from its calculated position function isDisplaced(element) { var left = 0; var top = 0; if(element.style.top) { var aMatch = element.style.top.match(/^-?(\d*)/); if(aMatch) top = aMatch[1]; } if(element.style.left) { var aMatch = element.style.left.match(/^-?(\d*)/); if(aMatch) left = aMatch[1]; } return (left != 0 || top != 0); } function showEvent(event, quiet) { var eventID = 'event' + event.id; var cellID = event.date; var existing = $(eventID); var target = $(cellID); // If the event is currently visible, but its new date is not visible, // then delete its element and bail. if(existing && !target) { new Effect.Fade(existing); waitForEffectQueue(); $(eventID).parentNode.removeChild($(eventID)); return; } // If neither the new nor the old dates are visible, then just bail. if(!target) return; // If we got this far, the target is definitely visible, and maybe there's an // existing element kicking around somewhere. var eventBox = document.createElement("DIV"); eventBox.className = "eventBox"; eventBox.id = eventID; if(event.desc) eventBox.innerHTML = event.desc.escapeHTML(); if(event.notes && event.notes.match(/\S/)) eventBox.title = event.notes; eventBox.ondblclick = function(evt) { if(window.event) evt = window.event; // IE compatibility Event.stop(evt); this._doubleClicked = true; eventEdit(this); }; eventBox.onclick = function(evt) { // If we're displaced, then a drag is in progress if(isDisplaced(this)) return; // We need to make sure that we don't respond to a double-click, so we // pause briefly and then check to see if the double-click handler has // grabbed the event. this._doubleClicked = false; JwacsLib.sleep(250); if(!this._doubleClicked) inPlaceEditEvent(this); }; if(existing) { // Changing the event but staying in the same day if(existing.parentNode.id == cellID) { existing.parentNode.replaceChild(eventBox, existing); new Effect.Highlight(eventBox); } // Moving to a new day else { var existingOffset = Position.cumulativeOffset(existing); var targetOffset = Position.cumulativeOffset(target); existing.parentNode.removeChild(existing); target.appendChild(eventBox); Effect.Queues.get(eventBox.id); // Move is annoying after drag-n-drop if(!quiet) { eventBox.style.left = (existingOffset[0] - targetOffset[0]) + "px"; eventBox.style.top = (existingOffset[1] - targetOffset[1]) + "px"; eventRevertEffect(eventBox, eventBox.id); } new Effect.Highlight(eventBox, {queue: {scope: eventBox.id, position: 'end'}}); } } else { // Showing a previously unshown event Element.hide(eventBox); target.appendChild(eventBox); if(quiet) new Effect.Appear(eventBox); else { Element.show(eventBox); new Effect.Highlight(eventBox); } } new Draggable(eventID, {scroll: window, revert: revertPredicate, reverteffect: eventRevertEffect, starteffect: eventStartEffect, endeffect:eventEndEffect}); return eventBox; } function addStatus(str) { var statusDiv = $('StatusDisplay'); if(!statusDiv) return; statusDiv.innerHTML = str.escapeHTML(); statusDiv.style.display = ''; } function removeStatus(str) { var statusDiv = $('StatusDisplay'); if(!statusDiv) return; statusDiv.style.display = 'none'; } function calcEventEditHtml(event) { return JwacsLib.fetchData("GET", "CalendarMark2-EventEdit.html"); } // ======= Server calls ============================================================================ function serverCall(method, url, params, status) { var currentStatus = status; var q = url; var connector = '?'; for(var field in params) { if(params[field] == undefined || typeof params[field] == 'function') continue; q += connector + field + "=" + escape(params[field]); connector = '&'; } try { addStatus(status); var text = JwacsLib.fetchData(method, q); currentStatus = null; removeStatus(status); return unpackResponse(text); } catch(e) { // Just passing through if(currentStatus) removeStatus(currentStatus); alert("Sorry, an error occurred while " + status + "."); throw e; } } function readEvents(s, e) { return serverCall("GET", serviceRootPath + "/event-query", {s: dateToStr(s), e: dateToStr(e)}, "fetching events"); } function fetchEvent(eventID) { var rows = serverCall("GET", serviceRootPath + "/event-query", {id: eventID}, "fetching event #" + eventID); return rows[0]; } function addEvent(event) { var rows = serverCall("POST", serviceRootPath + "/event-add", event, "saving new event '" + event.desc + "'"); return rows[0]; } function updateEvent(event) { var rows = serverCall("POST", serviceRootPath + "/event-update", event, "updating event #" + event.id); return rows[0]; } function deleteEvent(eventID) { serverCall("POST", serviceRootPath + "/event-del", {id: eventID}, "deleting event #" + eventID); } // ======= Event handlers ========================================================================= function jump(year, month) { var m = new Number(month); JwacsLib.newPage(monthNames[m] + " " + year, {year: year, month: m + 1}); showCalendarScreen(year, m); } function jumpFormSubmitted(evt) { Event.stop(window.event ? window.event : evt); var year = $F('jumpYear'); var month = $F('jumpMonth'); if(year < 1901) alert("Sorry, this calendar only supports years from 1901 onward"); else jump(year, month); } function eventEdit(elm) { //TODO Don't clobber the existing event if it's unsaved var event = {date: dateToStr(new Date)}; if(elm.id) { var aMatch = elm.id.match(/^event(\d+)/); if(aMatch) { // Edit event event = fetchEvent(aMatch[1]); if(!event) { alert("Sorry, event #" + aMatch[1] + " ('" + elm.innerHTML.unescapeHTML() + "') no longer exists."); new Effect.Fade(elm); waitForEffectQueue(); elm.parentNode.removeChild(elm); } } else { // Add new event event = {date: elm.id}; } } var child = window.open("about:blank", "addEvent", "width=400,height=500,toolbar=no,top=100,left=100"); var doc = child.document.open(); doc.write(calcEventEditHtml()); doc.close(); // Fill in the data if(event.id) { doc.title = "Edit event"; doc.getElementById("eventWindowTitle").innerHTML = "Edit event"; doc.getElementById("eventDeleteLink").style.display=""; doc.getElementById("eventID").value = event.id; } else { doc.title = "Add new event"; doc.getElementById("eventWindowTitle").innerHTML = "Add new event"; doc.getElementById("eventDeleteLink").style.display="none"; } if(event.desc) doc.getElementById("eventDesc").value = event.desc; if(event.date) { var d = strToDate(event.date); doc.getElementById("eventYear").value = d.getFullYear(); doc.getElementById("eventMonth").value = (d.getMonth() + 1); doc.getElementById("eventDay").value = d.getDate(); } if(event.notes) doc.getElementById("eventNotes").value = event.notes; doc.getElementById("eventDesc").focus(); } function revertPredicate(elm) { if(elm._successfulDrop) { elm._successfulDrop = null; return false; } return true; } function eventRevertEffect(eventElm, queue) { // HACK // We're kind of cheating by having `queue` be the second arg instead of the fourth; // Scriptaculous will pass in 2 numbers that we ignore on revert. For now, just ignore // anything that isn't a string. if(typeof queue != 'string') queue = null; var curX = parseInt(eventElm.style.left || '0'); var curY = parseInt(eventElm.style.top || '0'); var dur = Math.sqrt(Math.abs(curY^2)+Math.abs(curX^2)) * 0.02; if(queue) eventElm._revert = new Effect.Move(eventElm, {x: -curX, y: - curY, duration: dur, queue: { scope: queue, position: 'end'}}); else eventElm._revert = new Effect.Move(eventElm, {x: -curX, y: - curY, duration: dur}); } function eventStartEffect(element) { element._opacity = Element.getOpacity(element); element._cursor = Element.getStyle(element, 'cursor'); element.style.cursor = 'move'; new Effect.Opacity(element, {duration:0.2, from:element._opacity, to:0.7}); } function eventEndEffect(element) { element.style.cursor = element._cursor; var toOpacity = typeof element._opacity == 'number' ? element._opacity : 1.0; new Effect.Opacity(element, {duration: 0.2, from: 0.7, to: toOpacity}); } function eventDropped(eventElm, dayElm) { // No change here if(eventElm.parentNode == dayElm) return; // No revert necessary (we think) // Note that the call to updateEvent below causes a "suspend-return" of this handler, // since we're being called from untransformed code (viz. scriptaculous). So it is // important that we set the "no revert please" flag on eventElm _before_ we call // updateEvent. eventElm._successfulDrop = true; try { // Construct an event object from our cached data var eventID = eventElm.id.match(/^event(\d+)/)[1]; // Update event data to server with new date var updatedEvent = updateEvent({id: eventID, date: dayElm.id}); if(!updatedEvent) throw "sorry, the attempt to update event data failed"; } catch(e) { // I guess we need a revert effect after all eventRevertEffect(eventElm); return; } // Update display showEvent(updatedEvent, true); } function eventEditFormSubmitted(child, doc) { var eventID = doc.getElementById('eventID').value; var eventDesc = doc.getElementById('eventDesc').value; var eventDate = doc.getElementById('eventYear').value + "-" + doc.getElementById('eventMonth').value + "-" + doc.getElementById('eventDay').value; var eventNotes = doc.getElementById('eventNotes').value; if(!validateEventEditForm(doc)) return; var updatedEvent; if(eventID) updatedEvent = updateEvent({id: eventID, date: eventDate, desc: eventDesc, notes: eventNotes}); else updatedEvent = addEvent({date: eventDate, desc: eventDesc, notes: eventNotes}); // Firefox 1.0 doesn't seem to like it (and expresses this dislike by // crashing) if you try to close the child window from an XHR response thread // (which is where this code here executes, since addEvent and updateEvent are // both faux-blocking calls that resume their continuations in the // onReadyStateChange handler of an XHR object), so we add the yieldThread // call to force ourselves back onto the GUI thread. JwacsLib.yieldThread(); child.close(); showEvent(updatedEvent); } // Check the event edit form for errors, return true if everything is ok. // Adds error messages to the appropriate fields function validateEventEditForm(doc) { var year = doc.getElementById('eventYear').value; var month = doc.getElementById('eventMonth').value; var day = doc.getElementById('eventDay').value; var desc = doc.getElementById('eventDesc').value; function showError(elmName, errmsg) { var elm = doc.getElementById(elmName + '-error'); elm.style.display = ""; elm.innerHTML = errmsg.escapeHTML(); doc.getElementById(elmName).focus(); } function hideError(elmName) { var elm = doc.getElementById(elmName + '-error'); elm.style.display = "none"; elm.innerHTML = ""; } var yearOK = false; var monthOK = false; var dayOK = false; var descOK = false; if(isNaN(year)) showError("eventYear", "Please enter a numeric year value"); else if(year < 1901) showError("eventYear", "Sorry, this calendar only supports dates from 1901 onwards"); else { hideError("eventYear"); yearOK = true; } if(isNaN(month)) showError("eventMonth", "Please enter a numeric month value"); else if(month < 1 || month > 12) showError("eventMonth", "Please enter a month value between 1 and 12"); else { hideError("eventMonth"); monthOK = true; } if(isNaN(day)) showError("eventDay", "Please enter a numeric day value"); else if(yearOK && monthOK && (day < 1 || day > lastDay(year, month - 1))) showError("eventDay", "Please enter a day value between 1 and " + lastDay(year, month - 1)); else { hideError("eventDay"); dayOK = true; } if(!desc || desc.match(/^\s*$/)) showError("eventDesc", "Please enter an event description"); else { hideError("eventDesc"); descOK = true; } return descOK && yearOK && monthOK && dayOK; } function maybeDelete(child, doc) { var desc = doc.getElementById('eventDesc').value; var eventID = doc.getElementById('eventID').value; var shouldDelete = confirm("Really delete '" + desc + "'?"); if(shouldDelete) { deleteEvent(eventID); JwacsLib.yieldThread(); child.close(); var eventElm = $('event' + eventID); if(eventElm) { new Effect.Fade(eventElm); waitForEffectQueue(); eventElm.parentNode.removeChild(eventElm); } } else { child.focus(); doc.getElementById('eventNotes').focus(); } } // ======= In-place editing ======================================================================== // TODO maybe we want to package this up into a more generic object-style effect (as in scriptaculous) // TODO at the least we should factor inPlaceEdit and inPlaceAdd function inPlaceEditEvent(eventElm) { var editForm = document.createElement("FORM"); var textarea = document.createElement("TEXTAREA"); textarea.className = "inplaceEditor"; textarea.value = eventElm.innerHTML.unescapeHTML(); editForm.appendChild(textarea); Element.hide(eventElm); eventElm.parentNode.insertBefore(editForm, eventElm); textarea.focus(); textarea.onkeypress = function(evt) { if(window.event) evt = window.event; if(evt.keyCode == Event.KEY_ESC) { // Escape abandons changes editForm.parentNode.removeChild(editForm); Element.show(eventElm); } else if(evt.keyCode == Event.KEY_RETURN && !evt.shiftKey) { // Shift-return inserts a newline; plain return submits the form Event.stop(evt); // Validate if(!textarea.value || textarea.value.match(/^\s*$/)) { alert("Event descriptions must not be empty"); return; } editForm.parentNode.removeChild(editForm); Element.show(eventElm); var eventID = eventElm.id.match(/^event(\d+)$/)[1]; var updatedEvent = updateEvent({id: eventID, desc: textarea.value}); if(updatedEvent) showEvent(updatedEvent); } }; } function inPlaceAddEvent(dayElm) { var editForm = document.createElement("FORM"); var textarea = document.createElement("TEXTAREA"); textarea.className = "inplaceEditor"; editForm.appendChild(textarea); dayElm.appendChild(editForm); textarea.focus(); textarea.onkeypress = function(evt) { if(window.event) evt = window.event; if(evt.keyCode == Event.KEY_ESC) { // Escape abandons changes dayElm.removeChild(editForm); return; } else if(evt.keyCode == Event.KEY_RETURN && !evt.shiftKey) { // Shift-return inserts a newline; plain return submits the form Event.stop(evt); // Validate if(!textarea.value || textarea.value.match(/^\s*$/)) { alert("Event descriptions must not be empty"); return; } dayElm.removeChild(editForm); var eventID = "new"+(new Date).getTime(); var event = {id: eventID, desc: textarea.value, date: dayElm.id, notes: ""}; var eventElm = showEvent(event); var addedEvent = addEvent(event); dayElm.removeChild(eventElm); if(addedEvent) showEvent(addedEvent); } }; } //======= script.aculo.us enhancements ============================================================= // Resume a continuation on update Effect.QueuedAction = Class.create(); Object.extend(Object.extend(Effect.QueuedAction.prototype, Effect.Base.prototype), { initialize: function(continuation, scopeName) { var options = Object.extend({ queue: {position: 'end', scope: scopeName || 'global'} }, arguments[2] || {}); this.continuation = continuation; this.start(options); }, update: function() { JwacsLib.yieldThread(); resume this.continuation; } }); // Faux-blocks until all the events currently in the specified effect queue have completed function waitForEffectQueue(scopeName) { new Effect.QueuedAction(function_continuation, scopeName || 'global'); suspend; }