From 2eb5d0f170d9c5cae8210a2687b907244b8d3704 Mon Sep 17 00:00:00 2001 From: Jeroen Schweitzer Date: Thu, 2 Jul 2026 22:19:14 +0200 Subject: [PATCH] feat(canvas): JSONCanvas parser + model for .canvas files (T-322) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Parses the Obsidian .canvas format into a typed CanvasDoc — text / file / link / group nodes and edges (sides, end caps, colour, label) — and serialises back with round-trip fidelity. Unknown node types and entries missing required fields are skipped rather than fatal; end caps omit their spec defaults on write. Pure Dart, no I/O; runs under dart test. The foundation for the interactive canvas pane. Co-Authored-By: Claude Opus 4.8 (1M context) --- lib/src/canvas/json_canvas.dart | 285 ++++++++++++++++++++++++++ test/src/canvas/json_canvas_test.dart | 98 +++++++++ 2 files changed, 383 insertions(+) create mode 100644 lib/src/canvas/json_canvas.dart create mode 100644 test/src/canvas/json_canvas_test.dart diff --git a/lib/src/canvas/json_canvas.dart b/lib/src/canvas/json_canvas.dart new file mode 100644 index 00000000..a7bedb12 --- /dev/null +++ b/lib/src/canvas/json_canvas.dart @@ -0,0 +1,285 @@ +/// The Obsidian JSONCanvas format (`.canvas` files) as a pure Dart model +/// (T-322). Parses the on-disk JSON into typed nodes + edges and serialises +/// back, so the canvas pane can load, edit, and persist a `.canvas`. +/// +/// Per D-91 `.canvas` is an import format, not clide's native schema — this +/// model is the faithful parse; the pane lowers it onto the SVG substrate for +/// rendering. Pure data — no Flutter, no I/O here — so it runs under +/// `dart test`. Spec: https://jsoncanvas.org/spec/1.0/ +library; + +import 'dart:convert'; + +/// Which edge of a node an edge attaches to. +enum CanvasSide { + top, + right, + bottom, + left; + + static CanvasSide? parse(Object? v) => switch (v) { + 'top' => top, + 'right' => right, + 'bottom' => bottom, + 'left' => left, + _ => null, + }; + + String get wire => name; +} + +/// The endpoint decoration of an edge. Obsidian defaults `fromEnd` to none and +/// `toEnd` to arrow. +enum CanvasEnd { + none, + arrow; + + static CanvasEnd parse(Object? v, CanvasEnd fallback) => switch (v) { + 'none' => none, + 'arrow' => arrow, + _ => fallback, + }; + + String get wire => name; +} + +/// How a group node's background image is laid out. +enum CanvasBackgroundStyle { + cover, + ratio, + repeat; + + static CanvasBackgroundStyle? parse(Object? v) => switch (v) { + 'cover' => cover, + 'ratio' => ratio, + 'repeat' => repeat, + _ => null, + }; + + String get wire => name; +} + +/// A node in a canvas. Every node has an id, a position, a size, and an +/// optional [color] (a preset `"1".."6"` or a `#rrggbb` hex, per the spec). +sealed class CanvasNode { + const CanvasNode({required this.id, required this.x, required this.y, required this.width, required this.height, this.color}); + + final String id; + final double x, y, width, height; + final String? color; + + /// The `type` discriminator written to disk. + String get type; + + Map toJson(); + + Map _base() => {'id': id, 'type': type, 'x': x, 'y': y, 'width': width, 'height': height, if (color != null) 'color': color}; +} + +/// A free-text node holding a markdown string. +class TextNode extends CanvasNode { + const TextNode({required super.id, required super.x, required super.y, required super.width, required super.height, super.color, this.text = ''}); + + final String text; + + @override + String get type => 'text'; + + @override + Map toJson() => {..._base(), 'text': text}; +} + +/// An embedded vault file, optionally scrolled to a [subpath] heading/block. +class FileNode extends CanvasNode { + const FileNode({ + required super.id, + required super.x, + required super.y, + required super.width, + required super.height, + super.color, + required this.file, + this.subpath, + }); + + final String file; + final String? subpath; + + @override + String get type => 'file'; + + @override + Map toJson() => {..._base(), 'file': file, if (subpath != null) 'subpath': subpath}; +} + +/// An external URL card. +class LinkNode extends CanvasNode { + const LinkNode({required super.id, required super.x, required super.y, required super.width, required super.height, super.color, required this.url}); + + final String url; + + @override + String get type => 'link'; + + @override + Map toJson() => {..._base(), 'url': url}; +} + +/// A labelled rectangle that visually groups the nodes inside it. May carry a +/// background image. +class GroupNode extends CanvasNode { + const GroupNode({ + required super.id, + required super.x, + required super.y, + required super.width, + required super.height, + super.color, + this.label, + this.background, + this.backgroundStyle, + }); + + final String? label; + final String? background; + final CanvasBackgroundStyle? backgroundStyle; + + @override + String get type => 'group'; + + @override + Map toJson() => { + ..._base(), + if (label != null) 'label': label, + if (background != null) 'background': background, + if (backgroundStyle != null) 'backgroundStyle': backgroundStyle!.wire, + }; +} + +/// A directed connection between two nodes, optionally anchored to a specific +/// [fromSide]/[toSide] and decorated with end caps, a [color], and a [label]. +class CanvasEdge { + const CanvasEdge({ + required this.id, + required this.fromNode, + required this.toNode, + this.fromSide, + this.toSide, + this.fromEnd = CanvasEnd.none, + this.toEnd = CanvasEnd.arrow, + this.color, + this.label, + }); + + final String id; + final String fromNode, toNode; + final CanvasSide? fromSide, toSide; + final CanvasEnd fromEnd, toEnd; + final String? color; + final String? label; + + static CanvasEdge? _parse(Map m) { + final id = m['id'], from = m['fromNode'], to = m['toNode']; + if (id is! String || from is! String || to is! String) return null; + return CanvasEdge( + id: id, + fromNode: from, + toNode: to, + fromSide: CanvasSide.parse(m['fromSide']), + toSide: CanvasSide.parse(m['toSide']), + fromEnd: CanvasEnd.parse(m['fromEnd'], CanvasEnd.none), + toEnd: CanvasEnd.parse(m['toEnd'], CanvasEnd.arrow), + color: m['color'] as String?, + label: m['label'] as String?, + ); + } + + Map toJson() => { + 'id': id, + 'fromNode': fromNode, + if (fromSide != null) 'fromSide': fromSide!.wire, + 'toNode': toNode, + if (toSide != null) 'toSide': toSide!.wire, + // Only emit end caps when they differ from the spec defaults. + if (fromEnd != CanvasEnd.none) 'fromEnd': fromEnd.wire, + if (toEnd != CanvasEnd.arrow) 'toEnd': toEnd.wire, + if (color != null) 'color': color, + if (label != null) 'label': label, + }; +} + +/// A parsed `.canvas` document: its [nodes] and [edges]. +class CanvasDoc { + const CanvasDoc({this.nodes = const [], this.edges = const []}); + + final List nodes; + final List edges; + + bool get isEmpty => nodes.isEmpty && edges.isEmpty; + + /// Parse `.canvas` JSON text. Malformed JSON, or a top level that isn't an + /// object, throws [FormatException]; individual nodes/edges that are + /// unrecognised or missing required fields are skipped, not fatal. + factory CanvasDoc.parse(String source) { + final decoded = source.trim().isEmpty ? const {} : jsonDecode(source); + if (decoded is! Map) throw const FormatException('canvas: top level must be a JSON object'); + return CanvasDoc.fromJson(decoded.cast()); + } + + factory CanvasDoc.fromJson(Map m) { + final nodes = []; + for (final raw in (m['nodes'] as List? ?? const [])) { + if (raw is Map) { + final node = _parseNode(raw.cast()); + if (node != null) nodes.add(node); + } + } + final edges = []; + for (final raw in (m['edges'] as List? ?? const [])) { + if (raw is Map) { + final edge = CanvasEdge._parse(raw.cast()); + if (edge != null) edges.add(edge); + } + } + return CanvasDoc(nodes: nodes, edges: edges); + } + + /// Serialise back to the on-disk shape. Empty `nodes`/`edges` arrays are + /// always present, matching what Obsidian writes. + Map toJson() => { + 'nodes': [for (final n in nodes) n.toJson()], + 'edges': [for (final e in edges) e.toJson()], + }; + + String encode() => const JsonEncoder.withIndent('\t').convert(toJson()); + + static CanvasNode? _parseNode(Map m) { + final id = m['id']; + if (id is! String) return null; + final x = _num(m['x']), y = _num(m['y']), w = _num(m['width']), h = _num(m['height']); + if (x == null || y == null || w == null || h == null) return null; + final color = m['color'] as String?; + return switch (m['type']) { + 'text' => TextNode(id: id, x: x, y: y, width: w, height: h, color: color, text: m['text'] as String? ?? ''), + 'file' => + m['file'] is String + ? FileNode(id: id, x: x, y: y, width: w, height: h, color: color, file: m['file'] as String, subpath: m['subpath'] as String?) + : null, + 'link' => m['url'] is String ? LinkNode(id: id, x: x, y: y, width: w, height: h, color: color, url: m['url'] as String) : null, + 'group' => GroupNode( + id: id, + x: x, + y: y, + width: w, + height: h, + color: color, + label: m['label'] as String?, + background: m['background'] as String?, + backgroundStyle: CanvasBackgroundStyle.parse(m['backgroundStyle']), + ), + _ => null, // unknown/unsupported node type — skip + }; + } + + static double? _num(Object? v) => v is num ? v.toDouble() : null; +} diff --git a/test/src/canvas/json_canvas_test.dart b/test/src/canvas/json_canvas_test.dart new file mode 100644 index 00000000..eba09a54 --- /dev/null +++ b/test/src/canvas/json_canvas_test.dart @@ -0,0 +1,98 @@ +import 'package:clide/src/canvas/json_canvas.dart'; +import 'package:test/test.dart'; + +void main() { + const sample = ''' +{ + "nodes": [ + { "id": "t1", "type": "text", "x": 0, "y": 0, "width": 200, "height": 120, "text": "hello", "color": "3" }, + { "id": "f1", "type": "file", "x": 300, "y": 0, "width": 260, "height": 300, "file": "notes/a.md", "subpath": "#intro" }, + { "id": "l1", "type": "link", "x": 0, "y": 200, "width": 240, "height": 100, "url": "https://example.com" }, + { "id": "g1", "type": "group", "x": -40, "y": -40, "width": 700, "height": 500, "label": "Cluster", "backgroundStyle": "cover" } + ], + "edges": [ + { "id": "e1", "fromNode": "t1", "fromSide": "right", "toNode": "f1", "toSide": "left", "color": "2", "label": "see" } + ] +} +'''; + + test('parses every node type with its fields', () { + final doc = CanvasDoc.parse(sample); + expect(doc.nodes.map((n) => n.id), ['t1', 'f1', 'l1', 'g1']); + + final t = doc.nodes.whereType().single; + expect(t.text, 'hello'); + expect(t.color, '3'); + expect((t.x, t.y, t.width, t.height), (0.0, 0.0, 200.0, 120.0)); + + final f = doc.nodes.whereType().single; + expect(f.file, 'notes/a.md'); + expect(f.subpath, '#intro'); + + expect(doc.nodes.whereType().single.url, 'https://example.com'); + + final g = doc.nodes.whereType().single; + expect(g.label, 'Cluster'); + expect(g.backgroundStyle, CanvasBackgroundStyle.cover); + }); + + test('parses edges with sides, colour, label and default end caps', () { + final e = CanvasDoc.parse(sample).edges.single; + expect((e.fromNode, e.toNode), ('t1', 'f1')); + expect((e.fromSide, e.toSide), (CanvasSide.right, CanvasSide.left)); + expect(e.color, '2'); + expect(e.label, 'see'); + expect((e.fromEnd, e.toEnd), (CanvasEnd.none, CanvasEnd.arrow)); // spec defaults + }); + + test('skips unknown node types and nodes missing required fields', () { + final doc = CanvasDoc.parse(''' + { "nodes": [ + { "id": "ok", "type": "text", "x": 0, "y": 0, "width": 10, "height": 10 }, + { "id": "weird", "type": "portal", "x": 0, "y": 0, "width": 10, "height": 10 }, + { "id": "nofile", "type": "file", "x": 0, "y": 0, "width": 10, "height": 10 }, + { "id": "noxy", "type": "text", "width": 10, "height": 10 } + ] }'''); + expect(doc.nodes.map((n) => n.id), ['ok']); // the other three are dropped + expect(doc.nodes.single, isA()); + }); + + test('skips edges missing an id or endpoint', () { + final doc = CanvasDoc.parse(''' + { "edges": [ + { "id": "good", "fromNode": "a", "toNode": "b" }, + { "fromNode": "a", "toNode": "b" }, + { "id": "dangling", "fromNode": "a" } + ] }'''); + expect(doc.edges.map((e) => e.id), ['good']); + }); + + test('blank source is an empty document', () { + expect(CanvasDoc.parse(' ').isEmpty, isTrue); + expect(CanvasDoc.parse('{}').isEmpty, isTrue); + }); + + test('a non-object top level is a FormatException', () { + expect(() => CanvasDoc.parse('[1, 2, 3]'), throwsFormatException); + expect(() => CanvasDoc.parse('not json'), throwsFormatException); + }); + + test('round-trips through encode without losing fields', () { + final doc = CanvasDoc.parse(sample); + final reparsed = CanvasDoc.parse(doc.encode()); + expect(reparsed.toJson(), doc.toJson()); + }); + + test('toJson always carries both arrays; default end caps are omitted', () { + const doc = CanvasDoc(); + expect(doc.toJson(), {'nodes': [], 'edges': []}); + + const edge = CanvasEdge(id: 'e', fromNode: 'a', toNode: 'b'); // defaults none/arrow + expect(edge.toJson().containsKey('fromEnd'), isFalse); + expect(edge.toJson().containsKey('toEnd'), isFalse); + + const flipped = CanvasEdge(id: 'e', fromNode: 'a', toNode: 'b', fromEnd: CanvasEnd.arrow, toEnd: CanvasEnd.none); + expect(flipped.toJson()['fromEnd'], 'arrow'); + expect(flipped.toJson()['toEnd'], 'none'); + }); +}