diff --git a/app/templates/pages.js b/app/templates/pages.js
index e7953b8..be252e9 100644
--- a/app/templates/pages.js
+++ b/app/templates/pages.js
@@ -10,7 +10,7 @@ const {
} = require('./shared');
const { noteMetaFragment } = require('./fragments');
-const ASSET_VERSION = '20260731checkboxenter1';
+const ASSET_VERSION = '20260805blanklinetext1';
const noteFontFamilyCSS = (settings) => {
const f = settings.noteFontFamily || 'sans';
diff --git a/public/app.js b/public/app.js
index 8028a40..e18edbe 100644
--- a/public/app.js
+++ b/public/app.js
@@ -766,12 +766,22 @@ var _tinymcePostLoad=false;
// "Edited" → real save of the round-tripped markdown. See "Edited flash after
// restore" investigation.
var _tinymcePostLoadUntil=0;
+// Set by genuine user interaction inside the TinyMCE iframe (keystroke, paste,
+// cut, drop). Reset on every programmatic content load. The post-load reconcile
+// uses it to tell a REAL edit made during the quiet window apart from the
+// editor merely normalising the note on load: markdown->HTML->markdown is not
+// always the identity (e.g. an indented ``` fence loses its indent, a blank line
+// after an ATX heading is collapsed), and adopting those cosmetic rewrites as
+// "the user edited the note" produced an "Edited" flash plus a useless autosave
+// every single time such a note was opened.
+var _tinymceUserTypedSinceLoad=false;
var _pendingSearchHighlight=false;
var _tinymceShowRequested=false;
function _setTinyMCEContent(html){
if(!_tinymceEditor)return;
_dbgline('_setTinyMCEContent begin len='+((html||'').length));
_tinymceSuppressEdits=true;
+ _tinymceUserTypedSinceLoad=false;
try{
_tinymceEditor.setContent(html||'');
if(_tinymceEditor.undoManager)_tinymceEditor.undoManager.clear();
@@ -808,9 +818,23 @@ function _setTinyMCEContent(html){
if(_tinymceReadonly)return;
if(typeof _lazyTinyMCESyncBeforeSave==='function')_lazyTinyMCESyncBeforeSave();
if(typeof formHash==='function'&&formHash(form)!==_savedHash){
- _dbgline('post-load reconcile: form dirty, marking edited');
- if(typeof markEdited==='function')markEdited();
- if(typeof scheduleSave==='function')scheduleSave();
+ // The form differs from the snapshot taken before the editor
+ // loaded. Two very different reasons are possible:
+ // 1. the user really typed during the 800ms quiet window — that
+ // edit was absorbed (no markEdited fired) and must be saved;
+ // 2. nobody touched the note and the difference is purely
+ // TinyMCE/Turndown load normalisation (indented fence indent
+ // dropped, blank line after a heading collapsed, ...).
+ // Only (1) is an edit. Treating (2) as an edit flashed "Edited"
+ // and wrote a useless save every time such a note was opened.
+ if(_tinymceUserTypedSinceLoad){
+ _dbgline('post-load reconcile: user typed during window, marking edited');
+ if(typeof markEdited==='function')markEdited();
+ if(typeof scheduleSave==='function')scheduleSave();
+ }else{
+ _dbgline('post-load reconcile: load normalisation only, re-baselining hash');
+ if(typeof snapshotHash==='function')snapshotHash();
+ }
}else{
_dbgline('post-load reconcile: form clean, nothing to do');
}
@@ -1467,6 +1491,22 @@ function initPersistentTinyMCE(){
// auto-populate while typing in TinyMCE.
autoTitleFromTinyMCE(editor);
}
+ // Record GENUINE user interaction so the post-load reconcile can tell a
+ // real edit apart from load normalisation. Deliberately uses keydown /
+ // paste / cut / drop: those are never fired by editor.setContent(),
+ // whereas 'input' and 'SetContent' are, so they cannot be used here.
+ // Navigation-only keys are filtered out — pressing an arrow key is not
+ // an edit and must not promote a cosmetic rewrite into a saved change.
+ editor.on('keydown',function(e){
+ if(!e)return;
+ if(e.ctrlKey||e.metaKey)return; // shortcuts; paste/cut fire their own events
+ var k=e.key;
+ if(typeof k==='string'&&(k.length===1||k==='Enter'||k==='Backspace'||k==='Delete'||k==='Tab'))
+ _tinymceUserTypedSinceLoad=true;
+ });
+ editor.on('paste',function(){_tinymceUserTypedSinceLoad=true});
+ editor.on('cut',function(){_tinymceUserTypedSinceLoad=true});
+ editor.on('drop',function(){_tinymceUserTypedSinceLoad=true});
editor.on('input',function(){onEdit('input')});
editor.on('change',function(){onEdit('change')});
// TinyMCE's built-in commands (blocks dropdown -> FormatBlock, lists,
@@ -3923,18 +3963,64 @@ function htmlToMarkdown(el){
}
return out
}
+// Turndown computes an element's flanking whitespace from node.textContent, which
+// skips atomic children like (they contribute no text). For
+//
Label it therefore reports a LEADING whitespace run even though
+// that whitespace is interior to the produced markdown (which starts with
+// ""). replacementForNode() then does content.trim() — which cannot
+// remove an interior space — and still prepends the expelled whitespace, so the
+// space is DUPLICATED. That grows by one character on every
+// markdown->render->markdown cycle, i.e. every time the note is opened in rendered
+// mode, which makes the post-load reconcile in _setTinyMCEContent see a phantom
+// edit ("Edited" flash + useless autosave) and slowly corrupts the stored body.
+//
+// Hide such interior whitespace behind a sentinel so Turndown does not see a
+// flanking edge, then restore the EXACT original characters afterwards. The
+// character code is encoded in the sentinel because the run is frequently NBSP
+// (\u00a0) rather than a plain space — restoring a generic ' ' would itself change
+// the note body and keep the phantom-edit loop alive.
+var _INLINE_TAGS_RE_SRC='a|abbr|b|bdi|bdo|cite|code|del|em|i|ins|kbd|label|mark|q|s|small|span|strong|sub|sup|time|u|var';
+function _encodeProtectedSpace(ws){
+ return ws.replace(/[^\S\r\n]/g,function(c){return '\u2764S'+c.charCodeAt(0)+'\u2764'});
+}
+function _protectInlineLeadingSpace(html){
+ // Whitespace AFTER atomic children that open an inline element.
+ html=html.replace(new RegExp('(<(?:'+_INLINE_TAGS_RE_SRC+')\\b[^>]*>(?:<(?:img|br)\\b[^>]*>)+)([^\\S\\r\\n]+)(?=\\S)','gi'),
+ function(_m,open,ws){return open+_encodeProtectedSpace(ws)});
+ // Symmetric case: whitespace BEFORE atomic children that close an inline element.
+ html=html.replace(new RegExp('([^\\S\\r\\n]+)((?:<(?:img|br)\\b[^>]*>)+(?:'+_INLINE_TAGS_RE_SRC+')>)','gi'),
+ function(_m,ws,tail){return _encodeProtectedSpace(ws)+tail});
+ return html;
+}
+function _restoreProtectedSpace(md){
+ return md.replace(/\u2764S(\d+)\u2764/g,function(_m,code){return String.fromCharCode(parseInt(code,10))});
+}
function tinymceToMarkdown(html){
if(!html)return '';
html=html.replace(/\u200b/g,'');
// Normalise blank-line markers. TinyMCE strips the
from
//
]*\bclass="[^"]*\bmd-blank-line\b[^"]*"[^>]*)>[\s\S]*?<\/p>/gi,'
\u2764BR\u2764
'); + // = no output), swallowing the blank line. Rewrite the marker to the sentinel + // shape the blankLine rule reliably matches. + // + // ONLY rewrite markers that are still genuinely blank. A blank-line marker is a + // real, focusable paragraph in the iframe, so the caret lands in it whenever the + // user clicks on the empty space between two blocks — typing there puts the new + // text INSIDE the marker. Rewriting unconditionally replaced that text with the + // sentinel and silently destroyed it (typed a line, left the note, came back, + // gone). When the marker holds real content, leave the paragraph verbatim: with + // text present neither the blankLine nor the emptyP rule matches it, so Turndown + // converts it as an ordinary paragraph and the text survives. + html=html.replace(/]*\bclass="[^"]*\bmd-blank-line\b[^"]*"[^>]*)>([\s\S]*?)<\/p>/gi,function(whole,attrs,inner){ + var plain=String(inner||'').replace(/<[^>]*>/g,'').replace(/ | | /gi,''); + if(plain.replace(/[\s\u00a0\u200b]+/g,''))return whole; + return '
\u2764BR\u2764
'; + }); // tinyMCE appends a trailingblock. Strip before conversion // so it doesn't become a spurious newline. Do NOT strip from
((?:[^<]|<(?!\/p>))+?)
\s*<\/p>/gi,'
$1
'); + html=_protectInlineLeadingSpace(html); // Convert++' + nbsp + ' Label++
is a real, focusable paragraph in the TinyMCE + // iframe, so clicking the empty space between two blocks puts the caret inside + // it and typing puts the new text there. The blank-line normalisation used to + // rewrite the paragraph's content unconditionally, which silently DESTROYED + // that text: type a line after a checklist, leave the note, come back, gone. + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const shapes = [ + '
hello there
', // typed over thehello there
hello there
', // with inline markup + ]; + for (const p of shapes) { + const html = '']) { + const html = '
hello there
' + html.slice(at + marker.length); + const saved = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx); + assert.ok(saved.indexOf('hello there') !== -1, `typed text must be saved, got: ${JSON.stringify(saved)}`); + assert.ok(/- \[ \] Do not include AI/.test(saved), 'checklist must be intact'); + // Reopening must not change it again (no phantom edit, no further loss). + const reopened = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(renderMarkdown(saved)) + ')', ctx); + assert.equal(reopened, saved, 'the saved body must be stable on reopen'); +}); + +test('tinymceToMarkdown does not add a trailing space to a bare > blockquote line', () => { + // Turndown prefixes every blockquote line with '> ', so the empty line between + // two quoted paragraphs came back as '> ' where the source had a bare '>'. + // Trailing whitespace there is insignificant in CommonMark, and leaving it in + // made the note look dirty on every open. + const { renderMarkdown } = require('../app/templates'); + const ctx = makeTurndownCtx(); + runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown'); + const md = '> quote\n>\n> more'; + const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(renderMarkdown(md)) + ')', ctx); + assert.equal(result, md); + assert.ok(!/>[ \t]+$/m.test(result), `no quote line may end in whitespace, got: ${JSON.stringify(result)}`); +}); + +test('tinymceToMarkdown keeps the > prefix on soft-wrapped blockquote lines', () => { + // '> b\n> c' is ONE quoted paragraph with a soft break, so the renderer emits + // ''. Theb
c
a
b
x
")', ctx); + if (userTyped) vm.runInContext('_tinymceUserTypedSinceLoad=true', ctx); + // Run the 820ms reconcile callback (the last scheduled timer). + const reconcile = timers.find(t => t.ms === 820); + assert.ok(reconcile, 'expected a 820ms reconcile timer to be scheduled'); + reconcile.fn(); + return { + edited: vm.runInContext('_edited', ctx), + saved: vm.runInContext('_saved', ctx), + snapshots: vm.runInContext('_snapshots', ctx), + logs: vm.runInContext('_logs.join("|")', ctx), + }; +} + +test('post-load reconcile: pure load normalisation re-baselines instead of flashing Edited', () => { + // Reported bug: opening a note whose markdown is not round-trip identical + // (indented ``` fence loses its indent, blank line after an ATX heading is + // collapsed) flashed "Edited" and fired a useless autosave EVERY open. + const r = makeReconcileCtx({ userTyped: false, normalisedDiffers: true }); + assert.equal(r.edited, 0, 'markEdited must not fire for load normalisation'); + assert.equal(r.saved, 0, 'no autosave may be scheduled for load normalisation'); + assert.equal(r.snapshots, 1, 'the normalised content must become the new clean baseline'); +}); + +test('post-load reconcile: a real edit during the quiet window still saves', () => { + // The reconcile exists so an edit absorbed by the 800ms post-load window is + // not silently lost. That safety net must survive the fix above. + const r = makeReconcileCtx({ userTyped: true, normalisedDiffers: true }); + assert.equal(r.edited, 1, 'a genuine edit must still mark the note edited'); + assert.equal(r.saved, 1, 'a genuine edit must still schedule a save'); +}); + +test('post-load reconcile: identical content does nothing at all', () => { + const r = makeReconcileCtx({ userTyped: false, normalisedDiffers: false }); + assert.equal(r.edited, 0); + assert.equal(r.saved, 0); + assert.equal(r.snapshots, 0); +}); + +test('app.js: user-typed flag is wired to keydown/paste/cut/drop, never to input/SetContent', () => { + // editor.setContent() fires 'input' and 'SetContent', so those events cannot + // be used to detect real user interaction — using them would reintroduce the + // phantom "Edited" on open. + const idx = appSrc.indexOf('_tinymceUserTypedSinceLoad=true'); + assert.ok(idx !== -1, '_tinymceUserTypedSinceLoad must be set somewhere'); + for (const evt of ['keydown', 'paste', 'cut', 'drop']) { + assert.ok(new RegExp(`editor\\.on\\('${evt}'`).test(appSrc), + `expected an editor.on('${evt}') handler to exist`); + } + const setLine = appSrc.slice(appSrc.lastIndexOf('\n', idx - 200), idx + 40); + assert.ok(!/editor\.on\('(?:input|SetContent)'[^)]*\)\s*\{[^}]*_tinymceUserTypedSinceLoad=true/.test(setLine), + '_tinymceUserTypedSinceLoad must not be set from input/SetContent handlers'); + assert.ok(extractFn('_setTinyMCEContent').includes('_tinymceUserTypedSinceLoad=false'), + '_setTinyMCEContent must reset the user-typed flag on every programmatic load'); +});