Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
21 changes: 18 additions & 3 deletions CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -178,13 +178,28 @@ document can never contain something the editor can't open. A `.md`/`.markdown`
file takes the same route via `marked` (`breaks: true`, so a note's line breaks
survive as `<br>` and every remaining newline sits strictly between block tags),
followed by a `DOMParser` pass that degrades what the schema has no node for:
tables flatten to one paragraph per cell (as in `docx.ts`), GFM task items become
`☐`/`☑` text, and image references — markdown embeds no bytes — are kept only
when absolute, with relative paths dropped and warned about. YAML front matter is
GFM task items become `☐`/`☑` text, and image references — markdown embeds no
bytes — are kept only when absolute, with relative paths dropped and warned
about. Tables are **not** degraded any more (see the table note below). YAML front matter is
stripped, its `title` preferred over the filename. The three-stage pipeline, and
what adding a fourth format takes, are documented in
[`anything-frontend/src/lib/agent.md`](anything-frontend/src/lib/agent.md).

The editor schema also has **tables** (`@tiptap/extension-table`'s `TableKit`,
pinned to the exact version the rest of the `@tiptap/*` stack sits on — its
peers are exact, so a caret range pulls in a second `@tiptap/pm` and two
ProseMirror copies). Column resizing is deliberately off, which does *not* cost
the `div.tableWrapper` element — Tiptap installs its own `TableView` node view
for precisely the non-resizable case, in the read-only renderer too, and
`NOTE_PROSE_CLASSES` scrolls a too-wide table on that wrapper. Both importers
now produce real tables: `docx.ts` walks `w:tr`/`w:tc` (direct children only, so
a nested table isn't double-counted), turning `w:gridSpan` into `colspan` and
`w:vMerge` continuations into the starter cell's `rowspan`, and treating a row
as a header when it declares `w:tblHeader` or — since most Word tables never set
that — when the whole first row is bold; `markdown.ts` simply lets `marked`'s
GFM table markup through. Neither emits `<thead>`: the schema has no node for it
and ProseMirror descends through it anyway.

All endpoints are under `/api/somethings`:
- `GET /` — List all (non-deleted)
- `GET /{id}` — Get by ID
Expand Down
142 changes: 128 additions & 14 deletions anything-frontend/e2e/visual.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,90 @@ const mockNoteDetailWithImage = {
modifiedOn: null,
};

// A note whose body includes tables: a plain one, and a six-column one that is
// wider than the phone viewport, so the snapshot covers the wrapper's
// horizontal scroll as well as ordinary table typography.
/** Serves one note fixture from `GET /api/notes/{id}`, leaving other verbs to the default mocks. */
const routeNoteDetail = (page: Page, note: object) =>
page.route(/\/api\/notes\/\d+$/, (route) => {
if (route.request().method() === "GET") {
route.fulfill({ json: note });
} else {
route.continue();
}
});

const mockNoteDetailWithTable = {
id: 4,
title: "Camping kit",
contentJson: JSON.stringify({
type: "doc",
content: [
{ type: "paragraph", content: [{ type: "text", text: "What each of us is bringing:" }] },
{
type: "table",
content: [
{
type: "tableRow",
content: [
{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Item" }] }] },
{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Qty" }] }] },
{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Who" }] }] },
],
},
{
type: "tableRow",
content: [
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "Tent" }] }] },
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "1" }] }] },
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "Alex" }] }] },
],
},
{
type: "tableRow",
content: [
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "Stove" }] }] },
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "2" }] }] },
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "Sam" }] }] },
],
},
],
},
{ type: "paragraph", content: [{ type: "text", text: "Meal plan:" }] },
{
type: "table",
content: [
{
type: "tableRow",
content: [
{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Day" }] }] },
{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Breakfast" }] }] },
{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Lunch" }] }] },
{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Dinner" }] }] },
{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Driver" }] }] },
{ type: "tableHeader", content: [{ type: "paragraph", content: [{ type: "text", text: "Notes" }] }] },
],
},
{
type: "tableRow",
content: [
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "Friday" }] }] },
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "Porridge and coffee" }] }] },
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "Sandwiches" }] }] },
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "Chilli from the pot" }] }] },
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "Alex" }] }] },
{ type: "tableCell", content: [{ type: "paragraph", content: [{ type: "text", text: "Arrive before dark" }] }] },
],
},
],
},
],
}),
contentText: "What each of us is bringing: Item Qty Who Tent 1 Alex Stove 2 Sam",
createdOn: "2025-01-06T09:00:00Z",
modifiedOn: null,
};

