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

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions .changeset/add-tldraw-canvas-support.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
"@inkeep/open-knowledge": patch
---

Add support for standalone `tldraw` drawing canvases when opening `.tldr`, `.tldraw`, or `.okdraw` files. Canvas states are serialized to JSON and persisted directly in the file's collaborative CRDT state.
1 change: 1 addition & 0 deletions packages/app/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -161,6 +161,7 @@
"tailwind-merge": "^3.5.0",
"tailwind-scrollbar": "^4.0.2",
"tailwindcss": "^4.2.2",
"tldraw": "^5.2.5",
"tw-animate-css": "^1.4.0",
"ws": "^8.20.0",
"y-codemirror.next": "^0.3.5",
Expand Down
12 changes: 11 additions & 1 deletion packages/app/src/components/EditorActivityPool.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -34,7 +34,7 @@
* (invalidate + nav), or (c) Activity eviction from the MRU mount list.
*/

import { isManagedArtifactDocName, isMermaidDocFile } from '@inkeep/open-knowledge-core';
import { isManagedArtifactDocName, isMermaidDocFile, isTldrawDocFile } from '@inkeep/open-knowledge-core';
import { t } from '@lingui/core/macro';
import { Loader2, RefreshCw } from 'lucide-react';
import {
Expand Down Expand Up @@ -81,6 +81,11 @@ const MermaidDocEditor = lazy(async () => ({
default: (await import('./MermaidDocEditor')).MermaidDocEditor,
}));

// Standalone tldraw editor for `.tldr` / `.canvas` docs.
const LazyTldrawEditor = lazy(async () => ({
default: (await import('./TldrawDocEditor')).TldrawDocEditor,
}));

/**
* Large-doc threshold in Y.Text characters. Above this, the non-active editor
* is defer-mounted on cold load instead of pre-mounting both per
Expand Down Expand Up @@ -759,6 +764,7 @@ function ActivityEntry({
// Standalone Mermaid docs (`.mmd`/`.mermaid`) render a dedicated diagram+source
// editor instead of the markdown dual-editor (they are Y.Text-only, no bridge).
const isMermaid = isMermaidDocFile(entry.docName);
const isTldraw = isTldrawDocFile(entry.docName);

// Per-Activity portal target for <EditorContent>. Stable DOM element
// exclusively owned by THIS ActivityEntry — `useState` with a lazy
Expand Down Expand Up @@ -1009,6 +1015,10 @@ function ActivityEntry({
provider={entry.provider}
isSourceMode={isSourceMode}
/>
) : isTldraw ? (
<Suspense fallback={<EditorSkeleton />}>
<LazyTldrawEditor provider={entry.provider} docName={entry.docName} />
</Suspense>
) : (
/* Dual-editor mount with size-gated defer for large docs. Small
docs render both (pre-mount-both default — mode swap stays
Expand Down
86 changes: 86 additions & 0 deletions packages/app/src/components/TldrawDocEditor.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
/**
* Editor for a standalone tldraw doc (`.tldr`).
* Syncs the canvas state to the underlying `Y.Text('source')` CRDT as a JSON snapshot.
*/

import { useLingui } from '@lingui/react/macro';
import type { HocuspocusProvider } from '@hocuspocus/provider';
import { useEffect, useState } from 'react';
import { Tldraw, createTLStore, defaultShapeUtils, defaultBindingUtils } from 'tldraw';
import 'tldraw/tldraw.css';
import { replaceYText } from './MermaidDocEditor';

export function TldrawDocEditor({
provider,
}: {
docName: string;
provider: HocuspocusProvider;
}) {
const { t } = useLingui();
const ytext = provider.document.getText('source');

const [store] = useState(() => {
const newStore = createTLStore({ shapeUtils: defaultShapeUtils, bindingUtils: defaultBindingUtils });
const str = ytext.toString();
if (str && str.trim() !== '') {
try {
const snapshot = JSON.parse(str);
newStore.loadSnapshot(snapshot);
} catch (e) {
console.error('Failed to load tldraw snapshot', e);
}
}
return newStore;
});

useEffect(() => {
let lastSavedString = ytext.toString();

// Listen to local tldraw changes and save to Y.Text
const unlisten = store.listen(
() => {
const snapshot = store.getSnapshot();
const str = JSON.stringify(snapshot);
if (str !== lastSavedString) {
lastSavedString = str;
replaceYText(ytext, str);
}
},
{ source: 'user', scope: 'document' }
);

// Listen to remote Y.Text changes and load into tldraw
const sync = () => {
const str = ytext.toString();
if (str !== lastSavedString) {
lastSavedString = str;
if (str && str.trim() !== '') {
try {
const snapshot = JSON.parse(str);
store.loadSnapshot(snapshot);
} catch (e) {
console.error('Failed to parse remote tldraw update', e);
}
}
}
};

ytext.observe(sync);
return () => {
unlisten();
ytext.unobserve(sync);
};
}, [store, ytext]);

return (
<main
className="flex h-full min-h-0 flex-col bg-background relative"
aria-label={t`Tldraw canvas`}
data-tldraw-doc-editor=""
>
<div className="absolute inset-0 z-0">
<Tldraw store={store} autoFocus />
</div>
</main>
);
}
15 changes: 14 additions & 1 deletion packages/core/src/constants/upload.ts
Original file line number Diff line number Diff line change
Expand Up @@ -575,7 +575,7 @@ export const WIKI_EMBED_EXTENSIONS: ReadonlySet<string> = new Set([
'mobi',
]);

export type InlineAssetMediaKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'mermaid';
export type InlineAssetMediaKind = 'image' | 'video' | 'audio' | 'pdf' | 'text' | 'mermaid' | 'tldraw';

// Sidebar-clickable asset extensions, grouped by inline-render path.
// Each set is a STRICT subset of its canonical-class set (asserted below),
Expand Down Expand Up @@ -681,6 +681,8 @@ export const TEXT_VIEWER_FALLBACK_EXTENSIONS: ReadonlySet<string> = new Set(['ba
// view in the app-side viewer, so the file always reads.
export const MERMAID_FILE_EXTENSIONS: ReadonlySet<string> = new Set(['mmd', 'mermaid']);

// Standalone tldraw canvas files. Rendered via the editor's `<Tldraw>` component.
export const TLDRAW_FILE_EXTENSIONS: ReadonlySet<string> = new Set(['tldr', 'tldraw', 'okdraw']);
/**
* True when a path/docName names a standalone Mermaid source file. Pure string
* op (no `node:path`) so it runs in the browser too — core stays server-free.
Expand All @@ -698,6 +700,15 @@ export function isMermaidDocFile(path: string): boolean {
return MERMAID_FILE_EXTENSIONS.has(path.slice(lastDot + 1).toLowerCase());
}

/**
* True when a path/docName names a standalone tldraw source file.
*/
export function isTldrawDocFile(path: string): boolean {
const lastDot = path.lastIndexOf('.');
if (lastDot < 0) return false;
return TLDRAW_FILE_EXTENSIONS.has(path.slice(lastDot + 1).toLowerCase());
}

// Code-file extensions live in a sibling module so the
// language→extension table can be shared with the app-side TextViewer
// (which maps the same canonical IDs to CodeMirror language packs).
Expand All @@ -718,6 +729,7 @@ export const LINKABLE_ASSET_EXTENSIONS: ReadonlySet<string> = new Set([
...ASSET_EXTENSIONS,
...TEXT_VIEWER_FALLBACK_EXTENSIONS,
...MERMAID_FILE_EXTENSIONS,
...TLDRAW_FILE_EXTENSIONS,
]);

/**
Expand All @@ -739,6 +751,7 @@ export function mediaKindForSidebarAssetExtension(ext: string): InlineAssetMedia
if (SIDEBAR_TEXT_ASSET_EXTENSIONS.has(normalized)) return 'text';
if (TEXT_VIEWER_FALLBACK_EXTENSIONS.has(normalized)) return 'text';
if (MERMAID_FILE_EXTENSIONS.has(normalized)) return 'mermaid';
if (TLDRAW_FILE_EXTENSIONS.has(normalized)) return 'tldraw';
// Files whose extension matches a codeblock-supported language
// (`ts` / `py` / `go` / ...) open by default in the read-only
// text viewer with syntax highlighting — same kind dispatch as
Expand Down
1 change: 1 addition & 0 deletions packages/core/src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,7 @@ export {
INLINE_RENDERABLE_EXTENSIONS,
type InlineAssetMediaKind,
isMermaidDocFile,
isTldrawDocFile,
LINKABLE_ASSET_EXTENSIONS,
MERMAID_FILE_EXTENSIONS,
mediaKindForSidebarAssetExtension,
Expand Down
Loading