mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-10-19 10:13:51 +00:00
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  / <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:
parent
699f1f86ea
commit
91a8beffa0
13 changed files with 2456 additions and 36 deletions
|
|
@ -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];
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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')},
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
379
playwright-tests/md-extras.spec.js
Normal file
379
playwright-tests/md-extras.spec.js
Normal 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'] });
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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
1078
public/cm-extras.js
Normal file
File diff suppressed because it is too large
Load diff
46
public/codemirror.min.js
vendored
46
public/codemirror.min.js
vendored
File diff suppressed because one or more lines are too long
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -1766,6 +1766,236 @@ body.editor-markdown-mode .editor-toolbar {
|
|||
font-style: italic;
|
||||
}
|
||||
|
||||
/* ===== Markdown mode extras (public/cm-extras.js) ===== */
|
||||
/* Image previews under `` 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;
|
||||
|
|
|
|||
|
|
@ -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:""})}})}');
|
||||
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
485
tests/cmExtras.test.js
Normal 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 "y" width="300" /> `` ';
|
||||
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 "hi" & <b>" 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 + ')  \\[no](:/' + 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\n- [ ] no\n[l](:/' + ID + ')\n```\n- [ ] yes\n\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), '');
|
||||
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)  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 = '\n\ntext <img src=":/' + ID2 + '" width="50" /> \n\n```\n\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('\n\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('\n\n\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('\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\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('\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'));
|
||||
});
|
||||
Loading…
Add table
Add a link
Reference in a new issue