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:
parent
ba18a349d4
commit
9be979ee90
13 changed files with 2149 additions and 8 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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"
|
||||
}
|
||||
|
|
@ -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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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("");
|
||||
});
|
||||
});
|
||||
|
||||
// --------------------------------------------------------------------------
|
||||
// 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);
|
||||
});
|
||||
});
|
||||
|
|
@ -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");
|
||||
});
|
||||
});
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
];
|
||||
|
|
@ -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 ``;
|
||||
}
|
||||
|
||||
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 };
|
||||
}
|
||||
|
|
@ -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 && (
|
||||
<>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue