Write notes as formatted text, store them as Markdown
The editor was a textarea holding raw Markdown, which is the wrong thing to hand someone taking notes during a lesson: nobody types `##` and `**` while a teacher is talking. It now shows the note formatted and puts a toolbar above it — headings, bold, lists, tick boxes, quotes, links, tables — while the file on disk stays exactly what it was, because that is what the indexer reads and what outlives this app. `markdown.js` is the whole translation: `markdownToHtml` on the way in, `markdownFromDom` on the way out. The property that matters is that the round trip settles — one pass may tidy a note, a second must change nothing — because these notes are the only record of what was said in the room and there is nothing to restore a lossy save from. `editor.js` checks exactly that before opening a note formatted, and a note it cannot hold unchanged opens in the Markdown view and says so instead of being quietly reduced. No editor library: the content security policy allows no outside script and the app has no bundler, so this is `contenteditable` and `execCommand` with a tolerant serializer behind it — an element it does not model keeps its words and loses its tag. Pasted HTML is converted to Markdown before it reaches the document, which is the one place where sanitising and formatting are the same operation. Tested against `test/mini-dom.ts`, sixty lines of read-only DOM, rather than a headless browser or a DOM dependency; the toolbar itself was driven by hand in Firefox. WebKit has still never run it.
This commit is contained in:
@@ -1,4 +1,4 @@
|
||||
'use strict';
|
||||
import { createEditor } from './editor.js';
|
||||
|
||||
/*
|
||||
* The notes app.
|
||||
@@ -20,6 +20,10 @@
|
||||
* - **Say what state it is in.** "Gespeichert 14:02", "Nicht gespeichert",
|
||||
* "Offline — lokal gesichert". A silent editor over a flaky connection is
|
||||
* indistinguishable from one that is losing your work.
|
||||
*
|
||||
* What the person sees is formatted text with a toolbar; what is written to
|
||||
* disk is Markdown. `editor.js` is the whole of that translation — everything
|
||||
* here deals in Markdown strings and never touches the document.
|
||||
*/
|
||||
|
||||
const AUTOSAVE_MS = 2500;
|
||||
@@ -41,7 +45,10 @@ const ui = {
|
||||
dayDate: document.getElementById('day-date'),
|
||||
dayStatus: document.getElementById('day-status'),
|
||||
conflict: document.getElementById('day-conflict'),
|
||||
toolbar: document.getElementById('toolbar'),
|
||||
editor: document.getElementById('editor'),
|
||||
source: document.getElementById('source'),
|
||||
editorHint: document.getElementById('editor-hint'),
|
||||
save: document.getElementById('save'),
|
||||
fill: document.getElementById('fill'),
|
||||
lessonsHint: document.getElementById('lessons-hint'),
|
||||
@@ -68,6 +75,28 @@ const day = {
|
||||
timer: 0,
|
||||
};
|
||||
|
||||
/**
|
||||
* The formatted editor over the two elements that hold a note.
|
||||
*
|
||||
* It owns the document and the toolbar; this file only ever asks it for
|
||||
* Markdown and hands it Markdown back.
|
||||
*/
|
||||
const editor = createEditor({
|
||||
rich: ui.editor,
|
||||
source: ui.source,
|
||||
toolbar: ui.toolbar,
|
||||
onInput: markDirty,
|
||||
onModeChange: (mode) => {
|
||||
// Switching by hand is not a warning, so the automatic one goes away.
|
||||
hint(mode === 'source' ? 'Markdown-Ansicht. „MD" führt zurück.' : '');
|
||||
},
|
||||
});
|
||||
|
||||
function hint(message) {
|
||||
ui.editorHint.textContent = message;
|
||||
ui.editorHint.hidden = !message;
|
||||
}
|
||||
|
||||
// --- plumbing ------------------------------------------------------------
|
||||
|
||||
async function api(path, options) {
|
||||
@@ -129,9 +158,10 @@ function draftKey(date) {
|
||||
return DRAFT_PREFIX + date;
|
||||
}
|
||||
|
||||
function saveDraft() {
|
||||
function saveDraft(text) {
|
||||
try {
|
||||
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: ui.editor.value, at: Date.now() }));
|
||||
const value = text === undefined ? editor.getMarkdown() : text;
|
||||
localStorage.setItem(draftKey(day.date), JSON.stringify({ text: value, at: Date.now() }));
|
||||
} catch (error) {
|
||||
// A full or disabled localStorage must not break typing; the server copy
|
||||
// is still the real one.
|
||||
@@ -172,7 +202,8 @@ async function loadDay(date) {
|
||||
day.conflicted = false;
|
||||
ui.conflict.hidden = true;
|
||||
ui.dayDate.value = date;
|
||||
ui.editor.disabled = true;
|
||||
editor.setEnabled(false);
|
||||
hint('');
|
||||
setStatus('Wird geladen …');
|
||||
|
||||
let info;
|
||||
@@ -185,8 +216,8 @@ async function loadDay(date) {
|
||||
// no notes at all — and reporting that as "offline" would send someone
|
||||
// looking at their signal instead of at NOTES_DIR.
|
||||
if (error.status) {
|
||||
ui.editor.value = '';
|
||||
ui.editor.disabled = true;
|
||||
editor.setMarkdown('');
|
||||
editor.setEnabled(false);
|
||||
setStatus(error.message, 'error');
|
||||
ui.lessonsHint.textContent = '';
|
||||
return;
|
||||
@@ -194,8 +225,8 @@ async function loadDay(date) {
|
||||
// No status: the request never arrived. Fall back to whatever this device
|
||||
// has, rather than an empty editor that looks like a day with no notes.
|
||||
const draft = readDraft(date);
|
||||
ui.editor.disabled = false;
|
||||
ui.editor.value = draft ? draft.text : '';
|
||||
editor.setEnabled(true);
|
||||
editor.setMarkdown(draft ? draft.text : '');
|
||||
day.saved = '';
|
||||
day.modifiedAt = null;
|
||||
day.dirty = Boolean(draft);
|
||||
@@ -217,10 +248,25 @@ async function loadDay(date) {
|
||||
// is just the last save echoed back and offering it would be noise.
|
||||
const useDraft = draft && draft.text !== server && draft.text.trim() !== '';
|
||||
|
||||
ui.editor.value = useDraft ? draft.text : server;
|
||||
ui.editor.disabled = false;
|
||||
day.saved = info.exists ? info.text : '';
|
||||
day.dirty = ui.editor.value !== day.saved;
|
||||
editor.setEnabled(true);
|
||||
// The server's text first, and what the editor makes of it is the baseline.
|
||||
// Opening a note the editor would tidy — a table typed unevenly, `*` for
|
||||
// italics — must not count as an edit, or simply looking at a day would
|
||||
// rewrite the file.
|
||||
const loaded = editor.setMarkdown(server);
|
||||
day.saved = info.exists ? editor.getMarkdown() : '';
|
||||
if (useDraft) editor.setMarkdown(draft.text);
|
||||
day.dirty = editor.getMarkdown() !== day.saved;
|
||||
|
||||
// One note in a hundred: something the formatted view cannot hold without
|
||||
// changing it. It opens as Markdown rather than being quietly reduced.
|
||||
hint(
|
||||
!loaded.faithful
|
||||
? 'Diese Notiz enthält Formatierung, die die formatierte Ansicht nicht unverändert halten kann — deshalb Markdown.'
|
||||
: editor.mode === 'source'
|
||||
? 'Markdown-Ansicht. „MD" führt zurück.'
|
||||
: '',
|
||||
);
|
||||
|
||||
if (useDraft) {
|
||||
setStatus('Lokale, noch nicht gespeicherte Fassung wiederhergestellt.', 'warn');
|
||||
@@ -250,8 +296,9 @@ function describeLessons(info) {
|
||||
}
|
||||
|
||||
function markDirty() {
|
||||
day.dirty = ui.editor.value !== day.saved;
|
||||
saveDraft();
|
||||
const text = editor.getMarkdown();
|
||||
day.dirty = text !== day.saved;
|
||||
saveDraft(text);
|
||||
if (day.conflicted) return;
|
||||
if (day.dirty) setStatus('Nicht gespeichert …');
|
||||
window.clearTimeout(day.timer);
|
||||
@@ -261,7 +308,7 @@ function markDirty() {
|
||||
async function saveDay(automatic) {
|
||||
window.clearTimeout(day.timer);
|
||||
if (!day.dirty && automatic) return;
|
||||
const text = ui.editor.value;
|
||||
const text = editor.getMarkdown();
|
||||
setStatus('Wird gespeichert …');
|
||||
|
||||
try {
|
||||
@@ -424,17 +471,14 @@ ui.dayDate.addEventListener('change', () => {
|
||||
if (ui.dayDate.value) void loadDay(ui.dayDate.value);
|
||||
});
|
||||
|
||||
ui.editor.addEventListener('input', markDirty);
|
||||
ui.save.addEventListener('click', () => void saveDay(false));
|
||||
|
||||
ui.fill.addEventListener('click', () => {
|
||||
// Appended, never merged into place: the person's own text is not something
|
||||
// to reorder, and a heading in the wrong order is trivial to move.
|
||||
const separator = ui.editor.value.trim() ? '\n\n' : '';
|
||||
ui.editor.value = ui.editor.value.replace(/\s*$/, '') + separator + day.missing;
|
||||
editor.append(day.missing);
|
||||
day.missing = '';
|
||||
ui.fill.hidden = true;
|
||||
markDirty();
|
||||
});
|
||||
|
||||
// A phone locking, the app going to the background, or the tab closing: all of
|
||||
|
||||
Reference in New Issue
Block a user