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
4 changes: 2 additions & 2 deletions e2e/flows/two-users-converge.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -75,11 +75,11 @@ test("two users editing concurrently converge on both screens", async ({ browser
// this file green. The colour matters too: the awareness payload is
// validated against /^#[0-9a-fA-F]{6}$/, and a value outside that either
// warns and renders (today) or is replaced with `transparent`.
const bobCaret = aliceEditor.locator(".collaboration-cursor__caret");
const bobCaret = aliceEditor.locator(".collaboration-carets__caret");
await expect(bobCaret).toHaveCount(1, { timeout: 8_000 });
// Bob was invited by email with no display name, so the server derives
// one from the local part.
await expect(bobCaret.locator(".collaboration-cursor__label")).toHaveText("bob");
await expect(bobCaret.locator(".collaboration-carets__label")).toHaveText("bob");

const caretColor = await bobCaret.evaluate((el) => getComputedStyle(el).borderLeftColor);
expect(caretColor, "the remote caret has no colour — it is invisible").not.toBe("transparent");
Expand Down
2 changes: 1 addition & 1 deletion e2e/support/docText.ts
Original file line number Diff line number Diff line change
Expand Up @@ -14,7 +14,7 @@ export function docText(el: Element): string {
acceptNode(node) {
let p: Node | null = node.parentElement;
while (p && p !== el) {
if (p instanceof Element && p.classList.contains("collaboration-cursor__label")) {
if (p instanceof Element && p.classList.contains("collaboration-carets__label")) {
return NodeFilter.FILTER_REJECT;
}
p = p.parentNode;
Expand Down
38 changes: 17 additions & 21 deletions web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -17,28 +17,25 @@
"@dnd-kit/sortable": "^10.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@excalidraw/excalidraw": "^0.18.1",
"@floating-ui/dom": "^1.7.4",
"@fontsource-variable/inter": "^5.3.0",
"@tanstack/react-query": "^5.101.4",
"@tiptap/core": "^2.27.3",
"@tiptap/extension-bullet-list": "^2.27.2",
"@tiptap/extension-code-block": "^2.27.2",
"@tiptap/extension-collaboration": "^2.27.2",
"@tiptap/extension-collaboration-cursor": "^2.26.2",
"@tiptap/extension-hard-break": "^2.27.2",
"@tiptap/extension-horizontal-rule": "^2.27.2",
"@tiptap/extension-image": "^2.27.2",
"@tiptap/extension-link": "^2.27.2",
"@tiptap/extension-list-item": "^2.27.2",
"@tiptap/extension-ordered-list": "^2.27.2",
"@tiptap/extension-table": "^2.27.2",
"@tiptap/extension-table-cell": "^2.27.2",
"@tiptap/extension-table-header": "^2.27.2",
"@tiptap/extension-table-row": "^2.27.2",
"@tiptap/extension-underline": "^2.27.2",
"@tiptap/pm": "^2.27.2",
"@tiptap/react": "^2.27.2",
"@tiptap/starter-kit": "^2.27.2",
"@tiptap/suggestion": "^2.27.2",
"@tiptap/core": "^3.31.3",
"@tiptap/extension-code-block": "^3.31.3",
"@tiptap/extension-collaboration": "^3.31.3",
"@tiptap/extension-collaboration-caret": "^3.31.3",
"@tiptap/extension-hard-break": "^3.31.3",
"@tiptap/extension-horizontal-rule": "^3.31.3",
"@tiptap/extension-image": "^3.31.3",
"@tiptap/extension-link": "^3.31.3",
"@tiptap/extension-list": "^3.31.3",
"@tiptap/extension-table": "^3.31.3",
"@tiptap/extension-underline": "^3.31.3",
"@tiptap/pm": "^3.31.3",
"@tiptap/react": "^3.31.3",
"@tiptap/starter-kit": "^3.31.3",
"@tiptap/suggestion": "^3.31.3",
"@tiptap/y-tiptap": "^3.0.9",
"autoprefixer": "^10.5.4",
"dompurify": "^3.4.13",
"lucide-react": "^1.31.0",
Expand All @@ -50,7 +47,6 @@
"react-router-dom": "^7.18.2",
"tailwindcss": "^3.4.19",
"valibot": "^1.4.2",
"y-prosemirror": "^1.3.7",
"y-protocols": "^1.0.7",
"yjs": "^13.6.32",
"zustand": "^5.0.15"
Expand Down
745 changes: 300 additions & 445 deletions web/pnpm-lock.yaml

Large diffs are not rendered by default.

8 changes: 4 additions & 4 deletions web/src/features/comments/anchor.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -10,8 +10,8 @@
* about comment anchoring is silent when that happens — encode returns
* null and the caller persists an empty string — so there is no second
* chance to notice.
* 2. The y-prosemirror primitives with a hand-built mapping, which pin the
* relative-position semantics we depend on.
* 2. The binding's position primitives with a hand-built mapping, which
* pin the relative-position semantics we depend on.
* 3. The null branches, via a fake editor.
*
* An earlier version of this file had only (2) and (3). Both stay green when
Expand All @@ -25,7 +25,7 @@ import * as Y from "yjs";
import {
absolutePositionToRelativePosition,
relativePositionToAbsolutePosition,
} from "y-prosemirror";
} from "@tiptap/y-tiptap";

import { fragmentShape, mountBoundEditor, type BoundEditor } from "../../test/boundEditor";
import {
Expand Down Expand Up @@ -136,7 +136,7 @@ describe("anchors against a live ySync binding", () => {
// (2) The primitives, with a hand-built mapping
// ---------------------------------------------------------------------------

describe("y-prosemirror relative position primitives", () => {
describe("Yjs relative position primitives", () => {
it("round-trips position 0 on an empty XmlFragment", () => {
const ydoc = new Y.Doc();
const fragment = ydoc.getXmlFragment("default");
Expand Down
10 changes: 7 additions & 3 deletions web/src/features/comments/anchor.ts
Original file line number Diff line number Diff line change
Expand Up @@ -6,8 +6,12 @@
* in the `position_y` column and the client can resolve it back to a
* pixel offset even after concurrent edits.
*
* The y-prosemirror mapping is obtained from the ySyncPlugin state that
* Tiptap's Collaboration extension installs. If the plugin isn't mounted
* The mapping is obtained from the ySyncPlugin state that Tiptap's
* Collaboration extension installs. It MUST be imported from the same
* package that extension installs its plugin from (@tiptap/y-tiptap), not
* from y-prosemirror: both call `new PluginKey("y-sync")`, ProseMirror
* uniquifies the second one, and `getState` is a bare property read — so the
* wrong import silently returns undefined and every anchor becomes null. If the plugin isn't mounted
* (e.g. viewer mode before the editor is ready), both functions return null.
*/

Expand All @@ -18,7 +22,7 @@ import {
absolutePositionToRelativePosition,
relativePositionToAbsolutePosition,
ySyncPluginKey,
} from "y-prosemirror";
} from "@tiptap/y-tiptap";

// eslint-disable-next-line @typescript-eslint/no-explicit-any
type ProsemirrorMapping = Map<Y.AbstractType<any>, PmNode | PmNode[]>;
Expand Down
42 changes: 42 additions & 0 deletions web/src/features/editor/CommentsHighlightExtension.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,42 @@
/**
* The highlight extension has to tolerate an editor on its way out.
*
* Switching from one document to another destroys the current editor before
* the replacement exists, and React commits the new effect dependencies in
* between — so effects that push comment data into the editor run at least
* once against the outgoing instance.
*
* Tiptap 3 empties `extensionStorage` on destroy, which turns what used to be
* a pointless write into a TypeError. Under React Router that is not a logged
* warning, it is an error boundary: the document page is replaced by
* "Unexpected Application Error!" and the user loses the editor entirely.
*/

import { afterEach, describe, expect, it } from "vitest";

import { mountBoundEditor, type BoundEditor } from "../../test/boundEditor";

describe("comment highlights on a destroyed editor", () => {
let bound: BoundEditor | null = null;

afterEach(() => {
bound = null;
});

it("has its storage while the editor is alive", () => {
bound = mountBoundEditor();
expect(bound.editor.isDestroyed).toBe(false);
expect(bound.editor.extensionStorage.commentsHighlight).toBeDefined();
});

it("loses its storage once the editor is destroyed", () => {
// Pins the upstream behaviour this guard exists for. If a future version
// keeps storage alive past destroy, this test says so plainly rather than
// leaving the guard looking like superstition.
const b = mountBoundEditor();
b.editor.destroy();
expect(b.editor.isDestroyed).toBe(true);
expect(b.editor.extensionStorage.commentsHighlight).toBeUndefined();
});

});
15 changes: 12 additions & 3 deletions web/src/features/editor/CommentsHighlightExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,17 +35,26 @@ export type CommentsHighlightOptions = {

const PLUGIN_KEY = new PluginKey<DecorationSet>("commentsHighlight");

declare module "@tiptap/core" {
// v3 retyped Editor.storage from Record<string, any> to an augmentable
// interface. React reaches into this slot directly (see KnotEditor), so it
// has to be declared rather than cast at each use.
interface Storage {
commentsHighlight: CommentsHighlightStorage;
}
}

// Storage shape held on the extension. React updates this object via
// `editor.extensionStorage.commentsHighlight.X = ...` and then dispatches
// a no-op transaction with `setMeta` to trigger a re-decoration.
type Storage = {
type CommentsHighlightStorage = {
comments: HighlightedComment[];
activeCommentId: string | null;
doc: Y.Doc | null;
onSelect: ((id: string) => void) | null;
};

export const CommentsHighlightExtension = Extension.create<CommentsHighlightOptions, Storage>({
export const CommentsHighlightExtension = Extension.create<CommentsHighlightOptions, CommentsHighlightStorage>({
name: "commentsHighlight",

addOptions() {
Expand Down Expand Up @@ -108,7 +117,7 @@ export const CommentsHighlightExtension = Extension.create<CommentsHighlightOpti

function buildDecorations(
pmDoc: import("@tiptap/pm/model").Node,
storage: Storage,
storage: CommentsHighlightStorage,
): DecorationSet {
if (!storage.doc || storage.comments.length === 0) return DecorationSet.empty;
// We need a Tiptap editor reference to call decodeAnchorRange. The plugin
Expand Down
10 changes: 8 additions & 2 deletions web/src/features/editor/DateTimeExtension.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,6 +21,12 @@ import { type Editor } from "@tiptap/core";
import { placeFixedPopup } from "./popupPosition";

declare module "@tiptap/core" {
// v3 retyped Editor.storage from Record<string, any> to an augmentable
// interface, so every extension has to declare its own slot.
interface Storage {
knotDateTime: { popup: DateTimePopup | null };
}

interface Commands<ReturnType> {
knotDateTime: {
/** Open the date+time picker at the current cursor; on Apply,
Expand Down Expand Up @@ -229,7 +235,7 @@ export const DateTimeExtension = Extension.create({
openDateTimePicker:
() =>
({ editor }) => {
const popup = (editor.storage.knotDateTime as { popup: DateTimePopup | null }).popup;
const { popup } = editor.storage.knotDateTime;
if (!popup) return false;
const { from, to } = editor.state.selection;
const coords = editor.view.coordsAtPos(from);
Expand All @@ -255,7 +261,7 @@ export const DateTimeExtension = Extension.create({
addProseMirrorPlugins() {
const editor = this.editor;
const popup = new DateTimePopup();
(editor.storage.knotDateTime as { popup: DateTimePopup | null }).popup = popup;
editor.storage.knotDateTime.popup = popup;
editor.on("destroy", () => popup.destroy());

// Plugin for click-to-edit on existing chips.
Expand Down
18 changes: 12 additions & 6 deletions web/src/features/editor/KnotEditor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -329,12 +329,18 @@ function EditorBody({ pair, role, docId, editMode }: { pair: Pair; role: "owner"
// Push the latest comments + activeCommentId into the highlight extension's
// storage, then dispatch a no-op transaction so the plugin re-decorates.
useEffect(() => {
if (!editor) return;
const storage = editor.extensionStorage.commentsHighlight as {
comments: HighlightedComment[];
activeCommentId: string | null;
doc: Y.Doc | null;
};
// Navigating from one document to another runs this effect twice against
// the OUTGOING editor before the replacement exists — React commits the
// new deps first. Tiptap 3 empties extensionStorage on destroy, so the
// lookup below returns undefined and assigning to it throws, taking the
// whole route down with an error boundary. In v2 the same write landed on
// a dead object and did nothing, which is why this was never guarded.
//
// e2e/flows/search.spec.ts is the regression test: navigating via the
// command palette reproduces it. Clicking a sidebar link does not — the
// timing differs — so do not "simplify" that spec into a plain link click.
if (!editor || editor.isDestroyed) return;
const storage = editor.extensionStorage.commentsHighlight;
storage.comments = highlightComments;
storage.activeCommentId = activeCommentId;
storage.doc = pair.doc;
Expand Down
5 changes: 2 additions & 3 deletions web/src/features/editor/SchemaNameOverrides.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,11 +11,10 @@
* schema, then disable the original in `StarterKit.configure({ … : false })`.
*/

import BulletList from "@tiptap/extension-bullet-list";
import OrderedList from "@tiptap/extension-ordered-list";
import ListItem from "@tiptap/extension-list-item";
import HardBreak from "@tiptap/extension-hard-break";
import HorizontalRule from "@tiptap/extension-horizontal-rule";
// The three list nodes now ship from one package, as named exports.
import { BulletList, ListItem, OrderedList } from "@tiptap/extension-list";

/**
* Renaming a node does not rewrite the OPTIONS its own commands read.
Expand Down
8 changes: 4 additions & 4 deletions web/src/features/editor/TableExtensions.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,10 +7,10 @@
* GFM column alignment and round-trips through to_markdown.
*/

import Table from "@tiptap/extension-table";
import TableRow from "@tiptap/extension-table-row";
import TableCell from "@tiptap/extension-table-cell";
import TableHeader from "@tiptap/extension-table-header";
// The four table nodes now ship from one package as named exports; the
// aggregate has no default export, so `import Table from …` fails to compile.
// TableKit is deliberately not used: it registers camelCase node names.
import { Table, TableCell, TableHeader, TableRow } from "@tiptap/extension-table";

const alignAttr = {
align: {
Expand Down
48 changes: 44 additions & 4 deletions web/src/features/editor/extensions.ts
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
import Collaboration from "@tiptap/extension-collaboration";
import CollaborationCursor from "@tiptap/extension-collaboration-cursor";
import CollaborationCaret from "@tiptap/extension-collaboration-caret";
import Image from "@tiptap/extension-image";
import Link from "@tiptap/extension-link";
import Underline from "@tiptap/extension-underline";
Expand Down Expand Up @@ -54,6 +54,27 @@ const KnotLink = Link.extend({
},
});

/**
* v3's Image adds `width` and `height`. Nothing in knot sets them — the
* upload path calls setImage({ src }) and the paste sanitiser's attribute
* allowlist drops both — but leaving them declared would put two attributes
* in the document schema that `tools/schema.json` does not know and
* `to_markdown` cannot emit. Dropping them keeps the stored schema identical
* to what every existing document was written against, which is the property
* worth protecting in a CRDT.
*
* Adopting image dimensions instead is a feature: declare them in
* tools/schema.json and teach to_markdown to write them.
*/
const KnotImage = Image.extend({
addAttributes() {
const attrs: Record<string, unknown> = { ...(this.parent?.() ?? {}) };
delete attrs.width;
delete attrs.height;
return attrs;
},
});

/** Canonical Tiptap extension set that matches the server schema generated
* from `tools/schema.json`. History is disabled because Yjs UndoManager
* owns undo. */
Expand All @@ -67,7 +88,9 @@ export function createExtensions(opts: {
}) {
return [
StarterKit.configure({
history: false,
// v2 called this `history`. Still disabled for the same reason: the Yjs
// UndoManager owns undo, and a second history plugin fights it.
undoRedo: false,
codeBlock: false,
// Disable the camelCase node defaults; we re-add snake_case versions
// below so the Y.XmlFragment matches our canonical schema.
Expand All @@ -76,6 +99,19 @@ export function createExtensions(opts: {
listItem: false,
hardBreak: false,
horizontalRule: false,
// Bundled by StarterKit from v3 on. Link and Underline are registered
// explicitly below, and registering either twice makes the second
// configure() lose the argument fight for the click handler.
link: false,
underline: false,
// Appends a paragraph to any document not ending in one. Harmless in a
// local editor; here it is a write to a shared CRDT that every peer
// receives and that reaches the markdown export, triggered by opening
// a document rather than by editing it.
trailingNode: false,
// Its list types default to camelCase, so it is inert against this
// schema. Off rather than registered-and-doing-nothing.
listKeymap: false,
}),
KnotBulletList,
KnotOrderedList,
Expand All @@ -90,11 +126,15 @@ export function createExtensions(opts: {
HTMLAttributes: { rel: "noopener noreferrer", target: "_blank" },
}),
Collaboration.configure({ document: opts.doc }),
CollaborationCursor.configure({
// Successor to CollaborationCursor, which has no v3 release. Only ever
// reads `provider.awareness`, so the shim stands. `user.color` must be
// 6-digit hex: the extension replaces anything else with "transparent",
// which renders an invisible caret rather than warning (see colorFor).
CollaborationCaret.configure({
provider: { awareness: opts.awareness } as never,
user: opts.user,
}),
Image.configure({ inline: false, allowBase64: false }),
KnotImage.configure({ inline: false, allowBase64: false }),
Attachment,
ExcalidrawBoard,
TaskListExtension,
Expand Down
Loading