feat(editor): image/attachment lightbox, blank-line padding, round-trip + save-state fixes

- Double-click an image or viewable attachment in desktop rendered/preview mode
  opens an in-app lightbox (images + pdf + text); Esc/Close dismiss. Desktop
  single-click open removed.
- Dropped/pasted attachments (images AND documents) get a blank, deletable line
  before and after so stacked attachments stay individually removable; rendered
  mode uses the round-trip-safe md-blank-line marker.
- Fix: blank line inside a fenced code block was deleted on markdown<->render
  switches (heading-gap collapse treated a C #include as a heading); now masked
  around fenced code (_applyHeadingSpacing).
- Fix: switching render->markdown no longer marks an unchanged note "Edited"
  (reconcile save state after the content round-trip; genuine pending saves
  preserved).
- Tests never leave data: teardownTestData() purges notebooks + their notes +
  orphaned resources; global-teardown safety net sweeps test-prefixed leftovers;
  GET /api/web/notes/headers now returns parentId. Verified zero DB accumulation
  across repeated runs.
- Bump service worker cache to v43.
- Stale Playwright assertions fixed (#note-body -> .cm-content, custom code
  modal instead of TinyMCE codesample dialog).
This commit is contained in:
igor 2026-07-08 15:01:31 +12:00
parent 598a0333fe
commit 08e42d5227
21 changed files with 1187 additions and 248 deletions

View file

@ -271,7 +271,12 @@ Follow-up (still not done, out of scope):
- The upload modal (`openUploadModal()` → `uploadModalFiles()` → `insertUploadedFiles()`) is the primary picker/drag-drop path; it uploads to `/fragments/upload` and inserts into the live TinyMCE document (or the textarea/CM target when not in rich mode). On success (all files upload, no errors) the modal auto-dismisses; if any file errors it stays open showing per-file errors.
- Drag-and-drop directly onto TinyMCE works via `_uploadFileToTinyMCE()` (inserts `<img data-resource-id>` / `<a data-resource-id>` into the live editor). Markdown-mode drops route through `_uploadFileToCM()` (inserts `![](:/id)` at the CM cursor).
- **Dropped images always get a trailing blank line.** `_uploadFileToTinyMCE()` inserts the image as `<p><img></p><p></p>` (the empty `<p>` serialises to a blank markdown line via the `emptyP` Turndown rule); `_uploadFileToCM()` appends `\n\n` after the image reference. This keeps the caret on a fresh empty line so typed text is never glued to the image. Non-image files do NOT get the extra blank line. Covered by `tests/cm6MarkdownMode.test.js` (insertion contract) + `tests/appRuntime.test.js` (mode-switch round-trip, no mangling).
- **Dropped/pasted attachments (image AND document) are padded with a blank line before and after** so a single attachment stays easy to delete even when several are stacked. This padding is *only* about spacing around the inserted resource — it does not change how surrounding typed text is handled.
- Markdown mode (CM6): `_uploadFileToCM()` inserts `<pad>` + ref + `\n\n`, where `<pad>` is `''` at the very start of the doc, `\n` if the char before the cursor is already a newline, else `\n\n`. Both images and documents get this. (Plain source blank line; no `md-blank-line` marker needed because the user edits raw text here.)
- Rendered mode (TinyMCE): `_tinyMCEBlockAttachmentHtml(editor,inner)` wraps the image/link in its own `<p>` and adds a `<p class="md-blank-line"><br></p>` (the renderer's canonical deletable blank line — see `injectBlankLineBlocks`) before and after, for both images and documents. **Smart**: it skips the leading and/or trailing marker when the caret block is already empty or already adjacent to an existing blank-line paragraph (via `editor.selection.getNode()`), falling back to adding both when the selection API is unavailable (unit tests).
- Round-trip safety (rendered mode): `md-blank-line` paragraphs are pre-normalized in `tinymceToMarkdown()` and matched by the `blankLine`/`emptyP` Turndown rules → `\x00BL\x00` sentinel → `\n\n\n` (one extra newline = one blank line that re-renders as an `md-blank-line` `<p>`). Do NOT switch these separators to bare `<div><br></div>` or plain empty `<p></p>` — those get merged/dropped around block-level images and swallow the spacing after a few round-trips.
- `_buildMarkdownInsert()` (used by the upload-modal picker's textarea/CM targets) is unchanged — it still adds a single `\n` on each side as needed. It is deliberately NOT part of the blank-line padding change.
- Coverage: `tests/cm6MarkdownMode.test.js` (CM padding for image + document; TinyMCE `_tinyMCEBlockAttachmentHtml` markers + smart skip), `tests/previewRoundTrip.test.js` (blank line between stacked image/image, image/doc, doc/image survives render⇄markdown), `tests/appRuntime.test.js` (mode-switch round-trip, no mangling).
- Clipboard paste: images are uploaded by TinyMCE's built-in pipeline (`paste_data_images:true` + `automatic_uploads:true` + `images_upload_handler`); non-image clipboard files are handled by an explicit `editor.on('paste', ...)` handler that routes through `_uploadFileToTinyMCE()`.
- The Image/Media dialogs' Browse button is wired via `file_picker_callback` to `/fragments/upload`, returning a `/resources/<id>` URL.
- All upload paths produce `src="/resources/<id>"` / `href="/resources/<id>"`, which `tinymceToMarkdown()` (the `joplinImg`/`joplinLink` Turndown rules) converts to Joplin `![](:/id)` / `[](:/id)` on save. `data-resource-id` is added by drop/paste/upload-modal paths but is not required for the round-trip (matching is by `src`/`href`).
@ -506,6 +511,17 @@ If a UI action appears broken, check:
- Tests NEVER hardcode credentials. `playwright-tests/helpers.js` resolves the admin account from the environment in this order: `PLAYWRIGHT_ADMIN_EMAIL` → `PLAYWRIGHT_EMAIL` → `JOPLOCK_ADMIN_EMAIL` (and the `*_PASSWORD` equivalents). The dev container sets `JOPLOCK_ADMIN_*`, so the useful tests work against it out of the box.
- `login()` calls `requireCredentials()` and fails loudly if none are set. Admin-only specs (`resource-lifecycle`, `auth-rate-limit`) use `hasAdminCredentials()` to `test.skip` when unset. `helpers.js` exports `ADMIN_EMAIL`/`ADMIN_PASSWORD`/`hasAdminCredentials` so specs share one source of truth.
### Playwright data cleanup (do not leave notes/notebooks/resources behind)
- **Tests must not leave data in the shared Joplin DB.** Every spec that creates notebooks/notes/resources must clean them up.
- Use `teardownTestData(page, { folders, folderPrefixes, titlePrefixes, noteIds })` from `helpers.js` in a `finally` block. It permanently removes the matching notes (trash + purge via `DELETE /fragments/notes/:id` twice), deletes the folders, empties trash, and cleans orphaned resources. It is best-effort (never throws), so it is safe in `finally` even after a failed assertion.
- Prefer `{ folders: [folder] }` — purges every note inside the notebook *then* deletes the notebook. (Plain `deleteNotebook()` is NOT enough on its own: the app moves a deleted notebook's notes to **General** rather than removing them, so they leak.)
- For notes created outside a dedicated notebook (e.g. mobile "New note" in **All Notes**), capture the id with `getActiveNoteId(page)` and pass `{ noteIds: [id] }`.
- A suite-wide safety net runs automatically: `playwright.config.js` `globalTeardown` (`playwright-tests/global-teardown.js`) logs in once after the whole run and purges any leftover test-prefixed folders/notes + orphaned resources (folder name prefixes like `pw-`, `dnd-`, `esc-`, `search-`, `upload-`, `res-lifecycle-`, and known test note-title prefixes). Keep those prefix lists in sync when you add new test naming.
- Cleanup relies on `GET /api/web/notes/headers` returning `parentId` (added for this), `DELETE /api/web/folders/:id`, `DELETE /fragments/notes/:id` (trash then purge), `POST /fragments/trash/empty`, and `POST /admin/orphaned-resources/cleanup`.
- Verify a change doesn't leak by running a data-creating spec twice and confirming the DB item counts are identical before/after (they must be stable, i.e. zero accumulation).
## Development Stack
Use the dev compose stack for all development work. It includes Postgres, Joplin Server, and Joplock together.

View file

@ -320,7 +320,7 @@ const handle = async (url, request, response, ctx) => {
const auth = await authenticatedUser(request);
if (auth.error) { sendJson(response, 401, { error: auth.error }); return true; }
const headers = await itemService.noteHeadersByUserId(auth.user.id);
const minimalHeaders = headers.map(h => ({ id: h.id, title: h.title }));
const minimalHeaders = headers.map(h => ({ id: h.id, title: h.title, parentId: h.parentId }));
sendJson(response, 200, { items: minimalHeaders });
} catch (error) {
sendJson(response, 500, { error: error.message || `${error}` });

View file

@ -15,12 +15,14 @@
const { test, expect } = require('@playwright/test');
const {
login,
logout,
createNotebook,
createDesktopNote,
setNoteTitle,
acceptDialogs,
hasAdminCredentials,
slug,
teardownTestData,
} = require('./helpers');
const IFRAME = 'iframe.tox-edit-area__iframe';
@ -56,44 +58,49 @@ test.describe('AI prose completion (rendered / TinyMCE mode)', () => {
if (!(await aiConfigured(page))) test.skip(true, 'no keyed AI profile configured');
const folderName = slug('pw-ai-folder');
await createNotebook(page, folderName);
await createDesktopNote(page, folderName);
await setNoteTitle(page, slug('pw ai note'));
try {
await createNotebook(page, folderName);
await createDesktopNote(page, folderName);
await setNoteTitle(page, slug('pw ai note'));
await page.locator('#editor-panel #preview-toggle').click();
const body = page.frameLocator(IFRAME).locator('body');
await expect(page.locator(IFRAME)).toBeVisible({ timeout: 15000 });
await page.locator('#editor-panel #preview-toggle').click();
const body = page.frameLocator(IFRAME).locator('body');
await expect(page.locator(IFRAME)).toBeVisible({ timeout: 15000 });
await body.click();
await page.keyboard.type(PROMPT);
const seeded = (await body.innerText()).length;
await body.click();
await page.keyboard.type(PROMPT);
const seeded = (await body.innerText()).length;
// Ctrl-Space -> popup appears (provider round-trip).
await page.keyboard.press('Control+Space');
await expect(page.locator(POPUP)).toBeVisible({ timeout: 60000 });
// Nothing inserted yet.
expect((await body.innerText()).length).toBeLessThanOrEqual(seeded + 1);
// Ctrl-Space -> popup appears (provider round-trip).
await page.keyboard.press('Control+Space');
await expect(page.locator(POPUP)).toBeVisible({ timeout: 60000 });
// Nothing inserted yet.
expect((await body.innerText()).length).toBeLessThanOrEqual(seeded + 1);
// Esc discards the suggestion.
await page.keyboard.press('Escape');
await expect(page.locator(POPUP)).toBeHidden();
expect((await body.innerText()).length).toBeLessThanOrEqual(seeded + 1);
// Esc discards the suggestion.
await page.keyboard.press('Escape');
await expect(page.locator(POPUP)).toBeHidden();
expect((await body.innerText()).length).toBeLessThanOrEqual(seeded + 1);
// Request again and accept with Enter.
await body.click();
await page.keyboard.press('End');
await page.keyboard.press('Control+Space');
await expect(page.locator(POPUP)).toBeVisible({ timeout: 60000 });
await page.keyboard.press('Enter');
await expect(page.locator(POPUP)).toBeHidden();
await expect
.poll(async () => (await body.innerText()).length, { timeout: 10000 })
.toBeGreaterThan(seeded + 3);
expect(await body.innerText()).toContain('quick brown fox');
// Request again and accept with Enter.
await body.click();
await page.keyboard.press('End');
await page.keyboard.press('Control+Space');
await expect(page.locator(POPUP)).toBeVisible({ timeout: 60000 });
await page.keyboard.press('Enter');
await expect(page.locator(POPUP)).toBeHidden();
await expect
.poll(async () => (await body.innerText()).length, { timeout: 10000 })
.toBeGreaterThan(seeded + 3);
expect(await body.innerText()).toContain('quick brown fox');
// Accepted text synced into the markdown source of truth.
const noteBodyLen = await page.locator('#editor-panel #note-body').evaluate(el => el.value.length);
expect(noteBodyLen).toBeGreaterThan(0);
// Accepted text synced into the markdown source of truth.
const noteBodyLen = await page.locator('#editor-panel #note-body').evaluate(el => el.value.length);
expect(noteBodyLen).toBeGreaterThan(0);
} finally {
await teardownTestData(page, { folders: [folderName] });
await logout(page).catch(() => {});
}
});
test('AI-action Expander trigger shows the popup and inserts on accept', async ({ page }, testInfo) => {
@ -109,27 +116,41 @@ test.describe('AI prose completion (rendered / TinyMCE mode)', () => {
await expect(page.locator('body.app-shell')).toBeVisible();
const folderName = slug('pw-ai-trig-folder');
await createNotebook(page, folderName);
await createDesktopNote(page, folderName);
await setNoteTitle(page, slug('pw ai trigger note'));
try {
await createNotebook(page, folderName);
await createDesktopNote(page, folderName);
await setNoteTitle(page, slug('pw ai trigger note'));
await page.locator('#editor-panel #preview-toggle').click();
const body = page.frameLocator(IFRAME).locator('body');
await expect(page.locator(IFRAME)).toBeVisible({ timeout: 15000 });
await page.locator('#editor-panel #preview-toggle').click();
const body = page.frameLocator(IFRAME).locator('body');
await expect(page.locator(IFRAME)).toBeVisible({ timeout: 15000 });
await body.click();
await page.keyboard.type(PROMPT);
const seeded = (await body.innerText()).length;
await body.click();
await page.keyboard.type(PROMPT);
const seeded = (await body.innerText()).length;
// Typing the trigger fires the AI action on keyup: trigger removed, popup shown.
await page.keyboard.type(';;ai');
await expect(page.locator(POPUP)).toBeVisible({ timeout: 60000 });
await expect.poll(async () => await body.innerText()).not.toContain(';;ai');
// Typing the trigger fires the AI action on keyup: trigger removed, popup shown.
await page.keyboard.type(';;ai');
await expect(page.locator(POPUP)).toBeVisible({ timeout: 60000 });
await expect.poll(async () => await body.innerText()).not.toContain(';;ai');
await page.keyboard.press('Enter');
await expect(page.locator(POPUP)).toBeHidden();
await expect
.poll(async () => (await body.innerText()).length, { timeout: 10000 })
.toBeGreaterThan(seeded);
await page.keyboard.press('Enter');
await expect(page.locator(POPUP)).toBeHidden();
await expect
.poll(async () => (await body.innerText()).length, { timeout: 10000 })
.toBeGreaterThan(seeded);
} finally {
// Reset the AI expander we added, then purge the notebook + resources.
await page.evaluate(async () => {
await fetch('/api/web/settings', {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
credentials: 'same-origin',
body: JSON.stringify({ textExpanders: [] }),
}).catch(() => {});
}).catch(() => {});
await teardownTestData(page, { folders: [folderName] });
await logout(page).catch(() => {});
}
});
});

View file

@ -0,0 +1,93 @@
'use strict';
// Verifies the blank-line padding around inserted attachments in rendered
// (TinyMCE) mode: two images dropped one after another must end up separated
// by a blank line in the saved markdown, so each stays individually deletable.
// Desktop-only. Exercises _uploadFileToTinyMCE / _tinyMCEBlockAttachmentHtml
// (the drag-drop path).
const path = require('path');
const fs = require('fs');
const { test, expect } = require('@playwright/test');
const {
acceptDialogs,
createDesktopNote,
createNotebook,
hasAdminCredentials,
login,
logout,
setNoteBody,
setNoteTitle,
teardownTestData,
waitForSaved,
} = require('./helpers');
const TEST_IMAGE = path.resolve(__dirname, '..', 'public', 'icon-192.png');
const IMG_B64 = fs.readFileSync(TEST_IMAGE).toString('base64');
// Drop an image file onto the TinyMCE iframe body (the drag-drop upload path).
async function dropImageOnTinyMCE(page, name) {
await page.evaluate(async ({ name, data }) => {
const bytes = Uint8Array.from(atob(data), c => c.charCodeAt(0));
const file = new File([bytes], name, { type: 'image/png' });
const dt = new DataTransfer();
dt.items.add(file);
const iframe = document.querySelector('iframe.tox-edit-area__iframe');
const body = iframe.contentDocument.body;
const event = new DragEvent('drop', { dataTransfer: dt, bubbles: true, cancelable: true });
body.dispatchEvent(event);
}, { name, data: IMG_B64 });
await page.waitForTimeout(2500);
}
test.describe('Attachment blank-line padding (rendered mode)', () => {
test.beforeEach(({ page }) => acceptDialogs(page));
test('two images dropped in rendered mode are separated by a blank line in markdown', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
test.skip(!hasAdminCredentials(), 'Set JOPLOCK_ADMIN_EMAIL/JOPLOCK_ADMIN_PASSWORD (or PLAYWRIGHT_ADMIN_*) for this test');
const folder = `pw-padding-${Date.now()}`;
const noteTitle = `Padding note ${Date.now()}`;
try {
await login(page);
await createNotebook(page, folder);
await createDesktopNote(page, folder);
await setNoteTitle(page, noteTitle);
// Seed body, then switch to rich (TinyMCE) mode.
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setNoteBody(page, 'Intro text.');
await waitForSaved(page);
await page.locator('#editor-panel #preview-toggle').click();
await expect(page.locator('iframe.tox-edit-area__iframe')).toBeVisible({ timeout: 15000 });
// Drop two images back to back.
await dropImageOnTinyMCE(page, 'one.png');
await dropImageOnTinyMCE(page, 'two.png');
// Force a save round-trip and read the markdown body.
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await page.waitForTimeout(500);
const body = await page.locator('#editor-panel #note-body').inputValue();
// Two resource refs present.
const refs = body.match(/!\[[^\]]*\]\(:\/[0-9a-fA-F]{32}\)/g) || [];
expect(refs.length, `expected two image refs, body was:\n${body}`).toBeGreaterThanOrEqual(2);
// A blank line (>=1 empty line == a deletable md-blank-line paragraph on
// re-render) must separate the two images — they are not glued together.
const between = body.slice(
body.indexOf(refs[0]) + refs[0].length,
body.lastIndexOf(refs[1]),
);
expect(/\n[ \t]*\n/.test(between), `images must be separated by a blank line, got between: ${JSON.stringify(between)}`).toBe(true);
} finally {
await teardownTestData(page, { folders: [folder] });
await logout(page).catch(() => {});
}
});
});

View file

@ -0,0 +1,81 @@
'use strict';
// Regression: a blank line INSIDE a fenced code block must survive switching
// between markdown and rendered (TinyMCE) mode. The heading-gap collapse in
// tinymceToMarkdown() used to treat a C `#include` line as a markdown heading
// and delete the following blank line. Desktop-only.
const { test, expect } = require('@playwright/test');
const {
acceptDialogs,
createDesktopNote,
createNotebook,
hasAdminCredentials,
login,
logout,
setNoteBody,
setNoteTitle,
teardownTestData,
waitForSaved,
} = require('./helpers');
const FENCE = '```';
const CODE_BODY = [
`${FENCE}c`,
'#include <stdio.h>',
'',
'int main() {',
' printf("Hello, World!\\n");',
' return 0;',
'}',
FENCE,
].join('\n');
test.describe('Code block blank-line preservation', () => {
test.beforeEach(({ page }) => acceptDialogs(page));
test('blank line inside a code block survives markdown <-> rendered switches', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
test.skip(!hasAdminCredentials(), 'Set JOPLOCK_ADMIN_EMAIL/JOPLOCK_ADMIN_PASSWORD (or PLAYWRIGHT_ADMIN_*) for this test');
const folder = `pw-codeblank-${Date.now()}`;
try {
await login(page);
await createNotebook(page, folder);
await createDesktopNote(page, folder);
await setNoteTitle(page, `codeblank ${Date.now()}`);
await page.locator('#editor-panel #markdown-toggle').click();
await expect.poll(
async () => page.locator('#editor-panel #note-editor-form').evaluate(f => f.dataset.editorMode || ''),
{ timeout: 15000 },
).toBe('markdown');
await setNoteBody(page, CODE_BODY);
await waitForSaved(page);
const before = await page.locator('#editor-panel #note-body').inputValue();
expect(before, 'sanity: seeded body has the blank line').toMatch(/stdio\.h>\n\s*\n\s*int main/);
// markdown -> rendered (TinyMCE)
await page.locator('#editor-panel #preview-toggle').click();
await expect(page.locator('iframe.tox-edit-area__iframe')).toBeVisible({ timeout: 15000 });
await page.waitForTimeout(1500);
// rendered -> markdown
await page.locator('#editor-panel #markdown-toggle').click();
await expect.poll(
async () => page.locator('#editor-panel #note-editor-form').evaluate(f => f.dataset.editorMode || ''),
{ timeout: 15000 },
).toBe('markdown');
await page.waitForTimeout(500);
const after = await page.locator('#editor-panel #note-body').inputValue();
expect(after, `blank line between #include and int main() must survive. after=${JSON.stringify(after)}`)
.toMatch(/stdio\.h>\n\s*\n\s*int main/);
} finally {
await teardownTestData(page, { folders: [folder] });
await logout(page).catch(() => {});
}
});
});

View file

@ -77,7 +77,7 @@ test.describe('Desktop UI', () => {
await setNoteTitle(page, noteTitle);
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setNoteBody(page, noteBody);
await waitForSaved(page);
await expect(statusMeta).toBeVisible();
@ -130,7 +130,7 @@ test.describe('Desktop UI', () => {
await createDesktopNote(page, folderName);
await setNoteTitle(page, noteTitle);
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setNoteBody(page, USER_NOTE_BODY);
await waitForSaved(page);
const noteIdA = await page.locator('#editor-panel #note-editor-form').evaluate(form => form.dataset.noteId || '');
@ -144,7 +144,7 @@ test.describe('Desktop UI', () => {
await expect(page.frameLocator('iframe.tox-edit-area__iframe').locator('body')).toContainText('second paragraph, seems to');
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await expect(noteBody).toHaveValue(USER_NOTE_BODY);
await page.locator('#editor-panel #preview-toggle').click();
@ -169,7 +169,7 @@ test.describe('Desktop UI', () => {
await createDesktopNote(page, folderName);
await setNoteTitle(page, noteTitleA);
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setNoteBody(page, USER_NOTE_BODY);
await waitForSaved(page);
const noteIdA = await page.locator('#editor-panel #note-editor-form').evaluate(form => form.dataset.noteId || '');
@ -192,7 +192,7 @@ test.describe('Desktop UI', () => {
await createDesktopNote(page, folderName);
await setNoteTitle(page, noteTitleB);
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setNoteBody(page, 'temporary switch note');
await waitForSaved(page);
@ -206,7 +206,7 @@ test.describe('Desktop UI', () => {
expect(afterSwitchLayout).toEqual(beforeSwitchLayout);
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await expect(page.locator('#editor-panel #note-body')).toHaveValue(USER_NOTE_BODY);
await deleteNotebook(page, folderName);
@ -224,7 +224,7 @@ test.describe('Desktop UI', () => {
await createDesktopNote(page, folderName);
await setNoteTitle(page, noteTitle);
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setNoteBody(page, USER_NOTE_BODY);
await waitForSaved(page);
@ -284,7 +284,7 @@ test.describe('Desktop UI', () => {
await createDesktopNote(page, folderName);
await setNoteTitle(page, noteTitle);
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setNoteBody(page, `Before code.\n\n${fence}yaml\n${yamlCode}\n${fence}\n\nAfter code.`);
await waitForSaved(page);
@ -296,19 +296,25 @@ test.describe('Desktop UI', () => {
const codePre = iframeBody.locator('pre.language-yaml').first();
await codePre.click();
const codeDialog = page.locator('.tox-dialog[role="dialog"]').filter({ hasText: /code sample/i }).last();
await expect(codeDialog).toBeVisible({ timeout: 15000 });
await codeDialog.getByRole('combobox', { name: 'Language' }).click();
await page.getByRole('option', { name: /^JavaScript$/ }).click();
await codeDialog.getByRole('textbox', { name: 'Code view' }).fill('const answer = 42;\nconsole.log(answer);');
await codeDialog.getByRole('button', { name: /^Save$/ }).click();
// Rendered-mode <pre> editing opens the custom full-screen CM6 code modal
// (#code-modal), NOT TinyMCE's built-in codesample dialog.
const codeModal = page.locator('#code-modal');
await expect(codeModal).toBeVisible({ timeout: 15000 });
await codeModal.locator('#code-lang').selectOption('javascript');
const codeInput = codeModal.locator('#code-input .cm-content');
await codeInput.click();
await page.keyboard.press('Control+A');
await page.keyboard.press('Delete');
await page.keyboard.type('const answer = 42;\nconsole.log(answer);');
await codeModal.locator('#code-modal-submit').click();
await expect(codeModal).toBeHidden({ timeout: 15000 });
await expect.poll(async () => iframeBody.evaluate(body => !!body.querySelector('pre.language-javascript')), { timeout: 15000 }).toBe(true);
await expect(iframeBody).toContainText('const answer = 42;');
await page.locator('#editor-panel #markdown-toggle').click();
const noteBody = page.locator('#editor-panel #note-body');
await expect(noteBody).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await expect.poll(async () => noteBody.inputValue(), { timeout: 15000 }).toContain('```javascript');
await expect.poll(async () => noteBody.inputValue(), { timeout: 15000 }).toContain('const answer = 42;');
await expect.poll(async () => noteBody.inputValue(), { timeout: 15000 }).not.toContain('```yaml');
@ -488,7 +494,7 @@ test.describe('Desktop UI', () => {
await createDesktopNote(page, folderName);
await setNoteTitle(page, noteTitle);
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setNoteBody(page, 'anchor line');
await waitForSaved(page);
@ -552,7 +558,7 @@ test.describe('Desktop UI', () => {
// And the underlying markdown must reflect that we now have a third
// paragraph on its own line — not just extra <br>s stuck to paragraph 2.
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
const bodyValue = await page.locator('#editor-panel #note-body').inputValue();
const lines = bodyValue.split('\n');
const thirdIdx = lines.findIndex(l => l.trim() === 'third');

View file

@ -0,0 +1,138 @@
'use strict';
// Global safety-net cleanup: after the entire Playwright run, log in once and
// permanently remove any leftover test-created data (notebooks, their notes,
// and orphaned resources) so runs never accumulate data in the shared Joplin
// DB. Per-test teardown keeps things clean mid-run; this guarantees the final
// state is clean even if a test crashed before its own cleanup.
//
// It only touches folders whose names use the test naming conventions
// (slug() prefixes) so it never deletes a real user's notebooks.
const { chromium } = require('@playwright/test');
// Folder-name prefixes used by the test suite (see slug('...') calls), plus a
// few legacy prefixes from earlier/removed test versions so historical cruft in
// the shared dev DB also gets drained.
const TEST_FOLDER_PREFIXES = [
'pw-', 'pw ',
'dnd-',
'esc-',
'search-',
'upload-',
'orig-',
'new-',
'res-lifecycle-',
// legacy / removed-test prefixes
'up-reload-',
'pnb-', 'p3nb-', 'p4nb-', 'p5nb-',
'Findable-', 'Esc-',
];
// Note-title prefixes for notes created outside a dedicated notebook, or left
// orphaned in "General" by earlier incomplete cleanups. Only unambiguous test
// titles are listed so real notes are never touched. (Titles are auto-derived
// from the note body's first line, which is what these strings are.)
const TEST_TITLE_PREFIXES = [
'pw ',
'Anchor line',
'Desktop anchor line',
'Lightbox anchor text.',
'Drop anchor text.',
'Picker anchor text.',
'Resource test anchor text.',
'Intro text.',
'Findable note-',
'Findable-note',
'Esc-note',
'temporary switch note',
'This paragraph contains the zqxmrb',
];
function resolveCredentials() {
const email =
process.env.PLAYWRIGHT_ADMIN_EMAIL ||
process.env.PLAYWRIGHT_EMAIL ||
process.env.JOPLOCK_ADMIN_EMAIL ||
'';
const password =
process.env.PLAYWRIGHT_ADMIN_PASSWORD ||
process.env.PLAYWRIGHT_PASSWORD ||
process.env.JOPLOCK_ADMIN_PASSWORD ||
'';
return { email, password };
}
module.exports = async function globalTeardown(config) {
const { email, password } = resolveCredentials();
if (!email || !password) return; // nothing we can authenticate with — skip.
const baseURL =
(config && config.projects && config.projects[0] && config.projects[0].use && config.projects[0].use.baseURL) ||
process.env.PLAYWRIGHT_BASE_URL ||
'http://127.0.0.1:5445';
const browser = await chromium.launch();
try {
const page = await browser.newPage({ baseURL });
page.setDefaultTimeout(120000);
// Log in.
await page.goto('/login');
await page.getByPlaceholder('Email').fill(email);
await page.locator('#login-password').fill(password);
await page.getByRole('button', { name: 'Login' }).click();
await page.waitForURL(/\/$/, { timeout: 30000 }).catch(() => {});
// Purge test-prefixed folders + their notes + orphaned resources. Runs in
// bounded passes: deleting a folder moves its notes to General and each
// delete round-trips upstream, so a large backlog may need several passes.
// Each pass deletes at most `batch` folders to stay well under timeouts.
for (let pass = 0; pass < 40; pass += 1) {
const remaining = await page.evaluate(async ({ folderPrefixes, titlePrefixes, batch }) => {
const j = async (url, opts) => {
try {
const res = await fetch(url, { credentials: 'same-origin', ...opts });
const text = await res.text();
try { return { status: res.status, data: JSON.parse(text) }; }
catch { return { status: res.status, data: null }; }
} catch { return { status: 0, data: null }; }
};
const folderRes = await j('/api/web/folders');
const allFolders = (folderRes.data && folderRes.data.items) || [];
const testFolders = allFolders.filter(
f => typeof f.title === 'string' && folderPrefixes.some(p => f.title.startsWith(p)),
);
const batchFolders = testFolders.slice(0, batch);
const batchIds = new Set(batchFolders.map(f => f.id));
// Purge notes belonging to the folders in this batch, plus any
// stray notes matching a test title prefix.
const headerRes = await j('/api/web/notes/headers');
const notes = (headerRes.data && headerRes.data.items) || [];
const doomed = new Set();
for (const n of notes) {
if ((n.parentId && batchIds.has(n.parentId)) ||
titlePrefixes.some(p => typeof n.title === 'string' && n.title.startsWith(p))) {
doomed.add(n.id);
}
}
for (const id of doomed) {
await j('/fragments/notes/' + encodeURIComponent(id), { method: 'DELETE' });
await j('/fragments/notes/' + encodeURIComponent(id), { method: 'DELETE' });
}
await j('/fragments/trash/empty', { method: 'POST' });
for (const id of batchIds) {
await j('/api/web/folders/' + encodeURIComponent(id), { method: 'DELETE' });
}
await j('/admin/orphaned-resources/cleanup', { method: 'POST' });
return testFolders.length - batchFolders.length;
}, { folderPrefixes: TEST_FOLDER_PREFIXES, titlePrefixes: TEST_TITLE_PREFIXES, batch: 20 });
if (!remaining) break;
}
} catch {
// best-effort — never fail the run because global teardown hiccuped.
} finally {
await browser.close();
}
};

View file

@ -188,6 +188,88 @@ async function openMobileNote(page, noteTitle) {
await expect(mobileEditor(page)).toBeVisible();
}
// Best-effort id of the note currently open in the editor (desktop or mobile),
// read from the editor form's hx-put target. Returns '' if none.
async function getActiveNoteId(page) {
const form = page.locator('#editor-panel #note-editor-form, #mobile-editor-body #note-editor-form').first();
if (!(await form.count())) return '';
const hxPut = (await form.getAttribute('hx-put')) || '';
const id = hxPut.split('/').pop() || '';
return /^[0-9a-fA-F]{32}$/.test(id) ? id : '';
}
// Permanently remove test-created data so runs never leave notes/notebooks/
// resources in the shared Joplin DB. Best-effort: never throws, safe to call in
// a finally block even after a failed test. Requires an authenticated page.
//
// It deletes:
// - every non-deleted note whose parent is one of `folders` (matched by name)
// OR whose title matches one of `titlePrefixes`; each note is trashed then
// permanently deleted (DELETE /fragments/notes/:id twice).
// - the named `folders` themselves (DELETE /api/web/folders/:id).
// - any resources left orphaned afterwards (admin cleanup).
//
// NOTE: deleting a notebook in the app moves its notes to "General" rather than
// removing them, which is why we purge the notes explicitly BEFORE the folders.
async function teardownTestData(page, { folders = [], folderPrefixes = [], titlePrefixes = [], noteIds = [] } = {}) {
try {
await page.evaluate(async ({ folderNames, folderPrefixes, prefixes, ids }) => {
const j = async (url, opts) => {
try {
const res = await fetch(url, { credentials: 'same-origin', ...opts });
const text = await res.text();
try { return { status: res.status, data: JSON.parse(text) }; }
catch { return { status: res.status, data: null }; }
} catch { return { status: 0, data: null }; }
};
// Resolve target folders by exact name or name-prefix.
const folderRes = await j('/api/web/folders');
const allFolders = (folderRes.data && folderRes.data.items) || [];
const targetFolderIds = new Set(
allFolders
.filter(f =>
folderNames.includes(f.title) ||
folderPrefixes.some(p => p && typeof f.title === 'string' && f.title.startsWith(p)),
)
.map(f => f.id),
);
// Find notes to purge: explicit ids, notes in a target folder, or
// notes whose title matches a prefix.
const headerRes = await j('/api/web/notes/headers');
const notes = (headerRes.data && headerRes.data.items) || [];
const doomed = new Set(ids || []);
for (const n of notes) {
if (doomed.has(n.id)) continue;
if ((n.parentId && targetFolderIds.has(n.parentId)) ||
prefixes.some(p => p && typeof n.title === 'string' && n.title.startsWith(p))) {
doomed.add(n.id);
}
}
// Trash then permanently delete each note (DELETE twice).
for (const id of doomed) {
await j('/fragments/notes/' + encodeURIComponent(id), { method: 'DELETE' });
await j('/fragments/notes/' + encodeURIComponent(id), { method: 'DELETE' });
}
// Purge anything else already sitting in Trash from this run.
await j('/fragments/trash/empty', { method: 'POST' });
// Delete the test folders.
for (const id of targetFolderIds) {
await j('/api/web/folders/' + encodeURIComponent(id), { method: 'DELETE' });
}
// Remove now-orphaned resources (uploaded images/files).
await j('/admin/orphaned-resources/cleanup', { method: 'POST' });
}, { folderNames: folders, folderPrefixes, prefixes: titlePrefixes, ids: noteIds });
} catch {
// best-effort cleanup — never fail a test because teardown hiccuped
}
}
module.exports = {
acceptDialogs,
ADMIN_EMAIL: DEV_EMAIL,
@ -203,11 +285,13 @@ module.exports = {
openMobileFolder,
openMobileNote,
openSettings,
getActiveNoteId,
searchDesktop,
setNoteBody,
setNoteTitle,
setUiMode,
slug,
teardownTestData,
trashDesktopNote,
waitForSaved,
};

View file

@ -0,0 +1,109 @@
'use strict';
// Desktop double-click on an image / viewable attachment opens the in-app
// lightbox overlay (#resource-viewer) instead of navigating away or opening a
// new tab. Covers the TinyMCE (rendered) editor path, which is the desktop
// "rendered mode". Also asserts Close button + Escape dismissal.
const path = require('path');
const { test, expect } = require('@playwright/test');
const {
acceptDialogs,
createDesktopNote,
createNotebook,
hasAdminCredentials,
login,
logout,
setNoteBody,
setNoteTitle,
teardownTestData,
waitForSaved,
} = require('./helpers');
const TEST_IMAGE = path.resolve(__dirname, '..', 'public', 'icon-192.png');
// Upload TEST_IMAGE into the currently-open note (must be in rich/TinyMCE mode)
// via the upload modal, then return the 32-hex resource id parsed from the
// markdown body. Mirrors the flow used by resource-lifecycle.spec.js.
async function uploadImageAndGetResourceId(page) {
await page.evaluate(() => window.openUploadModal());
await expect(page.locator('#upload-modal')).toBeVisible({ timeout: 5000 });
await page.locator('#upload-modal-file-input').setInputFiles(TEST_IMAGE);
await expect(page.locator('#upload-insert-btn')).not.toBeDisabled({ timeout: 15000 });
// On full upload success the modal auto-dismisses (and inserts). If it is
// still open (e.g. it stayed for a partial batch), click Insert ourselves.
if (await page.locator('#upload-modal').isVisible()) {
await page.locator('#upload-insert-btn').click().catch(() => {});
}
await expect(page.locator('#upload-modal')).toBeHidden({ timeout: 10000 });
// Force a save round-trip (TinyMCE -> markdown) so the body is populated.
await page.waitForTimeout(2000);
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
const bodyText = await page.locator('#editor-panel #note-body').inputValue();
const match = bodyText.match(/!\[[^\]]*\]\(:\/([0-9a-fA-F]{32})\)/);
expect(match, 'Note body should contain an image reference ![...](:/<32-hex>)').toBeTruthy();
// Back to rich mode for the double-click interaction.
await page.locator('#editor-panel #preview-toggle').click();
await expect(page.locator('iframe.tox-edit-area__iframe')).toBeVisible({ timeout: 15000 });
return match[1];
}
test.describe('Desktop image/attachment double-click lightbox', () => {
test.beforeEach(({ page }) => acceptDialogs(page));
test('double-click an image in rendered mode opens the lightbox, Close and Esc dismiss it', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
test.skip(!hasAdminCredentials(), 'Set JOPLOCK_ADMIN_EMAIL/JOPLOCK_ADMIN_PASSWORD (or PLAYWRIGHT_ADMIN_*) for the image-lightbox test');
const folder = `pw-lightbox-${Date.now()}`;
const noteTitle = `Lightbox note ${Date.now()}`;
try {
await login(page);
await createNotebook(page, folder);
await createDesktopNote(page, folder);
await setNoteTitle(page, noteTitle);
// Seed body in markdown mode, then switch to rich (TinyMCE) for upload.
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setNoteBody(page, 'Lightbox anchor text.');
await waitForSaved(page);
await page.locator('#editor-panel #preview-toggle').click();
await expect(page.locator('iframe.tox-edit-area__iframe')).toBeVisible({ timeout: 15000 });
const resourceId = await uploadImageAndGetResourceId(page);
// The image lives inside the TinyMCE iframe body.
const frame = page.frameLocator('iframe.tox-edit-area__iframe');
const img = frame.locator(`img[data-resource-id="${resourceId}"]`);
await expect(img).toBeVisible({ timeout: 15000 });
// No lightbox yet.
await expect(page.locator('#resource-viewer')).toHaveCount(0);
// Double-click the image -> in-app lightbox overlay appears in the top document.
await img.dblclick();
const viewer = page.locator('#resource-viewer');
await expect(viewer).toBeVisible({ timeout: 10000 });
// It shows the image (not the "cannot preview" message).
await expect(viewer.locator('img.resource-viewer-img')).toBeVisible();
await expect(viewer.locator('.resource-viewer-msg')).toHaveCount(0);
// Close button dismisses it.
await viewer.getByRole('button', { name: 'Close' }).click();
await expect(page.locator('#resource-viewer')).toHaveCount(0);
// Re-open and dismiss with Escape.
await img.dblclick();
await expect(page.locator('#resource-viewer')).toBeVisible({ timeout: 10000 });
await page.keyboard.press('Escape');
await expect(page.locator('#resource-viewer')).toHaveCount(0);
} finally {
await teardownTestData(page, { folders: [folder] });
await logout(page).catch(() => {});
}
});
});

View file

@ -0,0 +1,90 @@
'use strict';
// Regression: switching editor mode (rendered <-> markdown) must NOT mark an
// unchanged note as "Edited". The markdown<->HTML round-trip emits synthetic
// input events (and can be slightly lossy), which used to flip the save status
// to "Edited" even though the user changed nothing. A real edit made after the
// switch must still register. Desktop-only.
const { test, expect } = require('@playwright/test');
const {
acceptDialogs,
createDesktopNote,
createNotebook,
hasAdminCredentials,
login,
logout,
setNoteBody,
setNoteTitle,
teardownTestData,
waitForSaved,
} = require('./helpers');
const FENCE = '```';
// A code block with a blank line round-trips slightly lossily through TinyMCE,
// which is exactly the case that produced the spurious "Edited".
const BODY = [
`${FENCE}c`,
'#include <stdio.h>',
'',
'int main() {',
' return 0;',
'}',
FENCE,
'',
'Some text after.',
].join('\n');
async function switchToMarkdown(page) {
await page.locator('#editor-panel #markdown-toggle').click();
await expect.poll(
async () => page.locator('#editor-panel #note-editor-form').evaluate(f => f.dataset.editorMode || ''),
{ timeout: 15000 },
).toBe('markdown');
}
async function switchToRich(page) {
await page.locator('#editor-panel #preview-toggle').click();
await expect(page.locator('iframe.tox-edit-area__iframe')).toBeVisible({ timeout: 15000 });
}
test.describe('Mode switch does not mark note edited', () => {
test.beforeEach(({ page }) => acceptDialogs(page));
test('rendered -> markdown on an unchanged note stays Saved; a later edit marks Edited', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
test.skip(!hasAdminCredentials(), 'Set JOPLOCK_ADMIN_EMAIL/JOPLOCK_ADMIN_PASSWORD (or PLAYWRIGHT_ADMIN_*) for this test');
const folder = `pw-modeedit-${Date.now()}`;
const status = page.locator('#editor-panel #autosave-status');
try {
await login(page);
await createNotebook(page, folder);
await createDesktopNote(page, folder);
await setNoteTitle(page, `modeedit ${Date.now()}`);
await switchToMarkdown(page);
await setNoteBody(page, BODY);
await waitForSaved(page);
// markdown -> rendered, settle, ensure saved.
await switchToRich(page);
await page.waitForTimeout(1500);
await waitForSaved(page).catch(() => {});
// rendered -> markdown: must NOT flip to "Edited".
await switchToMarkdown(page);
await page.waitForTimeout(400); // allow the suppression window to clear
await expect(status).toHaveText('Saved');
// A genuine edit after the switch must still register as "Edited".
await page.locator('#editor-panel .cm-content').click();
await page.keyboard.type(' EDIT');
await expect(status).toHaveText('Edited', { timeout: 5000 });
} finally {
await teardownTestData(page, { folders: [folder] });
await logout(page).catch(() => {});
}
});
});

View file

@ -6,12 +6,12 @@ const {
acceptDialogs,
createDesktopNote,
createNotebook,
deleteNotebook,
hasAdminCredentials,
login,
logout,
setNoteBody,
setNoteTitle,
teardownTestData,
waitForSaved,
} = require('./helpers');
@ -29,15 +29,8 @@ test.describe('Resource lifecycle', () => {
// --- helper to clean up even on failure ---
async function runCleanup() {
try {
// Delete any orphaned resources left by this test
await page.evaluate(async () => {
const res = await fetch('/admin/orphaned-resources/cleanup', { method: 'POST', credentials: 'same-origin' });
}).catch(() => {});
} catch {}
try {
await deleteNotebook(page, folderName).catch(() => {});
} catch {}
// Purge the test notebook's notes + the notebook + any orphaned resources.
await teardownTestData(page, { folders: [folderName] });
try {
await logout(page).catch(() => {});
} catch {}
@ -55,7 +48,7 @@ test.describe('Resource lifecycle', () => {
// 2. Set initial note body in markdown mode
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
await setNoteBody(page, 'Resource test anchor text.');
await waitForSaved(page);
@ -71,9 +64,12 @@ test.describe('Resource lifecycle', () => {
// 5. Wait for upload to complete (insert button enabled)
await expect(page.locator('#upload-insert-btn')).not.toBeDisabled({ timeout: 15000 });
// 6. Insert the uploaded file
await page.locator('#upload-insert-btn').click();
await expect(page.locator('#upload-modal')).toBeHidden({ timeout: 5000 });
// 6. Insert the uploaded file. On full success the modal auto-dismisses
// (and inserts) on its own; only click Insert if it is still open.
if (await page.locator('#upload-modal').isVisible()) {
await page.locator('#upload-insert-btn').click().catch(() => {});
}
await expect(page.locator('#upload-modal')).toBeHidden({ timeout: 10000 });
// 7. Force save via mode toggle (syncs TinyMCE -> markdown -> save)
await page.waitForTimeout(4000);
@ -86,7 +82,7 @@ test.describe('Resource lifecycle', () => {
// 8. Switch to markdown mode and verify the resource reference is in the body
await page.locator('#editor-panel #markdown-toggle').click();
await expect(page.locator('#editor-panel #note-body')).toBeVisible();
await expect(page.locator('#editor-panel .cm-content')).toBeVisible({ timeout: 15000 });
const bodyText = await page.locator('#editor-panel #note-body').inputValue();
const resourceMatch = bodyText.match(/!\[[^\]]*\]\(:\/([0-9a-fA-F]{32})\)/);
expect(resourceMatch, 'Note body should contain a resource reference like ![...](:/<32-hex>)').toBeTruthy();

View file

@ -7,11 +7,13 @@ const {
acceptDialogs,
createDesktopNote,
ensureMobileFoldersScreen,
getActiveNoteId,
login,
logout,
openMobileFolder,
setNoteBody,
setNoteTitle,
teardownTestData,
waitForSaved,
} = require('./helpers');
@ -31,25 +33,30 @@ test.describe('Uploads', () => {
if (request.url().includes('/fragments/upload')) uploadRequests.push(request.url());
});
await login(page);
await expect(page.locator('#mobile-folders-body')).toContainText('All Notes', { timeout: 15000 });
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-preview-toggle').click();
await expect(page.locator('#mobile-editor-body #note-preview')).toBeVisible();
let noteId = '';
try {
await login(page);
await expect(page.locator('#mobile-folders-body')).toContainText('All Notes', { timeout: 15000 });
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-preview-toggle').click();
await expect(page.locator('#mobile-editor-body #note-preview')).toBeVisible();
const uploadInput = page.locator('#mobile-editor-body #file-upload');
await expect(uploadInput).toHaveCount(1);
await uploadInput.setInputFiles(TEST_IMAGE);
const uploadInput = page.locator('#mobile-editor-body #file-upload');
await expect(uploadInput).toHaveCount(1);
await uploadInput.setInputFiles(TEST_IMAGE);
await expect.poll(() => uploadRequests.length, { timeout: 15000 }).toBeGreaterThan(0);
await expect(page.locator('#mobile-editor-body #note-preview img.preview-img')).toHaveCount(1, { timeout: 15000 });
expect(pageErrors, `page errors: ${pageErrors.join('\n')}`).toEqual([]);
expect(consoleMessages.filter(msg => /error|ReferenceError|TypeError/i.test(msg)), `console messages: ${consoleMessages.join('\n')}`).toEqual([]);
await logout(page);
await expect.poll(() => uploadRequests.length, { timeout: 15000 }).toBeGreaterThan(0);
await expect(page.locator('#mobile-editor-body #note-preview img.preview-img')).toHaveCount(1, { timeout: 15000 });
expect(pageErrors, `page errors: ${pageErrors.join('\n')}`).toEqual([]);
expect(consoleMessages.filter(msg => /error|ReferenceError|TypeError/i.test(msg)), `console messages: ${consoleMessages.join('\n')}`).toEqual([]);
noteId = await getActiveNoteId(page);
} finally {
await teardownTestData(page, { noteIds: noteId ? [noteId] : [] });
await logout(page).catch(() => {});
}
});
test('mobile multi-image upload preserves sequence and note title', async ({ page }, testInfo) => {
@ -61,104 +68,138 @@ test.describe('Uploads', () => {
if (request.url().includes('/fragments/upload')) uploadRequests.push(request.url());
});
await login(page);
await expect(page.locator('#mobile-folders-body')).toContainText('All Notes', { timeout: 15000 });
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();
let noteId = '';
try {
await login(page);
await expect(page.locator('#mobile-folders-body')).toContainText('All Notes', { timeout: 15000 });
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 setNoteBody(page, 'Anchor line');
await waitForSaved(page);
await page.locator('#mobile-md-toggle').click();
await setNoteBody(page, 'Anchor line');
await waitForSaved(page);
await page.locator('#mobile-preview-toggle').click();
await expect(page.locator('#mobile-editor-body #note-preview')).toBeVisible();
await page.locator('#mobile-preview-toggle').click();
await expect(page.locator('#mobile-editor-body #note-preview')).toBeVisible();
const imageBuffer = fs.readFileSync(TEST_IMAGE);
await page.locator('#mobile-editor-body #file-upload').setInputFiles([
{ name: 'first-image.png', mimeType: 'image/png', buffer: imageBuffer },
{ name: 'second-image.png', mimeType: 'image/png', buffer: imageBuffer },
]);
const imageBuffer = fs.readFileSync(TEST_IMAGE);
await page.locator('#mobile-editor-body #file-upload').setInputFiles([
{ name: 'first-image.png', mimeType: 'image/png', buffer: imageBuffer },
{ name: 'second-image.png', mimeType: 'image/png', buffer: imageBuffer },
]);
await expect.poll(() => uploadRequests.length, { timeout: 15000 }).toBe(2);
await expect(page.locator('#mobile-editor-body #note-preview img.preview-img')).toHaveCount(2, { timeout: 15000 });
await expect.poll(async () => page.locator('#mobile-editor-body #note-preview img.preview-img').evaluateAll(nodes => nodes.map(node => node.getAttribute('alt'))), { timeout: 15000 }).toEqual(['first-image.png', 'second-image.png']);
await expect(page.locator('#mobile-editor-body .editor-title-hidden')).toHaveValue('Anchor line');
await logout(page);
await expect.poll(() => uploadRequests.length, { timeout: 15000 }).toBe(2);
await expect(page.locator('#mobile-editor-body #note-preview img.preview-img')).toHaveCount(2, { timeout: 15000 });
await expect.poll(async () => page.locator('#mobile-editor-body #note-preview img.preview-img').evaluateAll(nodes => nodes.map(node => node.getAttribute('alt'))), { timeout: 15000 }).toEqual(['first-image.png', 'second-image.png']);
await expect(page.locator('#mobile-editor-body .editor-title-hidden')).toHaveValue('Anchor line');
noteId = await getActiveNoteId(page);
} finally {
await teardownTestData(page, { noteIds: noteId ? [noteId] : [], titlePrefixes: ['Anchor line'] });
await logout(page).catch(() => {});
}
});
test('mobile image-only upload does not promote filename to note title', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
acceptDialogs(page);
await login(page);
await expect(page.locator('#mobile-folders-body')).toContainText('All Notes', { timeout: 15000 });
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-preview-toggle').click();
await expect(page.locator('#mobile-editor-body #note-preview')).toBeVisible();
let noteId = '';
try {
await login(page);
await expect(page.locator('#mobile-folders-body')).toContainText('All Notes', { timeout: 15000 });
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-preview-toggle').click();
await expect(page.locator('#mobile-editor-body #note-preview')).toBeVisible();
await page.locator('#mobile-editor-body #file-upload').setInputFiles(TEST_IMAGE);
await page.locator('#mobile-editor-body #file-upload').setInputFiles(TEST_IMAGE);
await expect(page.locator('#mobile-editor-body #note-preview img.preview-img')).toHaveCount(1, { timeout: 15000 });
await expect(page.locator('#mobile-editor-body .editor-title-hidden')).toHaveValue('Untitled note', { timeout: 15000 });
await logout(page);
await expect(page.locator('#mobile-editor-body #note-preview img.preview-img')).toHaveCount(1, { timeout: 15000 });
await expect(page.locator('#mobile-editor-body .editor-title-hidden')).toHaveValue('Untitled note', { timeout: 15000 });
noteId = await getActiveNoteId(page);
} finally {
await teardownTestData(page, { noteIds: noteId ? [noteId] : [] });
await logout(page).catch(() => {});
}
});
test('mobile multi-image upload on blank note shows both images', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'mobile');
acceptDialogs(page);
await login(page);
await expect(page.locator('#mobile-folders-body')).toContainText('All Notes', { timeout: 15000 });
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-preview-toggle').click();
await expect(page.locator('#mobile-editor-body #note-preview')).toBeVisible();
let noteId = '';
try {
await login(page);
await expect(page.locator('#mobile-folders-body')).toContainText('All Notes', { timeout: 15000 });
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-preview-toggle').click();
await expect(page.locator('#mobile-editor-body #note-preview')).toBeVisible();
const imageBuffer = fs.readFileSync(TEST_IMAGE);
await page.locator('#mobile-editor-body #file-upload').setInputFiles([
{ name: 'blank-first.png', mimeType: 'image/png', buffer: imageBuffer },
{ name: 'blank-second.png', mimeType: 'image/png', buffer: imageBuffer },
]);
const imageBuffer = fs.readFileSync(TEST_IMAGE);
await page.locator('#mobile-editor-body #file-upload').setInputFiles([
{ name: 'blank-first.png', mimeType: 'image/png', buffer: imageBuffer },
{ name: 'blank-second.png', mimeType: 'image/png', buffer: imageBuffer },
]);
await expect(page.locator('#mobile-editor-body #note-preview img.preview-img')).toHaveCount(2, { timeout: 15000 });
await expect.poll(async () => page.locator('#mobile-editor-body #note-preview img.preview-img').evaluateAll(nodes => nodes.map(node => node.getAttribute('alt'))), { timeout: 15000 }).toEqual(['blank-first.png', 'blank-second.png']);
await expect(page.locator('#mobile-editor-body .editor-title-hidden')).toHaveValue('Untitled note', { timeout: 15000 });
await logout(page);
await expect(page.locator('#mobile-editor-body #note-preview img.preview-img')).toHaveCount(2, { timeout: 15000 });
await expect.poll(async () => page.locator('#mobile-editor-body #note-preview img.preview-img').evaluateAll(nodes => nodes.map(node => node.getAttribute('alt'))), { timeout: 15000 }).toEqual(['blank-first.png', 'blank-second.png']);
await expect(page.locator('#mobile-editor-body .editor-title-hidden')).toHaveValue('Untitled note', { timeout: 15000 });
noteId = await getActiveNoteId(page);
} finally {
await teardownTestData(page, { noteIds: noteId ? [noteId] : [] });
await logout(page).catch(() => {});
}
});
test('desktop preview-mode multi-image upload after existing text keeps both images in order', async ({ page }, testInfo) => {
test('desktop multi-image upload after existing text keeps both images in order', async ({ page }, testInfo) => {
test.skip(testInfo.project.name !== 'desktop');
acceptDialogs(page);
await login(page);
await page.locator('.nav-folder-title', { hasText: 'All Notes' }).first().click();
await createDesktopNote(page, 'All Notes');
await page.locator('#editor-panel #markdown-toggle').click();
await setNoteBody(page, 'Desktop anchor line');
await page.waitForTimeout(2500);
await page.locator('#editor-panel #preview-toggle').click();
await expect(page.locator('#editor-panel #note-preview')).toBeVisible();
let noteId = '';
try {
await login(page);
await page.locator('.nav-folder-title', { hasText: 'All Notes' }).first().click();
await createDesktopNote(page, 'All Notes');
await page.locator('#editor-panel #markdown-toggle').click();
await expect.poll(async () => page.locator('#editor-panel #note-editor-form').evaluate(form => form.dataset.editorMode || ''), { timeout: 15000 }).toBe('markdown');
await setNoteBody(page, 'Desktop anchor line');
await page.waitForTimeout(2500);
const imageBuffer = fs.readFileSync(TEST_IMAGE);
await page.locator('#editor-panel #file-upload').setInputFiles([
{ name: 'desktop-existing-first.png', mimeType: 'image/png', buffer: imageBuffer },
{ name: 'desktop-existing-second.png', mimeType: 'image/png', buffer: imageBuffer },
]);
const imageBuffer = fs.readFileSync(TEST_IMAGE);
await page.locator('#editor-panel #file-upload').setInputFiles([
{ name: 'desktop-existing-first.png', mimeType: 'image/png', buffer: imageBuffer },
{ name: 'desktop-existing-second.png', mimeType: 'image/png', buffer: imageBuffer },
]);
await expect(page.locator('#editor-panel #note-preview img.preview-img')).toHaveCount(2, { timeout: 15000 });
await expect.poll(async () => page.locator('#editor-panel #note-preview img.preview-img').evaluateAll(nodes => nodes.map(node => node.getAttribute('alt'))), { timeout: 15000 }).toEqual(['desktop-existing-first.png', 'desktop-existing-second.png']);
await expect(page.locator('#editor-panel .editor-title-hidden')).toHaveValue('Desktop anchor line');
// Both images must land in the markdown body, in the order selected, and
// the anchor text must survive. Read the source of truth (#note-body).
const noteBody = page.locator('#editor-panel #note-body');
await expect.poll(async () => {
const v = await noteBody.inputValue();
return (v.match(/!\[[^\]]*\]\(:\/[0-9a-fA-F]{32}\)/g) || []).length;
}, { timeout: 15000 }).toBeGreaterThanOrEqual(2);
await logout(page);
const body = await noteBody.inputValue();
expect(body).toContain('Desktop anchor line');
const firstIdx = body.indexOf('desktop-existing-first.png');
const secondIdx = body.indexOf('desktop-existing-second.png');
expect(firstIdx, 'first image must be present').toBeGreaterThanOrEqual(0);
expect(secondIdx, 'second image must be present').toBeGreaterThanOrEqual(0);
expect(firstIdx, 'images must keep upload order').toBeLessThan(secondIdx);
// Image filenames must not be promoted into the note title.
await expect(page.locator('#editor-panel .editor-title-hidden')).toHaveValue('Desktop anchor line');
noteId = await getActiveNoteId(page);
} finally {
await teardownTestData(page, { noteIds: noteId ? [noteId] : [], titlePrefixes: ['Desktop anchor line'] });
await logout(page).catch(() => {});
}
});
});

View file

@ -6,6 +6,7 @@ const baseURL = process.env.PLAYWRIGHT_BASE_URL || 'http://127.0.0.1:5445';
module.exports = defineConfig({
testDir: './playwright-tests',
globalTeardown: require.resolve('./playwright-tests/global-teardown.js'),
fullyParallel: false,
forbidOnly: !!process.env.CI,
retries: process.env.CI ? 2 : 0,

View file

@ -288,6 +288,23 @@ function queryActiveEditor(selector){var form=activeEditorForm();return form&&fo
function activeEditorMeta(){if(isMobileShellMode()){var mobileBody=document.getElementById('mobile-editor-body');var mobileMeta=mobileBody&&mobileBody.querySelector?mobileBody.querySelector('#note-meta'):null;if(mobileMeta)return mobileMeta}return document.getElementById('status-note-meta')}
function setSaveState(html,text){var s=queryActiveEditor('#autosave-status');if(s)s.innerHTML=html||'';var mobile=document.getElementById('mobile-editor-status');if(mobile)mobile.innerHTML=text?html:''}
function markEdited(){setSaveState('<span class="autosave-edited">Edited</span>','Edited');_log('markEdited')}
// After a programmatic mode switch, the markdown<->HTML round-trip fires
// synthetic input events (and can be slightly lossy). If, once the switch has
// settled, the form content still matches the last saved hash, the note is not
// actually dirty — reset the status to "Saved" instead of leaving a spurious
// "Edited". If the hash genuinely differs (e.g. an upload happened just before
// the switch), leave the edited/autosave flow alone so the change still saves.
function _reconcileSaveStateAfterModeSwitch(){
var apply=function(){
var form=activeEditorForm();if(!form)return;
if(typeof _activeEditorIsDirty==='function'?!_activeEditorIsDirty():formHash(form)===_savedHash){
if(_saveTimer){clearTimeout(_saveTimer);_saveTimer=null}
setSaveState('<span class="autosave-ok">Saved</span>','Saved');
}
};
if(typeof requestAnimationFrame==='function'){requestAnimationFrame(function(){setTimeout(apply,0)})}
else{setTimeout(apply,50)}
}
function renderNoteMeta(){var src=document.getElementById('note-meta');var mobileBody=document.getElementById('mobile-editor-body');if(isMobileShellMode()&&mobileBody){src=mobileBody.querySelector('#note-meta')||src}var target;if(isMobileShellMode()){target=src}else{target=document.getElementById('status-note-meta');if(src&&target){target.setAttribute('data-created-time',src.getAttribute('data-created-time')||'0');target.setAttribute('data-updated-time',src.getAttribute('data-updated-time')||'0')}}if(!target)return;var c=Number(target.getAttribute('data-created-time')||0),u=Number(target.getAttribute('data-updated-time')||0);if(!c&&!u){target.textContent='';return}var months=['Jan','Feb','Mar','Apr','May','Jun','Jul','Aug','Sep','Oct','Nov','Dec'];var fmt=function(ts){if(!ts)return '';var d=new Date(ts);return String(d.getDate()).padStart(2,'0')+'-'+months[d.getMonth()]+'-'+String(d.getFullYear()).slice(-2)};target.textContent='Created '+fmt(c)+' | Edited '+fmt(u)}
var _folderMenuState={id:'',title:''};
function closeFolderContextMenu(){var menu=document.getElementById('folder-context-menu');if(menu)menu.hidden=true}
@ -1011,6 +1028,21 @@ function initPersistentTinyMCE(){
e.stopPropagation();
_openTinyMCECodeBlock(pre);
});
// Double-click an image or attachment link → open the in-app lightbox
// (desktop only). Suppresses TinyMCE's built-in image dialog on dblclick.
editor.on('dblclick',function(e){
if(!isDesktopMode())return;
var target=e&&e.target?e.target:null;
if(!target||!target.closest)return;
var el=target.closest('img[data-resource-id],a[data-resource-id]');
if(!el)return;
var resourceId=el.getAttribute('data-resource-id')||'';
if(!resourceId)return;
e.preventDefault();
e.stopPropagation();
if(e.stopImmediatePropagation)e.stopImmediatePropagation();
_openResourceLightbox(resourceId);
});
// Drag-and-drop file upload directly into the note (no modal).
// We handle ALL dropped files ourselves (images + other files) and stop
// TinyMCE's default handling so images don't get inlined as base64.
@ -2452,6 +2484,30 @@ function _escapeHtmlAttr(s){return(s||'').replace(/&/g,'&amp;').replace(/"/g,'&q
function _maxUploadBytes(){var mb=(window._joplockConfig&&window._joplockConfig.maxUploadMb)||200;return Math.min(mb,200)*1024*1024}
function _fileTooLarge(file){return!!(file&&typeof file.size==='number'&&file.size>_maxUploadBytes())}
function _tooLargeMsg(file){var mb=Math.round((file&&file.size||0)/1048576);var lim=Math.round(_maxUploadBytes()/1048576);return'"'+((file&&file.name)||'file')+'" is too large ('+mb+'MB). Maximum upload size is '+lim+'MB.'}
// Canonical deletable blank line for rendered (TinyMCE) mode. This exact shape
// is what injectBlankLineBlocks() emits and tinymceToMarkdown() pre-normalizes,
// so it survives markdown<->render round-trips as a real empty paragraph the
// user can delete between stacked attachments.
var _MD_BLANK_LINE_P='<p class="md-blank-line"><br></p>';
function _isBlankLineBlock(el){return !!(el&&el.nodeType===1&&el.classList&&el.classList.contains('md-blank-line'))}
// Wrap an attachment (image/link HTML) in its own <p> with a blank (deletable)
// line before and after so it is isolated and easy to remove — for BOTH images
// and documents. "Smart": skip a side when the caret block is already empty or
// already next to a blank-line paragraph, so gaps don't pile up. When the
// selection API isn't available (unit tests) both sides are added.
function _tinyMCEBlockAttachmentHtml(editor,inner){
var leading=true,trailing=true;
try{
var node=editor&&editor.selection&&editor.selection.getNode?editor.selection.getNode():null;
if(node){
var block=node.closest?(node.closest('p,div,li,blockquote,h1,h2,h3,h4,h5,h6')||node):node;
if(_isBlankLineBlock(block)||!(block.textContent||'').trim())leading=false;
if(_isBlankLineBlock(block&&block.previousElementSibling))leading=false;
if(_isBlankLineBlock(block&&block.nextElementSibling))trailing=false;
}
}catch(_e){}
return (leading?_MD_BLANK_LINE_P:'')+'<p>'+inner+'</p>'+(trailing?_MD_BLANK_LINE_P:'');
}
function _uploadFileToTinyMCE(file,editor){
if(!file)return Promise.resolve();
if(_fileTooLarge(file)){alert(_tooLargeMsg(file));return Promise.reject(new Error('File too large'))}
@ -2467,15 +2523,15 @@ function _uploadFileToTinyMCE(file,editor){
.then(function(data){
var id=data.resourceId;
var name=_escapeHtmlAttr(file.name||'file');
var inner;
if(file.type&&file.type.startsWith('image/')){
// Insert the image on its own line/paragraph and ALWAYS follow it
// with a blank paragraph, so the caret lands on a fresh empty line
// and typed text is never glued to the image. The trailing empty
// <p> serialises to a blank markdown line via the emptyP rule.
editor.insertContent('<p><img src="/resources/'+id+'" data-resource-id="'+id+'" alt="'+name+'" /></p><p></p>');
inner='<img src="/resources/'+id+'" data-resource-id="'+id+'" alt="'+name+'" />';
}else{
editor.insertContent('<a href="/resources/'+id+'" data-resource-id="'+id+'">'+name+'</a>');
inner='<a href="/resources/'+id+'" data-resource-id="'+id+'">'+name+'</a>';
}
// Pad the attachment with a blank (deletable) line before and after so
// consecutive attachments stay individually removable in rendered mode.
editor.insertContent(_tinyMCEBlockAttachmentHtml(editor,inner));
// Sync textarea immediately so the source-of-truth (note-body) has
// the resource reference even if onEdit fires asynchronously.
var ta=getTA();
@ -2501,12 +2557,14 @@ function _uploadFileToCM(file){
if(data&&data.error){alert(data.error);throw new Error(data.error)}
var isImage=!!(file.type&&file.type.indexOf('image/')===0);
var md=data.markdown||((isImage?'!':'')+'['+(file.name||'file')+'](:/'+data.resourceId+')');
// Always add a blank line after an inserted image so the caret lands on
// a fresh empty line and typed text is never glued to the image.
if(isImage)md=md+'\n\n';
if(_cmView){
var pos=_cmView.state.selection.main.head;
_cmView.dispatch({changes:{from:pos,insert:md},selection:{anchor:pos+md.length}});
// Pad the attachment (image AND document) with a blank line before
// and after so it sits on its own line and stays easy to delete.
var doc=_cmView.state.doc.toString();
var pre=(pos>0&&!/\n\n$/.test(doc.slice(0,pos)))?(doc.charAt(pos-1)==='\n'?'\n':'\n\n'):'';
var insert=pre+md+'\n\n';
_cmView.dispatch({changes:{from:pos,insert:insert},selection:{anchor:pos+insert.length}});
cmSyncToTA();
}
});
@ -2732,6 +2790,28 @@ function getTurndown(){
// Each one = one extra blank line → \x00BL\x00 sentinel → post-processing converts to \n\n\n.
td.addRule('emptyP',{filter:function(n){return n.nodeName==='P'&&!n.querySelector('img')&&(!n.textContent.trim()||n.innerHTML==='<br>'||n.innerHTML==='\u2764BR\u2764')},replacement:function(){return '\x00BL\x00'}});
_tdService=td;return td}
// Collapse a blank line immediately after / before an ATX heading, but ONLY
// outside fenced code blocks. A code line like `#include <stdio.h>` (C) or
// `# comment` (bash) is NOT a markdown heading, and its following blank line
// must be preserved verbatim — otherwise round-tripping through rendered mode
// eats blank lines inside code. We mask ``` fenced ``` regions, apply the
// heading spacing fixes to the rest, then restore the code untouched.
function _applyHeadingSpacing(md){
var nl=String.fromCharCode(10);
var headingGapRe=new RegExp('^(#{1,6}[^'+nl+']*)'+nl+'{2,}(?=\\S)','gm');
var headingLeadRe=new RegExp('([^'+nl+'])'+nl+'{2,}(#{1,6}\\s)','g');
// Split on fenced code blocks (```lang ... ```), keeping the fences.
var fenceRe=/```[\s\S]*?```/g;
var out='';var last=0;var m;
var fix=function(seg){return seg.replace(headingLeadRe,'$1'+nl+'$2').replace(headingGapRe,'$1'+nl)};
while((m=fenceRe.exec(md))!==null){
out+=fix(md.slice(last,m.index));
out+=m[0]; // code block passes through unchanged
last=m.index+m[0].length;
}
out+=fix(md.slice(last));
return out;
}
function htmlToMarkdown(el){
var root=el.cloneNode(true);
root.querySelectorAll('.pre-copy-btn').forEach(function(btn){btn.remove()});
@ -2742,13 +2822,10 @@ function htmlToMarkdown(el){
var nbsp=String.fromCharCode(160);
while(md.indexOf(nbsp)>=0)md=md.split(nbsp).join('&nbsp;');
var nl=String.fromCharCode(10);
var headingGapRe=new RegExp('^(#{1,6}[^'+nl+']*)'+nl+'{2,}(?=\\S)','gm');
var headingLeadRe=new RegExp('([^'+nl+'])'+nl+'{2,}(#{1,6}\\s)','g');
md=md.split('<br/>').join('<br>');
md=md.split('<br>'+nl).join(nl);
while(md.indexOf('<br><br>')>=0)md=md.split('<br><br>').join('<br>'+nl);
md=md.replace(headingLeadRe,'$1'+nl+'$2');
md=md.replace(headingGapRe,'$1'+nl);
md=_applyHeadingSpacing(md);
md=md.replace(new RegExp(nl+nl+'<br>$'),'');
md=md.replace(/\n*(?:\x00BL\x00\n*)+/g,function(m){var count=(m.match(/\x00BL\x00/g)||[]).length;return nl+nl+Array(count+1).join(nl)});
var out='';
@ -2783,10 +2860,7 @@ function tinymceToMarkdown(html){
var td=getTurndown();
var md=td.turndown(html);
var nl=String.fromCharCode(10);
var headingGapRe=new RegExp('^(#{1,6}[^'+nl+']*)'+nl+'{2,}(?=\\S)','gm');
var headingLeadRe=new RegExp('([^'+nl+'])'+nl+'{2,}(#{1,6}\\s)','g');
md=md.replace(headingLeadRe,'$1'+nl+'$2');
md=md.replace(headingGapRe,'$1'+nl);
md=_applyHeadingSpacing(md);
// Normalise blank-line sentinels (md-blank-line divs + empty paragraphs)
md=md.replace(/\n*(?:\x00BL\x00\n*)+/g,function(m){var count=(m.match(/\x00BL\x00/g)||[]).length;return nl+nl+Array(count+1).join(nl)});
// Restore line-break sentinels as \n (soft breaks within paragraphs)
@ -2814,6 +2888,9 @@ function setEditorMode(mode){
mountMarkdownEditor(ta?ta.value:'');
_editorMode='markdown';
syncEditorModeButtons();
// The round-trip may have fired a spurious "Edited"; reset to "Saved" if
// the note is not actually dirty (but keep a real pending change/save).
_reconcileSaveStateAfterModeSwitch();
if(_searchSessionActive())setTimeout(applySearchHighlight,0);
return;
}
@ -2857,7 +2934,7 @@ function mountMarkdownEditor(content){
if(_cmView.focus)_cmView.focus();
}
}
document.addEventListener('keydown',function(e){if(e.key==='Escape'){var codeModal=document.getElementById('code-modal');if(codeModal&&!codeModal.hidden){closeCodeModal();return}closeFolderContextMenu();closeFolderModal();closeLinkModal();closeNewFolderModal();closeVaultModal();closeHistoryModal();closeEmptyTrashModal();var bar=document.getElementById('search-nav-bar');if(bar&&!bar.hidden){searchNavDismiss();return}var navSearch=document.getElementById('nav-search');if(navSearch&&navSearch.value){navSearch.value='';htmx.trigger(navSearch,'search-submit');return}}if(!getTA()&&!getPV()&&!getCM())return;if((e.ctrlKey||e.metaKey)&&!e.altKey&&e.code==='Space'){e.preventDefault();requestManualProseCompletion();return}if((e.ctrlKey||e.metaKey)&&e.key==='b'){e.preventDefault();wrapSel('**','**')}if((e.ctrlKey||e.metaKey)&&e.key==='i'){e.preventDefault();wrapSel('*','*')}if((e.ctrlKey||e.metaKey)&&e.key==='f'){e.preventDefault();if(_editorMode==='preview'&&_searchMarks.length){searchNavStep(1)}else{applySearchHighlight()}}});
document.addEventListener('keydown',function(e){if(e.key==='Escape'){var resViewer=document.getElementById('resource-viewer');if(resViewer){_closeResourceViewer();return}var codeModal=document.getElementById('code-modal');if(codeModal&&!codeModal.hidden){closeCodeModal();return}closeFolderContextMenu();closeFolderModal();closeLinkModal();closeNewFolderModal();closeVaultModal();closeHistoryModal();closeEmptyTrashModal();var bar=document.getElementById('search-nav-bar');if(bar&&!bar.hidden){searchNavDismiss();return}var navSearch=document.getElementById('nav-search');if(navSearch&&navSearch.value){navSearch.value='';htmx.trigger(navSearch,'search-submit');return}}if(!getTA()&&!getPV()&&!getCM())return;if((e.ctrlKey||e.metaKey)&&!e.altKey&&e.code==='Space'){e.preventDefault();requestManualProseCompletion();return}if((e.ctrlKey||e.metaKey)&&e.key==='b'){e.preventDefault();wrapSel('**','**')}if((e.ctrlKey||e.metaKey)&&e.key==='i'){e.preventDefault();wrapSel('*','*')}if((e.ctrlKey||e.metaKey)&&e.key==='f'){e.preventDefault();if(_editorMode==='preview'&&_searchMarks.length){searchNavStep(1)}else{applySearchHighlight()}}});
document.addEventListener('click',function(e){var menu=document.getElementById('folder-context-menu');if(menu&&!menu.hidden&&!menu.contains(e.target))closeFolderContextMenu()});
function highlightCodeBlocks(container){if(!window.hljs||!container)return;container.querySelectorAll('pre code[class*="language-"]').forEach(function(el){if(el.dataset.highlighted)return;window.hljs.highlightElement(el)})}
function ensureEditableAfterPre(pv){if(!pv)return;var pres=pv.querySelectorAll('pre');pres.forEach(function(pre){var next=pre.nextElementSibling;if(!next){var p=document.createElement('p');p.innerHTML='<br>';p.dataset.pvTrail='1';pv.appendChild(p)}})}
@ -2950,6 +3027,22 @@ function _openResourceViewer(blob,mime,filename){
else {var msg=document.createElement('div');msg.className='resource-viewer-msg';msg.textContent='This file type cannot be previewed. Use Save to download it.';body.appendChild(msg)}
overlay.appendChild(bar);overlay.appendChild(body);
document.body.appendChild(overlay);
// Close on backdrop click (clicks on the media/body do nothing).
overlay.addEventListener('click',function(e){if(e.target===overlay||e.target===body)_closeResourceViewer()});
// Own Escape handler + focus so the overlay closes even when the prior focus
// was inside the TinyMCE iframe (whose key events never reach document).
overlay.setAttribute('tabindex','-1');
overlay.addEventListener('keydown',function(e){if(e.key==='Escape'){e.preventDefault();e.stopPropagation();_closeResourceViewer()}});
try{closeBtn.focus()}catch(_e){}
}
// Double-click entry point: open a resource in the in-app lightbox overlay.
// Viewable types (image/pdf/text) render inline; anything else falls back to download.
function _openResourceLightbox(resourceId){
var id=resourceId||'';if(!id)return;
_fetchResourceMeta(id).then(function(meta){
if(meta&&!_canPreviewResourceMime(meta.mime)){_triggerResourceDownload(id);return}
_fetchResourceBlob(id).then(function(r){_openResourceViewer(r.blob,r.mime,r.filename)}).catch(function(){_triggerResourceDownload(id)});
}).catch(function(){_triggerResourceDownload(id)});
}
var _resourceActionViewportHandler=null;
function _positionResourceActions(anchorEl){
@ -3053,8 +3146,9 @@ function activatePV(pv){if(!pv)return;pv.contentEditable='true';initImgResize(pv
pv.addEventListener('blur',function(){_resetRingBuffer('pv-blur')});
pv.addEventListener('click',function(){hideRenderAutocompletePopup()});
pv.addEventListener('blur',function(){setTimeout(hideRenderAutocompletePopup,200)});
pv.addEventListener('click',function(e){var img=e.target.closest('img.preview-img[data-resource-id]');if(!img||!pv.contains(img))return;if(e.target.closest('.preview-img-download-btn'))return;if(!isDesktopMode())return;var resourceId=img.getAttribute('data-resource-id')||'';if(!resourceId)return;e.preventDefault();_openResourceView(resourceId)});
pv.addEventListener('click',function(e){var link=e.target.closest('a');if(link&&pv.contains(link)){var resId=link.getAttribute('data-resource-id')||'';if(resId&&_shouldUseResourceActions()){e.preventDefault();presentResourceActions(resId,link);return}var href=link.getAttribute('href')||'';if(href){e.preventDefault();window.open(href,'_blank','noopener');return}}});
// Desktop: double-click an image or attachment link → open the in-app lightbox.
pv.addEventListener('dblclick',function(e){if(!isDesktopMode())return;var el=e.target.closest('img.preview-img[data-resource-id],a[data-resource-id]');if(!el||!pv.contains(el))return;if(e.target.closest('.preview-img-download-btn'))return;var resourceId=el.getAttribute('data-resource-id')||'';if(!resourceId)return;e.preventDefault();_openResourceLightbox(resourceId)});
pv.addEventListener('click',function(e){var link=e.target.closest('a');if(link&&pv.contains(link)){var resId=link.getAttribute('data-resource-id')||'';if(resId){if(isDesktopMode()){e.preventDefault();return}if(_shouldUseResourceActions()){e.preventDefault();presentResourceActions(resId,link);return}}var href=link.getAttribute('href')||'';if(href){e.preventDefault();window.open(href,'_blank','noopener');return}}});
// Click checkbox icon to toggle checked state
pv.addEventListener('click',function(e){var cb=e.target.closest('.md-checkbox');if(!cb)return;var iconEl=cb.querySelector('.md-cb-icon');if(!iconEl){var txt=cb.firstChild;if(!txt||txt.nodeType!==3)return;var icon=txt.textContent.charAt(0);if(icon!=='\u2610'&&icon!=='\u2611')return;var r=document.createRange();r.setStart(txt,0);r.setEnd(txt,Math.min(2,txt.textContent.length));var iconRect=r.getBoundingClientRect();if(e.clientX>iconRect.right)return;e.preventDefault();var checked=!cb.classList.contains('checked');cb.classList.toggle('checked',checked);txt.textContent=(checked?'\u2611':'\u2610')+txt.textContent.slice(1);syncPV();return}var iconRect=iconEl.getBoundingClientRect();if(e.clientX>iconRect.right)return;e.preventDefault();var checked=!cb.classList.contains('checked');cb.classList.toggle('checked',checked);iconEl.textContent=checked?'\u2611':'\u2610';syncPV()});
// Enter inside code blocks should stay in the same block; Enter after checkbox creates new checkbox
@ -3078,7 +3172,7 @@ var _formHashExclude={baseUpdatedTime:true,forceSave:true,createCopy:true};funct
var _savedHash=0;
var _saveTimer=null;
var _saveTitleTimer=null;
function _anyModalOpen(){var ids=['code-modal','link-modal','folder-modal','history-modal','empty-trash-modal','upload-modal','vault-modal','new-folder-modal'];for(var i=0;i<ids.length;i++){var el=document.getElementById(ids[i]);if(el&&!el.hidden)return true}return false}
function _anyModalOpen(){var ids=['code-modal','link-modal','folder-modal','history-modal','empty-trash-modal','upload-modal','vault-modal','new-folder-modal','resource-viewer'];for(var i=0;i<ids.length;i++){var el=document.getElementById(ids[i]);if(el&&!el.hidden)return true}return false}
function scheduleSave(){if(_saveTimer)clearTimeout(_saveTimer);_saveTimer=setTimeout(function(){_saveTimer=null;if(_syncPVInFlight||_pvSyncTimer){_log('scheduleSave deferred, syncPV in flight');scheduleSave();return}if(_anyModalOpen()){_log('scheduleSave deferred, modal open');scheduleSave();return}var form=activeEditorForm();if(!form)return;var h=formHash(form);if(h===_savedHash){_log('scheduleSave skip, hash unchanged',h);setSaveState('<span class="autosave-ok">Saved</span>','Saved');return}_log('scheduleSave firing, hash',_savedHash,'->',h);htmx.trigger(form,'joplock:save')},2000)}
function scheduleSaveTitle(){var mobileTitle=document.getElementById('mobile-editor-title');if(mobileTitle&&document.activeElement===mobileTitle)return;// Don't save while user is still editing title
if(_saveTitleTimer)clearTimeout(_saveTitleTimer);if(_saveTimer)clearTimeout(_saveTimer);_saveTimer=null;_saveTitleTimer=setTimeout(function(){_saveTitleTimer=null;if(_anyModalOpen()){_log('scheduleSaveTitle deferred, modal open');scheduleSave();return}var form=activeEditorForm();if(!form)return;var h=formHash(form);if(h===_savedHash){_log('scheduleSaveTitle skip, hash unchanged',h);setSaveState('<span class="autosave-ok">Saved</span>','Saved');return}_log('scheduleSaveTitle firing');htmx.trigger(form,'joplock:save')},2000)}

View file

@ -1,4 +1,4 @@
const CACHE_NAME = 'joplock-shell-v42-20260707escsearch';
const CACHE_NAME = 'joplock-shell-v43-20260708imglightbox';
const STATIC_ASSETS = ['/styles.css', '/htmx.min.js', '/turndown.min.js', '/codemirror.min.js', '/hljs.min.js', '/manifest.webmanifest', '/icon.svg', '/icon-192.png', '/icon-512.png', '/maskable-icon-192.png', '/maskable-icon-512.png', '/apple-touch-icon.png', '/fonts/CascadiaMono-Regular.woff2', '/fonts/CascadiaMono-Bold.woff2'];
self.addEventListener('install', event => {

View file

@ -1889,7 +1889,7 @@ body.editor-markdown-mode .editor-toolbar {
.resource-action-sheet { right: auto; }
}
/* --- Resource viewer (PWA in-app preview) --- */
/* --- Resource viewer (in-app preview: PWA action sheet + desktop image/attachment double-click) --- */
.resource-viewer {
position: fixed; inset: 0; z-index: 10020;
display: flex; flex-direction: column;

View file

@ -53,6 +53,12 @@ function makeTurndownCtx() {
// Run a snippet of code and the named functions it depends on, in a fresh ctx.
function runWithDeps(ctx, ...fns) {
// tinymceToMarkdown / htmlToMarkdown now delegate to _applyHeadingSpacing;
// pull it in automatically so callers don't each have to list it.
if ((fns.includes('tinymceToMarkdown') || fns.includes('htmlToMarkdown')) && !fns.includes('_applyHeadingSpacing')) {
fns = [...fns];
fns.splice(fns.indexOf('getTurndown') >= 0 ? fns.indexOf('getTurndown') + 1 : 0, 0, '_applyHeadingSpacing');
}
for (const fn of fns) vm.runInContext(extractFn(fn), ctx);
}
@ -137,6 +143,26 @@ test('tinymceToMarkdown returns empty string for empty input', () => {
assert.equal(result.trim(), '');
});
test('tinymceToMarkdown preserves a blank line INSIDE a code block (C #include is not a heading)', () => {
const ctx = makeTurndownCtx();
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
// This is what TinyMCE getContent() produces for the reported code block.
const html = '<pre class="language-c"><code>#include &lt;stdio.h&gt;\n\nint main() {\n return 0;\n}\n</code></pre>';
const result = vm.runInContext('tinymceToMarkdown(' + JSON.stringify(html) + ')', ctx);
// The blank line between the #include and int main() MUST survive — the
// heading-gap collapse must not treat `#include` as a markdown heading.
assert.ok(/#include <stdio\.h>\n\nint main/.test(result),
`blank line inside code block must be preserved, got: ${JSON.stringify(result)}`);
});
test('tinymceToMarkdown still collapses a blank line after a real ATX heading', () => {
const ctx = makeTurndownCtx();
runWithDeps(ctx, 'getTurndown', 'tinymceToMarkdown');
const result = vm.runInContext('tinymceToMarkdown("<h1>Title</h1><p>Body text</p>")', ctx);
assert.ok(/^# Title\nBody text/.test(result),
`heading collapse must still apply outside code, got: ${JSON.stringify(result)}`);
});
// ---------------------------------------------------------------------------
// app.js source-level guards for window exports
// ---------------------------------------------------------------------------

View file

@ -325,11 +325,14 @@ test('CM6 mounts into #cm-host and syncs both ways with #note-body', () => {
});
// ---------------------------------------------------------------------------
// Drag-and-drop image insertion: ALWAYS add a blank line after the image
// Drag-and-drop attachment insertion: a BLANK LINE before and after
// ---------------------------------------------------------------------------
// Requirement: when an image is dropped into a note, a blank line must follow
// so the caret lands on a fresh empty line and typed text is never glued to the
// image. This must hold in both markdown mode (CM6) and rendered mode (TinyMCE).
// Requirement: when an image OR a document is dropped/pasted into a note, it is
// placed on its own line with a blank (empty, deletable) line before and after.
// The blank line lets the user remove a single attachment even when several sit
// one after another in rendered mode, and it must survive markdown<->render
// round-trips. This holds in both markdown mode (CM6) and rendered mode
// (TinyMCE). "Smart": existing blank lines are topped up, not stacked.
const RID = 'a'.repeat(32);
@ -351,8 +354,9 @@ function makeUploadCtx() {
},
dispatch(tr) {
if (tr.changes) {
const { from, insert } = tr.changes;
doc = doc.slice(0, from) + insert + doc.slice(from);
const { from, to, insert } = tr.changes;
const end = typeof to === 'number' ? to : from;
doc = doc.slice(0, from) + insert + doc.slice(end);
}
},
};
@ -378,45 +382,44 @@ function makeUploadCtx() {
vm.runInContext(extractFn('_fileTooLarge'), ctx);
vm.runInContext(extractFn('_tooLargeMsg'), ctx);
vm.runInContext(extractFn('_escapeHtmlAttr'), ctx);
vm.runInContext(extractFn('_normalizeUploadInsert'), ctx);
vm.runInContext(extractFn('_uploadFileToCM'), ctx);
return ctx;
}
test('markdown mode: dropping an image inserts markdown followed by a blank line', async () => {
test('markdown mode: dropping an image into an empty doc adds a trailing blank line', async () => {
const ctx = makeUploadCtx();
// A fake image File-like object (name + image mime).
ctx.file = { name: 'cat.png', type: 'image/png', size: 10 };
await vm.runInContext('_uploadFileToCM(file)', ctx);
const doc = ctx._getDoc();
assert.equal(doc, '![cat.png](:/' + RID + ')\n\n',
`image insert must end with a blank line (\\n\\n), got: ${JSON.stringify(doc)}`);
assert.ok(doc.endsWith('\n\n'), 'inserted image markdown must be followed by a blank line');
`image insert must end with a blank line, got: ${JSON.stringify(doc)}`);
});
test('markdown mode: dropping an image before existing text keeps a blank line between them', async () => {
test('markdown mode: dropping an image after text pads it with a blank line before and after', async () => {
const ctx = makeUploadCtx();
// Pre-seed the doc so the cursor is at the end, text already present is at start.
vm.runInContext('_cmView.dispatch({changes:{from:0,insert:"existing text"}})', ctx);
ctx.file = { name: 'pic.jpg', type: 'image/jpeg', size: 10 };
await vm.runInContext('_uploadFileToCM(file)', ctx);
const doc = ctx._getDoc();
assert.equal(doc, 'existing text![pic.jpg](:/' + RID + ')\n\n',
`got: ${JSON.stringify(doc)}`);
// The image reference must be followed by a blank line (nothing glued after it).
assert.ok(/\]\(:\/[a-f0-9]{32}\)\n\n$/.test(doc), 'image must be trailed by a blank line');
assert.equal(doc, 'existing text\n\n![pic.jpg](:/' + RID + ')\n\n',
`image must be padded above and below, got: ${JSON.stringify(doc)}`);
});
test('markdown mode: dropping a NON-image file does NOT add a blank line', async () => {
test('markdown mode: dropping a NON-image file also gets padded above and below', async () => {
const ctx = makeUploadCtx();
vm.runInContext('_cmView.dispatch({changes:{from:0,insert:"existing text"}})', ctx);
ctx.file = { name: 'doc.pdf', type: 'application/pdf', size: 10 };
await vm.runInContext('_uploadFileToCM(file)', ctx);
const doc = ctx._getDoc();
assert.equal(doc, '[doc.pdf](:/' + RID + ')',
`non-image link must NOT get a trailing blank line, got: ${JSON.stringify(doc)}`);
assert.equal(doc, 'existing text\n\n[doc.pdf](:/' + RID + ')\n\n',
`non-image link must also be padded above and below, got: ${JSON.stringify(doc)}`);
});
test('rendered mode: _uploadFileToTinyMCE wraps image in its own <p> and adds a trailing empty <p>', async () => {
// Drive the real helper with a mock editor that records insertContent().
// Build a TinyMCE upload context with a mock editor. `selNode` is what
// editor.selection.getNode() returns (null => selection API unavailable, both
// blank-line paragraphs are added).
function makeTinyMCECtx(selNode) {
const dom = new JSDOM('<!DOCTYPE html><body></body>', { url: 'https://joplock.test' });
const ctx = vm.createContext({
document: dom.window.document,
@ -430,41 +433,77 @@ test('rendered mode: _uploadFileToTinyMCE wraps image in its own <p> and adds a
vm.runInContext(extractFn('_fileTooLarge'), ctx);
vm.runInContext(extractFn('_tooLargeMsg'), ctx);
vm.runInContext(extractFn('_escapeHtmlAttr'), ctx);
// _MD_BLANK_LINE_P is a top-level `var`; inject it directly.
vm.runInContext('var _MD_BLANK_LINE_P=\'<p class="md-blank-line"><br></p>\';', ctx);
vm.runInContext(extractFn('_isBlankLineBlock'), ctx);
vm.runInContext(extractFn('_tinyMCEBlockAttachmentHtml'), ctx);
vm.runInContext(extractFn('_uploadFileToTinyMCE'), ctx);
vm.runInContext('function getTA(){return null}function tinymceToMarkdown(h){return h}', ctx);
const inserted = [];
vm.runInContext("function getTA(){return null}function tinymceToMarkdown(h){return h}", ctx);
ctx.ed = { insertContent(html) { inserted.push(html); }, getContent() { return inserted.join(""); } };
ctx.ed = {
insertContent(html) { inserted.push(html); },
getContent() { return inserted.join(''); },
selection: selNode ? { getNode: () => selNode } : undefined,
};
ctx._inserted = inserted;
ctx.dom = dom;
return ctx;
}
test('rendered mode: dropping an image wraps it in its own <p> with a blank line before and after', async () => {
const ctx = makeTinyMCECtx(null);
ctx.file = { name: 'cat.png', type: 'image/png', size: 10 };
await vm.runInContext('_uploadFileToTinyMCE(file, ed)', ctx);
assert.equal(inserted.length, 1, 'exactly one insertContent call');
const html = inserted[0];
assert.equal(ctx._inserted.length, 1, 'exactly one insertContent call');
const html = ctx._inserted[0];
assert.ok(/<p><img [^>]*src="\/resources\/a{32}"[^>]*><\/p>/.test(html),
`image must be wrapped in its own <p>, got: ${html}`);
assert.ok(/<\/p><p><\/p>$/.test(html),
`image paragraph must be followed by an empty <p> (the blank line), got: ${html}`);
assert.ok(/^<p class="md-blank-line"><br><\/p><p><img/.test(html),
`image must be preceded by a blank-line paragraph, got: ${html}`);
assert.ok(/<\/p><p class="md-blank-line"><br><\/p>$/.test(html),
`image must be followed by a blank-line paragraph, got: ${html}`);
});
test('rendered mode: _uploadFileToTinyMCE does NOT add a trailing empty <p> for non-images', async () => {
const dom = new JSDOM('<!DOCTYPE html><body></body>', { url: 'https://joplock.test' });
const ctx = vm.createContext({
document: dom.window.document,
window: dom.window,
alert: () => {},
Promise,
FormData: function FormData() { this.append = () => {}; },
fetch: () => Promise.resolve({ json: () => Promise.resolve({ resourceId: RID }) }),
});
vm.runInContext(extractFn('_maxUploadBytes'), ctx);
vm.runInContext(extractFn('_fileTooLarge'), ctx);
vm.runInContext(extractFn('_tooLargeMsg'), ctx);
vm.runInContext(extractFn('_escapeHtmlAttr'), ctx);
vm.runInContext(extractFn('_uploadFileToTinyMCE'), ctx);
const inserted = [];
vm.runInContext("function getTA(){return null}function tinymceToMarkdown(h){return h}", ctx);
ctx.ed = { insertContent(html) { inserted.push(html); }, getContent() { return inserted.join(""); } };
test('rendered mode: dropping a NON-image file also gets a blank line before and after', async () => {
const ctx = makeTinyMCECtx(null);
ctx.file = { name: 'doc.pdf', type: 'application/pdf', size: 10 };
await vm.runInContext('_uploadFileToTinyMCE(file, ed)', ctx);
assert.ok(/^<a href="\/resources\/a{32}"/.test(inserted[0]),
`non-image must be an anchor, got: ${inserted[0]}`);
assert.ok(!/<p><\/p>/.test(inserted[0]), 'non-image must NOT get a trailing empty <p>');
const html = ctx._inserted[0];
assert.ok(/<p><a href="\/resources\/a{32}"[^>]*>doc\.pdf<\/a><\/p>/.test(html),
`non-image must be wrapped in its own <p>, got: ${html}`);
assert.ok(html.startsWith('<p class="md-blank-line"><br></p><p><a '),
`non-image must be preceded by a blank-line paragraph, got: ${html}`);
assert.ok(html.endsWith('</p><p class="md-blank-line"><br></p>'),
`non-image must be followed by a blank-line paragraph, got: ${html}`);
});
test('rendered mode: does not add a leading blank line when the caret block is empty', async () => {
const ctx = makeTinyMCECtx(null);
// An empty paragraph as the caret block acts as its own leading gap.
const emptyP = ctx.dom.window.document.createElement('p');
ctx.ed.selection = { getNode: () => emptyP };
ctx.file = { name: 'cat.png', type: 'image/png', size: 10 };
await vm.runInContext('_uploadFileToTinyMCE(file, ed)', ctx);
const html = ctx._inserted[0];
assert.ok(!/^<p class="md-blank-line">/.test(html),
`empty caret block should not get an extra leading blank line, got: ${html}`);
assert.ok(/<\/p><p class="md-blank-line"><br><\/p>$/.test(html),
`trailing blank-line paragraph still required, got: ${html}`);
});
test('rendered mode: does not stack a blank line next to an existing blank-line paragraph', async () => {
const ctx = makeTinyMCECtx(null);
const doc = ctx.dom.window.document;
// Caret block has text, but the previous sibling is already a blank-line <p>.
const container = doc.createElement('div');
const prev = doc.createElement('p'); prev.className = 'md-blank-line'; prev.innerHTML = '<br>';
const block = doc.createElement('p'); block.textContent = 'hello';
container.appendChild(prev); container.appendChild(block);
ctx.ed.selection = { getNode: () => block };
ctx.file = { name: 'cat.png', type: 'image/png', size: 10 };
await vm.runInContext('_uploadFileToTinyMCE(file, ed)', ctx);
const html = ctx._inserted[0];
assert.ok(!/^<p class="md-blank-line">/.test(html),
`should not add a leading blank line next to an existing one, got: ${html}`);
});

View file

@ -1615,8 +1615,8 @@ test('GET /api/web/notes/headers returns minimal note headers', async () => {
assert.equal(res.statusCode, 200);
const payload = JSON.parse(res.body);
assert.deepEqual(payload.items, [
{ id: 'n1', title: 'Note 1' },
{ id: 'n2', title: 'Note 2' }
{ id: 'n1', title: 'Note 1', parentId: 'f1' },
{ id: 'n2', title: 'Note 2', parentId: 'f1' }
]);
});
});

View file

@ -107,9 +107,35 @@ const previewRoundTrip = markdown => {
md = md.split(`<br>${nl}`).join(nl);
while (md.includes('<br><br>')) md = md.split('<br><br>').join(`<br>${nl}`);
md = md.replace(/^-\s{2,}/gm, '- ');
md = md.replace(headingLeadRe, `$1${nl}$2`);
// Heading spacing collapse must NOT touch fenced code blocks (mirror the app's
// _applyHeadingSpacing). A C `#include` line is not a markdown heading.
const applyHeadingSpacing = s => {
const fix = seg => seg.replace(headingLeadRe, `$1${nl}$2`);
const fenceRe = /```[\s\S]*?```/g;
let acc = '';
let last = 0;
let mm;
while ((mm = fenceRe.exec(s)) !== null) {
acc += fix(s.slice(last, mm.index)) + mm[0];
last = mm.index + mm[0].length;
}
return acc + fix(s.slice(last));
};
md = applyHeadingSpacing(md);
md = md.replace(/^(\d+)\.\s+/gm, '$1. ');
md = md.replace(headingGapRe, `$1${nl}`);
// headingGapRe, also masked around code blocks.
{
const fixGap = seg => seg.replace(headingGapRe, `$1${nl}`);
const fenceRe = /```[\s\S]*?```/g;
let acc = '';
let last = 0;
let mm;
while ((mm = fenceRe.exec(md)) !== null) {
acc += fixGap(md.slice(last, mm.index)) + mm[0];
last = mm.index + mm[0].length;
}
md = acc + fixGap(md.slice(last));
}
md = md.replace(new RegExp(`${nl}${nl}<br>$`), '');
// Replace runs of blank-line placeholders: count them, emit \n\n + one extra \n per placeholder
md = md.replace(/\n*(?:\x00BL\x00\n*)+/g, m => {
@ -266,6 +292,15 @@ test('preview round-trip preserves fenced code block line breaks', () => {
assert.equal(previewRoundTrip(body), body);
});
test('preview round-trip preserves a blank line inside a code block', () => {
// A C #include line must not be treated as a markdown heading whose trailing
// blank line gets collapsed. The blank line between the two code lines stays.
const body = '```c\n#include <stdio.h>\n\nint main() {\n return 0;\n}\n```';
const out = previewRoundTrip(body);
assert.ok(/#include <stdio\.h>\n\nint main/.test(out),
`blank line inside code block must survive, got: ${JSON.stringify(out)}`);
});
test('preview round-trip ignores injected code block copy buttons', () => {
const body = '```\nline one\nline two\n```';
assert.equal(previewRoundTripWithCopyButtons(body), body);
@ -456,3 +491,39 @@ test('logged in layout has persistent TinyMCE host outside editor-panel and outs
assert.ok(appCloseIdx !== -1, '.app closing tag structure must be recognisable');
assert.ok(hostIdx > appCloseIdx, '#tinymce-host must be outside .app so mobile media query does not hide it');
});
// Attachment blank-line separators must survive the markdown<->render round-trip
// so that stacked attachments stay individually deletable in rendered mode.
const RID_A = 'a'.repeat(32);
const RID_B = 'b'.repeat(32);
test('a blank line between two images renders as a deletable empty paragraph', () => {
// This is what a drop produces: image, blank line, image.
const md = `![one](:/${RID_A})\n\n\n![two](:/${RID_B})`;
const html = renderMarkdown(md);
// The blank line becomes a real md-blank-line paragraph between the images.
assert.ok(/class="md-blank-line"/.test(html),
`expected a md-blank-line separator paragraph, got: ${html}`);
const firstImg = html.indexOf(`/resources/${RID_A}`);
const blank = html.indexOf('md-blank-line');
const secondImg = html.indexOf(`/resources/${RID_B}`);
assert.ok(firstImg < blank && blank < secondImg,
'blank-line paragraph must sit between the two images');
});
test('round-trip: blank line between two images is preserved (attachments stay separable)', () => {
const md = `![one](:/${RID_A})\n\n\n![two](:/${RID_B})`;
const out = previewRoundTrip(md);
// A blank line == a run of three newlines between the two image refs.
assert.ok(out.includes(`:/${RID_A})\n\n\n![two](:/${RID_B})`),
`blank line between images must survive the round-trip, got: ${JSON.stringify(out)}`);
});
test('round-trip: blank lines between three stacked attachments survive', () => {
const md = `![one](:/${RID_A})\n\n\n[doc](:/${RID_B})\n\n\n![three](:/${'c'.repeat(32)})`;
const out = previewRoundTrip(md);
assert.ok(out.includes(`:/${RID_A})\n\n\n[doc](:/${RID_B})`),
`gap between image and document must survive, got: ${JSON.stringify(out)}`);
assert.ok(out.includes(`:/${RID_B})\n\n\n![three]`),
`gap between document and image must survive, got: ${JSON.stringify(out)}`);
});

View file

@ -762,6 +762,39 @@ test('app script uses iOS-safe resource download helper', () => {
assert.ok(appJs.includes('function _openResourceViewer(blob,mime,filename){'));
});
test('double-click opens image/attachment in the in-app lightbox (desktop)', () => {
const appJs = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
// Lightbox entry point: viewable -> in-app viewer, else download.
assert.ok(appJs.includes('function _openResourceLightbox(resourceId){'));
assert.ok(appJs.includes('if(meta&&!_canPreviewResourceMime(meta.mime)){_triggerResourceDownload(id);return}'));
assert.ok(appJs.includes('_fetchResourceBlob(id).then(function(r){_openResourceViewer(r.blob,r.mime,r.filename)})'));
// TinyMCE (desktop rendered mode) dblclick wiring on img/attachment link.
assert.ok(appJs.includes("editor.on('dblclick',function(e){"));
assert.ok(appJs.includes("var el=target.closest('img[data-resource-id],a[data-resource-id]');"));
// Preview/contenteditable dblclick wiring, desktop-only, no single-click open.
assert.ok(appJs.includes("pv.addEventListener('dblclick',function(e){if(!isDesktopMode())return;var el=e.target.closest('img.preview-img[data-resource-id],a[data-resource-id]');"));
assert.ok(!appJs.includes('e.preventDefault();_openResourceView(resourceId)}'), 'desktop single-click image open removed');
// Esc closes the lightbox first; lightbox counts as an open modal.
assert.ok(appJs.includes("var resViewer=document.getElementById('resource-viewer');if(resViewer){_closeResourceViewer();return}"));
assert.ok(appJs.includes("'new-folder-modal','resource-viewer'"));
// Overlay owns its own Escape + focus so it closes even when focus was in the
// TinyMCE iframe (whose key events never reach the document handler).
assert.ok(appJs.includes("overlay.addEventListener('keydown',function(e){if(e.key==='Escape'){e.preventDefault();e.stopPropagation();_closeResourceViewer()}});"));
});
test('mode switch reconciles away the spurious "Edited" from the content round-trip', () => {
const appJs = fs.readFileSync(path.join(__dirname, '../public/app.js'), 'utf8');
// The reconcile helper exists and is invoked after switching to markdown.
assert.ok(appJs.includes('function _reconcileSaveStateAfterModeSwitch(){'));
assert.ok(appJs.includes('_reconcileSaveStateAfterModeSwitch();'));
// It only resets to "Saved" when the note is NOT actually dirty, so a real
// pending change (e.g. an upload right before the switch) still saves.
assert.ok(appJs.includes("if(typeof _activeEditorIsDirty==='function'?!_activeEditorIsDirty():formHash(form)===_savedHash){"));
assert.ok(appJs.includes('setSaveState(\'<span class="autosave-ok">Saved</span>\',\'Saved\');'));
// The form listeners are NOT globally gated (that would suppress real saves).
assert.ok(appJs.includes("form.addEventListener('input',function(e){if(_isLockedOverlayEventTarget(e.target))return;markEdited();scheduleSave()})"));
});
test('searchResultsFragment renders note items', () => {
const notes = [{ id: 'n1', title: 'My Note', body: '', bodyPreview: '', parentId: 'f1', deletedTime: 0 }];
const html = searchResultsFragment(notes);