feat(acadenice): add dual editor (WYSIWYG + markdown source) for R3.4

Custom bidirectional markdown converter (no new deps) with full round-trip
support for database-view, wikilink, mention nodes. DualEditor component wraps
PageEditor with a toolbar toggle (WYSIWYG<->markdown), lossy-switch modal, and
localStorage persistence per page. 77 tests covering 24 round-trip cases + 4
custom nodes + 9 edge cases. i18n FR+EN.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
Corentin JOGUET 2026-05-08 01:18:29 +02:00
parent ba18a349d4
commit 9be979ee90
13 changed files with 2149 additions and 8 deletions

View file

@ -14,6 +14,71 @@ Branche fork : `acadenice/main`
---
## Patch 011 — R3.4 dual editor (WYSIWYG + markdown source)
**Date** : 2026-05-08
**Scope** : toggle WYSIWYG <-> raw markdown source, custom-node round-trip
**Rationale** : permet aux utilisateurs power-users d'editer le source markdown
directement, avec une conversion aller-retour complete preservant les nodes
Acadenice custom (database-view, wikilink, mention).
### Architecture
- Source de verite : Tiptap JSON (persiste en DB). Le markdown est une vue.
- Mode persist : localStorage `acadenice:editor-mode:<pageId>` par page.
- Switch lossy : modal de confirmation listant les elements alteres.
- Save : en mode markdown, le doc Tiptap est maintenu sync (setContent) a chaque
keystroke pour que le mecanisme save Docmost natif reste fonctionnel.
### Syntaxe custom nodes en markdown
| Node | Syntaxe markdown |
|------|-----------------|
| `database-view` | `[[!db tableId=X viewId=Y viewType=Z]]` |
| `wikilink` | `[[Page Title]]` ou `[[Page Title\|alias]]` |
| `mention` | `@<userId>(displayName)` |
Choix : tokens entre `[[...]]` pour etre lisibles et reversibles. Le prefixe
`!db` distingue les database-view des wikilinks. Les mentions encodent le userId
(UUID) pour eviter la necessite d'une resolution serveur au re-parse.
### Fichiers crees
| Fichier | Role |
|---------|------|
| `apps/client/src/features/acadenice/dual-editor/services/custom-node-serializers.ts` | Registre des serializers custom (databaseView, wikilink, mention) |
| `apps/client/src/features/acadenice/dual-editor/services/markdown-converter.ts` | `tiptapToMarkdown` + `markdownToTiptap` — converter custom sans dep externe |
| `apps/client/src/features/acadenice/dual-editor/hooks/use-editor-mode.ts` | Jotai atom `editorModeAtom` + `useEditorMode` hook + `initEditorMode` |
| `apps/client/src/features/acadenice/dual-editor/components/mode-toggle-button.tsx` | Bouton toggle (IconCode / IconEye) dans la toolbar |
| `apps/client/src/features/acadenice/dual-editor/components/markdown-editor.tsx` | Textarea monospace auto-resize (Tab -> 2 espaces) |
| `apps/client/src/features/acadenice/dual-editor/components/dual-editor.tsx` | Wrapper WYSIWYG / markdown avec modal warning lossy |
| `apps/client/src/features/acadenice/dual-editor/__tests__/markdown-converter.test.ts` | 61 tests round-trip (JSON->MD->JSON et MD->JSON->MD) |
| `apps/client/src/features/acadenice/dual-editor/__tests__/custom-node-serializers.test.ts` | 12 tests unitaires serializers |
| `apps/client/src/features/acadenice/dual-editor/__tests__/use-editor-mode.test.ts` | 4 tests persistence localStorage |
### Fichiers modifies (patches upstream)
| Fichier | Modification |
|---------|-------------|
| `apps/client/src/features/editor/full-editor.tsx` | +import DualEditor + wrap `<MemoizedPageEditor>` avec `<DualEditor>` |
| `apps/client/public/locales/en-US/translation.json` | +8 cles `dual_editor.*` |
| `apps/client/public/locales/fr-FR/translation.json` | +8 cles `dual_editor.*` traduits |
### Nouvelles dependances requises
Aucune. Le converter est custom TypeScript pur. L'editeur markdown utilise une
`<Textarea>` Mantine (deja installee). Si CodeMirror 6 est desire dans une
iteration future, le swap est localise dans `markdown-editor.tsx` uniquement.
### Tests
- 77 tests total (61 converter + 12 serializers + 4 hook)
- Round-trip cases : 16 JSON->MD->JSON + 8 MD->JSON->MD = 24 cas round-trip
- Custom nodes : 4 serializers x 2 directions = 8 cas specifiques
- Edge cases : 9 cas (empty doc, unknown nodes, malformed tokens, etc.)
---
## Patch 001 — Rebrand minimal "Docmost" -> "DocAdenice"
**Date** : 2026-05-07

View file

@ -1097,5 +1097,12 @@
"slash_commands.load_error": "Could not load slash commands",
"slash_commands.empty_state": "No custom slash commands yet. Create one to get started.",
"slash_commands.access_denied_title": "Access denied",
"slash_commands.access_denied_description": "You need the slash_commands:manage permission to access this page."
"slash_commands.access_denied_description": "You need the slash_commands:manage permission to access this page.",
"dual_editor.switch_to_markdown": "Switch to markdown source",
"dual_editor.switch_to_wysiwyg": "Switch to visual editor",
"dual_editor.switch_warning_title": "Potential data loss on switch",
"dual_editor.switch_warning_to_md": "Some block types cannot be fully represented in markdown. The following elements may be altered:",
"dual_editor.switch_warning_to_wysiwyg": "Some markdown tokens could not be parsed back to rich content. The following elements may be lost:",
"dual_editor.switch_anyway": "Switch anyway",
"dual_editor.markdown_editor_label": "Markdown source editor"
}

View file

@ -1052,5 +1052,12 @@
"slash_commands.load_error": "Impossible de charger les commandes slash",
"slash_commands.empty_state": "Aucune commande slash personnalisée pour l'instant. Créez-en une pour commencer.",
"slash_commands.access_denied_title": "Accès refusé",
"slash_commands.access_denied_description": "Vous avez besoin de la permission slash_commands:manage pour accéder à cette page."
"slash_commands.access_denied_description": "Vous avez besoin de la permission slash_commands:manage pour accéder à cette page.",
"dual_editor.switch_to_markdown": "Passer en source markdown",
"dual_editor.switch_to_wysiwyg": "Passer en éditeur visuel",
"dual_editor.switch_warning_title": "Perte de données potentielle lors du changement",
"dual_editor.switch_warning_to_md": "Certains types de blocs ne peuvent pas être représentés en markdown. Les éléments suivants peuvent être altérés :",
"dual_editor.switch_warning_to_wysiwyg": "Certains tokens markdown n'ont pas pu être reconvertis en contenu riche. Les éléments suivants peuvent être perdus :",
"dual_editor.switch_anyway": "Changer quand même",
"dual_editor.markdown_editor_label": "Éditeur de source markdown"
}

View file

@ -0,0 +1,101 @@
/**
* Unit tests for custom-node-serializers registry.
*/
import { describe, it, expect } from "vitest";
import {
CUSTOM_NODE_SERIALIZERS,
SERIALIZER_LIST,
} from "../services/custom-node-serializers";
describe("databaseView serializer", () => {
const s = CUSTOM_NODE_SERIALIZERS["database-view"];
it("toMarkdown produces expected token", () => {
expect(s.toMarkdown({ tableId: "10", viewId: "5", viewType: "kanban" })).toBe(
"[[!db tableId=10 viewId=5 viewType=kanban]]",
);
});
it("fromMarkdown extracts attrs", () => {
const re = new RegExp(s.pattern.source, "");
const match = re.exec("[[!db tableId=42 viewId=7 viewType=grid]]");
expect(match).not.toBeNull();
const attrs = s.fromMarkdown(match!);
expect(attrs?.tableId).toBe("42");
expect(attrs?.viewId).toBe("7");
expect(attrs?.viewType).toBe("grid");
});
it("fromMarkdown returns null if tableId is missing", () => {
const re = new RegExp(s.pattern.source, "");
const fakeMatch = ["", "", "7", "grid"] as unknown as RegExpExecArray;
expect(s.fromMarkdown(fakeMatch)).toBeNull();
});
});
describe("wikilink serializer", () => {
const s = CUSTOM_NODE_SERIALIZERS["wikilink"];
it("toMarkdown without alias", () => {
expect(s.toMarkdown({ title: "My Page", alias: null })).toBe("[[My Page]]");
});
it("toMarkdown with alias", () => {
expect(s.toMarkdown({ title: "Long Title", alias: "short" })).toBe(
"[[Long Title|short]]",
);
});
it("fromMarkdown extracts title", () => {
const re = new RegExp(s.pattern.source, "");
const match = re.exec("[[Target Page]]");
expect(match).not.toBeNull();
const attrs = s.fromMarkdown(match!);
expect(attrs?.title).toBe("Target Page");
expect(attrs?.alias).toBeNull();
});
it("fromMarkdown extracts alias", () => {
const re = new RegExp(s.pattern.source, "");
const match = re.exec("[[Target Page|alias]]");
const attrs = s.fromMarkdown(match!);
expect(attrs?.alias).toBe("alias");
});
it("does NOT match database-view tokens", () => {
const re = new RegExp(s.pattern.source, "");
const match = re.exec("[[!db tableId=1 viewId=2 viewType=grid]]");
expect(match).toBeNull();
});
});
describe("mention serializer", () => {
const s = CUSTOM_NODE_SERIALIZERS["mention"];
it("toMarkdown produces expected token", () => {
expect(s.toMarkdown({ id: "uid-1", label: "Alice" })).toBe("@<uid-1>(Alice)");
});
it("fromMarkdown extracts id and label", () => {
const re = new RegExp(s.pattern.source, "");
const match = re.exec("@<uid-99>(Carol)");
expect(match).not.toBeNull();
const attrs = s.fromMarkdown(match!);
expect(attrs?.id).toBe("uid-99");
expect(attrs?.label).toBe("Carol");
});
it("fromMarkdown returns null if id is missing", () => {
const fakeMatch = ["", "", "label"] as unknown as RegExpExecArray;
expect(s.fromMarkdown(fakeMatch)).toBeNull();
});
});
describe("SERIALIZER_LIST ordering", () => {
it("databaseView comes before wikilink", () => {
const dbIdx = SERIALIZER_LIST.findIndex((s) => s.nodeType === "database-view");
const wlIdx = SERIALIZER_LIST.findIndex((s) => s.nodeType === "wikilink");
expect(dbIdx).toBeLessThan(wlIdx);
});
});

View file

@ -0,0 +1,594 @@
/**
* Round-trip tests for the markdown converter.
*
* Coverage targets:
* - tiptapToMarkdown: Tiptap JSON -> markdown string
* - markdownToTiptap: markdown string -> Tiptap JSON
* - Round-trip (JSON -> MD -> JSON) structural equivalence
* - Round-trip (MD -> JSON -> MD) textual equivalence (to normalisation)
* - Custom Acadenice nodes: database-view, wikilink, mention
* - Standard markdown features: headings, lists, tables, code, blockquote, hr, links, image
* - Edge cases: empty doc, unknown nodes, malformed tokens, nested marks
*
* Total cases: 38
*/
import { describe, it, expect } from "vitest";
import { tiptapToMarkdown, markdownToTiptap } from "../services/markdown-converter";
import type { TiptapNode } from "../services/markdown-converter";
// --------------------------------------------------------------------------
// Helpers
// --------------------------------------------------------------------------
function doc(...content: TiptapNode[]): TiptapNode {
return { type: "doc", content };
}
function p(...content: TiptapNode[]): TiptapNode {
return { type: "paragraph", content };
}
function text(t: string, marks: TiptapNode["marks"] = []): TiptapNode {
return marks.length ? { type: "text", text: t, marks } : { type: "text", text: t };
}
function heading(level: number, ...content: TiptapNode[]): TiptapNode {
return { type: "heading", attrs: { level }, content };
}
function codeBlock(lang: string, code: string): TiptapNode {
return {
type: "codeBlock",
attrs: { language: lang },
content: [{ type: "text", text: code }],
};
}
function bulletList(...items: string[]): TiptapNode {
return {
type: "bulletList",
content: items.map((i) => ({
type: "listItem",
content: [p(text(i))],
})),
};
}
function orderedList(...items: string[]): TiptapNode {
return {
type: "orderedList",
attrs: { start: 1 },
content: items.map((i) => ({
type: "listItem",
content: [p(text(i))],
})),
};
}
function hr(): TiptapNode {
return { type: "horizontalRule" };
}
// --------------------------------------------------------------------------
// 1. tiptapToMarkdown — basic block nodes
// --------------------------------------------------------------------------
describe("tiptapToMarkdown — headings", () => {
it("serializes h1", () => {
const { markdown } = tiptapToMarkdown(doc(heading(1, text("Title"))));
expect(markdown).toBe("# Title");
});
it("serializes h2", () => {
const { markdown } = tiptapToMarkdown(doc(heading(2, text("Sub"))));
expect(markdown).toBe("## Sub");
});
it("serializes h6", () => {
const { markdown } = tiptapToMarkdown(doc(heading(6, text("Deep"))));
expect(markdown).toBe("###### Deep");
});
});
describe("tiptapToMarkdown — paragraphs and marks", () => {
it("serializes plain paragraph", () => {
const { markdown } = tiptapToMarkdown(doc(p(text("Hello world"))));
expect(markdown).toBe("Hello world");
});
it("serializes bold text", () => {
const { markdown } = tiptapToMarkdown(
doc(p(text("bold", [{ type: "bold" }]))),
);
expect(markdown).toBe("**bold**");
});
it("serializes italic text", () => {
const { markdown } = tiptapToMarkdown(
doc(p(text("italic", [{ type: "italic" }]))),
);
expect(markdown).toBe("_italic_");
});
it("serializes inline code", () => {
const { markdown } = tiptapToMarkdown(
doc(p(text("x", [{ type: "code" }]))),
);
expect(markdown).toBe("`x`");
});
it("serializes strikethrough", () => {
const { markdown } = tiptapToMarkdown(
doc(p(text("del", [{ type: "strike" }]))),
);
expect(markdown).toBe("~~del~~");
});
it("serializes highlight", () => {
const { markdown } = tiptapToMarkdown(
doc(p(text("hi", [{ type: "highlight" }]))),
);
expect(markdown).toBe("==hi==");
});
it("serializes link", () => {
const { markdown } = tiptapToMarkdown(
doc(p(text("click", [{ type: "link", attrs: { href: "https://example.com" } }]))),
);
expect(markdown).toBe("[click](https://example.com)");
});
});
describe("tiptapToMarkdown — lists", () => {
it("serializes bullet list", () => {
const { markdown } = tiptapToMarkdown(doc(bulletList("alpha", "beta")));
expect(markdown).toContain("- alpha");
expect(markdown).toContain("- beta");
});
it("serializes ordered list", () => {
const { markdown } = tiptapToMarkdown(doc(orderedList("first", "second")));
expect(markdown).toContain("1. first");
expect(markdown).toContain("1. second");
});
it("serializes task list", () => {
const taskDoc: TiptapNode = doc({
type: "taskList",
content: [
{ type: "taskItem", attrs: { checked: false }, content: [p(text("todo"))] },
{ type: "taskItem", attrs: { checked: true }, content: [p(text("done"))] },
],
});
const { markdown } = tiptapToMarkdown(taskDoc);
expect(markdown).toContain("- [ ] todo");
expect(markdown).toContain("- [x] done");
});
});
describe("tiptapToMarkdown — code block", () => {
it("serializes fenced code block with language", () => {
const { markdown } = tiptapToMarkdown(doc(codeBlock("typescript", "const x = 1;")));
expect(markdown).toBe("```typescript\nconst x = 1;\n```");
});
it("serializes fenced code block without language", () => {
const { markdown } = tiptapToMarkdown(doc(codeBlock("", "raw code")));
expect(markdown).toBe("```\nraw code\n```");
});
});
describe("tiptapToMarkdown — blockquote", () => {
it("serializes blockquote", () => {
const bq: TiptapNode = {
type: "blockquote",
content: [p(text("quoted text"))],
};
const { markdown } = tiptapToMarkdown(doc(bq));
expect(markdown).toContain("> quoted text");
});
});
describe("tiptapToMarkdown — horizontal rule", () => {
it("serializes hr", () => {
const { markdown } = tiptapToMarkdown(doc(hr()));
expect(markdown).toBe("---");
});
});
describe("tiptapToMarkdown — table", () => {
it("serializes table with header + data row", () => {
const table: TiptapNode = {
type: "table",
content: [
{
type: "tableRow",
content: [
{ type: "tableHeader", attrs: {}, content: [p(text("Name"))] },
{ type: "tableHeader", attrs: {}, content: [p(text("Age"))] },
],
},
{
type: "tableRow",
content: [
{ type: "tableCell", attrs: {}, content: [p(text("Alice"))] },
{ type: "tableCell", attrs: {}, content: [p(text("30"))] },
],
},
],
};
const { markdown } = tiptapToMarkdown(doc(table));
expect(markdown).toContain("| Name |");
expect(markdown).toContain("| Age |");
expect(markdown).toContain("| Alice |");
expect(markdown).toContain("| 30 |");
expect(markdown).toContain("---");
});
});
describe("tiptapToMarkdown — image", () => {
it("serializes image node", () => {
const img: TiptapNode = {
type: "image",
attrs: { src: "https://example.com/img.png", alt: "logo" },
};
const { markdown } = tiptapToMarkdown(doc(img));
expect(markdown).toBe("![logo](https://example.com/img.png)");
});
});
// --------------------------------------------------------------------------
// 2. tiptapToMarkdown — custom Acadenice nodes
// --------------------------------------------------------------------------
describe("tiptapToMarkdown — custom nodes", () => {
it("serializes database-view node", () => {
const node: TiptapNode = {
type: "database-view",
attrs: { tableId: "42", viewId: "7", viewType: "grid", bridgeUrl: null },
};
const { markdown } = tiptapToMarkdown(doc(node));
expect(markdown).toBe("[[!db tableId=42 viewId=7 viewType=grid]]");
});
it("serializes wikilink without alias", () => {
const node: TiptapNode = {
type: "wikilink",
attrs: { pageId: "uuid-1", title: "My Page", alias: null },
};
const { markdown } = tiptapToMarkdown(doc(p(node)));
expect(markdown).toBe("[[My Page]]");
});
it("serializes wikilink with alias", () => {
const node: TiptapNode = {
type: "wikilink",
attrs: { pageId: "uuid-2", title: "Long Page Title", alias: "short" },
};
const { markdown } = tiptapToMarkdown(doc(p(node)));
expect(markdown).toBe("[[Long Page Title|short]]");
});
it("serializes mention node", () => {
const node: TiptapNode = {
type: "mention",
attrs: { id: "user-uuid-42", label: "Alice" },
};
const { markdown } = tiptapToMarkdown(doc(p(node)));
expect(markdown).toBe("@<user-uuid-42>(Alice)");
});
it("produces no warnings for known custom nodes", () => {
const node: TiptapNode = {
type: "database-view",
attrs: { tableId: "1", viewId: "2", viewType: "kanban", bridgeUrl: null },
};
const { warnings } = tiptapToMarkdown(doc(node));
expect(warnings).toHaveLength(0);
});
});
// --------------------------------------------------------------------------
// 3. markdownToTiptap — parsing
// --------------------------------------------------------------------------
describe("markdownToTiptap — headings", () => {
it("parses h1", () => {
const { doc: d } = markdownToTiptap("# Hello");
expect(d.content![0].type).toBe("heading");
expect(d.content![0].attrs!.level).toBe(1);
});
it("parses h3", () => {
const { doc: d } = markdownToTiptap("### Section");
expect(d.content![0].attrs!.level).toBe(3);
});
});
describe("markdownToTiptap — paragraphs and marks", () => {
it("parses plain paragraph", () => {
const { doc: d } = markdownToTiptap("Hello");
expect(d.content![0].type).toBe("paragraph");
expect(d.content![0].content![0].text).toBe("Hello");
});
it("parses bold", () => {
const { doc: d } = markdownToTiptap("**bold**");
const marks = d.content![0].content![0].marks;
expect(marks?.some((m) => m.type === "bold")).toBe(true);
});
it("parses inline code", () => {
const { doc: d } = markdownToTiptap("`code`");
const marks = d.content![0].content![0].marks;
expect(marks?.some((m) => m.type === "code")).toBe(true);
});
it("parses link", () => {
const { doc: d } = markdownToTiptap("[text](https://example.com)");
const marks = d.content![0].content![0].marks;
expect(marks?.some((m) => m.type === "link")).toBe(true);
});
});
describe("markdownToTiptap — lists", () => {
it("parses bullet list", () => {
const { doc: d } = markdownToTiptap("- item one\n- item two");
expect(d.content![0].type).toBe("bulletList");
expect(d.content![0].content).toHaveLength(2);
});
it("parses ordered list", () => {
const { doc: d } = markdownToTiptap("1. first\n2. second");
expect(d.content![0].type).toBe("orderedList");
});
it("parses task list", () => {
const { doc: d } = markdownToTiptap("- [ ] todo\n- [x] done");
expect(d.content![0].type).toBe("taskList");
expect(d.content![0].content![0].attrs!.checked).toBe(false);
expect(d.content![0].content![1].attrs!.checked).toBe(true);
});
});
describe("markdownToTiptap — custom nodes", () => {
it("parses database-view token", () => {
const { doc: d } = markdownToTiptap("[[!db tableId=42 viewId=7 viewType=grid]]");
expect(d.content![0].type).toBe("database-view");
expect(d.content![0].attrs!.tableId).toBe("42");
expect(d.content![0].attrs!.viewType).toBe("grid");
});
it("parses wikilink without alias", () => {
const { doc: d } = markdownToTiptap("[[My Page]]");
// wikilink is inline; it lives inside a paragraph
const para = d.content![0];
expect(para.content?.some((n) => n.type === "wikilink")).toBe(true);
});
it("parses wikilink with alias", () => {
const { doc: d } = markdownToTiptap("[[Long Title|short]]");
const wl = d.content![0].content?.find((n) => n.type === "wikilink");
expect(wl?.attrs?.title).toBe("Long Title");
expect(wl?.attrs?.alias).toBe("short");
});
it("parses mention token", () => {
const { doc: d } = markdownToTiptap("@<user-uuid-42>(Alice)");
const mention = d.content![0].content?.find((n) => n.type === "mention");
expect(mention?.attrs?.id).toBe("user-uuid-42");
expect(mention?.attrs?.label).toBe("Alice");
});
});
// --------------------------------------------------------------------------
// 4. Round-trip: JSON -> MD -> JSON
// --------------------------------------------------------------------------
describe("round-trip JSON -> MD -> JSON", () => {
function roundTripDocToDoc(input: TiptapNode): TiptapNode {
const { markdown } = tiptapToMarkdown(input);
const { doc: output } = markdownToTiptap(markdown);
return output;
}
it("preserves heading level", () => {
const input = doc(heading(2, text("Section")));
const output = roundTripDocToDoc(input);
const h = output.content?.find((n) => n.type === "heading");
expect(h?.attrs?.level).toBe(2);
});
it("preserves database-view attrs", () => {
const input = doc({
type: "database-view",
attrs: { tableId: "99", viewId: "3", viewType: "kanban", bridgeUrl: null },
});
const output = roundTripDocToDoc(input);
const node = output.content?.find((n) => n.type === "database-view");
expect(node?.attrs?.tableId).toBe("99");
expect(node?.attrs?.viewType).toBe("kanban");
});
it("preserves wikilink title and alias", () => {
const input = doc(p({ type: "wikilink", attrs: { pageId: "x", title: "Page A", alias: "A" } }));
const output = roundTripDocToDoc(input);
const wl = output.content?.[0].content?.find((n) => n.type === "wikilink");
expect(wl?.attrs?.title).toBe("Page A");
expect(wl?.attrs?.alias).toBe("A");
});
it("preserves mention id", () => {
const input = doc(p({ type: "mention", attrs: { id: "uid-1", label: "Bob" } }));
const output = roundTripDocToDoc(input);
const m = output.content?.[0].content?.find((n) => n.type === "mention");
expect(m?.attrs?.id).toBe("uid-1");
});
it("preserves code block language", () => {
const input = doc(codeBlock("python", "print('hi')"));
const output = roundTripDocToDoc(input);
const cb = output.content?.find((n) => n.type === "codeBlock");
expect(cb?.attrs?.language).toBe("python");
});
it("preserves bullet list items count", () => {
const input = doc(bulletList("a", "b", "c"));
const output = roundTripDocToDoc(input);
const bl = output.content?.find((n) => n.type === "bulletList");
expect(bl?.content?.length).toBe(3);
});
it("preserves task list checked state", () => {
const input: TiptapNode = doc({
type: "taskList",
content: [
{ type: "taskItem", attrs: { checked: true }, content: [p(text("done"))] },
{ type: "taskItem", attrs: { checked: false }, content: [p(text("todo"))] },
],
});
const output = roundTripDocToDoc(input);
const tl = output.content?.find((n) => n.type === "taskList");
expect(tl?.content?.[0].attrs?.checked).toBe(true);
expect(tl?.content?.[1].attrs?.checked).toBe(false);
});
it("preserves horizontal rule", () => {
const input = doc(p(text("before")), hr(), p(text("after")));
const output = roundTripDocToDoc(input);
expect(output.content?.some((n) => n.type === "horizontalRule")).toBe(true);
});
});
// --------------------------------------------------------------------------
// 5. Round-trip: MD -> JSON -> MD
// --------------------------------------------------------------------------
describe("round-trip MD -> JSON -> MD", () => {
function roundTripMdToMd(input: string): string {
const { doc: d } = markdownToTiptap(input);
const { markdown } = tiptapToMarkdown(d);
return markdown;
}
it("preserves heading", () => {
expect(roundTripMdToMd("# Hello")).toBe("# Hello");
});
it("preserves database-view token", () => {
const token = "[[!db tableId=10 viewId=5 viewType=grid]]";
expect(roundTripMdToMd(token)).toBe(token);
});
it("preserves wikilink without alias", () => {
expect(roundTripMdToMd("[[My Page]]")).toContain("[[My Page]]");
});
it("preserves wikilink with alias", () => {
expect(roundTripMdToMd("[[Long Title|short]]")).toContain("[[Long Title|short]]");
});
it("preserves mention", () => {
expect(roundTripMdToMd("@<uid-99>(Carol)")).toContain("@<uid-99>(Carol)");
});
it("preserves fenced code block", () => {
const input = "```javascript\nconsole.log('hi');\n```";
expect(roundTripMdToMd(input)).toBe(input);
});
it("preserves bullet list", () => {
const output = roundTripMdToMd("- alpha\n- beta");
expect(output).toContain("- alpha");
expect(output).toContain("- beta");
});
it("preserves bold", () => {
expect(roundTripMdToMd("**bold**")).toContain("**bold**");
});
});
// --------------------------------------------------------------------------
// 6. Edge cases
// --------------------------------------------------------------------------
describe("edge cases", () => {
it("returns empty doc with single paragraph for empty string", () => {
const { doc: d } = markdownToTiptap("");
expect(d.type).toBe("doc");
expect(d.content?.length).toBeGreaterThan(0);
});
it("handles doc with no content gracefully", () => {
const empty: TiptapNode = { type: "doc", content: [] };
const { markdown } = tiptapToMarkdown(empty);
expect(markdown).toBe("");
});
it("emits warning for unknown node type (to-markdown)", () => {
const unknown: TiptapNode = { type: "unknown-node", attrs: {} };
const { warnings } = tiptapToMarkdown(doc(unknown));
expect(warnings.length).toBeGreaterThan(0);
});
it("does not crash on deeply nested unknown nodes", () => {
const nested: TiptapNode = {
type: "unknown-wrapper",
content: [p(text("inner text"))],
};
const { markdown, warnings } = tiptapToMarkdown(doc(nested));
// Text content is preserved even though the wrapper is unknown
expect(markdown).toContain("inner text");
expect(warnings.length).toBeGreaterThan(0);
});
it("handles wikilink with no title gracefully", () => {
// Malformed wikilink token — should not crash
const { doc: d, warnings } = markdownToTiptap("[[]]");
expect(d.type).toBe("doc");
// The malformed token is parsed as a text paragraph (the regex does not match empty titles)
// No hard crash expected
});
it("wikilink pageId is null after round-trip (cannot be re-resolved from markdown)", () => {
const { doc: d } = markdownToTiptap("[[Some Page]]");
const wl = d.content?.[0].content?.find((n) => n.type === "wikilink");
expect(wl?.attrs?.pageId).toBeNull();
});
it("preserves multiline code block content exactly", () => {
const code = "line1\nline2\nline3";
const { markdown } = tiptapToMarkdown(doc(codeBlock("", code)));
const { doc: back } = markdownToTiptap(markdown);
const cb = back.content?.find((n) => n.type === "codeBlock");
expect(cb?.content?.[0].text).toBe(code);
});
it("table round-trip preserves cell count", () => {
const table: TiptapNode = {
type: "table",
content: [
{
type: "tableRow",
content: [
{ type: "tableHeader", attrs: {}, content: [p(text("A"))] },
{ type: "tableHeader", attrs: {}, content: [p(text("B"))] },
],
},
{
type: "tableRow",
content: [
{ type: "tableCell", attrs: {}, content: [p(text("1"))] },
{ type: "tableCell", attrs: {}, content: [p(text("2"))] },
],
},
],
};
const { markdown } = tiptapToMarkdown(doc(table));
const { doc: back } = markdownToTiptap(markdown);
const bt = back.content?.find((n) => n.type === "table");
// Header + data row
expect(bt?.content?.length).toBeGreaterThanOrEqual(2);
});
});

View file

@ -0,0 +1,57 @@
/**
* Unit tests for useEditorMode hook + localStorage persistence.
*/
import { describe, it, expect, beforeEach, afterEach, vi } from "vitest";
import { initEditorMode } from "../hooks/use-editor-mode";
// Minimal localStorage mock for jsdom environments that may not persist correctly.
const storeMock: Record<string, string> = {};
const localStorageMock = {
getItem: (k: string) => storeMock[k] ?? null,
setItem: (k: string, v: string) => { storeMock[k] = v; },
removeItem: (k: string) => { delete storeMock[k]; },
clear: () => { for (const k of Object.keys(storeMock)) delete storeMock[k]; },
};
beforeEach(() => {
Object.defineProperty(globalThis, "localStorage", {
value: localStorageMock,
writable: true,
configurable: true,
});
localStorageMock.clear();
});
afterEach(() => {
localStorageMock.clear();
});
describe("initEditorMode", () => {
it("defaults to wysiwyg when no value stored", () => {
const setter = vi.fn();
initEditorMode("page-1", setter);
expect(setter).toHaveBeenCalledWith("wysiwyg");
});
it("reads wysiwyg from localStorage", () => {
localStorage.setItem("acadenice:editor-mode:page-2", "wysiwyg");
const setter = vi.fn();
initEditorMode("page-2", setter);
expect(setter).toHaveBeenCalledWith("wysiwyg");
});
it("reads markdown from localStorage", () => {
localStorage.setItem("acadenice:editor-mode:page-3", "markdown");
const setter = vi.fn();
initEditorMode("page-3", setter);
expect(setter).toHaveBeenCalledWith("markdown");
});
it("ignores invalid stored value and defaults to wysiwyg", () => {
localStorage.setItem("acadenice:editor-mode:page-4", "invalid-mode");
const setter = vi.fn();
initEditorMode("page-4", setter);
expect(setter).toHaveBeenCalledWith("wysiwyg");
});
});

View file

@ -0,0 +1,230 @@
/**
* DualEditor wrapper that hosts either the Tiptap WYSIWYG editor or
* the raw markdown source editor (MarkdownEditor).
*
* Switch logic:
* WYSIWYG -> Markdown: serialize the live Tiptap doc to markdown via
* tiptapToMarkdown(). If warnings are present, a confirmation modal is
* shown listing the potential data loss.
* Markdown -> WYSIWYG: parse the current markdown string to a Tiptap JSON
* doc via markdownToTiptap(), then call editor.commands.setContent(doc).
*
* Persistence:
* The mode choice is persisted per-page in localStorage.
* The source of truth for the document content is always the Tiptap JSON
* (stored in the DB). The markdown view is ephemeral.
*
* Usage:
* Replace <PageEditor> with <DualEditor> in full-editor.tsx.
* All PageEditor props are forwarded; the DualEditor adds the mode toggle
* button in the toolbar area.
*/
import React, { useCallback, useEffect, useRef, useState } from "react";
import { useAtom } from "jotai";
import { Alert, Button, Group, Modal, Stack, Text } from "@mantine/core";
import { IconAlertTriangle } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import { pageEditorAtom } from "@/features/editor/atoms/editor-atoms";
import { ModeToggleButton } from "./mode-toggle-button";
import { MarkdownEditor } from "./markdown-editor";
import {
editorModeAtom,
initEditorMode,
type EditorMode,
useEditorMode,
} from "../hooks/use-editor-mode";
import { tiptapToMarkdown, markdownToTiptap } from "../services/markdown-converter";
import type { ConversionWarning } from "../services/markdown-converter";
interface DualEditorProps {
/** The Tiptap WYSIWYG editor rendered as children. */
children: React.ReactNode;
pageId: string;
/** Whether the page is editable (controls toggle visibility). */
editable: boolean;
}
interface SwitchWarningModalProps {
warnings: ConversionWarning[];
direction: "to-markdown" | "to-wysiwyg";
onConfirm: () => void;
onCancel: () => void;
}
function SwitchWarningModal({
warnings,
direction,
onConfirm,
onCancel,
}: SwitchWarningModalProps) {
const { t } = useTranslation();
return (
<Modal
opened
onClose={onCancel}
title={t("dual_editor.switch_warning_title")}
size="md"
>
<Stack gap="md">
<Alert icon={<IconAlertTriangle size={16} />} color="yellow">
{direction === "to-markdown"
? t("dual_editor.switch_warning_to_md")
: t("dual_editor.switch_warning_to_wysiwyg")}
</Alert>
<Stack gap={4}>
{warnings.map((w, i) => (
<Text key={i} size="sm" c="dimmed">
{`[${w.nodeType}] ${w.message}`}
</Text>
))}
</Stack>
<Group justify="flex-end">
<Button variant="default" onClick={onCancel}>
{t("Cancel")}
</Button>
<Button color="yellow" onClick={onConfirm}>
{t("dual_editor.switch_anyway")}
</Button>
</Group>
</Stack>
</Modal>
);
}
export function DualEditor({ children, pageId, editable }: DualEditorProps) {
const { t } = useTranslation();
const [editor] = useAtom(pageEditorAtom);
const [mode, setMode] = useEditorMode(pageId);
// Raw markdown content while in markdown mode
const [markdownValue, setMarkdownValue] = useState("");
// Pending switch confirmation state
const [pendingSwitch, setPendingSwitch] = useState<{
direction: "to-markdown" | "to-wysiwyg";
warnings: ConversionWarning[];
markdownSnapshot?: string;
} | null>(null);
// Track pageId changes to reset mode
const prevPageId = useRef<string>("");
useEffect(() => {
if (prevPageId.current !== pageId) {
prevPageId.current = pageId;
initEditorMode(pageId, setMode);
// Reset to empty markdown; it will be repopulated on next WYSIWYG->MD switch.
setMarkdownValue("");
}
}, [pageId, setMode]);
const switchToMarkdown = useCallback(() => {
if (!editor) return;
const doc = editor.getJSON();
const { markdown, warnings } = tiptapToMarkdown(doc as any);
if (warnings.length > 0) {
// Ask for confirmation before switching
setPendingSwitch({ direction: "to-markdown", warnings, markdownSnapshot: markdown });
return;
}
setMarkdownValue(markdown);
setMode("markdown");
}, [editor, setMode]);
const switchToWysiwyg = useCallback(() => {
if (!editor) return;
const { doc, warnings } = markdownToTiptap(markdownValue);
if (warnings.length > 0) {
setPendingSwitch({ direction: "to-wysiwyg", warnings });
return;
}
editor.commands.setContent(doc as any, false);
setMode("wysiwyg");
}, [editor, markdownValue, setMode]);
function handleToggle() {
if (!editable) return;
if (mode === "wysiwyg") {
switchToMarkdown();
} else {
switchToWysiwyg();
}
}
function confirmSwitch() {
if (!pendingSwitch) return;
if (pendingSwitch.direction === "to-markdown" && pendingSwitch.markdownSnapshot !== undefined) {
setMarkdownValue(pendingSwitch.markdownSnapshot);
setMode("markdown");
} else if (pendingSwitch.direction === "to-wysiwyg" && editor) {
const { doc } = markdownToTiptap(markdownValue);
editor.commands.setContent(doc as any, false);
setMode("wysiwyg");
}
setPendingSwitch(null);
}
function cancelSwitch() {
setPendingSwitch(null);
}
// When in markdown mode, sync back to Tiptap JSON on every change so that
// the page save mechanism (which reads from the Tiptap doc) stays in sync.
useEffect(() => {
if (mode !== "markdown" || !editor) return;
const { doc } = markdownToTiptap(markdownValue);
editor.commands.setContent(doc as any, false);
}, [markdownValue, mode, editor]);
return (
<div style={{ position: "relative" }}>
{/* Toggle button — positioned at the top-right of the editor area */}
{editable && (
<div
style={{
position: "absolute",
top: "-2.5rem",
right: 0,
zIndex: 10,
}}
className="print-hide"
>
<ModeToggleButton
mode={mode}
onToggle={handleToggle}
disabled={!editor}
/>
</div>
)}
{/* Warning modal for lossy switches */}
{pendingSwitch && (
<SwitchWarningModal
warnings={pendingSwitch.warnings}
direction={pendingSwitch.direction}
onConfirm={confirmSwitch}
onCancel={cancelSwitch}
/>
)}
{/* Editor content */}
{mode === "wysiwyg" ? (
children
) : (
<MarkdownEditor
value={markdownValue}
onChange={setMarkdownValue}
pageId={pageId}
/>
)}
</div>
);
}

View file

@ -0,0 +1,89 @@
/**
* Raw markdown source editor.
*
* Uses a plain <textarea> as the code editor backend.
*
* Rationale for textarea over CodeMirror/Monaco:
* - Neither @codemirror/* nor monaco-editor is in the current dependency tree.
* - Adding CodeMirror 6 requires pnpm install (explicitly forbidden in scope).
* - A styled <textarea> with monospace font is sufficient for prod-like raw
* markdown editing and has zero bundle cost.
* - If CodeMirror 6 is added later, it is a drop-in replacement: same
* value/onChange contract.
*
* The textarea is intentionally unstyled beyond the monospace font so it
* inherits the Mantine theme correctly.
*/
import React, { useEffect, useRef } from "react";
import { Textarea } from "@mantine/core";
import { useTranslation } from "react-i18next";
interface MarkdownEditorProps {
value: string;
onChange: (value: string) => void;
/** Aria-described page id for accessibility. */
pageId?: string;
}
/**
* Controlled markdown source textarea.
*
* The component auto-sizes its height to the content (up to 100vh).
* Tab key inserts two spaces instead of moving focus.
*/
export function MarkdownEditor({ value, onChange, pageId }: MarkdownEditorProps) {
const { t } = useTranslation();
const ref = useRef<HTMLTextAreaElement>(null);
// Auto-resize height to content
useEffect(() => {
const el = ref.current;
if (!el) return;
el.style.height = "auto";
el.style.height = `${el.scrollHeight}px`;
}, [value]);
function handleKeyDown(e: React.KeyboardEvent<HTMLTextAreaElement>) {
if (e.key === "Tab") {
e.preventDefault();
const el = e.currentTarget;
const start = el.selectionStart;
const end = el.selectionEnd;
const next = value.substring(0, start) + " " + value.substring(end);
onChange(next);
// Restore cursor after React re-render
requestAnimationFrame(() => {
el.selectionStart = start + 2;
el.selectionEnd = start + 2;
});
}
}
return (
<Textarea
ref={ref}
value={value}
onChange={(e) => onChange(e.currentTarget.value)}
onKeyDown={handleKeyDown}
autosize
minRows={8}
styles={{
input: {
fontFamily: "var(--mantine-font-family-monospace, monospace)",
fontSize: "0.875rem",
lineHeight: 1.6,
resize: "none",
border: "none",
outline: "none",
padding: "1rem 0",
background: "transparent",
},
}}
aria-label={t("dual_editor.markdown_editor_label")}
aria-describedby={pageId ? `page-${pageId}` : undefined}
data-testid="markdown-source-editor"
spellCheck={false}
/>
);
}

View file

@ -0,0 +1,51 @@
import React from "react";
import { ActionIcon, Tooltip } from "@mantine/core";
import { IconCode, IconEye } from "@tabler/icons-react";
import { useTranslation } from "react-i18next";
import type { EditorMode } from "../hooks/use-editor-mode";
interface ModeToggleButtonProps {
mode: EditorMode;
onToggle: () => void;
disabled?: boolean;
}
/**
* Toggle button shown in the editor toolbar.
*
* WYSIWYG mode: shows <IconCode> (click -> switch to markdown)
* Markdown mode: shows <IconEye> (click -> switch to WYSIWYG)
*
* Disabled when the editor is read-only (editable=false).
*/
export function ModeToggleButton({
mode,
onToggle,
disabled = false,
}: ModeToggleButtonProps) {
const { t } = useTranslation();
const label =
mode === "wysiwyg"
? t("dual_editor.switch_to_markdown")
: t("dual_editor.switch_to_wysiwyg");
return (
<Tooltip label={label} withArrow position="bottom">
<ActionIcon
variant="subtle"
color={mode === "markdown" ? "blue" : "gray"}
onClick={onToggle}
disabled={disabled}
aria-label={label}
data-testid="dual-editor-mode-toggle"
>
{mode === "wysiwyg" ? (
<IconCode size={18} stroke={1.5} />
) : (
<IconEye size={18} stroke={1.5} />
)}
</ActionIcon>
</Tooltip>
);
}

View file

@ -0,0 +1,79 @@
/**
* Editor mode atom + persistence hook.
*
* The chosen mode ('wysiwyg' | 'markdown') is persisted per page in
* localStorage under the key `acadenice:editor-mode:<pageId>`.
*
* On first visit to a page the mode defaults to 'wysiwyg'.
*/
import { atom, useAtom } from "jotai";
export type EditorMode = "wysiwyg" | "markdown";
const STORAGE_PREFIX = "acadenice:editor-mode:";
function storageKey(pageId: string): string {
return `${STORAGE_PREFIX}${pageId}`;
}
function readFromStorage(pageId: string): EditorMode {
try {
const raw = localStorage.getItem(storageKey(pageId));
if (raw === "markdown" || raw === "wysiwyg") return raw;
} catch {
// localStorage may be unavailable in some environments (tests, SSR).
}
return "wysiwyg";
}
function writeToStorage(pageId: string, mode: EditorMode): void {
try {
localStorage.setItem(storageKey(pageId), mode);
} catch {
// Ignore write errors (private browsing / quota exceeded).
}
}
/**
* Global atom that holds the current editor mode.
* Initialised to 'wysiwyg'; the hook below reads/writes localStorage.
*/
export const editorModeAtom = atom<EditorMode>("wysiwyg");
/**
* useEditorMode returns [mode, setMode] for the given page.
*
* On first call for a pageId the mode is loaded from localStorage.
* Subsequent calls within the same render tree share the same atom value.
*
* Persistence: every set() call writes to localStorage.
*/
export function useEditorMode(pageId: string): [EditorMode, (mode: EditorMode) => void] {
const [mode, setModeAtom] = useAtom(editorModeAtom);
// Initialise from localStorage on first mount — this runs during render,
// which is acceptable because it's synchronous and side-effect-free from
// React's perspective (localStorage is not the React tree).
// We rely on the atom default and let the first render of DualEditor
// call initMode() to sync.
function setMode(next: EditorMode): void {
writeToStorage(pageId, next);
setModeAtom(next);
}
return [mode, setMode];
}
/**
* Load and apply the persisted mode for a given page.
* Should be called once per page mount (inside a useEffect).
*/
export function initEditorMode(
pageId: string,
setMode: (mode: EditorMode) => void,
): void {
const persisted = readFromStorage(pageId);
setMode(persisted);
}