// A note with no body, to cover the "nothing written yet" detail state.
const mockEmptyNoteDetail = {
id: 2,
Expand Down Expand Up @@ -1028,19 +1112,17 @@ test.describe("Visual Snapshots - Authenticated Pages", () => {
// The title lives in the top bar only — there is no title field on the page.
await expect(page.getByRole("heading", { name: "Wifi password", level: 1 })).toBeVisible();
await expect(page.getByRole("toolbar", { name: "Formatting" })).toBeVisible();
// Named explicitly because a toolbar button is a small fraction of a
// mostly-blank full-page screenshot — under the snapshot comparison's
// diff-ratio threshold, so a baseline predating it would still "match".
await expect(page.getByRole("button", { name: "Insert table" })).toBeVisible();
await expect(page.getByRole("button", { name: "Rename note" })).toBeVisible();
await expect(page.getByRole("button", { name: "Save" })).toHaveCount(0);
await expect(page).toHaveScreenshot("note-detail.png", screenshotOptions);
});

test("note detail - empty body", async ({ page }) => {
await page.route(/\/api\/notes\/\d+$/, (route) => {
if (route.request().method() === "GET") {
route.fulfill({ json: mockEmptyNoteDetail });
} else {
route.continue();
}
});
await routeNoteDetail(page, mockEmptyNoteDetail);
await page.goto("/notes/2");
await page.waitForLoadState("networkidle");
// The placeholder is CSS-generated, so assert on the editing surface itself.
Expand All @@ -1049,19 +1131,51 @@ test.describe("Visual Snapshots - Authenticated Pages", () => {
});

test("note detail - with an embedded image", async ({ page }) => {
await page.route(/\/api\/notes\/\d+$/, (route) => {
if (route.request().method() === "GET") {
route.fulfill({ json: mockNoteDetailWithImage });
} else {
route.continue();
}
});
await routeNoteDetail(page, mockNoteDetailWithImage);
await page.goto("/notes/3");
await page.waitForLoadState("networkidle");
await expect(page.getByRole("img")).toBeVisible();
await expect(page).toHaveScreenshot("note-detail-with-image.png", screenshotOptions);
});

test("note detail - with tables", async ({ page }) => {
await routeNoteDetail(page, mockNoteDetailWithTable);
await page.goto("/notes/4");
await page.waitForLoadState("networkidle");
// Assert the table actually rendered before screenshotting: a baseline
// generated before the table nodes existed would otherwise pass silently.
await expect(page.getByRole("columnheader", { name: "Item" })).toBeVisible();
await expect(page.getByRole("cell", { name: "Tent" })).toBeVisible();
await expect(page).toHaveScreenshot("note-detail-with-table.png", screenshotOptions);
});

test("note detail - table controls with the caret in a cell", async ({ page }) => {
await routeNoteDetail(page, mockNoteDetailWithTable);
await page.goto("/notes/4");
await page.waitForLoadState("networkidle");
await page.getByRole("cell", { name: "Tent" }).click();
// Clicking a cell only moves the selection, which changes no React state —
// this row appearing is what proves the toolbar subscribes to the editor
// rather than reading it during render.
await expect(page.getByRole("toolbar", { name: "Table" })).toBeVisible();
await expect(page.getByRole("button", { name: "Add row" })).toBeVisible();
await expect(page).toHaveScreenshot("note-detail-table-toolbar.png", screenshotOptions);
});

test("note detail - table in the read-only offline view", async ({ page }) => {
await page.addInitScript(() => {
Object.defineProperty(navigator, "onLine", { get: () => false, configurable: true });
});
await routeNoteDetail(page, mockNoteDetailWithTable);
await page.goto("/notes/4");
await page.waitForLoadState("networkidle");
// The read-only renderer shares NOTE_PROSE_CLASSES with the editor, so this
// is what proves a table looks the same in both surfaces.
await expect(page.getByRole("columnheader", { name: "Item" })).toBeVisible();
await expect(page.getByRole("toolbar", { name: "Formatting" })).toHaveCount(0);
await expect(page).toHaveScreenshot("note-detail-table-readonly.png", screenshotOptions);
});

test("note - autosave indicator after an edit", async ({ page }) => {
await page.goto("/notes/1");
await page.waitForLoadState("networkidle");
Expand Down
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
27 changes: 15 additions & 12 deletions anything-frontend/package-lock.json

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

1 change: 1 addition & 0 deletions anything-frontend/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,7 @@
"@tanstack/react-query-devtools": "^5.101.4",
"@tanstack/react-query-persist-client": "^5.101.4",
"@tiptap/extension-image": "^3.29.2",
"@tiptap/extension-table": "3.29.2",
"@tiptap/extensions": "^3.29.2",
"@tiptap/pm": "^3.29.2",
"@tiptap/react": "^3.29.2",
Expand Down
3 changes: 3 additions & 0 deletions anything-frontend/src/components/agent.md
Original file line number Diff line number Diff line change
Expand Up @@ -48,6 +48,9 @@ The note editor is Tiptap (ProseMirror). `NoteWorkspace` is the whole note scree
- **One schema, two modes.** `NoteEditor` (editable) and `NoteContentView` (read-only, used when offline) both build from `noteExtensions` in `@/lib/notes/extensions`. Never hand-render the stored JSON — registering a node in that array is what makes it render in both modes, and it is the intended extension point for a future `entityReference` node linking to a recipe or shopping list (inline, atomic, `attrs: { entityType, entityId, label }`, no text child — the shape the backend's `NoteContent.ExtractPlainText` already flattens into the search index).
- **Always load the editor via `next/dynamic` with `ssr: false`.** ProseMirror constructs against the DOM, so a server render throws; `useEditor` also needs `immediatelyRender: false` under the App Router or the first client render mismatches. Lazy loading additionally keeps ~200 KB off the notes list and home card, which render server-provided plain-text snippets and never need the editor.
- **`NoteEditor` reads `value` once, on mount.** A background refetch (autosave invalidates `["note", id]` on every save) must not clobber in-flight edits, so the detail page keys the workspace on the note id to load a different note.
- **The toolbar must read editor state through `useEditorState`, never during render.** `useEditor` defaults `shouldRerenderOnTransaction` to `false` in Tiptap v3, and moving the caret changes no React state — so `editor.isActive(...)` read straight in the render body goes stale the moment the selection moves without an edit. That is invisible for `aria-pressed` but fatal for `NoteEditorToolbar`'s second row, which appears only while the caret is inside a table. `useEditorState` subscribes to the editor's `transaction`/`update` events and deep-compares its selector result, so it re-renders when a flag actually flips rather than on every keystroke. It also means a stubbed `Editor` in Jest needs `on`/`off` methods or the toolbar throws on render.
- **Table controls are a sibling `role="toolbar"`, not a nested one.** Nesting one `toolbar` role inside another is invalid ARIA, and keeping them siblings leaves the existing `getByRole("toolbar", { name: "Formatting" })` locators working. The row is gated on `isActive("table")`; the sticky bar grows by a row when the caret enters a table, which is accepted rather than reserved for, since an always-present empty band would sit above every note.
- **Column resizing is off, and that is what gives tables their scroll wrapper.** Tiptap's `Table` installs its own `TableView` node view (a `div.tableWrapper`) for exactly the *non*-resizable case — in the read-only renderer too — so `NOTE_PROSE_CLASSES` scrolls a too-wide table on `.tableWrapper`. Don't reach for prosemirror-tables' own `TableView`: that one is only constructed by the `columnResizing` plugin, which is never registered here. The editor surface also needs `min-w-0` (flex items default to `min-width: auto`, which would let a wide table stretch the column instead of scrolling inside it).
- **Mock the editor in Jest.** jsdom lacks the layout APIs ProseMirror calls (`Range.getClientRects` and friends), so component tests `jest.mock("./NoteEditor")` and replace it with a button that fires `onChange` with a staged document — see `NoteWorkspace.test.tsx`. Editor behaviour itself is covered by the Playwright visual specs. Note that unmounting flushes a pending autosave, so such a suite must `jest.clearAllMocks()` in `beforeEach`, not `afterEach` — the previous test's teardown lands a save while `afterEach` is still running.

### Full-height pages
Expand Down
4 changes: 2 additions & 2 deletions anything-frontend/src/components/notes/NoteEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -56,7 +56,7 @@ export function NoteEditor({ value, onChange, label = DEFAULT_LABEL, onUploadIma
"aria-label": label,
"aria-multiline": "true",
// `note-editor` is the hook the placeholder CSS in globals.css targets.
class: `note-editor ${NOTE_PROSE_CLASSES} grow w-full px-4 py-3 focus:outline-none`,
class: `note-editor ${NOTE_PROSE_CLASSES} grow w-full min-w-0 px-4 py-3 focus:outline-none`,
},
},
onUpdate: ({ editor: updated }) => onChange(updated.getJSON()),
Expand All @@ -76,7 +76,7 @@ export function NoteEditor({ value, onChange, label = DEFAULT_LABEL, onUploadIma
<div className="sticky top-14 z-30 bg-white dark:bg-gray-800">
<NoteEditorToolbar editor={editor} onUploadImage={onUploadImage} />
</div>
<EditorContent editor={editor} className="flex grow flex-col" />
<EditorContent editor={editor} className="flex min-w-0 grow flex-col" />
</div>
);
}
Loading
Loading