mirror of
https://github.com/abort-retry-ignore/joplock.git
synced 2026-10-19 10:13:51 +00:00
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:
parent
598a0333fe
commit
08e42d5227
21 changed files with 1187 additions and 248 deletions
|
|
@ -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 `` 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)` 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.
|
||||
|
|
|
|||
|
|
@ -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}` });
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
93
playwright-tests/attachment-padding.spec.js
Normal file
93
playwright-tests/attachment-padding.spec.js
Normal 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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
81
playwright-tests/code-block-blank-line.spec.js
Normal file
81
playwright-tests/code-block-blank-line.spec.js
Normal 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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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');
|
||||
|
|
|
|||
138
playwright-tests/global-teardown.js
Normal file
138
playwright-tests/global-teardown.js
Normal 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();
|
||||
}
|
||||
};
|
||||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
109
playwright-tests/image-lightbox.spec.js
Normal file
109
playwright-tests/image-lightbox.spec.js
Normal 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 ').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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
90
playwright-tests/mode-switch-edited.spec.js
Normal file
90
playwright-tests/mode-switch-edited.spec.js
Normal 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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
@ -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 ').toBeTruthy();
|
||||
|
|
|
|||
|
|
@ -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(() => {});
|
||||
}
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
138
public/app.js
138
public/app.js
|
|
@ -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,'&').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(' ');
|
||||
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)}
|
||||
|
|
|
|||
|
|
@ -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 => {
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 <stdio.h>\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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
|
|
|||
|
|
@ -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, '\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\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\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}`);
|
||||
});
|
||||
|
||||
|
|
|
|||
|
|
@ -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' }
|
||||
]);
|
||||
});
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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 = `\n\n\n`;
|
||||
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 = `\n\n\n`;
|
||||
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`),
|
||||
`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 = `\n\n\n[doc](:/${RID_B})\n\n\n})`;
|
||||
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)}`);
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue