Files
Netcatty/components/notes/NotesManager.test.tsx
T
陈大猫andnetcatty-bot f9803f3642 fix(notes): domain paste policy + Codex review follow-ups (#2869)
* fix(notes): domain paste policy + Codex review follow-ups

Move clipboard paste resolution to domain/notes (pure, no React) and
address post-#2868 review items: structured text/plain wins over
wrapper HTML, caret-safe insert for long pastes (no EOF append),
https/protocol-relative image src, entity decode, quote-aware img
attrs, code-region protection, nested HTML islands, hard-break
preservation, height-only image CSS, and complete small-icon widths.

* fix: address Codex review on PR #2869

* perf(notes): cut hover re-renders and heavy image :has() CSS

Reduce perceived lag in large notes by (1) coalescing link-hover
setState with equality checks and rAF, (2) replacing the combinatorial
:has(img[width]) matrix with data-note-img-size markers plus lazy
images, and (3) merging DOM decoration into one observer that debounces
in edit mode so Lexical keystrokes do not re-walk anchors every frame.

* perf(notes): render preview with Streamdown instead of MDXEditor

Vault note preview no longer mounts Lexical/MDXEditor/CodeMirror for
read-only viewing. Preview uses static Streamdown (same stack as chat)
with HTML islands for centered heroes and sized images; edit mode keeps
MDXEditor. This cuts large-note scroll and mode-switch cost while
preserving host-link click handling and compact badge image layout.

* fix(notes): render markdown inside center HTML for Streamdown preview

Streamdown/remark does not parse Markdown nested in HTML blocks, so
README-style <div align="center"> heroes showed raw # ** and badge
syntax. Preprocess preview markdown: expand centered islands via marked
GFM to HTML, and rewrite relative images so Streamdown no longer shows
"Image blocked" chips under app CSP.

* fix(notes): GitHub-style preview with react-markdown

Replace Streamdown note preview (chat-oriented Image blocked chrome and
poor README layout) with react-markdown + remark-gfm + rehype-raw/sanitize
and github-markdown-css. Center HTML islands still expand via marked so
pasted heroes render; relative images and empty links are dropped quietly
instead of alt-as-title or blocked chips.

* fix(notes): keep relative images; map Vite public/ to site root

Stop dropping non-https note images. Preserve absolute and relative src
as-is, except rewrite repo public/ assets to site-root paths
(public/icon.png → /icon.png) so Vite/Electron serve them without
/public/* warnings. Unwrap oversized center blocks so Features-style
body stays left-aligned in preview like edit mode.

* fix(notes): left-align preview body; center only README hero

Force markdown-body text-align left and direct block headings/lists left.
After the first hero thematic break, strip align=center / text-align:center
so Catty/Features sections no longer inherit full-width centering. Tighten
oversized-center heuristics (Catty Agent, lists, blockquotes) without
treating the Netcatty logo title as a body section.

* fix(notes): force preview body left-align (nuclear CSS + unwrap)

Catty/Features still rendered centered because small align=center shells
and inheritance beat partial CSS. Force every node left via * selector,
re-enable center only under [align=center], unwrap all center shells after
the hero --- band, and set inline textAlign left on the preview root.

* fix(notes): stop /public asset requests; fix preview center inheritance

Root cause of body still centering: CSS re-applied text-align:center to
all descendants of [align=center], so one large shell centered Catty/lists.
Keep * left; only the shell itself may center (inline badges).

Root cause of Vite /public/* warnings: stored notes still had /public/...
paths and MDX loaded them raw. normalizeNotePublicAssetPaths rewrites
/public/ and public/ to site-root paths for both edit and preview display.

* revert(notes): restore MDXEditor for preview (drop GitHub markdown path)

GitHub-style react-markdown preview caused layout and asset-path issues
that diverged from edit mode. Preview again uses MDXEditor with
readOnly + mode key remount, matching the pre-Streamdown/GitHub path.
Keep display-time public/ → / rewrite so Vite does not request /public/*.

* perf(notes): reuse MDX editor when switching notes

Remove key={noteId} remount that tore down Lexical/CodeMirror on every
note change. Pass noteId and call setMarkdown on switch instead, after
the existing draft flush, so selection stays snappy and only document
content is replaced.

* perf(notes): defer setMarkdown on switch so selection paints first

Yield a double rAF before Lexical import on note change, show a
blocking overlay for large notes, and ignore external sync while a
swap is in flight. Hardens flaky Suspense loading assertions in SSR tests.

* fix(notes): align task-list checkboxes and make them clickable in preview

Center GFM checkboxes on the first text line, give them a solid hit target,
and toggle markdown from preview clicks since Lexical readOnly blocks
CheckListPlugin. Pure toggle helper lives in domain/notes/taskList.

* fix(notes): close multi-agent review findings on switch/paste/tasks

Gate stale onChange during deferred note-switch import, keep draft refs
in display-markdown space, baseline paste settle on live getMarkdown to
avoid false success after muted setMarkdown, mask code regions in task
toggles, and drop abandoned GitHub/Streamdown preview module and deps.

* fix(notes): address remaining Codex review findings

Clear Lexical undo history on note switch, always inert-block during
deferred setMarkdown, re-annotate host links and image sizes on attribute
mutations, harden entity/code-region paste paths, and angle-bracket image
destinations that contain spaces.

* fix(notes): tighten Codex follow-ups on src, code masks, tasks

Prefer real img src over data-src, normalize public/ paths only outside
code, mask indented/blockquote fences, and accept 1) ordered task markers.

* fix(notes): keep swap payloads fresh; keep nested list tasks

Refresh deferred setMarkdown when the same note's value changes mid-yield,
and stop treating nested list lines as indented code so task toggles stay
aligned with the preview DOM.

* test(notes): update swap guard source assertions

* fix(notes): collision-free code masks, real href, cancel hover rAF

Use a document-unique mask sentinel, match anchor href with attribute
boundaries (not data-href), and cancel the pending link-hover frame on
mouse leave so chips cannot reappear after exit.

* fix(notes): accept longer closing fences in code masks

CommonMark allows closing fences with more backticks/tildes than the
opener; line-scan masking now matches length >= open so public/ paths and
task samples inside those blocks stay protected.

* fix(notes): harden CommonMark fence, span, and indented-code masks

Use homogeneous fence openers, multi-backtick inline spans, and list-context
aware indented-code detection so samples stay masked without breaking nested
task indexing.

* fix(notes): task spacing, angled image dests, deep blockquote fences

Require whitespace after task brackets, parse <angled> image destinations
with spaces, and mask fenced code at any blockquote depth.

* fix(notes): raw-text HTML elements and HTML comment task masks

Locate script/style closers without nested-tag walks so raw bodies with
'<' are removed, and mask HTML comments before task indexing so preview
toggles hit visible checkboxes only.

* perf(notes): drop fade animation on note switch and editor load

Use an instant solid cover instead of opacity/transform fade-in so Lexical
swaps do not pay extra composite cost that can feel more janky than a hard cut.

---------

Co-authored-by: netcatty-bot <308658023+netcatty-bot@users.noreply.github.com>
2026-08-10 22:33:26 +08:00

324 lines
12 KiB
TypeScript

import test from "node:test";
import assert from "node:assert/strict";
import { readFileSync } from "node:fs";
import React from "react";
import { renderToStaticMarkup } from "react-dom/server";
import { I18nProvider } from "../../application/i18n/I18nProvider.tsx";
import type { VaultNote } from "../../types.ts";
import { TooltipProvider } from "../ui/tooltip.tsx";
import {
getFallbackNoteSelectionState,
getNoteActionTargetGroup,
getNoteGroupSelectionState,
getNotesGroupDropAction,
getNoteSelectionState,
getValidatedNoteSelectionState,
getSelectedVaultNote,
isNoteFolderTreeSelected,
NotesManager,
} from "./NotesManager.tsx";
const note = (overrides: Partial<VaultNote> = {}): VaultNote => ({
id: "note-1",
title: "Postgres failover checklist",
content: "# Steps\n\nPromote replica",
group: "Ops",
createdAt: 1,
updatedAt: 1,
order: 1000,
...overrides,
});
const renderNotes = (
notes: VaultNote[] = [note()],
displayMode: React.ComponentProps<typeof NotesManager>["displayMode"] = "full",
noteGroups: string[] = ["Ops"],
openNoteId: string | null = null,
) => renderToStaticMarkup(
<I18nProvider locale="en">
<TooltipProvider>
<NotesManager
notes={notes}
noteGroups={noteGroups}
hosts={[]}
onUpdateNotes={() => undefined}
onUpdateNoteGroups={() => undefined}
displayMode={displayMode}
openNoteId={openNoteId}
/>
</TooltipProvider>
</I18nProvider>,
);
test("NotesManager renders notes tree and selected markdown editor", () => {
const markup = renderNotes();
assert.match(markup, /Ops/);
assert.match(markup, /Postgres failover checklist/);
// React.lazy may show Suspense fallback or the resolved editor depending on
// whether a prior test already settled the MDX chunk.
assert.ok(
markup.includes('data-notes-editor-loading="true"')
|| markup.includes("editable markdown")
|| markup.includes("Promote replica"),
"full mode should mount the note editor (loading fallback or resolved MDX)",
);
});
test("NotesManager marks selected notebook rows with shared tree state", () => {
const markup = renderNotes();
assert.match(markup, /data-vault-tree-row="group"/);
assert.match(markup, /data-vault-tree-row="item"/);
assert.equal(markup.match(/data-selected="true"/g)?.length, 1);
assert.match(markup, /data-vault-tree-row="group"[^>]*data-selected="false"/);
assert.match(markup, /data-vault-tree-row="item"[^>]*data-selected="true"/);
});
test("NotesManager balances folder and note tree icon sizes", () => {
const markup = renderNotes();
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-folder/);
assert.match(markup, /width="16" height="16"[^>]*class="lucide lucide-file-text/);
});
test("NotesManager selection helpers keep note and folder selection exclusive", () => {
const notes = [note(), note({ id: "note-2", title: "Deploy", group: "Deploy" })];
const noteSelection = getNoteSelectionState(notes[0], false);
const sidebarNoteSelection = getNoteSelectionState(notes[0], true);
const groupSelection = getNoteGroupSelectionState("Ops");
assert.equal(getSelectedVaultNote(notes, "note-1")?.title, "Postgres failover checklist");
assert.equal(getSelectedVaultNote(notes, null), null);
assert.deepEqual(noteSelection, {
selectedNoteId: "note-1",
selectedGroup: null,
overlayNoteId: null,
});
assert.deepEqual(sidebarNoteSelection, {
selectedNoteId: "note-1",
selectedGroup: null,
overlayNoteId: "note-1",
});
assert.deepEqual(groupSelection, {
selectedNoteId: null,
selectedGroup: "Ops",
overlayNoteId: null,
});
assert.equal(isNoteFolderTreeSelected("Ops", "note-1", "Ops"), false);
assert.equal(isNoteFolderTreeSelected("Ops", null, "Ops"), true);
assert.equal(getNoteActionTargetGroup(notes[0], "Deploy"), "Ops");
assert.equal(getNoteActionTargetGroup(null, "Deploy"), "Deploy");
});
test("NotesManager note creation, duplicate, and delete fallback selections stay exclusive", () => {
const notes = [
note(),
note({ id: "note-2", title: "Deploy", group: "Deploy", order: 2000 }),
];
const createdOrDuplicated = note({ id: "new-note", group: "Ops" });
assert.deepEqual(getNoteSelectionState(createdOrDuplicated, false), {
selectedNoteId: "new-note",
selectedGroup: null,
overlayNoteId: null,
});
assert.deepEqual(getNoteSelectionState(createdOrDuplicated, true), {
selectedNoteId: "new-note",
selectedGroup: null,
overlayNoteId: "new-note",
});
assert.deepEqual(getFallbackNoteSelectionState(notes.slice(1), false), {
selectedNoteId: "note-2",
selectedGroup: null,
overlayNoteId: null,
});
assert.deepEqual(getFallbackNoteSelectionState(notes.slice(1), true), {
selectedNoteId: null,
selectedGroup: null,
overlayNoteId: null,
});
});
test("NotesManager selects the first loaded note when full mode has no selection", () => {
const notes = [
note({ id: "note-2", title: "Loaded note", group: "Ops", order: 2000 }),
];
assert.deepEqual(getValidatedNoteSelectionState(notes, null, null, false), {
selectedNoteId: "note-2",
selectedGroup: null,
overlayNoteId: null,
});
assert.equal(getValidatedNoteSelectionState(notes, null, "Ops", false), null);
assert.equal(getValidatedNoteSelectionState(notes, null, null, true), null);
});
test("NotesManager group drop helper separates reorder, inside, and ignored drops", () => {
assert.equal(getNotesGroupDropAction("Ops", "Deploy", "before"), "reorder");
assert.equal(getNotesGroupDropAction("Ops", "Deploy", "after"), "reorder");
assert.equal(getNotesGroupDropAction("Ops", "Deploy", "inside"), "inside");
assert.equal(getNotesGroupDropAction("Ops", "Ops", "before"), "ignore");
assert.equal(getNotesGroupDropAction("Ops", "Ops/DB", "inside"), "ignore");
assert.equal(getNotesGroupDropAction(null, "Deploy", "before"), "ignore");
});
test("NotesManager exposes shared tree drag targets and context menus", () => {
const markup = renderNotes();
assert.match(markup, /data-notes-drop-zone="root"/);
assert.match(markup, /data-notes-drag-kind="group"/);
assert.match(markup, /data-notes-drag-kind="note"/);
assert.match(markup, /data-notes-context-menu="group"/);
assert.match(markup, /data-notes-context-menu="note"/);
assert.match(markup, /draggable="true"/);
assert.match(markup, /data-open="false"/);
assert.match(markup, /role="separator"/);
});
test("NotesManager restores and persists the note editor mode without resetting on note switch", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /STORAGE_KEY_VAULT_NOTES_EDITOR_MODE/);
assert.match(source, /useStoredString<NoteEditorMode>\(\s*STORAGE_KEY_VAULT_NOTES_EDITOR_MODE,\s*"edit",\s*isNoteEditorMode/s);
assert.doesNotMatch(source, /localStorageAdapter/);
assert.doesNotMatch(source, /setNoteEditorMode\("edit"\)/);
});
test("NotesManager renders nested notebook folders", () => {
const markup = renderNotes([
note({
group: "Ops/DB/Failover",
title: "Replica promotion",
content: "Promote replica",
}),
]);
assert.match(markup, /Ops/);
assert.match(markup, /DB/);
assert.match(markup, /Failover/);
assert.match(markup, /Replica promotion/);
});
test("NotesManager keeps saved notebook folder order", () => {
const markup = renderNotes(
[
note({ id: "alpha-note", title: "Alpha note", group: "Alpha" }),
note({ id: "beta-note", title: "Beta note", group: "Beta" }),
],
"full",
["Beta", "Alpha"],
);
assert.ok(markup.indexOf("Beta") < markup.indexOf("Alpha"));
});
test("NotesManager renders empty state", () => {
const markup = renderNotes([]);
assert.match(markup, /No notes yet/);
assert.match(markup, /New Note/);
assert.match(markup, /Import Markdown/);
assert.doesNotMatch(markup, /data-notes-drop-zone="root"/);
});
test("NotesManager exposes markdown import controls", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /notes\.action\.importMarkdown/);
assert.match(source, /importMarkdownPayloadsToVaultNotes/);
assert.match(source, /accept="\.md,\.markdown,\.txt"/);
assert.match(source, /importTargetGroupRef/);
assert.match(source, /openImportMarkdownPicker/);
assert.match(source, /notes\.action\.importMarkdown[\s\S]*openImportMarkdownPicker\(groupPath\)/);
assert.match(source, /multiple/);
});
test("NotesManager exposes markdown export controls", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /notes\.action\.exportNote/);
assert.match(source, /notes\.action\.exportGroup/);
assert.match(source, /notes\.action\.exportAll/);
assert.match(source, /buildVaultNoteMarkdownExportFiles/);
assert.match(source, /buildTextFilesZipBlob/);
assert.match(source, /downloadNotesBlob/);
assert.match(source, /text\/markdown;charset=utf-8/);
});
test("NotesManager shows placeholder label for notes without titles", () => {
const markup = renderNotes([note({ title: "" })]);
assert.match(markup, /Note title/);
});
test("NotesManager flushes drafts on pagehide/beforeunload", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /addEventListener\("pagehide"/);
assert.match(source, /addEventListener\("beforeunload"/);
assert.match(source, /flushNoteDraft/);
});
test("NotesManager flushes drafts when retained mounts become inactive", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /isActive\?: boolean/);
assert.match(source, /if \(isActive\) return;/);
assert.match(source, /useLayoutEffect\(\(\) => \{\s*if \(isActive\) return;/);
assert.match(source, /visibilitychange/);
});
test("NotesManager tree rename allows clearing note titles", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
const renameBlock = source.match(/onRenameCommit=\{\(name\) => \{[\s\S]*?\}\}/)?.[0] ?? "";
assert.match(renameBlock, /renameNoteFromTree\(note\.id, name\)/);
assert.doesNotMatch(renameBlock, /if \(!title\) return/);
assert.match(source, /note\.id === noteId \? \{ \.\.\.note, title: nextTitle, updatedAt \}/);
});
test("NotesManager sidebar mode renders list without editor by default", () => {
const markup = renderNotes([note()], "sidebar");
assert.match(markup, /Ops/);
assert.match(markup, /Postgres failover checklist/);
assert.doesNotMatch(markup, /editable markdown/);
assert.doesNotMatch(markup, /data-notes-editor-loading="true"/);
});
test("NotesManager sidebar mode opens the requested note without selecting its folder", () => {
const markup = renderNotes(
[
note(),
note({
id: "note-2",
title: "Deploy overlay",
content: "Deploy overlay content",
group: "Ops",
order: 2000,
}),
],
"sidebar",
["Ops"],
"note-2",
);
assert.match(markup, /Deploy overlay/);
assert.ok(
markup.includes('data-notes-editor-loading="true"')
|| markup.includes("Deploy overlay content")
|| markup.includes("editable markdown"),
"openNoteId should mount the sidebar note overlay editor",
);
assert.equal(markup.match(/data-selected="true"/g)?.length, 1);
assert.match(markup, /data-vault-tree-row="group"[^>]*data-selected="false"/);
assert.match(markup, /data-vault-tree-row="item"[^>]*data-selected="true"[^>]*data-note-id="note-2"/);
});
test("NotesManager can re-open the same sidebar note when the request id changes", () => {
const source = readFileSync(new URL("./NotesManager.tsx", import.meta.url), "utf8");
assert.match(source, /openNoteRequestId\?: number \| null/);
assert.match(source, /\[isSidebarMode, onOpenNoteIdHandled, openNoteId, openNoteRequestId, sortedNotes\]/);
});