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
27 changes: 27 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
164 changes: 154 additions & 10 deletions src/core/canvas-state.ts
Original file line number Diff line number Diff line change
@@ -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';

Expand All @@ -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<CanvasNode>): void;

// Per-field update methods. Each is a type-safe alternative to the prior
// updateNodeContent(nodeId, Partial<CanvasNode>) signature, which was
// FFI-hostile (Partial<T> 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;
Expand Down Expand Up @@ -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;
Expand Down Expand Up @@ -195,14 +265,88 @@ export function createCanvasState(doc: CanvasDocument): CanvasState {
history.record(op);
},

updateNodeContent(nodeId: string, changes: Partial<CanvasNode>): void {
updateNodeColor(nodeId: string, color: CanvasColor | undefined): void {
const node = nodes.get(nodeId);
if (!node) return;
const prevValues: Partial<CanvasNode> = {};
for (const key of Object.keys(changes) as Array<keyof CanvasNode>) {
(prevValues as Record<string, unknown>)[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);
},
Expand Down
97 changes: 89 additions & 8 deletions src/core/operations.ts
Original file line number Diff line number Diff line change
@@ -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}
Expand All @@ -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<T>`
// 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<CanvasNode>;
prevValues: Partial<CanvasNode>;
background: string | undefined;
backgroundStyle: GroupBackgroundStyle | undefined;
prevBackground: string | undefined;
prevBackgroundStyle: GroupBackgroundStyle | undefined;
};

export function invertOperation(op: Operation): Operation {
Expand Down Expand Up @@ -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,
};
}
}
Expand Down
1 change: 1 addition & 0 deletions src/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -22,6 +22,7 @@ export {
createCommandHistory,
invertOperation,
type CanvasCommandHistory,
type GroupBackgroundStyle,
type Operation,
} from './core/operations';
export {createCanvasState, type CanvasState} from './core/canvas-state';
Expand Down
8 changes: 8 additions & 0 deletions src/renderer/CanvasContext.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
}

Expand Down
7 changes: 6 additions & 1 deletion src/renderer/CanvasView.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down