diff --git a/README.md b/README.md index d9a3cca..98e84cc 100644 --- a/README.md +++ b/README.md @@ -54,6 +54,33 @@ The library exposes two layers: The renderer is consumed through `CanvasView`. The core surface (`parseCanvas`, `createCanvasState`, etc.) is exported alongside if you need to inspect or mutate documents independently. +### Markdown rendering + +Text-node bodies are parsed by `paragraphBuilder` (built on the unified / +remark ecosystem) and rendered through Skia's text API — not as native React +text components. Going through Skia is intentional: it bypasses a +`react-native-macos` rendering limitation where native `Text` stops drawing +beyond ~1500px from the parent view's origin, which would otherwise punch +holes in any sufficiently large canvas. + +**Supported:** + +- Inline marks: bold, italic, code spans, link text (rendered as styled runs, + not as tappable affordances) +- Blocks: paragraphs, headings, lists (ordered / unordered / task), code + blocks, blockquotes, tables +- GFM extensions: strikethrough, task list checkboxes +- YAML frontmatter (parsed and stripped — used by the Canvas Candy extension + layer for `cssclasses`, not rendered as document content) + +**Not supported:** anything that requires a React component overlay (images +embedded in markdown, syntax-highlighted code blocks, callouts beyond what +the `extensions/callouts.ts` layer recognises). + +> `CanvasView` exposes a `renderMarkdown` prop for historical reasons; **it +> is deprecated and ignored**. Text rendering always goes through the Skia +> path described above. See [#26](https://github.com/workspace-sh/react-native-jsoncanvas/issues/26). + ## Development ```sh diff --git a/src/core/canvas-state.ts b/src/core/canvas-state.ts index 1be8eae..503a006 100644 --- a/src/core/canvas-state.ts +++ b/src/core/canvas-state.ts @@ -1,9 +1,20 @@ -import type {CanvasDocument, CanvasNode, CanvasEdge, Rect} from './types'; +import type { + CanvasColor, + CanvasDocument, + CanvasEdge, + CanvasNode, + FileNode, + GroupNode, + LinkNode, + Rect, + TextNode, +} from './types'; import {createSpatialIndex, type SpatialIndex} from './spatial-index'; import { createCommandHistory, invertOperation, type CanvasCommandHistory, + type GroupBackgroundStyle, type Operation, } from './operations'; @@ -23,7 +34,22 @@ export interface CanvasState { resizeNode(nodeId: string, width: number, height: number): void; addEdge(edge: CanvasEdge): void; removeEdge(edgeId: string): void; - updateNodeContent(nodeId: string, changes: Partial): void; + + // Per-field update methods. Each is a type-safe alternative to the prior + // updateNodeContent(nodeId, Partial) signature, which was + // FFI-hostile (Partial has no clean Rust equivalent). Each method is a + // no-op when the node is missing or of the wrong type. Pass `undefined` to + // clear an optional field. + updateNodeColor(nodeId: string, color: CanvasColor | undefined): void; + updateTextNodeText(nodeId: string, text: string): void; + updateLinkNodeUrl(nodeId: string, url: string): void; + updateFileNode(nodeId: string, file: string, subpath: string | undefined): void; + updateGroupNodeLabel(nodeId: string, label: string | undefined): void; + updateGroupNodeBackground( + nodeId: string, + background: string | undefined, + backgroundStyle: GroupBackgroundStyle | undefined, + ): void; undo(): void; redo(): void; @@ -86,10 +112,54 @@ export function createCanvasState(doc: CanvasDocument): CanvasState { edges.delete(op.edgeId); break; } - case 'updateNodeContent': { + case 'updateNodeColor': { const node = nodes.get(op.nodeId); if (!node) return; - const updated = {...node, ...op.changes} as CanvasNode; + const updated: CanvasNode = {...node, color: op.color}; + nodes.set(op.nodeId, updated); + spatialIndex.update(updated); + break; + } + case 'updateTextNodeText': { + const node = nodes.get(op.nodeId); + if (!node || node.type !== 'text') return; + const updated: TextNode = {...node, text: op.text}; + nodes.set(op.nodeId, updated); + spatialIndex.update(updated); + break; + } + case 'updateLinkNodeUrl': { + const node = nodes.get(op.nodeId); + if (!node || node.type !== 'link') return; + const updated: LinkNode = {...node, url: op.url}; + nodes.set(op.nodeId, updated); + spatialIndex.update(updated); + break; + } + case 'updateFileNode': { + const node = nodes.get(op.nodeId); + if (!node || node.type !== 'file') return; + const updated: FileNode = {...node, file: op.file, subpath: op.subpath}; + nodes.set(op.nodeId, updated); + spatialIndex.update(updated); + break; + } + case 'updateGroupNodeLabel': { + const node = nodes.get(op.nodeId); + if (!node || node.type !== 'group') return; + const updated: GroupNode = {...node, label: op.label}; + nodes.set(op.nodeId, updated); + spatialIndex.update(updated); + break; + } + case 'updateGroupNodeBackground': { + const node = nodes.get(op.nodeId); + if (!node || node.type !== 'group') return; + const updated: GroupNode = { + ...node, + background: op.background, + backgroundStyle: op.backgroundStyle, + }; nodes.set(op.nodeId, updated); spatialIndex.update(updated); break; @@ -195,14 +265,88 @@ export function createCanvasState(doc: CanvasDocument): CanvasState { history.record(op); }, - updateNodeContent(nodeId: string, changes: Partial): void { + updateNodeColor(nodeId: string, color: CanvasColor | undefined): void { const node = nodes.get(nodeId); if (!node) return; - const prevValues: Partial = {}; - for (const key of Object.keys(changes) as Array) { - (prevValues as Record)[key] = node[key]; - } - const op: Operation = {type: 'updateNodeContent', nodeId, changes, prevValues}; + const op: Operation = { + type: 'updateNodeColor', + nodeId, + color, + prevColor: node.color, + }; + applyOperation(op); + history.record(op); + }, + + updateTextNodeText(nodeId: string, text: string): void { + const node = nodes.get(nodeId); + if (!node || node.type !== 'text') return; + const op: Operation = { + type: 'updateTextNodeText', + nodeId, + text, + prevText: node.text, + }; + applyOperation(op); + history.record(op); + }, + + updateLinkNodeUrl(nodeId: string, url: string): void { + const node = nodes.get(nodeId); + if (!node || node.type !== 'link') return; + const op: Operation = { + type: 'updateLinkNodeUrl', + nodeId, + url, + prevUrl: node.url, + }; + applyOperation(op); + history.record(op); + }, + + updateFileNode(nodeId: string, file: string, subpath: string | undefined): void { + const node = nodes.get(nodeId); + if (!node || node.type !== 'file') return; + const op: Operation = { + type: 'updateFileNode', + nodeId, + file, + subpath, + prevFile: node.file, + prevSubpath: node.subpath, + }; + applyOperation(op); + history.record(op); + }, + + updateGroupNodeLabel(nodeId: string, label: string | undefined): void { + const node = nodes.get(nodeId); + if (!node || node.type !== 'group') return; + const op: Operation = { + type: 'updateGroupNodeLabel', + nodeId, + label, + prevLabel: node.label, + }; + applyOperation(op); + history.record(op); + }, + + updateGroupNodeBackground( + nodeId: string, + background: string | undefined, + backgroundStyle: GroupBackgroundStyle | undefined, + ): void { + const node = nodes.get(nodeId); + if (!node || node.type !== 'group') return; + const op: Operation = { + type: 'updateGroupNodeBackground', + nodeId, + background, + backgroundStyle, + prevBackground: node.background, + prevBackgroundStyle: node.backgroundStyle, + }; applyOperation(op); history.record(op); }, diff --git a/src/core/operations.ts b/src/core/operations.ts index a2cdba1..caa9173 100644 --- a/src/core/operations.ts +++ b/src/core/operations.ts @@ -1,4 +1,7 @@ -import type {CanvasNode, CanvasEdge} from './types'; +import type {CanvasNode, CanvasEdge, CanvasColor} from './types'; + +/** Background fit modes for group-node images. Mirrors GroupNode.backgroundStyle. */ +export type GroupBackgroundStyle = 'cover' | 'ratio' | 'repeat'; export type Operation = | {type: 'addNode'; node: CanvasNode} @@ -14,11 +17,50 @@ export type Operation = } | {type: 'addEdge'; edge: CanvasEdge} | {type: 'removeEdge'; edgeId: string; edge: CanvasEdge} + // Per-field update variants. Each carries the new value and the prev value + // for invertOperation. Replaces the old `updateNodeContent` / `Partial` + // shape that didn't translate cleanly to a Rust enum over JSI/wasm-bindgen. + // x / y / width / height live in moveNode / resizeNode above and are + // intentionally not included here. + | { + type: 'updateNodeColor'; + nodeId: string; + color: CanvasColor | undefined; + prevColor: CanvasColor | undefined; + } + | { + type: 'updateTextNodeText'; + nodeId: string; + text: string; + prevText: string; + } + | { + type: 'updateLinkNodeUrl'; + nodeId: string; + url: string; + prevUrl: string; + } + | { + type: 'updateFileNode'; + nodeId: string; + file: string; + subpath: string | undefined; + prevFile: string; + prevSubpath: string | undefined; + } + | { + type: 'updateGroupNodeLabel'; + nodeId: string; + label: string | undefined; + prevLabel: string | undefined; + } | { - type: 'updateNodeContent'; + type: 'updateGroupNodeBackground'; nodeId: string; - changes: Partial; - prevValues: Partial; + background: string | undefined; + backgroundStyle: GroupBackgroundStyle | undefined; + prevBackground: string | undefined; + prevBackgroundStyle: GroupBackgroundStyle | undefined; }; export function invertOperation(op: Operation): Operation { @@ -49,12 +91,51 @@ export function invertOperation(op: Operation): Operation { return {type: 'removeEdge', edgeId: op.edge.id, edge: op.edge}; case 'removeEdge': return {type: 'addEdge', edge: op.edge}; - case 'updateNodeContent': + case 'updateNodeColor': + return { + type: 'updateNodeColor', + nodeId: op.nodeId, + color: op.prevColor, + prevColor: op.color, + }; + case 'updateTextNodeText': + return { + type: 'updateTextNodeText', + nodeId: op.nodeId, + text: op.prevText, + prevText: op.text, + }; + case 'updateLinkNodeUrl': + return { + type: 'updateLinkNodeUrl', + nodeId: op.nodeId, + url: op.prevUrl, + prevUrl: op.url, + }; + case 'updateFileNode': + return { + type: 'updateFileNode', + nodeId: op.nodeId, + file: op.prevFile, + subpath: op.prevSubpath, + prevFile: op.file, + prevSubpath: op.subpath, + }; + case 'updateGroupNodeLabel': + return { + type: 'updateGroupNodeLabel', + nodeId: op.nodeId, + label: op.prevLabel, + prevLabel: op.label, + }; + case 'updateGroupNodeBackground': return { - type: 'updateNodeContent', + type: 'updateGroupNodeBackground', nodeId: op.nodeId, - changes: op.prevValues, - prevValues: op.changes, + background: op.prevBackground, + backgroundStyle: op.prevBackgroundStyle, + prevBackground: op.background, + prevBackgroundStyle: op.backgroundStyle, }; } } diff --git a/src/index.ts b/src/index.ts index 4b902a5..2c127d8 100644 --- a/src/index.ts +++ b/src/index.ts @@ -22,6 +22,7 @@ export { createCommandHistory, invertOperation, type CanvasCommandHistory, + type GroupBackgroundStyle, type Operation, } from './core/operations'; export {createCanvasState, type CanvasState} from './core/canvas-state'; diff --git a/src/renderer/CanvasContext.tsx b/src/renderer/CanvasContext.tsx index 6adb134..a40cf82 100644 --- a/src/renderer/CanvasContext.tsx +++ b/src/renderer/CanvasContext.tsx @@ -3,6 +3,14 @@ import type {ColorScheme} from './theme'; interface CanvasContextValue { colorScheme: ColorScheme; + /** + * @deprecated Vestigial. No renderer code consumes this — `TextNodeContent` + * returns null intentionally (a react-native-macos limitation that capped + * native Text rendering beyond ~1500px from the parent View's origin + * forced text through the Skia path). All text-node bodies render via + * `SkiaTextRenderer` and `paragraphBuilder`, regardless of whether this is + * provided. Will be removed in a future major version. See #26. + */ renderMarkdown?: (text: string, colorScheme: ColorScheme) => React.ReactElement; } diff --git a/src/renderer/CanvasView.tsx b/src/renderer/CanvasView.tsx index a477896..82774e9 100644 --- a/src/renderer/CanvasView.tsx +++ b/src/renderer/CanvasView.tsx @@ -27,7 +27,12 @@ interface Props { content: string; /** Directory containing the canvas file, used to resolve relative paths. */ basePath?: string; - /** Optional markdown renderer injected by the app. */ + /** + * @deprecated Vestigial. No renderer code consumes this — `TextNodeContent` + * returns null and all text-node bodies render via Skia's `paragraphBuilder`. + * The prop is accepted (and ignored) so existing consumers don't break. + * Will be removed in a future major version. See #26. + */ renderMarkdown?: (text: string, colorScheme: ColorScheme) => React.ReactElement; /** Saved view state to restore (pan/zoom position). */ initialViewState?: ViewState;