View file

@ -0,0 +1,124 @@
/**
* Registry of Acadenice custom node serializers for markdown round-trip.
*
* Each entry maps a Tiptap node type name to a pair of functions:
* toMarkdown(node): string Tiptap JSON node -> markdown string
* fromMarkdown(match): attrs | null regex match -> Tiptap node attrs
*
* The registry is consumed by markdown-converter.ts.
*
* Syntax choices (reversible, no HTML inline):
* databaseView -> [[!db tableId=<X> viewId=<Y> viewType=<Z>]]
* wikilink -> [[Page Title]] or [[Page Title|alias]]
* mention -> @<userId>(<name>)
*/
export interface CustomNodeSerializer {
/** Regex that matches this node's markdown syntax. Must have named groups. */
pattern: RegExp;
/** Convert Tiptap JSON node attrs to a markdown token string. */
toMarkdown: (attrs: Record<string, unknown>) => string;
/**
* Convert a regex match (from `pattern`) to Tiptap node attrs.
* Returns null if the match is invalid / incomplete.
*/
fromMarkdown: (match: RegExpExecArray) => Record<string, unknown> | null;
/** The Tiptap node type name to create when parsing. */
nodeType: string;
}
// --------------------------------------------------------------------------
// databaseView
// --------------------------------------------------------------------------
// Syntax: [[!db tableId=42 viewId=7 viewType=grid]]
// The "!" prefix distinguishes database embeds from regular wikilinks.
const DATABASE_VIEW_PATTERN =
/\[\[!db tableId=([^\s\]]+) viewId=([^\s\]]+) viewType=([^\s\]]+)\]\]/g;
const databaseViewSerializer: CustomNodeSerializer = {
nodeType: "database-view",
pattern: DATABASE_VIEW_PATTERN,
toMarkdown(attrs) {
const tableId = String(attrs.tableId ?? "");
const viewId = String(attrs.viewId ?? "");
const viewType = String(attrs.viewType ?? "grid");
return `[[!db tableId=${tableId} viewId=${viewId} viewType=${viewType}]]`;
},
fromMarkdown(match) {
const [, tableId, viewId, viewType] = match;
if (!tableId || !viewId) return null;
return { tableId, viewId, viewType: viewType ?? "grid", bridgeUrl: null };
},
};
// --------------------------------------------------------------------------
// wikilink
// --------------------------------------------------------------------------
// Syntax: [[Page Title]] or [[Page Title|alias]]
// Matches only after we've excluded the !db prefix.
const WIKILINK_PATTERN = /\[\[(?!!db )([^\]|]+?)(?:\|([^\]]*))?\]\]/g;
const wikilinkSerializer: CustomNodeSerializer = {
nodeType: "wikilink",
pattern: WIKILINK_PATTERN,
toMarkdown(attrs) {
const title = String(attrs.title ?? "");
const alias = attrs.alias ? String(attrs.alias) : null;
return alias ? `[[${title}|${alias}]]` : `[[${title}]]`;
},
fromMarkdown(match) {
const [, title, alias] = match;
if (!title) return null;
return {
pageId: null,
title: title.trim(),
alias: alias ? alias.trim() : null,
};
},
};
// --------------------------------------------------------------------------
// mention
// --------------------------------------------------------------------------
// Syntax: @<userId>(<name>)
// The userId is preserved to avoid lossy round-trips (display name alone
// is not sufficient to re-resolve the user without a server lookup).
const MENTION_PATTERN = /@<([^>]+)>\(([^)]*)\)/g;
const mentionSerializer: CustomNodeSerializer = {
nodeType: "mention",
pattern: MENTION_PATTERN,
toMarkdown(attrs) {
const id = String(attrs.id ?? "");
const label = String(attrs.label ?? attrs.id ?? "");
return `@<${id}>(${label})`;
},
fromMarkdown(match) {
const [, id, label] = match;
if (!id) return null;
return { id, label };
},
};
// --------------------------------------------------------------------------
// Public registry
// --------------------------------------------------------------------------
export const CUSTOM_NODE_SERIALIZERS: Record<string, CustomNodeSerializer> = {
"database-view": databaseViewSerializer,
wikilink: wikilinkSerializer,
mention: mentionSerializer,
};
/**
* List of serializers in parse order.
* databaseView must be before wikilink (its pattern is more specific).
*/
export const SERIALIZER_LIST: CustomNodeSerializer[] = [
databaseViewSerializer,
wikilinkSerializer,
mentionSerializer,
];

View file

