mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-10-19 10:13:51 +00:00
fix(rendered): stop phantom edits and text loss in markdown round-trip
Rendered mode (TinyMCE) briefly showed "Edited" and fired a useless autosave
every time certain notes were opened, and text typed between two blocks could
be lost entirely.
Data loss: <p class="md-blank-line"> is a real, focusable paragraph in the
iframe, so clicking the gap between blocks puts the caret inside it. The
blank-line normalisation in tinymceToMarkdown() rewrote such paragraphs
unconditionally ("empty or not"), replacing whatever the user had just typed
with the <br> sentinel before Turndown ever saw it. Now only markers that are
still genuinely blank collapse to the sentinel; a marker holding real content
is left verbatim and converts as an ordinary paragraph.
Unbounded whitespace growth: Turndown derives an element's flanking whitespace
from node.textContent, which skips atomic children. For <a><img/> Label</a>
it reported leading whitespace that is actually interior to the output, so
replacementForNode()'s content.trim() could not remove it yet it was prepended
anyway. The run grew by one character on every open (12 -> 13 -> 14 ...),
slowly corrupting the stored body. Interior whitespace next to <img>/<br> is
now hidden behind a sentinel that encodes the exact character (these runs are
frequently NBSP, so restoring a plain space would itself dirty the note).
Blockquotes: a soft-wrapped quote line lost its "> " prefix because the <br>
sentinel is restored after Turndown has prefixed its own lines, so "> b\n> c"
came back as "> b\nc" and the second line escaped the quote. The prefix is now
carried across the break. Bare "> " lines also no longer keep a trailing space.
Phantom edit: _savedHash is snapshotted before the editor loads, so the 820ms
post-load reconcile compared normalised content against the raw server body and
treated any cosmetic rewrite as a user edit. Some normalisations cannot be
represented in HTML at all (an indented ``` fence loses its indent, a blank
line after an ATX heading is collapsed by design). The reconcile now
distinguishes the two cases via a _tinymceUserTypedSinceLoad flag, wired to
keydown/paste/cut/drop -- deliberately not input/SetContent, which setContent()
itself fires. A real edit during the quiet window still saves; pure load
normalisation just re-baselines the hash.
Bump ASSET_VERSION and the service-worker cache name so browsers refetch app.js.
Tests: 790 pass (was 776).
This commit is contained in:
parent
5d251cc015
commit
21166ff0e6
5 changed files with 398 additions and 10 deletions
|
|
@ -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';
|
||||
|
|
|
|||
117
public/app.js
117
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 <img> (they contribute no text). For
|
||||
// <a><img/> Label</a> 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 <br> from
|
||||
// <p class="md-blank-line"><br></p> on setContent, leaving an empty
|
||||
// <p class="md-blank-line"></p> — which Turndown drops entirely (empty block
|
||||
// = no output), swallowing the blank line. Rewrite any md-blank-line paragraph
|
||||
// (empty or not) to the sentinel shape the blankLine rule reliably matches.
|
||||
html=html.replace(/<p([^>]*\bclass="[^"]*\bmd-blank-line\b[^"]*"[^>]*)>[\s\S]*?<\/p>/gi,'<p$1>\u2764BR\u2764</p>');
|
||||
// = 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(/<p([^>]*\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 '<p'+attrs+'>\u2764BR\u2764</p>';
|
||||
});
|
||||
// tinyMCE appends a trailing <br> to every non-empty <p> block. Strip before conversion
|
||||
// so it doesn't become a spurious newline. Do NOT strip from <p><br></p> (blank lines).
|
||||
html=html.replace(/<p>((?:[^<]|<(?!\/p>))+?)<br\s*\/?>\s*<\/p>/gi,'<p>$1</p>');
|
||||
html=_protectInlineLeadingSpace(html);
|
||||
// Convert <br> (line breaks within paragraphs and blank-line divs) to a
|
||||
// sentinel Turndown won't touch. Restore as \n after conversion.
|
||||
// For md-blank-line divs the blankLine rule matches the sentinel text.
|
||||
|
|
@ -3950,8 +4036,23 @@ function tinymceToMarkdown(html){
|
|||
md=_applyHeadingSpacing(md);
|
||||
// Normalise blank-line sentinels (md-blank-line divs + empty paragraphs)
|
||||
md=md.replace(/\n*(?:\x00BL\x00\n*)+/g,function(m){var count=(m.match(/\x00BL\x00/g)||[]).length;return nl+nl+Array(count+1).join(nl)});
|
||||
// Restore line-break sentinels as \n (soft breaks within paragraphs)
|
||||
md=md.split('\u2764BR\u2764').join(nl);
|
||||
// Restore line-break sentinels as \n (soft breaks within paragraphs).
|
||||
// Inside a blockquote the sentinel must also re-apply the quote prefix:
|
||||
// Turndown prefixes the lines it emits, but these newlines are injected
|
||||
// afterwards, so '> b<br>c' would come back as '> b\nc' — the second line
|
||||
// escaping the blockquote entirely (real content corruption, and a permanent
|
||||
// dirty-on-open diff). Carry the leading '>' run of the line across the break.
|
||||
md=md.split(nl).map(function(lineText){
|
||||
if(lineText.indexOf('\u2764BR\u2764')<0)return lineText;
|
||||
var qm=lineText.match(/^((?:[ \t]*>)+[ \t]?)/);
|
||||
return lineText.split('\u2764BR\u2764').join(nl+(qm?qm[1]:''));
|
||||
}).join(nl);
|
||||
md=_restoreProtectedSpace(md);
|
||||
// Turndown prefixes every line of a blockquote with '> ', including the empty
|
||||
// lines between paragraphs, yielding '> ' where the source had a bare '>'.
|
||||
// Trailing whitespace on a quote-only line is insignificant in CommonMark, so
|
||||
// strip it — otherwise the note is reported dirty on every open.
|
||||
md=md.replace(/^([ \t]*>+)[ \t]+$/gm,'$1');
|
||||
var out='';
|
||||
for(var i=0;i<md.length;i++){
|
||||
var ch=md.charAt(i),nx=md.charAt(i+1);
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
const CACHE_NAME = 'joplock-shell-v75-20260731checkboxenter1';
|
||||
const CACHE_NAME = 'joplock-shell-v77-20260805blanklinetext1';
|
||||
const STATIC_ASSETS = ['/styles.css', '/htmx.min.js', '/turndown.min.js', '/codemirror.min.js', '/hljs.min.js', '/manifest.webmanifest', '/icon.svg', '/icon-192.png', '/icon-512.png', '/maskable-icon-192.png', '/maskable-icon-512.png', '/apple-touch-icon.png', '/fonts/CascadiaMono-Regular.woff2', '/fonts/CascadiaMono-Bold.woff2'];
|
||||
|
||||
self.addEventListener('install', event => {
|
||||
|
|
|
|||
|
|
@ -59,6 +59,14 @@ function runWithDeps(ctx, ...fns) {
|
|||
fns = [...fns];
|
||||
fns.splice(fns.indexOf('getTurndown') >= 0 ? fns.indexOf('getTurndown') + 1 : 0, 0, '_applyHeadingSpacing');
|
||||
}
|
||||
if (fns.includes('tinymceToMarkdown') && !fns.includes('_protectInlineLeadingSpace')) {
|
||||
fns = [...fns];
|
||||
// _protectInlineLeadingSpace needs the shared inline-tag list plus the
|
||||
// encode/restore pair that round-trips the exact whitespace characters.
|
||||
vm.runInContext("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';", ctx);
|
||||
const at = fns.indexOf('getTurndown') >= 0 ? fns.indexOf('getTurndown') + 1 : 0;
|
||||
fns.splice(at, 0, '_encodeProtectedSpace', '_protectInlineLeadingSpace', '_restoreProtectedSpace');
|
||||
}
|
||||
for (const fn of fns) vm.runInContext(extractFn(fn), ctx);
|
||||
}
|
||||
|
||||
|
|
@ -143,6 +151,181 @@ test('tinymceToMarkdown returns empty string for empty input', () => {
|
|||
assert.equal(result.trim(), '');
|
||||
});
|
||||
|
||||
test('tinymceToMarkdown does not duplicate space after inline image in link', () => {
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
const nbsp = '\u00a0'.repeat(12);
|
||||
const imageId = 'a'.repeat(32);
|
||||
const html = '<p>++' + nbsp + '<a href="https://e.com"><img src="/resources/' + imageId + '" alt="ic" /> Label</a>++</p>';
|
||||
const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx);
|
||||
assert.equal(result, '++' + nbsp + '[ Label](https://e.com)++');
|
||||
const whitespace = result.match(/^\+\+([^\[]*)\[/);
|
||||
assert.ok(whitespace, `expected link after opening markers, got: ${JSON.stringify(result)}`);
|
||||
assert.equal(whitespace[1].length, 12);
|
||||
});
|
||||
|
||||
test('tinymceToMarkdown is idempotent for rendered inline image link', () => {
|
||||
const { renderMarkdown } = require('../app/templates');
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
const md = '++' + '\u00a0'.repeat(12) + '[ Hybrid Savings commercials.xlsx](https://example.com/x.xlsx)++';
|
||||
const results = [];
|
||||
let current = md;
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
const html = renderMarkdown(current);
|
||||
current = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx);
|
||||
results.push(current);
|
||||
}
|
||||
assert.equal(results[0], md);
|
||||
assert.equal(results[4], md);
|
||||
for (const result of results) {
|
||||
const whitespace = result.match(/^\+\+([^\[]*)\[/);
|
||||
assert.ok(whitespace, `expected link after opening markers, got: ${JSON.stringify(result)}`);
|
||||
assert.equal(whitespace[1].length, 12);
|
||||
}
|
||||
});
|
||||
|
||||
test('tinymceToMarkdown keeps genuine leading link whitespace outside brackets', () => {
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
const html = '<a href="https://e.com"> Label</a>';
|
||||
const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx);
|
||||
assert.equal(result, '[Label](https://e.com)');
|
||||
});
|
||||
|
||||
test('tinymceToMarkdown restores an NBSP (not a plain space) after an inline image', () => {
|
||||
// The real-world shape (Outlook-pasted link with an inline xlsx icon) separates
|
||||
// the image from the label with a NON-BREAKING space, not an ASCII space.
|
||||
// Turndown's edgeWhitespace uses \s, which matches NBSP, so the NBSP was expelled
|
||||
// and duplicated. Restoring the sentinel as a generic ' ' would also change the
|
||||
// body (NBSP -> space) and keep the phantom-edit loop alive, so the exact
|
||||
// character must come back.
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
const imageId = 'b'.repeat(32);
|
||||
const html = '<p><a href="https://e.com"><img src="/resources/' + imageId + '" alt="ic" />\u00a0Label</a></p>';
|
||||
const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx);
|
||||
assert.equal(result, '[\u00a0Label](https://e.com)');
|
||||
assert.ok(result.indexOf('\u00a0') !== -1, 'NBSP must survive as NBSP');
|
||||
});
|
||||
|
||||
test('tinymceToMarkdown is idempotent for the real NBSP-separated icon link over 5 opens', () => {
|
||||
// Regression for the reported bug: opening the note flashed "Edited" and fired a
|
||||
// useless autosave because the whitespace run grew by one character per open
|
||||
// (12 -> 13 -> 14 ...). Mirrors the stored body of the affected note.
|
||||
const { renderMarkdown } = require('../app/templates');
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
const md = '++' + '\u00a0'.repeat(12)
|
||||
+ '[\u00a0Hybrid Savings commercials.xlsx](https://example.com/x.xlsx)++';
|
||||
let current = md;
|
||||
for (let i = 0; i < 5; i += 1) {
|
||||
current = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(renderMarkdown(current)) + ')', ctx);
|
||||
assert.equal(current, md, `round-trip ${i + 1} must be byte-identical, got: ${JSON.stringify(current)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('tinymceToMarkdown keeps text typed INTO a blank-line marker (data-loss regression)', () => {
|
||||
// A <p class="md-blank-line"> 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 = [
|
||||
'<p class="md-blank-line">hello there</p>', // typed over the <br>
|
||||
'<p class="md-blank-line">hello there<br></p>', // typed, TinyMCE re-added <br>
|
||||
'<p class="md-blank-line">hello <strong>there</strong></p>', // with inline markup
|
||||
];
|
||||
for (const p of shapes) {
|
||||
const html = '<div class="md-checkbox"> GPU</div>\n' + p + '\n<h2>April</h2>';
|
||||
const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx);
|
||||
assert.ok(result.indexOf('hello') !== -1,
|
||||
`text typed into a blank-line marker must survive, lost with ${p} -> ${JSON.stringify(result)}`);
|
||||
assert.ok(result.indexOf('- [ ] GPU') !== -1, 'the checklist item must still convert');
|
||||
}
|
||||
});
|
||||
|
||||
test('tinymceToMarkdown still collapses a genuinely blank blank-line marker', () => {
|
||||
// The counterpart guard: an untouched marker (or one whose <br> TinyMCE stripped)
|
||||
// must still become a real blank line, otherwise spacing collapses.
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
for (const p of ['<p class="md-blank-line"><br></p>', '<p class="md-blank-line"></p>', '<p class="md-blank-line"> </p>']) {
|
||||
const html = '<div class="md-checkbox"> GPU</div>\n' + p + '\n<h2>April</h2>';
|
||||
const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx);
|
||||
assert.equal(result, '- [ ] GPU\n\n\n## April',
|
||||
`blank marker ${p} must still produce a blank line, got: ${JSON.stringify(result)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('typing after a checklist survives a full render -> edit -> markdown -> reopen cycle', () => {
|
||||
// End-to-end shape of the reported bug, including stability on reopen.
|
||||
const { renderMarkdown } = require('../app/templates');
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
const body = '- [ ] GPU\n- [ ] Do not include AI\n\n\n## April\n';
|
||||
let html = renderMarkdown(body);
|
||||
const marker = '<p class="md-blank-line"><br></p>';
|
||||
const at = html.indexOf(marker);
|
||||
assert.ok(at !== -1, 'expected a blank-line marker between the checklist and the heading');
|
||||
html = html.slice(0, at) + '<p class="md-blank-line">hello there</p>' + 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
|
||||
// '<blockquote><p>b<br>c</p></blockquote>'. The <br> sentinel is restored to a
|
||||
// newline AFTER Turndown has prefixed its own lines, so without carrying the
|
||||
// quote prefix across the break the second line came back as bare 'c' —
|
||||
// escaping the blockquote entirely on the next render.
|
||||
const { renderMarkdown } = require('../app/templates');
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
for (const md of ['> b\n> c', '> a\n>\n> b\n> c']) {
|
||||
const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(renderMarkdown(md)) + ')', ctx);
|
||||
assert.equal(result, md, `blockquote must round-trip, got: ${JSON.stringify(result)}`);
|
||||
}
|
||||
});
|
||||
|
||||
test('tinymceToMarkdown still turns a plain paragraph soft break into a bare newline', () => {
|
||||
// Guard: the quote-prefix carry must not leak into non-quoted paragraphs.
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
const result = vm.runInContext('tinymceToMarkdown("<p>a<br>b</p>")', ctx);
|
||||
assert.equal(result, 'a\nb');
|
||||
});
|
||||
|
||||
test('tinymceToMarkdown does not duplicate whitespace before a trailing inline image', () => {
|
||||
// Symmetric case: whitespace immediately before an <img> that closes the link.
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
const imageId = 'c'.repeat(32);
|
||||
const html = '<p><a href="https://e.com">Label\u00a0<img src="/resources/' + imageId + '" alt="ic" /></a></p>';
|
||||
const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx);
|
||||
assert.equal(result, '[Label\u00a0](https://e.com)');
|
||||
});
|
||||
|
||||
test('tinymceToMarkdown preserves a blank line INSIDE a code block (C #include is not a heading)', () => {
|
||||
const ctx = makeTurndownCtx();
|
||||
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
|
||||
|
|
|
|||
|
|
@ -382,3 +382,107 @@ test('app.js: _tinymceFirstBlockText / autoTitleFromTinyMCE exist and are DOM-on
|
|||
'autoTitleFromTinyMCE must read via _tinymceFirstBlockText');
|
||||
});
|
||||
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Post-load reconcile: load normalisation must NOT be mistaken for a user edit
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
// Build a context that runs _setTinyMCEContent's reconcile with controllable timers.
|
||||
function makeReconcileCtx({ userTyped, normalisedDiffers }) {
|
||||
const dom = new JSDOM('<!DOCTYPE html><body><form id="note-editor-form">'
|
||||
+ '<input name="body" value="stored" /></form></body>');
|
||||
const timers = [];
|
||||
const editor = {
|
||||
setContent() {},
|
||||
undoManager: { clear() {} },
|
||||
getBody: () => dom.window.document.body,
|
||||
};
|
||||
const ctx = vm.createContext({
|
||||
document: dom.window.document,
|
||||
window: dom.window,
|
||||
Date,
|
||||
_tinymceEditor: editor,
|
||||
_tinymceSuppressEdits: false,
|
||||
_tinymceReadonly: false,
|
||||
_tinymcePostLoad: false,
|
||||
_tinymcePostLoadUntil: 0,
|
||||
_tinymceUserTypedSinceLoad: false,
|
||||
_pendingSearchHighlight: false,
|
||||
_savedHash: 111,
|
||||
_edited: 0,
|
||||
_saved: 0,
|
||||
_snapshots: 0,
|
||||
_logs: [],
|
||||
setTimeout: (fn, ms) => { timers.push({ fn, ms }); return timers.length; },
|
||||
});
|
||||
vm.runInContext(`
|
||||
function activeEditorForm(){return document.getElementById('note-editor-form')}
|
||||
function _dbgline(){_logs.push(Array.from(arguments).join(' '))}
|
||||
function _log(){}
|
||||
function markEdited(){_edited++}
|
||||
function scheduleSave(){_saved++}
|
||||
function snapshotHash(){_snapshots++; _savedHash=formHash()}
|
||||
function formHash(){return ${normalisedDiffers ? 222 : 111}}
|
||||
function _lazyTinyMCESyncBeforeSave(){return true}
|
||||
function ensureTinyMCEEditableAfterPre(){}
|
||||
function initTinyMCECodeCopyButtons(){}
|
||||
function _applyTinyMCESpellcheck(){}
|
||||
function activeSearchTerm(){return ''}
|
||||
function applySearchHighlight(){}
|
||||
`, ctx);
|
||||
vm.runInContext(extractFn('_setTinyMCEContent'), ctx);
|
||||
vm.runInContext('_setTinyMCEContent("<p>x</p>")', 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');
|
||||
});
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue