Implements docs/offline-sync-plan.md milestones M0–M2. M0 (PWA shell): - Add vite-plugin-pwa (generateSW, registerType: 'prompt', manifest:false); NetworkOnly for /api,/collab,/socket.io, NetworkFirst for GET /api, navigateFallback to index.html. - Register SW via useRegisterSW with a Mantine update prompt; skip registration inside Capacitor native WebView (is-capacitor guard). M1 (harden CRDT body + title into Yjs): - Lift the per-page Y.Doc/Hocuspocus providers into a shared hook+context so body and title editors share one doc. - Move the page title into a dedicated 'title' Yjs fragment (CRDT, offline- tolerant); drop the REST title save. Server persists the title fragment to page.title and seeds it for legacy pages (empty-fragment guard); a collab rename emits a treeUpdate so other users' tree/breadcrumbs refresh. - Persist the rebuilt ydoc on the content->ydoc path to neutralize the Yjs duplication trap. Add a 3-state sync indicator. M2 (offline read/navigation): - Persist React Query to IndexedDB (idb-keyval persister, version buster, selected roots only). - "Make available offline" action warms page, space, tree (root+ancestors+ children) and comments under exact hook keys, plus the page ydoc. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
272 lines
7.1 KiB
TypeScript
272 lines
7.1 KiB
TypeScript
import { StarterKit } from '@tiptap/starter-kit';
|
|
import { TextAlign } from '@tiptap/extension-text-align';
|
|
import { Superscript } from '@tiptap/extension-superscript';
|
|
import SubScript from '@tiptap/extension-subscript';
|
|
import { Typography } from '@tiptap/extension-typography';
|
|
import { TextStyle } from '@tiptap/extension-text-style';
|
|
import { Color } from '@tiptap/extension-color';
|
|
import { Youtube } from '@tiptap/extension-youtube';
|
|
import { TaskList, TaskItem } from '@tiptap/extension-list';
|
|
import {
|
|
Heading,
|
|
Callout,
|
|
Comment,
|
|
CustomCodeBlock,
|
|
Details,
|
|
DetailsContent,
|
|
DetailsSummary,
|
|
LinkExtension,
|
|
MathBlock,
|
|
MathInline,
|
|
TableHeader,
|
|
TableCell,
|
|
TableRow,
|
|
CustomTable,
|
|
TiptapImage,
|
|
TiptapVideo,
|
|
TiptapAudio,
|
|
TiptapPdf,
|
|
PageBreak,
|
|
TrailingNode,
|
|
Attachment,
|
|
Drawio,
|
|
Excalidraw,
|
|
Embed,
|
|
HtmlEmbed,
|
|
Mention,
|
|
Subpages,
|
|
Highlight,
|
|
Indent,
|
|
UniqueID,
|
|
Columns,
|
|
Column,
|
|
Status,
|
|
addUniqueIdsToDoc,
|
|
htmlToMarkdown,
|
|
TransclusionSource,
|
|
TransclusionReference,
|
|
FootnoteReference,
|
|
FootnotesList,
|
|
FootnoteDefinition,
|
|
PageEmbed,
|
|
} from '@docmost/editor-ext';
|
|
import { generateText, getSchema, JSONContent } from '@tiptap/core';
|
|
import { generateHTML, generateJSON } from '../common/helpers/prosemirror/html';
|
|
// @tiptap/html library works best for generating prosemirror json state but not HTML
|
|
// see: https://github.com/ueberdosis/tiptap/issues/5352
|
|
// see:https://github.com/ueberdosis/tiptap/issues/4089
|
|
//import { generateJSON } from '@tiptap/html';
|
|
import { Node, Schema } from '@tiptap/pm/model';
|
|
import * as Y from 'yjs';
|
|
import { Logger } from '@nestjs/common';
|
|
import { TiptapTransformer } from '@hocuspocus/transformer';
|
|
|
|
export const tiptapExtensions = [
|
|
StarterKit.configure({
|
|
codeBlock: false,
|
|
link: false,
|
|
trailingNode: false,
|
|
heading: false,
|
|
}),
|
|
Heading,
|
|
UniqueID.configure({
|
|
types: ['heading', 'paragraph', 'transclusionSource'],
|
|
}),
|
|
Comment,
|
|
TextAlign.configure({ types: ['heading', 'paragraph'] }),
|
|
Indent,
|
|
TaskList,
|
|
TaskItem.configure({
|
|
nested: true,
|
|
}),
|
|
LinkExtension,
|
|
Superscript,
|
|
SubScript,
|
|
Highlight,
|
|
Typography,
|
|
TrailingNode,
|
|
TextStyle,
|
|
Color,
|
|
MathInline,
|
|
MathBlock,
|
|
Details,
|
|
DetailsContent,
|
|
DetailsSummary,
|
|
CustomTable,
|
|
TableCell,
|
|
TableRow,
|
|
TableHeader,
|
|
Youtube,
|
|
TiptapImage,
|
|
TiptapVideo,
|
|
TiptapAudio,
|
|
TiptapPdf,
|
|
PageBreak,
|
|
Callout,
|
|
Attachment,
|
|
CustomCodeBlock,
|
|
Drawio,
|
|
Excalidraw,
|
|
Embed,
|
|
// Registered server-side so the node survives schema parsing/serialization.
|
|
// Authoring is gated to admins at the document WRITE paths (see
|
|
// stripHtmlEmbedNodes usage in persistence/page services), NOT here.
|
|
HtmlEmbed,
|
|
Mention,
|
|
Subpages,
|
|
Columns,
|
|
Column,
|
|
Status,
|
|
TransclusionSource,
|
|
TransclusionReference,
|
|
FootnoteReference,
|
|
FootnotesList,
|
|
FootnoteDefinition,
|
|
PageEmbed,
|
|
] as any;
|
|
|
|
export function jsonToHtml(tiptapJson: any) {
|
|
return generateHTML(tiptapJson, tiptapExtensions);
|
|
}
|
|
|
|
export function htmlToJson(html: string) {
|
|
const pmJson = generateJSON(html, tiptapExtensions);
|
|
|
|
try {
|
|
return addUniqueIdsToDoc(pmJson, tiptapExtensions);
|
|
} catch (error) {
|
|
console.warn('failed to add unique ids to doc', error);
|
|
return pmJson;
|
|
}
|
|
}
|
|
|
|
export function jsonToText(tiptapJson: JSONContent) {
|
|
return generateText(tiptapJson, tiptapExtensions);
|
|
}
|
|
|
|
/**
|
|
* Build a standalone Y.Doc that holds ONLY the page title, in a dedicated Yjs
|
|
* fragment named exactly 'title' (the collaborative title-editor contract with
|
|
* the client). The ProseMirror shape is a doc with a single level-1 heading
|
|
* whose text is the title (empty title => heading with no text child).
|
|
*
|
|
* The encoded state of the returned doc can be merged into a body doc via
|
|
* `Y.applyUpdate(doc, Y.encodeStateAsUpdate(titleSeed))` to seed the title
|
|
* fragment for legacy pages. Seeding MUST be guarded by an emptiness check on
|
|
* the existing 'title' fragment to avoid the Yjs duplication trap.
|
|
*/
|
|
export function buildTitleSeedYdoc(title: string): Y.Doc {
|
|
return TiptapTransformer.toYdoc(
|
|
{
|
|
type: 'doc',
|
|
content: [
|
|
{
|
|
type: 'heading',
|
|
attrs: { level: 1 },
|
|
content: title ? [{ type: 'text', text: title }] : [],
|
|
},
|
|
],
|
|
},
|
|
'title',
|
|
tiptapExtensions,
|
|
);
|
|
}
|
|
|
|
export function jsonToNode(tiptapJson: JSONContent) {
|
|
const schema = getSchema(tiptapExtensions);
|
|
try {
|
|
return Node.fromJSON(schema, tiptapJson);
|
|
} catch (error) {
|
|
if (
|
|
error instanceof RangeError &&
|
|
error.message.includes('Unknown node type')
|
|
) {
|
|
Logger.warn('Stripping unknown node types from document:', error.message);
|
|
const cleanedJson = stripUnknownNodes(tiptapJson, schema);
|
|
return Node.fromJSON(schema, cleanedJson);
|
|
}
|
|
throw error;
|
|
}
|
|
}
|
|
|
|
export function getPageId(documentName: string) {
|
|
return documentName.split('.')[1];
|
|
}
|
|
|
|
export function isEmptyParagraphDoc(tiptapJson: JSONContent): boolean {
|
|
if (!tiptapJson || tiptapJson.type !== 'doc') return false;
|
|
const content = tiptapJson.content;
|
|
if (!Array.isArray(content) || content.length !== 1) return false;
|
|
const child = content[0];
|
|
if (!child || child.type !== 'paragraph') return false;
|
|
return (
|
|
!child.content ||
|
|
(Array.isArray(child.content) && child.content.length === 0)
|
|
);
|
|
}
|
|
|
|
function stripUnknownNodes(
|
|
json: JSONContent,
|
|
schema: Schema,
|
|
): JSONContent | null {
|
|
if (!json || typeof json !== 'object') return json;
|
|
|
|
// Recursively clean children first, flattening any unwrapped content
|
|
if (json.content && Array.isArray(json.content)) {
|
|
const newContent: JSONContent[] = [];
|
|
for (const child of json.content) {
|
|
const cleaned = stripUnknownNodes(child, schema);
|
|
if (Array.isArray(cleaned)) {
|
|
newContent.push(...cleaned);
|
|
} else if (cleaned) {
|
|
newContent.push(cleaned);
|
|
}
|
|
}
|
|
json.content = newContent;
|
|
}
|
|
|
|
// Check if this node is unknown AFTER processing children
|
|
if (json.type && !schema.nodes[json.type]) {
|
|
// Unwrap: return cleaned children directly instead of wrapping
|
|
return (
|
|
json.content && json.content.length > 0 ? json.content : null
|
|
) as any;
|
|
}
|
|
|
|
return json;
|
|
}
|
|
|
|
export function prosemirrorNodeToYElement(node: any): Y.XmlElement | Y.XmlText {
|
|
if (node.type === 'text') {
|
|
const ytext = new Y.XmlText();
|
|
ytext.insert(0, node.text || '');
|
|
if (node.marks?.length > 0) {
|
|
const attrs: Record<string, any> = {};
|
|
for (const mark of node.marks) {
|
|
attrs[mark.type] = mark.attrs || true;
|
|
}
|
|
ytext.format(0, node.text?.length || 0, attrs);
|
|
}
|
|
return ytext;
|
|
}
|
|
|
|
const element = new Y.XmlElement(node.type);
|
|
if (node.attrs) {
|
|
for (const [key, value] of Object.entries(node.attrs)) {
|
|
if (value !== null && value !== undefined) {
|
|
element.setAttribute(key, value as any);
|
|
}
|
|
}
|
|
}
|
|
if (node.content?.length > 0) {
|
|
const children = node.content.map(prosemirrorNodeToYElement);
|
|
element.insert(0, children);
|
|
}
|
|
return element;
|
|
}
|
|
|
|
export function jsonToMarkdown(tiptapJson: any): string {
|
|
const html = jsonToHtml(tiptapJson);
|
|
return htmlToMarkdown(html);
|
|
}
|