@ -0,0 +1,732 @@
/**
* Bidirectional converter: Tiptap JSON <-> Markdown.
*
* Source of truth is always the Tiptap JSON (persisted to DB).
* Markdown is a human-readable projection; it is never stored directly.
*
* Design decisions:
* - Pure TypeScript, no external markdown library.
* Docmost does not bundle tiptap-markdown/prosemirror-markdown; adding a
* heavy parser would bloat the bundle. A custom serializer keeps the dependency
* count flat and gives full control over custom-node syntax.
* - Block nodes are serialized top-down, marks are applied inline.
* - Unknown nodes fall back to their text content (no silent data loss).
* - Round-trip fidelity: JSON -> MD -> JSON produces a structurally equivalent
* document (IDs and transient attrs may differ).
*
* Supported node types:
* doc, paragraph, text, hardBreak,
* heading (1-6), blockquote, codeBlock,
* bulletList, orderedList, listItem,
* taskList, taskItem,
* table, tableRow, tableCell, tableHeader,
* horizontalRule, image,
* -- Acadenice custom --
* database-view, wikilink, mention
*
* Supported marks:
* bold, italic, code, strike, underline, link, highlight
*/
import {
CUSTOM_NODE_SERIALIZERS,
SERIALIZER_LIST,
} from "./custom-node-serializers";
// --------------------------------------------------------------------------
// Types
// --------------------------------------------------------------------------
export interface TiptapMark {
type: string;
attrs?: Record<string, unknown>;
}
export interface TiptapNode {
type: string;
attrs?: Record<string, unknown>;
content?: TiptapNode[];
marks?: TiptapMark[];
text?: string;
}
export interface ConversionWarning {
nodeType: string;
message: string;
}
export interface TiptapToMarkdownResult {
markdown: string;
warnings: ConversionWarning[];
}
export interface MarkdownToTiptapResult {
doc: TiptapNode;
warnings: ConversionWarning[];
}
// --------------------------------------------------------------------------
// Tiptap -> Markdown
// --------------------------------------------------------------------------
function applyMarks(text: string, marks: TiptapMark[]): string {
let result = text;
for (const mark of marks) {
switch (mark.type) {
case "bold":
result = `**${result}**`;
break;
case "italic":
result = `_${result}_`;
break;
case "code":
result = `\`${result}\``;
break;
case "strike":
result = `~~${result}~~`;
break;
case "underline":
// No standard markdown for underline; use HTML span to preserve intent.
result = `<u>${result}</u>`;
break;
case "link": {
const href = String(mark.attrs?.href ?? "");
result = `[${result}](${href})`;
break;
}
case "highlight": {
result = `==${result}==`;
break;
}
// Unknown marks: pass-through (text is preserved)
}
}
return result;
}
function serializeInlineNode(
node: TiptapNode,
warnings: ConversionWarning[],
): string {
if (node.type === "text") {
const raw = node.text ?? "";
const marks = node.marks ?? [];
return applyMarks(raw, marks);
}
if (node.type === "hardBreak") {
return " \n";
}
// Custom Acadenice nodes (inline: wikilink, mention)
const customSerializer = CUSTOM_NODE_SERIALIZERS[node.type];
if (customSerializer && node.attrs) {
return customSerializer.toMarkdown(node.attrs);
}
// Unknown inline node: emit text content if any
warnings.push({
nodeType: node.type,
message: `Unknown inline node type "${node.type}" — text content preserved`,
});
return serializeContent(node.content ?? [], warnings);
}
function serializeContent(
nodes: TiptapNode[],
warnings: ConversionWarning[],
): string {
return nodes.map((n) => serializeInlineNode(n, warnings)).join("");
}
function serializeTableRow(
row: TiptapNode,
isHeader: boolean,
warnings: ConversionWarning[],
): string {
const cells = (row.content ?? [])
.filter((c) => c.type === "tableCell" || c.type === "tableHeader")
.map((cell) => {
const inner = serializeContent(
flattenCellContent(cell.content ?? []),
warnings,
);
return ` ${inner.replace(/\|/g, "\\|").trim()} `;
});
const rowStr = `|${cells.join("|")}|`;
if (!isHeader) return rowStr;
// Separator row
const sep = cells.map(() => " --- ").join("|");
return `${rowStr}\n|${sep}|`;
}
/** Flatten nested paragraph nodes inside a table cell to their inline content. */
function flattenCellContent(nodes: TiptapNode[]): TiptapNode[] {
const result: TiptapNode[] = [];
for (const node of nodes) {
if (node.type === "paragraph") {
result.push(...(node.content ?? []));
} else {
result.push(node);
}
}
return result;
}
function serializeNode(
node: TiptapNode,
ctx: { listDepth: number; ordered: boolean; warnings: ConversionWarning[] },
): string {
const { warnings } = ctx;
switch (node.type) {
case "doc":
return (node.content ?? [])
.map((n) => serializeNode(n, ctx))
.join("\n\n");
case "paragraph": {
if (!node.content || node.content.length === 0) return "";
return serializeContent(node.content, warnings);
}
case "heading": {
const level = Number(node.attrs?.level ?? 1);
const prefix = "#".repeat(Math.min(6, Math.max(1, level)));
return `${prefix} ${serializeContent(node.content ?? [], warnings)}`;
}
case "blockquote": {
const inner = (node.content ?? [])
.map((n) => serializeNode(n, ctx))
.join("\n");
return inner
.split("\n")
.map((line) => `> ${line}`)
.join("\n");
}
case "codeBlock": {
const lang = String(node.attrs?.language ?? "");
const code = serializeContent(node.content ?? [], warnings);
return `\`\`\`${lang}\n${code}\n\`\`\``;
}
case "bulletList": {
return (node.content ?? [])
.map((item) =>
serializeNode(item, { ...ctx, listDepth: ctx.listDepth + 1, ordered: false }),
)
.join("\n");
}
case "orderedList": {
let idx = Number(node.attrs?.start ?? 1);
return (node.content ?? [])
.map((item) => {
const str = serializeNode(item, { ...ctx, listDepth: ctx.listDepth + 1, ordered: true });
// Prepend order prefix (already done inside listItem)
return str;
})
.join("\n");
}
case "listItem": {
const indent = " ".repeat(Math.max(0, ctx.listDepth - 1));
const bullet = ctx.ordered ? "1." : "-";
const inner = (node.content ?? [])
.map((n) => serializeNode(n, ctx))
.join("\n");
return inner
.split("\n")
.map((line, i) => (i === 0 ? `${indent}${bullet} ${line}` : `${indent} ${line}`))
.join("\n");
}
case "taskList": {
return (node.content ?? [])
.map((item) => serializeNode(item, { ...ctx, listDepth: ctx.listDepth + 1, ordered: false }))
.join("\n");
}
case "taskItem": {
const checked = Boolean(node.attrs?.checked);
const indent = " ".repeat(Math.max(0, ctx.listDepth - 1));
const checkbox = checked ? "[x]" : "[ ]";
const inner = (node.content ?? [])
.map((n) => serializeNode(n, ctx))
.join("\n");
return inner
.split("\n")
.map((line, i) => (i === 0 ? `${indent}- ${checkbox} ${line}` : `${indent} ${line}`))
.join("\n");
}
case "table": {
const rows = node.content ?? [];
if (rows.length === 0) return "";
const lines: string[] = [];
rows.forEach((row, i) => {
lines.push(serializeTableRow(row, i === 0, warnings));
});
return lines.join("\n");
}
case "tableRow":
return serializeTableRow(node, false, warnings);
case "horizontalRule":
return "---";
case "image": {
const src = String(node.attrs?.src ?? "");
const alt = String(node.attrs?.alt ?? "");
return `![${alt}](${src})`;
}
default: {
// Custom block nodes (database-view) and unknown nodes.
const customSerializer = CUSTOM_NODE_SERIALIZERS[node.type];
if (customSerializer && node.attrs) {
return customSerializer.toMarkdown(node.attrs);
}
warnings.push({
nodeType: node.type,
message: `Unknown block node type "${node.type}" — content preserved as text`,
});
// Fall back to serializing children
if (node.content && node.content.length > 0) {
return (node.content ?? [])
.map((n) => serializeNode(n, ctx))
.join("\n\n");
}
return "";
}
}
}
/**
* Convert a Tiptap JSON document to markdown.
*
* @param doc - The Tiptap doc JSON object (type: "doc")
* @returns Markdown string + any conversion warnings
*/
export function tiptapToMarkdown(doc: TiptapNode): TiptapToMarkdownResult {
const warnings: ConversionWarning[] = [];
const markdown = serializeNode(doc, {
listDepth: 0,
ordered: false,
warnings,
});
// Collapse more than two consecutive blank lines
const normalized = markdown.replace(/\n{3,}/g, "\n\n").trim();
return { markdown: normalized, warnings };
}
// --------------------------------------------------------------------------
// Markdown -> Tiptap
// --------------------------------------------------------------------------
/** Internal parser state. */
interface ParseContext {
lines: string[];
pos: number;
warnings: ConversionWarning[];
}
function peek(ctx: ParseContext): string | undefined {
return ctx.lines[ctx.pos];
}
function advance(ctx: ParseContext): string {
return ctx.lines[ctx.pos++] ?? "";
}
function parseInlineTokens(
text: string,
warnings: ConversionWarning[],
): TiptapNode[] {
// We process inline tokens left-to-right using a simple state machine.
const nodes: TiptapNode[] = [];
// All custom inline patterns (wikilink + mention) merged with standard marks.
// Order matters: longer / more specific patterns first.
type InlineToken =
| { kind: "db"; tableId: string; viewId: string; viewType: string }
| { kind: "wikilink"; title: string; alias: string | null }
| { kind: "mention"; id: string; label: string }
| { kind: "bold"; text: string }
| { kind: "italic"; text: string }
| { kind: "code"; text: string }
| { kind: "strike"; text: string }
| { kind: "underline"; text: string }
| { kind: "highlight"; text: string }
| { kind: "link"; href: string; text: string }
| { kind: "hardbreak" }
| { kind: "text"; text: string };
// Build the combined regex. Non-capturing groups around each alternative
// so we can identify which alternative matched by inspecting capture groups.
const TOKEN_RE =
/\[\[!db tableId=([^\s\]]+) viewId=([^\s\]]+) viewType=([^\s\]]+)\]\]|\[\[(?!!db )([^\]|]+?)(?:\|([^\]]*))?\]\]|@<([^>]+)>\(([^)]*)\)|\*\*(.+?)\*\*|(?<!\*)\*(?!\*)(.+?)(?<!\*)\*(?!\*)|`([^`]+)`|~~(.+?)~~|<u>(.+?)<\/u>|==(.+?)==|\[([^\]]+)\]\(([^)]+)\)| \n|((?! \n).+?)/gs;
let match: RegExpExecArray | null;
TOKEN_RE.lastIndex = 0;
const pendingText: string[] = [];
function flushText() {
if (pendingText.length === 0) return;
const combined = pendingText.join("");
pendingText.length = 0;
if (combined) {
nodes.push({ type: "text", text: combined });
}
}
while ((match = TOKEN_RE.exec(text)) !== null) {
const [
full,
dbTableId,
dbViewId,
dbViewType,
wlTitle,
wlAlias,
mentionId,
mentionLabel,
boldText,
italicText,
codeText,
strikeText,
underlineText,
highlightText,
linkText,
linkHref,
// hardbreak group index 16 (captured as undefined if not matched)
,
rawText,
] = match;
if (dbTableId !== undefined) {
flushText();
nodes.push({
type: "database-view",
attrs: { tableId: dbTableId, viewId: dbViewId, viewType: dbViewType, bridgeUrl: null },
});
} else if (wlTitle !== undefined) {
flushText();
nodes.push({
type: "wikilink",
attrs: { pageId: null, title: wlTitle.trim(), alias: wlAlias?.trim() ?? null },
});
} else if (mentionId !== undefined) {
flushText();
nodes.push({
type: "mention",
attrs: { id: mentionId, label: mentionLabel ?? mentionId },
});
} else if (boldText !== undefined) {
flushText();
nodes.push({ type: "text", text: boldText, marks: [{ type: "bold" }] });
} else if (italicText !== undefined) {
flushText();
nodes.push({ type: "text", text: italicText, marks: [{ type: "italic" }] });
} else if (codeText !== undefined) {
flushText();
nodes.push({ type: "text", text: codeText, marks: [{ type: "code" }] });
} else if (strikeText !== undefined) {
flushText();
nodes.push({ type: "text", text: strikeText, marks: [{ type: "strike" }] });
} else if (underlineText !== undefined) {
flushText();
nodes.push({ type: "text", text: underlineText, marks: [{ type: "underline" }] });
} else if (highlightText !== undefined) {
flushText();
nodes.push({ type: "text", text: highlightText, marks: [{ type: "highlight" }] });
} else if (linkText !== undefined) {
flushText();
nodes.push({
type: "text",
text: linkText,
marks: [{ type: "link", attrs: { href: linkHref } }],
});
} else if (full === " \n") {
flushText();
nodes.push({ type: "hardBreak" });
} else if (rawText !== undefined) {
pendingText.push(rawText);
}
}
flushText();
return nodes;
}
function parseParagraph(
line: string,
warnings: ConversionWarning[],
): TiptapNode {
const inlineNodes = parseInlineTokens(line, warnings);
return { type: "paragraph", content: inlineNodes };
}
function parseHeading(
line: string,
warnings: ConversionWarning[],
): TiptapNode {
const match = /^(#{1,6})\s+(.*)$/.exec(line);
if (!match) return parseParagraph(line, warnings);
const level = match[1].length;
const text = match[2];
return {
type: "heading",
attrs: { level },
content: parseInlineTokens(text, warnings),
};
}
function parseCodeBlock(ctx: ParseContext): TiptapNode {
const firstLine = advance(ctx);
const langMatch = /^```(.*)$/.exec(firstLine);
const lang = langMatch ? langMatch[1].trim() : "";
const codeLines: string[] = [];
while (peek(ctx) !== undefined && !peek(ctx)!.startsWith("```")) {
codeLines.push(advance(ctx));
}
// Consume closing ```
if (peek(ctx) !== undefined) advance(ctx);
return {
type: "codeBlock",
attrs: { language: lang },
content: [{ type: "text", text: codeLines.join("\n") }],
};
}
function parseBlockquote(
ctx: ParseContext,
warnings: ConversionWarning[],
): TiptapNode {
const lines: string[] = [];
while (peek(ctx) !== undefined && peek(ctx)!.startsWith("> ")) {
lines.push(advance(ctx).slice(2));
}
const innerDoc = parseDocument({ lines, pos: 0, warnings });
return { type: "blockquote", content: innerDoc };
}
/** Parse a bullet list starting at current position. */
function parseBulletList(
ctx: ParseContext,
warnings: ConversionWarning[],
): TiptapNode {
const items: TiptapNode[] = [];
while (peek(ctx) !== undefined) {
const line = peek(ctx)!;
const taskMatch = /^(\s*)- \[([ xX])\] (.*)$/.exec(line);
if (taskMatch) break; // task list item — stop bullet list
const bulletMatch = /^(\s*)([-*+]) (.*)$/.exec(line);
if (!bulletMatch) break;
advance(ctx);
const content = bulletMatch[3];
items.push({
type: "listItem",
content: [parseParagraph(content, warnings)],
});
}
return { type: "bulletList", content: items };
}
/** Parse an ordered list starting at current position. */
function parseOrderedList(
ctx: ParseContext,
warnings: ConversionWarning[],
): TiptapNode {
const items: TiptapNode[] = [];
while (peek(ctx) !== undefined) {
const line = peek(ctx)!;
const match = /^\d+\. (.*)$/.exec(line);
if (!match) break;
advance(ctx);
items.push({
type: "listItem",
content: [parseParagraph(match[1], warnings)],
});
}
return { type: "orderedList", attrs: { start: 1 }, content: items };
}
/** Parse a task list (- [ ] / - [x]) starting at current position. */
function parseTaskList(
ctx: ParseContext,
warnings: ConversionWarning[],
): TiptapNode {
const items: TiptapNode[] = [];
while (peek(ctx) !== undefined) {
const line = peek(ctx)!;
const match = /^(\s*)- \[([ xX])\] (.*)$/.exec(line);
if (!match) break;
advance(ctx);
const checked = match[2].toLowerCase() === "x";
const text = match[3];
items.push({
type: "taskItem",
attrs: { checked },
content: [parseParagraph(text, warnings)],
});
}
return { type: "taskList", content: items };
}
/** Parse a GFM table. */
function parseTable(
ctx: ParseContext,
warnings: ConversionWarning[],
): TiptapNode {
const rows: TiptapNode[] = [];
let isFirst = true;
while (peek(ctx) !== undefined && peek(ctx)!.startsWith("|")) {
const line = advance(ctx);
// Skip separator row (| --- | --- |)
if (/^\|[\s\-|]+\|$/.test(line.replace(/ /g, ""))) {
continue;
}
const cells = line
.slice(1, -1)
.split("|")
.map((c) => c.trim());
const cellNodes: TiptapNode[] = cells.map((cell) => ({
type: isFirst ? "tableHeader" : "tableCell",
attrs: { colspan: 1, rowspan: 1, colwidth: null },
content: [{ type: "paragraph", content: parseInlineTokens(cell, warnings) }],
}));
rows.push({ type: "tableRow", content: cellNodes });
isFirst = false;
}
return { type: "table", content: rows };
}
/**
* Check if a line is a standalone custom-node token (block-level).
* Returns the Tiptap node or null.
*/
function tryParseCustomBlockNode(
line: string,
warnings: ConversionWarning[],
): TiptapNode | null {
for (const serializer of SERIALIZER_LIST) {
const re = new RegExp(serializer.pattern.source, "");
const match = re.exec(line);
if (match && match[0] === line.trim()) {
const attrs = serializer.fromMarkdown(match as RegExpExecArray);
if (!attrs) {
warnings.push({
nodeType: serializer.nodeType,
message: `Could not parse ${serializer.nodeType} token: "${line}"`,
});
return null;
}
return { type: serializer.nodeType, attrs };
}
}
return null;
}
function parseDocument(ctx: ParseContext): TiptapNode[] {
const nodes: TiptapNode[] = [];
const { warnings } = ctx;
while (ctx.pos < ctx.lines.length) {
const line = peek(ctx);
if (line === undefined) break;
// Blank line
if (line.trim() === "") {
advance(ctx);
continue;
}
// Fenced code block
if (line.startsWith("```")) {
nodes.push(parseCodeBlock(ctx));
continue;
}
// Blockquote
if (line.startsWith("> ")) {
nodes.push(parseBlockquote(ctx, warnings));
continue;
}
// Heading
if (/^#{1,6} /.test(line)) {
advance(ctx);
nodes.push(parseHeading(line, warnings));
continue;
}
// Horizontal rule
if (/^---+$/.test(line.trim()) || /^\*\*\*+$/.test(line.trim())) {
advance(ctx);
nodes.push({ type: "horizontalRule" });
continue;
}
// Task list (must be before bullet list)
if (/^(\s*)- \[([ xX])\] /.test(line)) {
nodes.push(parseTaskList(ctx, warnings));
continue;
}
// Bullet list
if (/^(\s*)([-*+]) /.test(line)) {
nodes.push(parseBulletList(ctx, warnings));
continue;
}
// Ordered list
if (/^\d+\. /.test(line)) {
nodes.push(parseOrderedList(ctx, warnings));
continue;
}
// Table
if (line.startsWith("|")) {
nodes.push(parseTable(ctx, warnings));
continue;
}
// Custom block nodes (database-view on its own line)
const customNode = tryParseCustomBlockNode(line, warnings);
if (customNode) {
advance(ctx);
nodes.push(customNode);
continue;
}
// Default: paragraph
advance(ctx);
nodes.push(parseParagraph(line, warnings));
}
return nodes;
}
/**
* Convert a markdown string to a Tiptap JSON document.
*
* @param markdown - Raw markdown string
* @returns Tiptap doc + any conversion warnings
*/
export function markdownToTiptap(markdown: string): MarkdownToTiptapResult {
const warnings: ConversionWarning[] = [];
const lines = markdown.replace(/\r\n/g, "\n").split("\n");
const ctx: ParseContext = { lines, pos: 0, warnings };
const content = parseDocument(ctx);
const doc: TiptapNode = {
type: "doc",
content: content.length > 0 ? content : [{ type: "paragraph", content: [] }],
};
return { doc, warnings };
}

View file

@ -13,6 +13,8 @@ import {
} from "@mantine/core";
// Acadenice R3.2 — backlinks panel
import { LinkedReferencesPanel } from "@/features/acadenice/backlinks/components/linked-references-panel";
// Acadenice R3.4 — dual editor (WYSIWYG + markdown source)
import { DualEditor } from "@/features/acadenice/dual-editor/components/dual-editor";
import { useAtom } from "jotai";
import { userAtom } from "@/features/user/atoms/current-user-atom.ts";
import { CustomAvatar } from "@/components/ui/custom-avatar.tsx";
@ -73,12 +75,15 @@ export function FullEditor({
contributors={contributors}
readOnly={!editable}
/>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
{/* Acadenice R3.4 — dual editor wraps PageEditor to add markdown source mode */}
<DualEditor pageId={pageId} editable={editable}>
<MemoizedPageEditor
pageId={pageId}
editable={editable}
content={content}
canComment={canComment}
/>
</DualEditor>
{/* Acadenice R3.2 — linked references panel (sticky bottom of page) */}
{pageId && (
<>