feat(editor): rich markdown mode (inline images, chips, checkboxes, tables, ...)

New public/cm-extras.js (loaded after codemirror.min.js, before app.js) adds
Obsidian-style niceties to the CodeMirror 6 markdown editor:

- inline image previews under ![](:/id) / <img src=":/id"> lines, with
  drag-to-resize that writes <img ... width="N" /> (same shape as the Turndown
  joplinImg rule, so rendered mode honours and round-trips it); previews load
  through a refcounted blob-URL cache because /resources/:id is no-store
- attachment / note-link chips with hover info; click opens the note or the
  lightbox
- clickable task checkboxes, code-block background + Copy button
- Tab / Shift-Tab pipe-table alignment and cell navigation (Enter is
  deliberately NOT hijacked - it corrupts hand-typed tables)
- paste-as-markdown for rich HTML, URL-over-selection -> link, and image/file
  paste upload in markdown mode (capture-phase, since CM6's own paste handler
  would replace the selection with "" for an image-only clipboard)
- word-count status bar with heading outline, heading/list/code folding
- optional live preview that hides formatting marks off the caret construct

Four per-user settings (Settings -> Markdown editor): mdInlineWidgets,
mdStatusBar, mdFolding (default on) and mdLivePreview (default off).

Also fixes: CodeMirror's built-in drop handler pasted the contents of dropped
text-like files on top of the upload (file drops are now claimed); the caret
stayed painted at a stale y after an image finished loading; settings-grid
selects ballooned when a neighbouring field was tall.

cm-build/index.js exports the extra CM symbols the extras need
(Decoration, WidgetType, StateField, foldGutter, ...) and the bundle is
rebuilt. Covered by tests/cmExtras.test.js (pure helpers + real CM6 in jsdom)
and playwright-tests/md-extras.spec.js (real browser, desktop + mobile).
This commit is contained in:
igor 2026-10-06 17:11:10 +13:00
parent 699f1f86ea
commit 91a8beffa0
13 changed files with 2456 additions and 36 deletions

View file

@ -228,7 +228,7 @@ const handle = async (url, request, response, ctx) => {
const body = await parseBody(request);
const current = await settingsService.settingsByUserId(auth.user.id);
const updates = {};
const allowedKeys = ['theme', 'noteFontSize', 'mobileNoteFontSize', 'codeFontSize', 'markdownFontSize', 'noteMonospace', 'noteFontFamily', 'newlineBehavior', 'noteOpenMode', 'resumeLastNote', 'dateFormat', 'datetimeFormat', 'liveSearch', 'highlightActiveLine', 'confirmTrash', 'encryptionAutoLockMinutes', 'uiMode', 'proseAutocompleteSentenceCount', 'openRouterApiKey', 'openRouterModel', 'aiProfiles', 'textExpanders'];
const allowedKeys = ['theme', 'noteFontSize', 'mobileNoteFontSize', 'codeFontSize', 'markdownFontSize', 'noteMonospace', 'noteFontFamily', 'newlineBehavior', 'noteOpenMode', 'resumeLastNote', 'dateFormat', 'datetimeFormat', 'liveSearch', 'highlightActiveLine', 'mdInlineWidgets', 'mdLivePreview', 'mdStatusBar', 'mdFolding', 'confirmTrash', 'encryptionAutoLockMinutes', 'uiMode', 'proseAutocompleteSentenceCount', 'openRouterApiKey', 'openRouterModel', 'aiProfiles', 'textExpanders'];
for (const key of allowedKeys) {
if (body[key] !== undefined) updates[key] = body[key];
}

View file

@ -144,6 +144,10 @@ const defaultSettings = Object.freeze({
theme: 'earth',
liveSearch: false,
highlightActiveLine: true,
mdInlineWidgets: true,
mdLivePreview: false,
mdStatusBar: true,
mdFolding: true,
confirmTrash: true,
encryptionAutoLockMinutes: 5,
uiMode: 'auto',
@ -221,6 +225,10 @@ const normalizeSettings = settings => ({
newlineBehavior: (settings.newlineBehavior === 'linebreak' || settings.newlineBehavior === 'invert') ? settings.newlineBehavior : defaultSettings.newlineBehavior,
liveSearch: !!Number(settings.liveSearch) || settings.liveSearch === true || settings.liveSearch === '1',
highlightActiveLine: settings.highlightActiveLine !== false && settings.highlightActiveLine !== '0' && settings.highlightActiveLine !== 0,
mdInlineWidgets: settings.mdInlineWidgets !== false && settings.mdInlineWidgets !== '0' && settings.mdInlineWidgets !== 0,
mdLivePreview: settings.mdLivePreview === true || settings.mdLivePreview === '1' || settings.mdLivePreview === 1,
mdStatusBar: settings.mdStatusBar !== false && settings.mdStatusBar !== '0' && settings.mdStatusBar !== 0,
mdFolding: settings.mdFolding !== false && settings.mdFolding !== '0' && settings.mdFolding !== 0,
confirmTrash: settings.confirmTrash !== false && settings.confirmTrash !== '0' && settings.confirmTrash !== 0,
encryptionAutoLockMinutes: normalizeInteger(settings.encryptionAutoLockMinutes, defaultSettings.encryptionAutoLockMinutes, 0, 480),
uiMode: validUiModes.includes(settings.uiMode) ? settings.uiMode : defaultSettings.uiMode,

View file

@ -10,7 +10,7 @@ const {
} = require('./shared');
const { noteMetaFragment } = require('./fragments');
const ASSET_VERSION = '20260921autolink';
const ASSET_VERSION = '20261005mdextras';
const noteFontFamilyCSS = (settings) => {
const f = settings.noteFontFamily || 'sans';
@ -106,6 +106,7 @@ const layoutPage = (options = {}) => {
<script src="/htmx.min.js"></script>
<script src="/turndown.min.js"></script>
<script src="/codemirror.min.js?v=${ASSET_VERSION}"></script>
<script src="/cm-extras.js?v=${ASSET_VERSION}"></script>
<script src="/prism.min.js"></script>
<script src="/tinymce/tinymce.min.js"></script>
<script src="/hljs.min.js"></script>
@ -121,6 +122,10 @@ const layoutPage = (options = {}) => {
datetimeFormat:${JSON.stringify(String(settings.datetimeFormat || 'YYYY-MM-DD HH:mm'))},
liveSearch:${settings.liveSearch ? 'true' : 'false'},
highlightActiveLine:${settings.highlightActiveLine !== false ? 'true' : 'false'},
mdInlineWidgets:${settings.mdInlineWidgets !== false ? 'true' : 'false'},
mdLivePreview:${settings.mdLivePreview === true ? 'true' : 'false'},
mdStatusBar:${settings.mdStatusBar !== false ? 'true' : 'false'},
mdFolding:${settings.mdFolding !== false ? 'true' : 'false'},
confirmTrash:${settings.confirmTrash !== false ? 'true' : 'false'},
encryptionAutoLockMinutes:${JSON.stringify(settings.encryptionAutoLockMinutes || 5)},
uiMode:${JSON.stringify(settings.uiMode || 'auto')},

View file

@ -223,10 +223,6 @@ const settingsPage = (options = {}) => {
<option value="invert"${settings.newlineBehavior==='invert'?' selected':''}>Line breaks with list support</option>
</select>
</label>
<label class="settings-field settings-checkbox">
<span>Markdown editor</span>
<label><input type="checkbox" id="settings-highlight-active-line" onchange="saveSetting('highlightActiveLine',this.checked?'1':'0')"${settings.highlightActiveLine !== false ? ' checked' : ''} /> Highlight current line in editor</label>
</label>
<label class="settings-field">
<span>Open notes in</span>
<select id="settings-note-open-mode" class="login-input" onchange="saveSetting('noteOpenMode',this.value)">
@ -262,6 +258,16 @@ const settingsPage = (options = {}) => {
${validDatetimeFormats.map(f => `<option value="${escapeHtml(f)}"${(settings.datetimeFormat || 'YYYY-MM-DD HH:mm') === f ? ' selected' : ''}>${escapeHtml(f)}</option>`).join('')}
</select>
</label>
<div class="settings-field settings-checkbox settings-field-wide">
<span>Markdown editor</span>
<div class="settings-checkbox-group">
<label><input type="checkbox" id="settings-highlight-active-line" onchange="saveSetting('highlightActiveLine',this.checked?'1':'0')"${settings.highlightActiveLine !== false ? ' checked' : ''} /> Highlight current line in editor</label>
<label><input type="checkbox" id="settings-md-inline-widgets" onchange="saveSetting('mdInlineWidgets',this.checked?'1':'0')"${settings.mdInlineWidgets !== false ? ' checked' : ''} /> Show images, attachments, checkboxes and code Copy buttons inline</label>
<label><input type="checkbox" id="settings-md-live-preview" onchange="saveSetting('mdLivePreview',this.checked?'1':'0')"${settings.mdLivePreview === true ? ' checked' : ''} /> Hide formatting marks (#, **, links) on lines you are not editing</label>
<label><input type="checkbox" id="settings-md-status-bar" onchange="saveSetting('mdStatusBar',this.checked?'1':'0')"${settings.mdStatusBar !== false ? ' checked' : ''} /> Show word count and heading outline bar</label>
<label><input type="checkbox" id="settings-md-folding" onchange="saveSetting('mdFolding',this.checked?'1':'0')"${settings.mdFolding !== false ? ' checked' : ''} /> Show fold arrows for headings, lists and code blocks</label>
</div>
</div>
</div>
</section>
</div>

View file

@ -1,10 +1,10 @@
// CM6 bundle entry point — exports everything on window.CM
import { EditorView } from "@codemirror/view";
import { EditorState } from "@codemirror/state";
import { EditorView, Decoration, WidgetType, ViewPlugin, hoverTooltip, showPanel, GutterMarker } from "@codemirror/view";
import { EditorState, StateField, StateEffect, RangeSetBuilder, Prec, Compartment, EditorSelection } from "@codemirror/state";
import { markdown, markdownLanguage } from "@codemirror/lang-markdown";
import { keymap, placeholder, drawSelection, highlightActiveLine } from "@codemirror/view";
import { defaultKeymap, history, historyKeymap, indentWithTab } from "@codemirror/commands";
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, HighlightStyle, StreamLanguage } from "@codemirror/language";
import { syntaxHighlighting, defaultHighlightStyle, bracketMatching, HighlightStyle, StreamLanguage, syntaxTree, foldGutter, codeFolding, foldKeymap, foldService, foldEffect, unfoldEffect, foldedRanges, foldable, foldAll, unfoldAll } from "@codemirror/language";
import { tags } from "@lezer/highlight";
import { searchKeymap, highlightSelectionMatches, openSearchPanel, SearchQuery, setSearchQuery } from "@codemirror/search";
@ -70,4 +70,30 @@ window.CM = {
// Autocomplete support
autocompletion,
startCompletion,
// Rich markdown-mode extensions (public/cm-extras.js): decorations/widgets,
// state fields, panels, tooltips and folding.
Decoration,
WidgetType,
ViewPlugin,
hoverTooltip,
showPanel,
GutterMarker,
StateField,
StateEffect,
RangeSetBuilder,
Prec,
Compartment,
EditorSelection,
syntaxTree,
foldGutter,
codeFolding,
foldKeymap,
foldService,
foldEffect,
unfoldEffect,
foldedRanges,
foldable,
foldAll,
unfoldAll,
};

View file

@ -0,0 +1,379 @@
'use strict';
// Real-browser coverage for the markdown-mode (CodeMirror 6) extras in
// public/cm-extras.js: inline image previews + drag-resize, attachment chips,
// clickable task checkboxes, table helper, paste-as-markdown, status bar +
// outline, folding, live-preview marker hiding and the code-block Copy button.
// Desktop only.
const fs = require('fs');
const path = require('path');
const { test, expect } = require('@playwright/test');
const {
acceptDialogs,
createDesktopNote,
createNotebook,
ensureMobileFoldersScreen,
login,
openMobileFolder,
setNoteTitle,
teardownTestData,
waitForSaved,
} = require('./helpers');
const TEST_IMAGE = path.resolve(__dirname, '..', 'public', 'icon-192.png');
const IMG_B64 = fs.readFileSync(TEST_IMAGE).toString('base64');
const PDF_B64 = Buffer.from('%PDF-1.1\n1 0 obj<</Type/Catalog>>endobj\ntrailer<</Root 1 0 R>>\n%%EOF').toString('base64');
async function setDoc(page, text) {
await page.evaluate(t => {
const v = getCM();
v.dispatch({ changes: { from: 0, to: v.state.doc.length, insert: t }, selection: { anchor: 0 } });
}, text);
}
const getDoc = page => page.evaluate(() => getCM().state.doc.toString());
// Upload a file through the real drop handler on the CM content element.
async function dropFile(page, { name, mime, data }) {
await page.evaluate(async ({ name, mime, data }) => {
const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
const dt = new DataTransfer();
dt.items.add(new File([bytes], name, { type: mime }));
const target = document.querySelector('#editor-panel .cm-content');
target.dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
}, { name, mime, data });
}
async function pasteData(page, { text, html }) {
await page.evaluate(({ text, html }) => {
const dt = new DataTransfer();
if (text != null) dt.setData('text/plain', text);
if (html != null) dt.setData('text/html', html);
const target = document.querySelector('#editor-panel .cm-content');
target.dispatchEvent(new ClipboardEvent('paste', { clipboardData: dt, bubbles: true, cancelable: true }));
}, { text, html });
}
async function openFreshNote(page, folder, title) {
await login(page);
await createNotebook(page, folder);
await createDesktopNote(page, folder);
await setNoteTitle(page, title);
const md = page.locator('#editor-panel #markdown-toggle');
if (await md.count()) await md.click();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
}
test.describe('Markdown mode extras', () => {
test.beforeEach(({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
acceptDialogs(page);
});
let folder;
test.beforeEach(async ({ page }, testInfo) => {
if (testInfo.project.name !== 'desktop') return;
folder = `pw-mdx-${Date.now()}-${Math.random().toString(36).slice(2, 6)}`;
await openFreshNote(page, folder, `MD extras ${Date.now()}`);
});
test.afterEach(async ({ page }, testInfo) => {
if (testInfo.project.name !== 'desktop') return;
await teardownTestData(page, { folders: [folder] });
});
test('uploaded image shows an inline preview that can be resized and reset', async ({ page }) => {
await setDoc(page, 'Intro\n\n');
await dropFile(page, { name: 'pic.png', mime: 'image/png', data: IMG_B64 });
const img = page.locator('#editor-panel .cm-jl-img img').first();
await expect(img).toBeVisible({ timeout: 15000 });
await expect.poll(() => img.evaluate(i => i.complete && i.naturalWidth), { timeout: 10000 }).toBeGreaterThan(0);
expect(await getDoc(page)).toMatch(/!\[pic\.png\]\(:\/[0-9a-f]{32}\)/);
await page.screenshot({ path: 'test-results/mdx-image-preview.png' });
// Real mouse drag on the resize handle.
const fig = page.locator('#editor-panel .cm-jl-img').first();
await fig.hover();
const handle = fig.locator('.cm-jl-img-handle');
const hb = await handle.boundingBox();
const before = (await img.boundingBox()).width;
await page.mouse.move(hb.x + hb.width / 2, hb.y + hb.height / 2);
await page.mouse.down();
await page.mouse.move(hb.x + hb.width / 2 - 60, hb.y + hb.height / 2, { steps: 6 });
await page.mouse.up();
await expect.poll(() => getDoc(page), { timeout: 5000 }).toMatch(/<img src=":\/[0-9a-f]{32}" alt="pic\.png" width="\d+" \/>/);
const width = Number((await getDoc(page)).match(/width="(\d+)"/)[1]);
expect(width).toBeLessThan(before);
await expect.poll(async () => Math.round((await page.locator('#editor-panel .cm-jl-img img').first().boundingBox()).width), { timeout: 5000 }).toBe(width);
// Rendered mode honours the same width, and the width survives a round trip.
await page.locator('#editor-panel #preview-toggle').click();
const frame = page.frameLocator('iframe.tox-edit-area__iframe');
await expect(frame.locator('img[width]').first()).toBeVisible({ timeout: 15000 });
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
expect(await getDoc(page)).toMatch(new RegExp(`width="${width}"`));
// Double-click the handle: back to natural size, plain markdown.
const fig2 = page.locator('#editor-panel .cm-jl-img').first();
await fig2.hover();
await fig2.locator('.cm-jl-img-handle').dblclick();
await expect.poll(() => getDoc(page), { timeout: 5000 }).toMatch(/!\[pic\.png\]\(:\/[0-9a-f]{32}\)/);
});
test('clicking a preview selects its source so Delete removes the image', async ({ page }) => {
await setDoc(page, 'Top\n\n');
await dropFile(page, { name: 'del.png', mime: 'image/png', data: IMG_B64 });
const img = page.locator('#editor-panel .cm-jl-img img').first();
await expect(img).toBeVisible({ timeout: 15000 });
await img.click();
await page.keyboard.press('Delete');
await expect(page.locator('#editor-panel .cm-jl-img')).toHaveCount(0);
expect(await getDoc(page)).not.toMatch(/:\/[0-9a-f]{32}/);
});
test('an attachment becomes a chip with hover info; the caret reveals the raw link', async ({ page }) => {
await setDoc(page, 'Files:\n\n');
await dropFile(page, { name: 'spec-sheet.pdf', mime: 'application/pdf', data: PDF_B64 });
const chip = page.locator('#editor-panel .cm-jl-chip').first();
await expect(chip).toBeVisible({ timeout: 15000 });
await expect(chip).toContainText('spec-sheet.pdf');
await expect(chip).toHaveAttribute('data-kind', 'resource', { timeout: 10000 });
// A text-like file must be uploaded, NOT also pasted into the note as text
// (CodeMirror's own drop handler used to do exactly that).
expect(await getDoc(page)).not.toContain('%PDF');
await page.screenshot({ path: 'test-results/mdx-chip.png' });
// Hover tooltip with file info.
await chip.hover();
await expect(page.locator('.cm-tooltip .cm-jl-tip')).toContainText('spec-sheet.pdf', { timeout: 8000 });
// Put the caret inside the link text: the raw markdown is revealed.
await page.evaluate(() => {
const v = getCM();
const i = v.state.doc.toString().indexOf('spec-sheet');
v.focus();
v.dispatch({ selection: { anchor: i + 2 } });
});
await expect(page.locator('#editor-panel .cm-jl-chip')).toHaveCount(0);
await expect(page.locator('#editor-panel .cm-content')).toContainText('](:/');
});
test('task checkboxes toggle on click and the change is saved', async ({ page }) => {
await setDoc(page, '- [ ] first\n- [x] second\n');
const boxes = page.locator('#editor-panel .cm-jl-check');
await expect(boxes).toHaveCount(2);
await expect(page.locator('#editor-panel .cm-jl-task-done')).toContainText('second');
await boxes.first().click();
await expect.poll(() => getDoc(page)).toBe('- [x] first\n- [x] second\n');
await boxes.nth(1).click();
await expect.poll(() => getDoc(page)).toBe('- [x] first\n- [ ] second\n');
await expect(page.locator('#editor-panel #note-body')).toHaveValue('- [x] first\n- [ ] second\n');
await page.screenshot({ path: 'test-results/mdx-checkboxes.png' });
});
test('table helper: Tab aligns and moves between cells and appends rows; typing a table by hand still works', async ({ page }) => {
await setDoc(page, '| a | b |\n|-|-|\n| 1 | 2 |\n');
await page.evaluate(() => {
const v = getCM();
v.focus();
v.dispatch({ selection: { anchor: v.state.doc.toString().indexOf('1') + 1 } });
});
await page.keyboard.press('Tab');
expect(await getDoc(page)).toBe('| a | b |\n| --- | --- |\n| 1 | 2 |\n');
await page.keyboard.type('X');
await page.keyboard.press('Tab'); // last cell of last row -> new row
await page.keyboard.type('new');
await page.keyboard.press('Tab');
await page.keyboard.type('cell');
await page.keyboard.press('Tab');
const doc = await getDoc(page);
expect(doc).toBe('| a | b |\n| --- | ---- |\n| 1 | 2X |\n| new | cell |\n| | |\n');
await page.keyboard.press('Shift+Tab');
await page.keyboard.type('!');
expect((await getDoc(page)).split('\n')[3]).toMatch(/^\| new\s+\| cell!\s+\|$/);
// Enter is never hijacked: a table typed by hand, row by row, stays exactly as typed.
await setDoc(page, '');
await page.evaluate(() => getCM().focus());
await page.keyboard.type('| h1 | h2 |\n|---|---|\n| a | b |\n| c | d |\n');
expect(await getDoc(page)).toBe('| h1 | h2 |\n|---|---|\n| a | b |\n| c | d |\n');
});
test('paste: rich HTML becomes markdown, URL over a selection becomes a link, code stays raw', async ({ page }) => {
await setDoc(page, '');
await page.evaluate(() => getCM().focus());
await pasteData(page, { text: 'Title\nitem', html: '<h2>Title</h2><ul><li>item one</li><li><a href="https://example.com/x">link</a></li></ul>' });
const doc = await getDoc(page);
expect(doc).toContain('## Title');
expect(doc).toMatch(/-\s+item one/);
expect(doc).toContain('[link](https://example.com/x)');
await setDoc(page, 'see the docs here');
await page.evaluate(() => {
const v = getCM();
v.focus();
v.dispatch({ selection: { anchor: 8, head: 12 } });
});
await pasteData(page, { text: 'https://example.com/docs' });
expect(await getDoc(page)).toBe('see the [docs](https://example.com/docs) here');
// Inside a fenced code block HTML is not converted.
await setDoc(page, '```\n\n```');
await page.evaluate(() => { const v = getCM(); v.focus(); v.dispatch({ selection: { anchor: 4 } }); });
await pasteData(page, { text: 'raw text', html: '<h1>raw text</h1>' });
expect(await getDoc(page)).toBe('```\nraw text\n```');
});
test('status bar shows counts; outline jumps to a heading; headings fold', async ({ page }) => {
const body = '# One\n\nfirst section text\n\n## Two\n\nsecond section text\n\n### Three\n\nthird\n';
await setDoc(page, body);
const status = page.locator('#editor-panel .cm-jl-status');
await expect(status).toContainText(/\d+ words/);
await page.screenshot({ path: 'test-results/mdx-statusbar.png' });
await page.locator('#editor-panel .cm-jl-outline-btn').click();
const items = page.locator('#editor-panel .cm-jl-outline-item');
await expect(items).toHaveText(['One', 'Two', 'Three']);
await items.nth(2).click();
await expect.poll(() => page.evaluate(() => {
const v = getCM();
return v.state.doc.lineAt(v.state.selection.main.head).text;
})).toBe('### Three');
// Fold "## Two" through its gutter marker.
const lineCountBefore = await page.locator('#editor-panel .cm-content .cm-line').count();
const twoLine = page.locator('#editor-panel .cm-content .cm-line', { hasText: '## Two' });
await twoLine.hover();
await page.locator('#editor-panel .cm-foldGutter .cm-gutterElement', { has: page.locator('.cm-jl-fold-marker.open') }).nth(1).click();
await expect(page.locator('#editor-panel .cm-jl-fold-placeholder')).toHaveCount(1);
await expect(page.locator('#editor-panel .cm-content')).not.toContainText('second section text');
expect(await page.locator('#editor-panel .cm-content .cm-line').count()).toBeLessThan(lineCountBefore);
// The folded text is still in the document / textarea.
expect(await getDoc(page)).toContain('second section text');
await page.screenshot({ path: 'test-results/mdx-fold.png' });
await page.locator('#editor-panel .cm-jl-fold-placeholder').click();
await expect(page.locator('#editor-panel .cm-content')).toContainText('second section text');
});
test('code blocks get a Copy button that copies just the code', async ({ page, context }) => {
await context.grantPermissions(['clipboard-read', 'clipboard-write']);
await setDoc(page, 'Before\n\n```js\nconst a = 1;\nconsole.log(a);\n```\n\nAfter\n');
await expect(page.locator('#editor-panel .cm-jl-codeline')).toHaveCount(4);
await page.locator('#editor-panel .cm-jl-copy').click();
await expect(page.locator('#editor-panel .cm-jl-copy')).toHaveText('Copied');
expect(await page.evaluate(() => navigator.clipboard.readText())).toBe('const a = 1;\nconsole.log(a);');
await page.screenshot({ path: 'test-results/mdx-codeblock.png' });
});
test('note links show hover info with the target note title', async ({ page }) => {
const id = await page.evaluate(() => (document.querySelector('#editor-panel #note-editor-form').getAttribute('hx-put') || '').split('/').pop());
await setDoc(page, `Self link: [My own note](:/${id}) end\n`);
// Let the autosave land first: the note-title cache is invalidated by saves,
// and the tooltip reads the saved title.
await waitForSaved(page);
const chip = page.locator('#editor-panel .cm-jl-chip').first();
await expect(chip).toBeVisible();
await expect(chip).toHaveAttribute('data-kind', 'note', { timeout: 10000 });
await page.mouse.move(5, 5);
await chip.hover();
await expect(page.locator('.cm-tooltip .cm-jl-tip')).toContainText('MD extras', { timeout: 8000 });
});
});
test.describe('Markdown mode live preview (setting)', () => {
test('hides formatting marks off the caret line and reveals them on it', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
acceptDialogs(page);
const folder = `pw-mdx-lp-${Date.now()}`;
try {
await openFreshNote(page, folder, `LP ${Date.now()}`);
const put = value => page.evaluate(async v => (await fetch('/api/web/settings', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ mdLivePreview: v }),
})).status, value);
expect(await put(true)).toBe(204);
await page.reload();
await expect(page.locator('body.app-shell')).toBeVisible({ timeout: 15000 });
// Reopen the note in markdown mode.
await page.locator(`.nav-folder[data-folder-title="${folder}"]`).first().click().catch(() => {});
const note = page.locator(`.nav-folder[data-folder-title="${folder}"] .notelist-item`).first();
await expect(note).toBeVisible({ timeout: 15000 });
await note.click();
const md = page.locator('#editor-panel #markdown-toggle');
if (await md.count()) await md.click();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setDoc(page, '# Big title\n\nsome **bold** and [a link](https://example.com) and `code`\n\nlast line\n');
await page.evaluate(() => { const v = getCM(); v.focus(); v.dispatch({ selection: { anchor: v.state.doc.length } }); });
const content = page.locator('#editor-panel .cm-content');
await expect(content).not.toContainText('# Big title');
await expect(content).not.toContainText('**bold**');
await expect(content).not.toContainText('](https://example.com)');
await expect(content).toContainText('some bold and a link and code');
await page.screenshot({ path: 'test-results/mdx-livepreview-hidden.png' });
// Marks are revealed per construct: caret in the bold word shows ** only...
await page.evaluate(() => { const v = getCM(); v.dispatch({ selection: { anchor: v.state.doc.toString().indexOf('bold') } }); });
await expect(content).toContainText('**bold**');
await expect(content).not.toContainText('](https://example.com)');
await expect(content).not.toContainText('# Big title');
// ...caret in the link shows the link syntax.
await page.evaluate(() => { const v = getCM(); v.dispatch({ selection: { anchor: v.state.doc.toString().indexOf('a link') + 2 } }); });
await expect(content).toContainText('[a link](https://example.com)');
await expect(content).not.toContainText('**bold**');
// ...and the heading's when the caret goes there.
await page.evaluate(() => { getCM().dispatch({ selection: { anchor: 3 } }); });
await expect(content).toContainText('# Big title');
await page.screenshot({ path: 'test-results/mdx-livepreview-reveal.png' });
} finally {
await page.evaluate(async () => fetch('/api/web/settings', {
method: 'PUT', headers: { 'Content-Type': 'application/json' }, credentials: 'same-origin', body: JSON.stringify({ mdLivePreview: false }),
})).catch(() => {});
await teardownTestData(page, { folders: [folder] });
}
});
});
test.describe('Markdown mode extras (mobile shell)', () => {
test('checkboxes toggle on tap, previews fit the screen, status bar is visible', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
acceptDialogs(page);
try {
await login(page);
await ensureMobileFoldersScreen(page);
await openMobileFolder(page, 'All Notes');
await page.locator('#mobile-notes-screen .mobile-header-btn[title="New note"]').click();
await expect(page.locator('#mobile-editor-screen.mobile-screen-active')).toBeVisible();
await page.locator('#mobile-md-toggle').click();
await expect(page.locator('#mobile-editor-body #cm-host')).toBeVisible();
await setDoc(page, '# pw-mdx-mobile heading\n\n- [ ] task one\n- [x] task two\n\n');
await page.evaluate(async data => {
const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
const dt = new DataTransfer();
dt.items.add(new File([bytes], 'm.png', { type: 'image/png' }));
const v = getCM();
v.dispatch({ selection: { anchor: v.state.doc.length } });
document.querySelector('#mobile-editor-body .cm-content').dispatchEvent(new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true }));
}, IMG_B64);
const img = page.locator('#mobile-editor-body .cm-jl-img img').first();
await expect(img).toBeVisible({ timeout: 15000 });
const vw = page.viewportSize().width;
const box = await img.boundingBox();
expect(box.x + box.width).toBeLessThanOrEqual(vw + 1);
await page.locator('#mobile-editor-body .cm-jl-check').first().tap();
await expect.poll(() => getDoc(page)).toContain('- [x] task one');
const status = page.locator('#mobile-editor-body .cm-jl-status');
await expect(status).toBeVisible();
const sb = await status.boundingBox();
expect(sb.y + sb.height).toBeLessThanOrEqual(page.viewportSize().height + 1);
await page.screenshot({ path: 'test-results/mdx-mobile.png' });
} finally {
await teardownTestData(page, { titlePrefixes: ['pw-mdx-mobile'] });
}
});
});

View file

@ -275,6 +275,13 @@ var _defaultNoteOpenMode=_cfg.noteOpenMode||'markdown';
// preference means open in CM6, on desktop and mobile.
function preferredEditorMode(){var v='';try{v=(window._joplockConfig&&window._joplockConfig.noteOpenMode)||''}catch(_e){}if(!v)v=_defaultNoteOpenMode||'';return v==='markdown'?'markdown':'rich'}
var _highlightActiveLine=_cfg.highlightActiveLine!==false;
// Markdown-mode (CM6) extras — see public/cm-extras.js. Inline widgets (image
// previews, attachment chips, task checkboxes, code Copy button), status bar and
// folding default ON; hiding formatting markers ("live preview") defaults OFF.
var _mdInlineWidgets=_cfg.mdInlineWidgets!==false;
var _mdLivePreview=_cfg.mdLivePreview===true;
var _mdStatusBar=_cfg.mdStatusBar!==false;
var _mdFolding=_cfg.mdFolding!==false;
var _mobileStartup=_cfg.mobileStartup||null;
var _uiMode=_cfg.uiMode||'auto';
var _mobileShellMaxWidth=768;
@ -3178,6 +3185,55 @@ function _cmLanguageDescription(C,name,alias,buildSupport){
load:function(){return Promise.resolve(support)}
});
}
// ---- CM6 extras: host hooks (images, chips, links, paste) -----------------
var _linkInfoCache={};
// What does `:/<id>` point at? A note (title from the headers cache) or an
// attachment (HEAD /resources/<id>). Attachment info is immutable, so cached.
function _resolveInternalLink(id){
if(_linkInfoCache[id])return Promise.resolve(_linkInfoCache[id]);
return fetchNoteHeaders().then(function(hs){
for(var i=0;i<hs.length;i++)if(hs[i].id===id)return {kind:'note',title:hs[i].title};
return _fetchResourceMeta(id).then(function(m){var info={kind:'resource',filename:m.filename,mime:m.mime};_linkInfoCache[id]=info;return info},function(){return {kind:'unknown'}});
});
}
// Open another note from a `[title](:/noteId)` link: reuse the note-list row
// (keeps selection/save logic identical to a normal click) or load the editor
// fragment directly when the note is not in the visible list.
function _openNoteById(id){
if(!id)return;
var row=document.querySelector('.notelist-item[data-note-id="'+id+'"]');
if(row){row.click();return}
if(typeof isMobile==='function'&&isMobile()&&typeof window.mobilePushEditor==='function'){window.mobilePushEditor(id,typeof _state!=='undefined'&&_state?_state.folderId:'');return}
if(window.htmx)window.htmx.ajax('GET','/fragments/editor/'+encodeURIComponent(id),{target:'#editor-panel',swap:'innerHTML'});
}
// Clipboard HTML -> markdown for paste in markdown mode (web pages, Word, Docs).
function _pasteHtmlToMarkdown(html){
var tpl=document.createElement('template');
tpl.innerHTML=html;
var frag=tpl.content;
frag.querySelectorAll('meta,style,script,link,title').forEach(function(n){n.remove()});
// Google Docs wraps the whole selection in <b style="font-weight:normal">.
frag.querySelectorAll('b[style*="font-weight:normal"],b[style*="font-weight: normal"]').forEach(function(b){while(b.firstChild)b.parentNode.insertBefore(b.firstChild,b);b.remove()});
var wrap=document.createElement('div');
wrap.appendChild(frag);
var md=getTurndown().turndown(wrap.innerHTML);
return md.replace(/\x00BL\x00/g,'\n').replace(/\u00a0/g,' ').replace(/\n{3,}/g,'\n\n').trim();
}
function _cmExtraHooks(){
return {
fetchResourceBlob:function(id){return _fetchResourceBlob(id).then(function(r){return r.blob})},
openResource:function(id){_openResourceLightbox(id)},
openNote:_openNoteById,
resolveLinkInfo:_resolveInternalLink,
copyText:_copyTextToClipboard,
htmlToMarkdown:_pasteHtmlToMarkdown
};
}
function _cmExtraExtensions(C){
if(!window.JoplockMd)return [];
try{return window.JoplockMd.createExtensions(C,_cmExtraHooks(),{inlineWidgets:_mdInlineWidgets,livePreview:_mdLivePreview,statusBar:_mdStatusBar,folding:_mdFolding})}
catch(err){console.error('[joplock] markdown extras failed to initialise',err);return []}
}
function initCM(host,content){
if(_cmView){_cmView.destroy();_cmView=null}
var C=window.CM;
@ -3185,7 +3241,7 @@ function initCM(host,content){
'&':{height:'100%'},
'.cm-scroller':{overflow:'auto',fontFamily:'"Cascadia Mono",monospace',lineHeight:'1.7'},
'.cm-content':{padding:'16px 20px',caretColor:'var(--accent)'},
'.cm-gutters':{display:'none'},
'.cm-gutters':_mdFolding?{backgroundColor:'transparent',border:'none',color:'var(--text-dim)'}:{display:'none'},
'.cm-search.cm-panel':{display:'none'},
'.cm-searchMatch':{backgroundColor:'#ffe066',color:'#111',borderRadius:'2px'},
'.cm-searchMatch.cm-searchMatch-selected':{backgroundColor:'#ff9800',color:'#111',borderRadius:'2px'},
@ -3266,6 +3322,7 @@ function initCM(host,content){
}},...C.defaultKeymap,...C.historyKeymap,...C.searchKeymap.filter(function(b){var k=b.key||'';return k!=='Mod-f'&&k!=='F3'&&k!=='Mod-g'}),C.indentWithTab]),
C.placeholder('Start writing...'),
C.autocompletion({ override: [manualProseCompletionSource,noteCompletionSource] }),
..._cmExtraExtensions(C),
onUpdate,
C.EditorView.lineWrapping,
C.EditorView.domEventHandlers({click:function(e,view){var pos=view.posAtCoords({x:e.clientX,y:e.clientY});if(pos==null)return false;var line=view.state.doc.lineAt(pos);var text=line.text;var offset=pos-line.from;var m;var linkRe=/\[([^\]]*)\]\((https?:\/\/[^)\s]+)\)/g;while((m=linkRe.exec(text))!==null){if(offset>=m.index&&offset<=m.index+m[0].length){var url=m[2];if(e.ctrlKey||e.metaKey){window.open(url,'_blank','noopener');return true}_copyTextToClipboard(url,function(ok){if(ok)_showLinkCopiedToast(e.clientX,e.clientY)});return true}}var urlRe=/https?:\/\/[^\s)>\]]+/g;while((m=urlRe.exec(text))!==null){if(offset>=m.index&&offset<=m.index+m[0].length){if(e.ctrlKey||e.metaKey){window.open(m[0],'_blank','noopener');return true}_copyTextToClipboard(m[0],function(ok){if(ok)_showLinkCopiedToast(e.clientX,e.clientY)});return true}}return false;}})
@ -3297,6 +3354,18 @@ function initCM(host,content){
e.preventDefault();if(e.dataTransfer){try{e.dataTransfer.dropEffect='copy'}catch(_){}}
}
});
// Clipboard paste of images / files into the markdown editor. CM6's built-in
// paste handler only reads text/plain, so an image-only clipboard (screenshot,
// "Copy image") is silently swallowed. Listen in the CAPTURE phase on the
// editor root so we run before CM's handler (which would otherwise replace
// the selection with an empty string) and only take over when the clipboard
// really carries uploadable files.
_cmView.dom.addEventListener('paste',function(e){
var files=_clipboardFilesToUpload(e.clipboardData||window.clipboardData);
if(!files.length)return;
e.preventDefault();e.stopPropagation();
files.reduce(function(pr,file){return pr.then(function(){return _uploadFileToCM(file)})},Promise.resolve()).then(function(){markEdited();scheduleSave()}).catch(function(err){console.error('CM paste upload failed:',err)});
},true);
_cmView.contentDOM.addEventListener('drop',function(e){
var files=e.dataTransfer&&e.dataTransfer.files;
if(!files||!files.length)return;
@ -3678,6 +3747,31 @@ function _uploadFileToTinyMCE(file,editor){
}
});
}
// Files on the clipboard that should be uploaded as attachments (markdown mode).
// Returns [] when the paste should fall through to normal text handling.
// * Non-image files (PDF, ...) are always uploaded.
// * Images are uploaded unless the clipboard also carries BOTH text/plain and
// text/html — that is a rich-text/spreadsheet copy (e.g. Excel cells ship a
// preview bitmap), where the user wants the text, not the picture.
function _clipboardFilesToUpload(cd){
if(!cd)return [];
var files=[];
var items=cd.items;
if(items&&items.length){
for(var i=0;i<items.length;i++){
if(items[i].kind==='file'){var f=items[i].getAsFile();if(f)files.push(f)}
}
}else if(cd.files&&cd.files.length){
files=Array.prototype.slice.call(cd.files);
}
if(!files.length)return [];
var hasText=false,hasHtml=false;
try{hasText=!!cd.getData('text/plain');hasHtml=!!cd.getData('text/html')}catch(_e){}
if(hasText&&hasHtml){
files=files.filter(function(f){return !(f.type&&f.type.indexOf('image/')===0)});
}
return files;
}
// Upload a file and insert its markdown reference at the CodeMirror cursor.
function _uploadFileToCM(file){
if(!file||!_cmView)return Promise.resolve();

1078
public/cm-extras.js Normal file

File diff suppressed because it is too large Load diff

File diff suppressed because one or more lines are too long

View file

@ -1,5 +1,5 @@
const CACHE_NAME = 'joplock-shell-v78-20260819prism';
const STATIC_ASSETS = ['/styles.css', '/htmx.min.js', '/turndown.min.js', '/codemirror.min.js', '/prism.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'];
const CACHE_NAME = 'joplock-shell-v79-20261005mdextras';
const STATIC_ASSETS = ['/styles.css', '/htmx.min.js', '/turndown.min.js', '/codemirror.min.js', '/cm-extras.js', '/prism.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 => {
event.waitUntil(

View file

@ -1766,6 +1766,236 @@ body.editor-markdown-mode .editor-toolbar {
font-style: italic;
}
/* ===== Markdown mode extras (public/cm-extras.js) ===== */
/* Image previews under `![](:/id)` lines */
.cm-host .cm-jl-imgrow {
display: flex;
flex-wrap: wrap;
gap: 12px;
align-items: flex-start;
padding: 4px 0 10px;
font-family: system-ui, sans-serif;
}
.cm-host .cm-jl-img {
position: relative;
display: inline-block;
max-width: 100%;
line-height: 0;
}
.cm-host .cm-jl-img img {
display: block;
max-width: 100%;
height: auto;
border-radius: 6px;
border: 1px solid var(--border);
background: var(--bg-elevated);
cursor: zoom-in;
user-select: none;
-webkit-user-drag: none;
}
.cm-host .cm-jl-img-natural img { max-height: 360px; width: auto; }
.cm-host .cm-jl-img.loading { min-width: 96px; min-height: 56px; border-radius: 6px; background: var(--bg-elevated); }
.cm-host .cm-jl-img.loading img { opacity: 0; }
.cm-host .cm-jl-img.broken {
padding: 8px 12px;
line-height: 1.4;
font-size: 0.85em;
color: var(--text-dim);
border: 1px dashed var(--border);
border-radius: 6px;
}
.cm-host .cm-jl-img-handle {
position: absolute;
right: -7px;
bottom: -7px;
width: 16px;
height: 16px;
border-radius: 50%;
background: var(--accent);
border: 2px solid var(--bg-editor, var(--bg));
cursor: nwse-resize;
opacity: 0;
transition: opacity 0.12s;
touch-action: none;
z-index: 2;
}
.cm-host .cm-jl-img:hover .cm-jl-img-handle,
.cm-host .cm-jl-img.resizing .cm-jl-img-handle { opacity: 0.95; }
.cm-host .cm-jl-img.resizing img { outline: 2px solid var(--accent); }
@media (hover: none) { .cm-host .cm-jl-img-handle { opacity: 0.85; width: 22px; height: 22px; right: -9px; bottom: -9px; } }
/* Attachment / note-link chips (replace `[text](:/id)`) */
.cm-host .cm-jl-chip {
display: inline-flex;
align-items: center;
gap: 5px;
padding: 0 9px 0 7px;
margin: 0 1px;
border: 1px solid var(--border);
border-radius: 999px;
background: var(--bg-hover);
color: var(--text);
font-size: 0.92em;
line-height: 1.6;
cursor: pointer;
vertical-align: baseline;
white-space: nowrap;
max-width: 26em;
}
.cm-host .cm-jl-chip:hover { border-color: var(--accent); background: var(--bg-active); }
.cm-host .cm-jl-chip-label { overflow: hidden; text-overflow: ellipsis; }
.cm-host .cm-jl-chip-icon::before { content: "\1F517"; font-size: 0.9em; }
.cm-host .cm-jl-chip[data-kind="resource"] .cm-jl-chip-icon::before { content: "\1F4CE"; }
.cm-host .cm-jl-chip[data-kind="note"] .cm-jl-chip-icon::before { content: "\1F4C4"; }
/* Clickable task checkboxes */
.cm-host .cm-jl-check {
display: inline-block;
position: relative;
width: 1.05em;
height: 1.05em;
margin: 0 0.15em;
vertical-align: -0.15em;
border: 1.5px solid var(--text-dim);
border-radius: 4px;
cursor: pointer;
box-sizing: border-box;
}
.cm-host .cm-jl-check:hover { border-color: var(--accent); }
.cm-host .cm-jl-check.checked { background: var(--accent); border-color: var(--accent); }
.cm-host .cm-jl-check.checked::after {
content: "";
position: absolute;
left: 0.28em;
top: 0.04em;
width: 0.28em;
height: 0.55em;
border: solid var(--text-on-accent, #000);
border-width: 0 2px 2px 0;
transform: rotate(45deg);
}
.cm-host .cm-jl-task-done { text-decoration: line-through; color: var(--text-dim); }
/* Fenced code blocks */
.cm-host .cm-jl-codeline { background: var(--bg-elevated); }
.cm-host .cm-jl-codeline-first { border-top-left-radius: 6px; border-top-right-radius: 6px; }
.cm-host .cm-jl-codeline-last { border-bottom-left-radius: 6px; border-bottom-right-radius: 6px; }
.cm-host .cm-jl-copy {
float: right;
margin-left: 8px;
padding: 0 8px;
font: inherit;
font-size: 0.78em;
line-height: 1.6;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 5px;
cursor: pointer;
opacity: 0.55;
}
.cm-host .cm-jl-codeline-first:hover .cm-jl-copy,
.cm-host .cm-jl-copy:focus { opacity: 1; color: var(--text); border-color: var(--accent); }
/* Fold gutter */
.cm-host .cm-foldGutter { width: 18px; }
.cm-host .cm-foldGutter .cm-gutterElement { text-align: center; cursor: pointer; padding: 0; }
.cm-host .cm-jl-fold-marker { opacity: 0; font-size: 0.8em; color: var(--text-dim); transition: opacity 0.12s; }
.cm-host .cm-gutters:hover .cm-jl-fold-marker,
.cm-host .cm-jl-fold-marker.closed { opacity: 0.9; }
.cm-host .cm-jl-fold-marker:hover { color: var(--accent); }
.cm-host .cm-jl-fold-placeholder {
margin: 0 4px;
padding: 0 6px;
border: 1px solid var(--border);
border-radius: 4px;
background: var(--bg-hover);
color: var(--text-dim);
cursor: pointer;
}
/* Status bar + outline */
.cm-host .cm-panels-bottom {
background: var(--statusbar-bg, var(--bg-side));
border-top: 1px solid var(--border);
color: var(--text-dim);
}
.cm-host .cm-jl-status {
position: relative;
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
padding: 2px 12px;
font-family: system-ui, sans-serif;
font-size: 12px;
line-height: 22px;
}
.cm-host .cm-jl-stat { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.cm-host .cm-jl-outline-btn {
font: inherit;
color: var(--text-dim);
background: transparent;
border: 1px solid var(--border);
border-radius: 5px;
padding: 0 8px;
line-height: 18px;
cursor: pointer;
flex: 0 0 auto;
}
.cm-host .cm-jl-outline-btn:hover { color: var(--text); border-color: var(--accent); }
.cm-host .cm-jl-outline {
position: absolute;
right: 8px;
bottom: 100%;
min-width: 220px;
max-width: min(420px, 90vw);
max-height: 50vh;
overflow-y: auto;
margin-bottom: 4px;
padding: 4px 0;
background: var(--bg-side);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 8px 24px var(--shadow, rgba(0,0,0,0.3));
z-index: 20;
}
.cm-host .cm-jl-outline[hidden] { display: none; }
.cm-host .cm-jl-outline-item {
display: block;
width: 100%;
text-align: left;
font: inherit;
line-height: 1.5;
padding: 3px 10px;
color: var(--text);
background: transparent;
border: 0;
cursor: pointer;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
}
.cm-host .cm-jl-outline-item:hover { background: var(--bg-hover); }
.cm-host .cm-jl-outline-empty { padding: 6px 12px; color: var(--text-dim); }
/* Link hover info */
.cm-tooltip.cm-tooltip-hover {
background: var(--bg-side);
color: var(--text);
border: 1px solid var(--border);
border-radius: 8px;
box-shadow: 0 6px 20px var(--shadow, rgba(0,0,0,0.3));
}
.cm-tooltip.cm-tooltip-hover .cm-jl-tip {
padding: 6px 10px;
font-family: system-ui, sans-serif;
font-size: 13px;
max-width: 340px;
}
.cm-jl-tip-title { color: var(--text); font-weight: 600; word-break: break-word; }
.cm-jl-tip-sub { color: var(--text-dim); font-size: 12px; margin-top: 2px; }
.note-autocomplete-popup,
.cm-tooltip-autocomplete {
border: 1px solid var(--border);
@ -2542,12 +2772,25 @@ select.login-input option {
display: grid;
gap: 8px;
color: var(--text);
/* Grid cells stretch to the tallest cell in their row; without this the
rows inside a field stretch too and a <select> balloons to fill it. */
align-content: start;
}
.settings-checkbox {
align-content: start;
}
/* A field that needs the whole row (e.g. a group of checkboxes). */
.settings-field-wide {
grid-column: 1 / -1;
}
.settings-checkbox-group {
display: flex;
flex-wrap: wrap;
gap: 8px 28px;
}
.settings-actions {
display: flex;
justify-content: flex-end;

View file

@ -344,7 +344,7 @@ test('CM6 mounts into #cm-host and syncs both ways with #note-body', () => {
const w = dom.window;
w.eval(cmBundle);
// Minimal globals that initCM references (autocomplete/prose/ring-buffer are stubbed).
w.eval('var _cmView=null,_highlightActiveLine=false,_ringBufFedFromBeforeinput=false;');
w.eval('var _cmView=null,_highlightActiveLine=false,_ringBufFedFromBeforeinput=false,_mdFolding=false;function _cmExtraExtensions(){return []}');
w.eval('function _ringBufAccepts(){return false}function _feedRingBuffer(){}function _resetRingBuffer(){}');
w.eval('function maybeTriggerManualProseFromCM(){}function requestManualProseCompletion(){return true}');
w.eval('function manualProseCompletionSource(){return null}function noteCompletionSource(){return null}');
@ -563,3 +563,99 @@ test('rendered mode: does not stack a blank line next to an existing blank-line
`should not add a leading blank line next to an existing one, got: ${html}`);
});
// ---------------------------------------------------------------------------
// Clipboard paste of images/files in markdown mode. CM6's own paste handler
// only reads text/plain, so a screenshot / "Copy image" paste used to do
// nothing at all. Real CM6 bundle + real initCM; only the network is mocked.
// ---------------------------------------------------------------------------
function mountCmForPaste(initialDoc) {
const { editorFragment } = require('../app/templates');
const cmBundle = fs.readFileSync(path.join(__dirname, '../public/codemirror.min.js'), 'utf8');
const frag = editorFragment(
{ id: 'n1', title: 'T', body: initialDoc, parentId: 'f1', deletedTime: 0, createdTime: 1, updatedTime: 2 },
[{ id: 'f1', title: 'F' }],
);
const dom = new JSDOM('<!DOCTYPE html><body><div id="editor-panel">' + frag + '</div></body>', {
runScripts: 'outside-only', url: 'https://joplock.test', pretendToBeVisual: true,
});
const w = dom.window;
w.eval(cmBundle);
w.eval('var _cmView=null,_highlightActiveLine=false,_ringBufFedFromBeforeinput=false,_mdFolding=false;function _cmExtraExtensions(){return []}');
w.eval('function _ringBufAccepts(){return false}function _feedRingBuffer(){}function _resetRingBuffer(){}');
w.eval('function maybeTriggerManualProseFromCM(){}function requestManualProseCompletion(){return true}');
w.eval('function manualProseCompletionSource(){return null}function noteCompletionSource(){return null}');
w.eval('function activeEditorForm(){return document.getElementById("note-editor-form")}');
w.eval('function queryActiveEditor(sel){var f=activeEditorForm();return f&&f.querySelector?f.querySelector(sel):null}');
w.eval('function getTA(){return queryActiveEditor("#note-body")}');
w.eval('var _edited=0,_saved=0;function markEdited(){_edited++}function scheduleSave(){_saved++}');
w.eval('var _uploads=[];function fetch(){_uploads.push(1);return Promise.resolve({json:function(){return Promise.resolve({resourceId:"' + RID + '",markdown:"![image.png](:/' + RID + ')"})}})}');
for (const fn of [
'_cmNormalizeLanguageSupport', '_cmLanguageDescription', 'getCM', 'cmSyncToTA', 'cmSetVal',
'_maxUploadBytes', '_fileTooLarge', '_tooLargeMsg', '_clipboardFilesToUpload', '_uploadFileToCM',
'initCM', 'mountMarkdownEditor',
]) w.eval(extractFn(fn));
w.eval('mountMarkdownEditor(getTA().value)');
return w;
}
function pasteInto(w, { files = [], text = '', html = '' }) {
const ev = new w.Event('paste', { bubbles: true, cancelable: true });
const items = files.map(f => ({ kind: 'file', type: f.type, getAsFile: () => f }));
Object.defineProperty(ev, 'clipboardData', {
value: {
items,
files,
getData: t => (t === 'text/plain' ? text : t === 'text/html' ? html : ''),
},
});
w.document.querySelector('#cm-host .cm-content').dispatchEvent(ev);
return ev;
}
const tick = () => new Promise(r => setTimeout(r, 20));
test('markdown mode: pasting an image-only clipboard uploads it and inserts markdown', async () => {
const w = mountCmForPaste('hello');
const png = new w.File([new Uint8Array([1, 2, 3])], 'image.png', { type: 'image/png' });
const ev = pasteInto(w, { files: [png] });
assert.equal(ev.defaultPrevented, true, 'paste must be handled (default prevented)');
await tick();
assert.equal(w.eval('_uploads.length'), 1, 'image must be uploaded');
assert.match(w.eval('getCM().state.doc.toString()'), new RegExp('!\\[image\\.png\\]\\(:/' + RID + '\\)'));
assert.equal(w.eval('getTA().value'), w.eval('getCM().state.doc.toString()'), 'textarea mirrors the doc');
assert.equal(w.eval('_edited'), 1, 'markEdited must fire so the note saves');
assert.equal(w.eval('_saved'), 1, 'scheduleSave must fire');
try { w.eval('getCM().destroy()'); } catch (_e) { /* ignore */ }
});
test('markdown mode: pasting a non-image file also uploads it', async () => {
const w = mountCmForPaste('');
const pdf = new w.File([new Uint8Array([1])], 'a.pdf', { type: 'application/pdf' });
pasteInto(w, { files: [pdf] });
await tick();
assert.equal(w.eval('_uploads.length'), 1);
try { w.eval('getCM().destroy()'); } catch (_e) { /* ignore */ }
});
test('markdown mode: plain-text paste is untouched (no upload, not intercepted)', async () => {
const w = mountCmForPaste('hello');
pasteInto(w, { text: 'just text' });
await tick();
// (CM's own handler preventDefaults text pastes itself, so defaultPrevented
// isn't a useful signal here — assert that OUR upload path did not run.)
assert.equal(w.eval('_uploads.length'), 0, 'text paste must not upload');
assert.equal(w.eval('_edited'), 0, 'our paste handler must not mark the note edited');
try { w.eval('getCM().destroy()'); } catch (_e) { /* ignore */ }
});
test('markdown mode: spreadsheet-style paste (text + html + preview bitmap) keeps the text', async () => {
const w = mountCmForPaste('hello');
const png = new w.File([new Uint8Array([1])], 'image.png', { type: 'image/png' });
pasteInto(w, { files: [png], text: 'a\tb', html: '<table></table>' });
await tick();
assert.equal(w.eval('_uploads.length'), 0, 'rich text copy must not upload its preview image');
assert.equal(w.eval('_edited'), 0);
try { w.eval('getCM().destroy()'); } catch (_e) { /* ignore */ }
});

485
tests/cmExtras.test.js Normal file
View file

@ -0,0 +1,485 @@
'use strict';
// Unit + jsdom tests for public/cm-extras.js (markdown-mode CodeMirror extras).
// The pure helpers run in plain Node; the extension tests mount the REAL CM6
// bundle in jsdom (no layout engine, so a few DOM measuring APIs are stubbed).
const test = require('node:test');
const assert = require('node:assert/strict');
const fs = require('node:fs');
const path = require('node:path');
const { JSDOM } = require('jsdom');
const extras = require('../public/cm-extras.js');
const cmBundle = fs.readFileSync(path.join(__dirname, '../public/codemirror.min.js'), 'utf8');
const extrasSrc = fs.readFileSync(path.join(__dirname, '../public/cm-extras.js'), 'utf8');
const ID = 'a'.repeat(32);
const ID2 = 'b'.repeat(32);
const tick = (ms = 20) => new Promise(r => setTimeout(r, ms));
// ---------------------------------------------------------------------------
// Pure helpers
// ---------------------------------------------------------------------------
test('computeFences: fenced blocks, tildes, unclosed fences, inline ``` is not a fence', () => {
const lines = ['a', '```js', 'b', '```', 'c', '~~~', 'd', '', '```inline``` x', 'e'];
const { inFence, fences } = extras.computeFences(lines);
assert.deepEqual(inFence, [false, true, true, true, false, true, true, true, true, true].map((v, i) => (i === 8 ? true : v)));
assert.deepEqual(fences.map(f => [f.start, f.end, f.closed, f.lang]), [[1, 3, true, 'js'], [5, 9, false, '']]);
// A one-line ```code``` span is NOT a fence.
const solo = extras.computeFences(['x', '```inline``` y', 'z']);
assert.deepEqual(solo.inFence, [false, false, false]);
});
test('findImageRefs: markdown + <img> forms, alt unescaping, code spans ignored', () => {
const line = 'hi ![al\\]t]( :/' + ID + ' "t") and <img src=":/' + ID2 + '" alt="x &quot;y" width="300" /> `![c](:/' + ID + ')` ![ext](https://x/y.png)';
const refs = extras.findImageRefs(line);
assert.equal(refs.length, 2);
assert.deepEqual([refs[0].kind, refs[0].id, refs[0].alt, refs[0].width], ['md', ID, 'al]t', 0]);
assert.deepEqual([refs[1].kind, refs[1].id, refs[1].alt, refs[1].width], ['html', ID2, 'x "y', 300]);
assert.equal(line.slice(refs[1].from, refs[1].to).startsWith('<img'), true);
});
test('buildImageSource round-trips through findImageRefs and matches the Turndown <img> shape', () => {
const plain = extras.buildImageSource({ id: ID, alt: 'a]b' }, 0);
assert.equal(plain, '![a\\]b](:/' + ID + ')');
assert.equal(extras.findImageRefs(plain)[0].alt, 'a]b');
const sized = extras.buildImageSource({ id: ID, alt: 'say "hi" & <b>' }, 123.4);
assert.equal(sized, '<img src=":/' + ID + '" alt="say &quot;hi&quot; &amp; &lt;b&gt;" width="123" />');
const back = extras.findImageRefs(sized)[0];
assert.deepEqual([back.alt, back.width, back.id], ['say "hi" & <b>', 123, ID]);
});
test('findLinkRefs: adjacent links, ignores images / escaped / nested-image labels / code', () => {
const line = '[a](:/' + ID + ')[b](:/' + ID2 + ') ![i](:/' + ID + ') \\[no](:/' + ID + ') [![x](:/' + ID + ')](:/' + ID2 + ') `[c](:/' + ID + ')`';
assert.deepEqual(extras.findLinkRefs(line).map(l => [l.text, l.id]), [['a', ID], ['b', ID2]]);
});
test('findTaskMarker', () => {
assert.deepEqual(extras.findTaskMarker(' - [x] done'), { from: 4, to: 7, checked: true });
assert.deepEqual(extras.findTaskMarker('1. [ ] a'), { from: 3, to: 6, checked: false });
assert.deepEqual(extras.findTaskMarker('> - [X] q'), { from: 4, to: 7, checked: true });
assert.equal(extras.findTaskMarker('- [ ]nospace'), null);
assert.equal(extras.findTaskMarker('text [ ] x'), null);
});
test('scanDoc: never scans inside fenced code; collects headings with absolute offsets', () => {
const text = '# Title\n\n```js\n![x](:/' + ID + ')\n- [ ] no\n[l](:/' + ID + ')\n```\n- [ ] yes\n![p](:/' + ID + ')\n## Sub ##\n';
const m = extras.scanDoc(text);
assert.equal(m.images.length, 1);
assert.equal(text.slice(m.images[0].from, m.images[0].to), '![p](:/' + ID + ')');
assert.equal(m.links.length, 0);
assert.equal(m.tasks.length, 1);
assert.equal(text.slice(m.tasks[0].from, m.tasks[0].to), '[ ]');
assert.deepEqual(m.headings.map(h => [h.level, h.text]), [[1, 'Title'], [2, 'Sub']]);
assert.equal(text.slice(m.headings[1].from, m.headings[1].from + 2), '##');
assert.deepEqual(m.fences.map(f => [f.start, f.end]), [[2, 6]]);
});
test('countWords / readingMinutes', () => {
assert.equal(extras.countWords(''), 0);
assert.equal(extras.countWords("Hello world! It's a [link](http://x.y) ![i](:/" + ID + ') done.'), 6);
assert.equal(extras.countWords('- [ ] first\n- [x] second'), 2);
assert.equal(extras.readingMinutes(0), 0);
assert.equal(extras.readingMinutes(10), 1);
assert.equal(extras.readingMinutes(660), 3);
});
test('tables: block detection, row splitting, alignment + caret offsets', () => {
const lines = ['text', '| a | b |', '|:-|-:|', '| 1 | 2 |', '', '| x |'];
const get = i => lines[i];
assert.deepEqual(extras.tableBlockAt(get, lines.length, 3, null), { start: 1, end: 3 });
assert.equal(extras.tableBlockAt(get, lines.length, 0, null), null);
assert.equal(extras.tableBlockAt(get, lines.length, 5, null), null, 'a single row without a delimiter row is not a table');
assert.equal(extras.tableBlockAt(get, lines.length, 3, [false, true, true, true, false, false]), null, 'tables inside fences are ignored');
assert.deepEqual(extras.splitRow('| a \\| b | `c` | |'), ['a \\| b', '`c`', '']);
const res = extras.formatTable(['| name | qty |', '|:--|--:|', '| apple | 3 |', '| fig | 12 |']);
assert.deepEqual(res.lines, ['| name | qty |', '| :---- | --: |', '| apple | 3 |', '| fig | 12 |']);
// caret offsets: end of content in each cell
assert.equal(res.lines[2].slice(0, res.cells[2][1].end), '| apple | 3');
assert.equal(res.lines[3].slice(0, res.cells[3][0].end), '| fig');
const centered = extras.formatTable(['| a |', '|:-:|', '| bb |']);
assert.deepEqual(centered.lines, ['| a |', '| :-: |', '| bb |'].map((l, i) => (i === 0 ? '| a |' : l)));
assert.equal(extras.cellIndexAt('| a | b | c |', 3), 0);
assert.equal(extras.cellIndexAt('| a | b | c |', 6), 1);
assert.equal(extras.cellIndexAt('| a | b | c |', 10), 2);
assert.equal(extras.emptyRowFor(3), '| | | |');
});
test('paste helpers: single URL, HTML worth converting', () => {
assert.equal(extras.isSingleUrl('https://a.b/c?d=1'), true);
assert.equal(extras.isSingleUrl(' mailto:a@b.c '), true);
assert.equal(extras.isSingleUrl('see https://a.b'), false);
assert.equal(extras.isSingleUrl('ftp://a.b'), false);
assert.equal(extras.shouldConvertHtml('<span style="color:red">a</span>'), false);
assert.equal(extras.shouldConvertHtml('<div>plain div</div>'), false);
assert.equal(extras.shouldConvertHtml('<h1>x</h1>'), true);
assert.equal(extras.shouldConvertHtml('<p>see <a href="https://x">this</a></p>'), true);
assert.equal(extras.shouldConvertHtml('<b>x</b>', ['text/html', 'vscode-editor-data']), false, 'VS Code copies stay plain');
});
test('cm-extras.js has no regex lookbehind (breaks old iOS Safari at parse time)', () => {
assert.equal(/\(\?<[!=]/.test(extrasSrc), false);
});
// ---------------------------------------------------------------------------
// jsdom harness with the real CM6 bundle
// ---------------------------------------------------------------------------
function mount(doc, opts, hooks, pre) {
const dom = new JSDOM('<!DOCTYPE html><body><div id="host"></div></body>', {
runScripts: 'outside-only', url: 'https://joplock.test', pretendToBeVisual: true,
});
const w = dom.window;
// jsdom has no layout: give CM's measuring code something to chew on.
w.Range.prototype.getClientRects = () => [];
w.Range.prototype.getBoundingClientRect = () => ({ top: 0, bottom: 0, left: 0, right: 0, width: 0, height: 0 });
if (pre) pre(w);
w.eval(cmBundle);
w.eval(extrasSrc);
const C = w.CM;
const exts = w.JoplockMd.createExtensions(C, hooks || {}, opts || {});
const view = new C.EditorView({
state: C.EditorState.create({ doc, extensions: [C.markdown({ base: C.markdownLanguage }), C.history(), C.keymap.of(C.defaultKeymap), ...exts] }),
parent: w.document.getElementById('host'),
});
const focus = on => Object.defineProperty(view, 'hasFocus', { get: () => on, configurable: true });
return { w, C, view, focus, text: () => view.dom.querySelector('.cm-content').textContent, q: sel => view.dom.querySelectorAll(sel) };
}
const ALL = { inlineWidgets: true, livePreview: false, statusBar: false, folding: false };
const doc = h => h.view.state.doc.toString();
test('image preview: one row per image line, none inside code fences, none when the setting is off', () => {
const body = '![a](:/' + ID + ')\n\ntext <img src=":/' + ID2 + '" width="50" /> ![b](:/' + ID + ')\n\n```\n![no](:/' + ID + ')\n```\n';
const h = mount(body, ALL);
const rows = h.q('.cm-jl-imgrow');
assert.equal(rows.length, 2);
assert.equal(rows[0].querySelectorAll('.cm-jl-img').length, 1);
assert.equal(rows[1].querySelectorAll('.cm-jl-img').length, 2, 'two images on one line share one widget row');
assert.equal(h.q('.cm-jl-img img')[1].style.width, '50px', 'explicit <img width> is honoured');
h.view.destroy();
const off = mount(body, { inlineWidgets: false });
assert.equal(off.q('.cm-jl-imgrow').length, 0);
assert.equal(off.q('.cm-jl-chip').length, 0);
off.view.destroy();
});
test('image preview loads through the blob hook and falls back to an error placeholder', async () => {
const blobs = [];
const h = mount('![a](:/' + ID + ')\n![b](:/' + ID2 + ')\n', ALL, {
fetchResourceBlob: id => (id === ID ? Promise.resolve({ fake: 'blob' }) : Promise.reject(new Error('404'))),
}, w => {
w.URL.createObjectURL = blob => { blobs.push(blob); return 'blob:test/1'; };
w.URL.revokeObjectURL = () => {};
});
await tick(30);
const figs = h.q('.cm-jl-img');
assert.equal(figs.length, 2);
assert.equal(figs[0].querySelector('img').getAttribute('src'), 'blob:test/1');
assert.equal(blobs.length, 1);
assert.equal(figs[1].classList.contains('broken'), true);
assert.match(figs[1].textContent, /unavailable/);
h.view.destroy();
});
test('image blobs are fetched once per resource and reused when the widget is rebuilt', async () => {
let fetches = 0;
const h = mount('![a](:/' + ID + ')\n\n![a](:/' + ID + ')\n', ALL, {
fetchResourceBlob: () => { fetches++; return Promise.resolve({}); },
}, w => { w.URL.createObjectURL = () => 'blob:x'; w.URL.revokeObjectURL = () => {}; });
await tick(30);
assert.equal(h.q('.cm-jl-img img').length, 2);
assert.equal(fetches, 1, 'same resource shown twice must hit the network once');
h.view.destroy();
});
test('resize drag rewrites the source to <img width>, even after lines were inserted above; double-click resets', async () => {
const h = mount('![pic](:/' + ID + ')\n', ALL);
await tick(10);
// Shift the widget down: positions captured at creation are now stale.
h.view.dispatch({ changes: { from: 0, insert: 'line one\nline two\n' } });
await tick(10);
const handle = h.q('.cm-jl-img-handle')[0];
const fire = (type, x) => handle.dispatchEvent(new h.w.MouseEvent(type, { bubbles: true, cancelable: true, clientX: x }));
fire('pointerdown', 100); fire('pointermove', 260); fire('pointerup', 260);
assert.equal(doc(h), 'line one\nline two\n<img src=":/' + ID + '" alt="pic" width="360" />\n');
await tick(10);
assert.equal(h.q('.cm-jl-img img')[0].style.width, '360px');
h.q('.cm-jl-img-handle')[0].dispatchEvent(new h.w.MouseEvent('dblclick', { bubbles: true, cancelable: true }));
assert.equal(doc(h), 'line one\nline two\n![pic](:/' + ID + ')\n');
// Resizing is a single undoable change.
h.view.destroy();
});
test('resize and checkbox clicks are ignored while the editor is read-only', () => {
const h = mount('![pic](:/' + ID + ')\n- [ ] todo\n', ALL);
h.view.contentDOM.setAttribute('contenteditable', 'false'); // what _applyFormReadonly does
const before = doc(h);
const handle = h.q('.cm-jl-img-handle')[0];
['pointerdown', 'pointermove', 'pointerup'].forEach((t, i) => handle.dispatchEvent(new h.w.MouseEvent(t, { bubbles: true, cancelable: true, clientX: 100 + i * 100 })));
h.q('.cm-jl-check')[0].dispatchEvent(new h.w.MouseEvent('mousedown', { bubbles: true, cancelable: true }));
assert.equal(doc(h), before);
h.view.destroy();
});
test('task checkboxes: render, toggle, strike done items, undo restores', () => {
const h = mount('- [ ] one\n- [x] two\n* [X] three\n1. [ ] four\n', ALL);
assert.equal(h.q('.cm-jl-check').length, 4);
assert.equal(h.q('.cm-jl-check.checked').length, 2);
assert.equal(h.q('.cm-jl-task-done').length, 2);
h.q('.cm-jl-check')[0].dispatchEvent(new h.w.MouseEvent('mousedown', { bubbles: true, cancelable: true }));
assert.equal(doc(h).split('\n')[0], '- [x] one');
h.q('.cm-jl-check')[1].dispatchEvent(new h.w.MouseEvent('mousedown', { bubbles: true, cancelable: true }));
assert.equal(doc(h).split('\n')[1], '- [ ] two');
h.C.EditorView && h.view.dispatch({ userEvent: 'noop' });
h.view.destroy();
});
test('chips: render for [text](:/id), click opens note vs attachment by resolved kind, caret reveals raw text', async () => {
const opened = [];
const h = mount('see [spec.pdf](:/' + ID + ') and [Other note](:/' + ID2 + ') end\n', ALL, {
resolveLinkInfo: id => Promise.resolve(id === ID ? { kind: 'resource', filename: 'spec.pdf' } : { kind: 'note', title: 'Other note' }),
openResource: id => opened.push(['resource', id]),
openNote: id => opened.push(['note', id]),
});
const chips = h.q('.cm-jl-chip');
assert.equal(chips.length, 2);
assert.equal(h.text().includes('](:/'), false, 'raw link syntax is hidden');
await tick(20);
assert.deepEqual([...h.q('.cm-jl-chip')].map(c => c.getAttribute('data-kind')), ['resource', 'note']);
h.q('.cm-jl-chip')[0].dispatchEvent(new h.w.MouseEvent('click', { bubbles: true, cancelable: true }));
h.q('.cm-jl-chip')[1].dispatchEvent(new h.w.MouseEvent('click', { bubbles: true, cancelable: true }));
await tick(20);
assert.deepEqual(opened, [['resource', ID], ['note', ID2]]);
// Caret inside the first link (editor focused) shows the raw markdown for that link only.
h.focus(true);
h.view.dispatch({ selection: { anchor: doc(h).indexOf('spec.pdf') + 2 } });
assert.equal(h.q('.cm-jl-chip').length, 1);
assert.ok(h.text().includes('[spec.pdf](:/' + ID + ')'));
h.view.destroy();
});
test('code fences: line decorations + Copy button copies only the code', () => {
const copied = [];
const h = mount('intro\n\n```js\nconst a = 1;\n indented();\n```\n\n```\nsecond\n```\n', ALL, { copyText: (t, cb) => { copied.push(t); cb(true); } });
assert.equal(h.q('.cm-jl-codeline').length, 7);
assert.equal(h.q('.cm-jl-codeline-first').length, 2);
assert.equal(h.q('.cm-jl-codeline-last').length, 2);
const btns = h.q('.cm-jl-copy');
assert.equal(btns.length, 2);
btns[0].dispatchEvent(new h.w.MouseEvent('click', { bubbles: true, cancelable: true }));
btns[1].dispatchEvent(new h.w.MouseEvent('click', { bubbles: true, cancelable: true }));
assert.deepEqual(copied, ['const a = 1;\n indented();', 'second']);
assert.equal(h.q('.cm-jl-copy')[0].textContent, 'Copied');
h.view.destroy();
});
test('live preview hides marks off the construct and reveals them on it (needs focus)', () => {
const h = mount('# Title\n\nsome **bold** and [t](http://x.y) and `code` ~~gone~~ end\n', { livePreview: true });
assert.equal(h.text(), 'Titlesome bold and t and code gone end');
h.focus(true);
h.view.dispatch({ selection: { anchor: doc(h).indexOf('bold') + 1 } });
assert.ok(h.text().includes('**bold**'));
assert.ok(!h.text().includes('](http'));
h.view.dispatch({ selection: { anchor: doc(h).indexOf('t](') } });
assert.ok(h.text().includes('[t](http://x.y)'));
h.view.dispatch({ selection: { anchor: 2 } });
assert.ok(h.text().startsWith('# Title'));
h.view.destroy();
});
test('live preview leaves internal links to the chips when inline widgets are on', () => {
const h = mount('[f.pdf](:/' + ID + ') and [web](http://a.b)\n', { livePreview: true, inlineWidgets: true });
assert.equal(h.q('.cm-jl-chip').length, 1);
assert.equal(h.text().includes('http://a.b'), false);
h.view.destroy();
});
test('paste: HTML -> markdown, URL over selection -> link, raw inside code, plain-paste shortcut respected', () => {
const h = mount('', {}, { htmlToMarkdown: html => '**' + html.replace(/<[^>]+>/g, '') + '**' });
const paste = o => {
const e = new h.w.Event('paste', { bubbles: true, cancelable: true });
Object.defineProperty(e, 'clipboardData', { value: { types: o.types || [], items: [], files: [], getData: t => (t === 'text/plain' ? (o.text || '') : t === 'text/html' ? (o.html || '') : '') } });
h.view.contentDOM.dispatchEvent(e);
};
const set = (text, from, to) => h.view.dispatch({ changes: { from: 0, to: h.view.state.doc.length, insert: text }, selection: { anchor: from, head: to == null ? from : to } });
set('', 0);
paste({ text: 'Hi', html: '<h1>Hi</h1>' });
assert.equal(doc(h), '**Hi**');
set('see docs here', 4, 8);
paste({ text: 'https://example.com/d' });
assert.equal(doc(h), 'see [docs](https://example.com/d) here');
set('see [x] here', 4, 7);
paste({ text: 'https://e.com' });
assert.equal(doc(h), 'see [\\[x\\]](https://e.com) here', 'brackets in the label are escaped');
set('```\n\n```', 4);
paste({ text: 'raw', html: '<h1>raw</h1>' });
assert.equal(doc(h), '```\nraw\n```');
set('', 0);
paste({ text: 'plain only' });
assert.equal(doc(h), 'plain only');
set('', 0);
paste({ text: 'code', html: '<span style="color:red">code</span>' });
assert.equal(doc(h), 'code', 'unstructured HTML keeps the plain text');
// Ctrl+Shift+V (paste as plain text) bypasses conversion.
set('', 0);
h.view.contentDOM.dispatchEvent(new h.w.KeyboardEvent('keydown', { key: 'V', ctrlKey: true, shiftKey: true, bubbles: true }));
paste({ text: 'Hi', html: '<h1>Hi</h1>' });
assert.equal(doc(h), 'Hi');
h.view.destroy();
});
test('file drops are claimed so CodeMirror does not paste text-file contents into the note', async () => {
const h = mount('start', {});
const file = new h.w.File(['SECRET TEXT FILE BODY'], 'notes.txt', { type: 'text/plain' });
const e = new h.w.Event('drop', { bubbles: true, cancelable: true });
Object.defineProperty(e, 'dataTransfer', { value: { files: [file], types: ['Files'], getData: () => '' } });
h.view.contentDOM.dispatchEvent(e);
await tick(60);
assert.equal(e.defaultPrevented, true);
assert.equal(doc(h), 'start', 'the dropped file text must not be inserted by CM itself');
h.view.destroy();
});
test('table helper: Tab aligns and walks cells, appends a row, Shift-Tab goes back, Enter is left alone', () => {
const h = mount('| a | b |\n|-|-|\n| 1 | 2 |\n', {});
const key = (k, shift) => {
const e = new h.w.KeyboardEvent('keydown', { key: k, keyCode: k === 'Tab' ? 9 : 13, shiftKey: !!shift, bubbles: true, cancelable: true });
h.view.contentDOM.dispatchEvent(e);
return e.defaultPrevented;
};
const rows = () => doc(h).split('\n');
const caretLine = () => h.view.state.doc.lineAt(h.view.state.selection.main.head);
const col = () => h.view.state.selection.main.head - caretLine().from;
h.view.dispatch({ selection: { anchor: doc(h).indexOf('1') + 1 } });
assert.equal(key('Tab'), true);
assert.deepEqual(rows().slice(0, 3), ['| a | b |', '| --- | --- |', '| 1 | 2 |']);
assert.equal(caretLine().text, '| 1 | 2 |');
assert.equal(col(), '| 1 | 2'.length, 'caret at the end of the next cell content');
key('Tab'); // last cell of last row -> new row
assert.equal(rows()[3], '| | |');
assert.equal(caretLine().number, 4);
assert.equal(col(), 2);
key('Tab', true); // back to previous row's last cell
assert.equal(caretLine().number, 3);
key('Tab', true);
key('Tab', true);
key('Tab', true); // header, last cell -> first cell -> stays
assert.equal(caretLine().number, 1);
// Enter is NOT intercepted: typing a table row by row must keep working.
const at = h.view.state.doc.line(3).to;
const before = doc(h);
h.view.dispatch({ selection: { anchor: at } });
key('Enter');
assert.equal(doc(h), before.slice(0, at) + '\n' + before.slice(at), 'Enter is a plain newline, not an inserted table row');
h.view.destroy();
});
test('table helper leaves normal text alone (Tab / Enter fall through)', () => {
const h = mount('plain text\n\n```\n| a | b |\n|-|-|\n| 1 | 2 |\n```\n', {});
const key = k => {
const e = new h.w.KeyboardEvent('keydown', { key: k, keyCode: k === 'Tab' ? 9 : 13, bubbles: true, cancelable: true });
h.view.contentDOM.dispatchEvent(e);
return e.defaultPrevented;
};
h.view.dispatch({ selection: { anchor: 3 } });
assert.equal(key('Tab'), false);
h.view.dispatch({ selection: { anchor: doc(h).indexOf('| 1') + 3 } });
assert.equal(key('Tab'), false, 'tables inside code fences are not touched');
h.view.destroy();
});
test('status bar: word count, reading time and an outline that jumps to headings', async () => {
const h = mount('# One\n\nhello brave new world\n\n## Two\n\nmore\n\n### Three\n', { statusBar: true });
await tick(10);
const status = h.q('.cm-jl-status')[0];
assert.ok(status);
assert.match(status.textContent, /\d+ words/);
assert.match(status.textContent, /Ln 1, Col 1/);
h.view.dispatch({ selection: { anchor: 8, head: 13 } });
await tick(160);
assert.match(h.q('.cm-jl-stat')[0].textContent, /1 selected/);
h.q('.cm-jl-outline-btn')[0].dispatchEvent(new h.w.MouseEvent('click', { bubbles: true }));
const items = [...h.q('.cm-jl-outline-item')];
assert.deepEqual(items.map(i => i.textContent), ['One', 'Two', 'Three']);
assert.deepEqual(items.map(i => i.style.paddingLeft), ['10px', '24px', '38px']);
items[1].dispatchEvent(new h.w.MouseEvent('click', { bubbles: true }));
assert.equal(h.view.state.selection.main.head, doc(h).indexOf('## Two'));
assert.equal(h.q('.cm-jl-outline')[0].hidden, true, 'popup closes after picking a heading');
h.view.destroy();
});
test('folding: gutter is only present when enabled and headings fold', () => {
const on = mount('# One\n\ntext\n\n## Two\n\nmore\n', { folding: true });
assert.ok(on.view.dom.querySelector('.cm-foldGutter'));
const range = on.C.foldable(on.view.state, 0, on.view.state.doc.line(1).to);
assert.ok(range && range.to > range.from);
on.view.destroy();
const off = mount('# One\n\ntext\n', { folding: false });
assert.equal(off.view.dom.querySelector('.cm-foldGutter'), null);
off.view.destroy();
});
test('hover tooltip source exists for internal links and createExtensions tolerates missing hooks', () => {
const h = mount('[a](:/' + ID + ')\n', ALL, undefined);
assert.ok(h.view.dom.querySelector('.cm-jl-chip'));
h.view.destroy();
});
// ---------------------------------------------------------------------------
// Host-app integration
// ---------------------------------------------------------------------------
test('page loads cm-extras.js after CodeMirror and before app.js; settings plumbing exists', () => {
const { layoutPage } = require('../app/templates');
const html = layoutPage({ user: { email: 'u@e.com', fullName: 'U' }, navContent: '', settings: { mdLivePreview: true, mdFolding: false } });
const scripts = [...html.matchAll(/<script src="([^"]+)"/g)].map(m => m[1]);
const cm = scripts.findIndex(s => s.includes('codemirror.min.js'));
const ex = scripts.findIndex(s => s.includes('cm-extras.js'));
const app = scripts.findIndex(s => s.includes('app.js'));
assert.ok(cm !== -1 && ex !== -1 && app !== -1);
assert.ok(cm < ex && ex < app, 'order must be codemirror -> cm-extras -> app');
assert.match(html, /mdLivePreview:true/);
assert.match(html, /mdFolding:false/);
assert.match(html, /mdInlineWidgets:true/);
assert.match(html, /mdStatusBar:true/);
});
test('settings service normalizes the markdown-mode toggles with sane defaults', () => {
const { normalizeSettings } = require('../app/settingsService');
const d = normalizeSettings({});
assert.deepEqual([d.mdInlineWidgets, d.mdLivePreview, d.mdStatusBar, d.mdFolding], [true, false, true, true]);
const o = normalizeSettings({ mdInlineWidgets: '0', mdLivePreview: '1', mdStatusBar: 0, mdFolding: false });
assert.deepEqual([o.mdInlineWidgets, o.mdLivePreview, o.mdStatusBar, o.mdFolding], [false, true, false, false]);
});
test('settings API whitelists the new keys and the settings page renders their checkboxes', () => {
const api = fs.readFileSync(path.join(__dirname, '../app/routes/api.js'), 'utf8');
for (const k of ['mdInlineWidgets', 'mdLivePreview', 'mdStatusBar', 'mdFolding']) assert.ok(api.includes(`'${k}'`), `${k} must be an allowed settings key`);
const settingsTpl = fs.readFileSync(path.join(__dirname, '../app/templates/settings.js'), 'utf8');
for (const id of ['settings-md-inline-widgets', 'settings-md-live-preview', 'settings-md-status-bar', 'settings-md-folding']) assert.ok(settingsTpl.includes(id), `${id} checkbox must exist`);
});
test('app.js wires the extras into initCM and guards against failures', () => {
const app = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
assert.ok(app.includes('..._cmExtraExtensions(C)'), 'initCM must spread the extras into the extension list');
const fnStart = app.indexOf('function _cmExtraExtensions');
const body = app.slice(fnStart, fnStart + 500);
assert.ok(body.includes('try{') && body.includes('catch'), 'extras must never prevent the editor from mounting');
assert.ok(app.includes('function _pasteHtmlToMarkdown'));
assert.ok(app.includes('function _openNoteById'));
});