diff --git a/README.md b/README.md index ed69fa9..52076e6 100644 --- a/README.md +++ b/README.md @@ -12,6 +12,11 @@ no account and no hidden state. **The whole spec.** All four node types (`text`, `file`, `link`, `group`) and every edge attribute (`fromSide`/`toSide`, `fromEnd`/`toEnd`, `label`, `color`), plus both `canvasColor` forms — the six presets and `#rrggbb` hex. +The format as this editor reads and writes it is written down in +[`spec/spec.md`](spec/spec.md), the handful of additions to JSON Canvas 1.0 are +listed on their own in [`spec/extensions.md`](spec/extensions.md), and the +original 1.0 spec sits next to them in [`spec/1.0.md`](spec/1.0.md) to diff +against. **It does not damage files it does not fully understand.** Unknown top-level keys, unknown node/edge attributes and even unknown node `type`s are carried @@ -43,6 +48,12 @@ Group `background` images are drawn with the fitting their `backgroundStyle` asks for (`cover`, `ratio` or `repeat`). Paths are stored relative to the canvas, so a canvas plus its files stays portable. +**A display text for link nodes**: name a link in the inspector and the canvas +shows the name instead of the address, with the full URL in its tooltip. It is +stored as a `label` attribute — the name groups and edges already use — so a +tool that does not know about it carries it through untouched. This is an +addition to the format; see [`spec/extensions.md`](spec/extensions.md). + **The usual editor comforts**: undo/redo of every change, copy/paste/duplicate, grouping, alignment, z-order, a properties inspector, snap-to-grid, light and dark themes, and a warning before you lose unsaved work. diff --git a/examples/tour.canvas b/examples/tour.canvas index 9599b14..a7ad42a 100644 --- a/examples/tour.canvas +++ b/examples/tour.canvas @@ -3,7 +3,7 @@ {"id": "group-basics", "type": "group", "label": "The four node types", "x": -520, "y": -320, "width": 1100, "height": 480, "color": "6"}, {"id": "text", "type": "text", "text": "## text\n\nMarkdown: **bold**, *italic*, `code`,\n[links](https://jsoncanvas.org) and lists.\n\n- [x] renders inline\n- [ ] edit with a double-click", "x": -480, "y": -250, "width": 300, "height": 200, "color": "5"}, {"id": "file", "type": "file", "file": "../README.md", "subpath": "#What it does", "x": -140, "y": -250, "width": 320, "height": 380}, - {"id": "link", "type": "link", "url": "https://jsoncanvas.org/spec/1.0/", "x": 220, "y": -250, "width": 320, "height": 140}, + {"id": "link", "type": "link", "url": "https://jsoncanvas.org/spec/1.0/", "label": "The JSON Canvas spec", "x": 220, "y": -250, "width": 320, "height": 140}, {"id": "note", "type": "text", "text": "### group\n\nThe box around all of this is a group node. Drag it and everything inside comes along.", "x": 220, "y": -60, "width": 320, "height": 190, "color": "3"}, {"id": "edges", "type": "text", "text": "## Edges\n\nSides, arrow heads, labels and colours are all part of the format — set them in the inspector.", "x": -480, "y": 240, "width": 320, "height": 160, "color": "4"} ], diff --git a/spec/1.0.md b/spec/1.0.md new file mode 100644 index 0000000..a463e34 --- /dev/null +++ b/spec/1.0.md @@ -0,0 +1,102 @@ +# JSON Canvas Spec + +Version 1.0 — 2024-03-11 + +## Top level + +The top level of JSON Canvas contains two arrays: + +- `nodes` (optional, array of nodes) +- `edges` (optional, array of edges) + +## Nodes + +Nodes are objects within the canvas. Nodes may be text, files, links, or groups. + +Nodes are placed in the array in ascending order by z-index. The first node in the array should be displayed below all other nodes, and the last node in the array should be displayed on top of all other nodes. + +### Generic node + +All nodes include the following attributes: + +- `id` (required, string) is a unique ID for the node. +- `type` (required, string) is the node type. + - `text` + - `file` + - `link` + - `group` +- `x` (required, integer) is the `x` position of the node in pixels. +- `y` (required, integer) is the `y` position of the node in pixels. +- `width` (required, integer) is the width of the node in pixels. +- `height` (required, integer) is the height of the node in pixels. +- `color` (optional, `canvasColor`) is the color of the node, see the Color section. + +### Text type nodes + +Text type nodes store text. Along with generic node attributes, text nodes include the following attribute: + +- `text` (required, string) in plain text with Markdown syntax. + +### File type nodes + +File type nodes reference other files or attachments, such as images, videos, etc. Along with generic node attributes, file nodes include the following attributes: + +- `file` (required, string) is the path to the file within the system. +- `subpath` (optional, string) is a subpath that may link to a heading or a block. Always starts with a `#`. + +### Link type nodes + +Link type nodes reference a URL. Along with generic node attributes, link nodes include the following attribute: + +- `url` (required, string) + +### Group type nodes + +Group type nodes are used as a visual container for nodes within it. Along with generic node attributes, group nodes include the following attributes: + +- `label` (optional, string) is a text label for the group. +- `background` (optional, string) is the path to the background image. +- `backgroundStyle` (optional, string) is the rendering style of the background image. Valid values: + - `cover` fills the entire width and height of the node. + - `ratio` maintains the aspect ratio of the background image. + - `repeat` repeats the image as a pattern in both x/y directions. + +## Edges + +Edges are lines that connect one node to another. + +- `id` (required, string) is a unique ID for the edge. +- `fromNode` (required, string) is the node `id` where the connection starts. +- `fromSide` (optional, string) is the side where this edge starts. Valid values: + - `top` + - `right` + - `bottom` + - `left` +- `fromEnd` (optional, string) is the shape of the endpoint at the edge start. Defaults to `none` if not specified. Valid values: + - `none` + - `arrow` +- `toNode` (required, string) is the node `id` where the connection ends. +- `toSide` (optional, string) is the side where this edge ends. Valid values: + - `top` + - `right` + - `bottom` + - `left` +- `toEnd` (optional, string) is the shape of the endpoint at the edge end. Defaults to `arrow` if not specified. Valid values: + - `none` + - `arrow` +- `color` (optional, `canvasColor`) is the color of the line, see the Color section. +- `label` (optional, string) is a text label for the edge. + + +## Color + +The `canvasColor` type is used to encode color data for nodes and edges. Colors attributes expect a string. Colors can be specified in hex format e.g. `"#FF0000"`, or using one of the preset colors, e.g. `"1"` for red. Six preset colors exist, mapped to the following numbers: + +- `"1"` red +- `"2"` orange +- `"3"` yellow +- `"4"` green +- `"5"` cyan +- `"6"` purple + +Specific values for the preset colors are intentionally not defined so that applications can tailor the presets to their specific brand colors or color scheme. diff --git a/spec/extensions.md b/spec/extensions.md new file mode 100644 index 0000000..1abe54d --- /dev/null +++ b/spec/extensions.md @@ -0,0 +1,52 @@ +# Additions to JSON Canvas 1.0 + +Everything `jsoncanvas-desktop` does beyond JSON Canvas 1.0, and nothing else. +The original is kept alongside as [1.0.md](1.0.md); the whole format, original +and additions together, is in [spec.md](spec.md). + +Two rules keep the additions from splitting the format in two: + +1. **Every addition is optional.** A file that uses none of them is a plain JSON + Canvas 1.0 file, and a reader that ignores all of them sees a complete canvas — + an addition may improve how something is displayed, never what it means. +2. **An addition reuses a name the format already has** where one fits, so that + the same idea is not called two things in one file. + +A client that does not know an addition will ignore it. Whether it keeps the +attribute when it saves is up to that client; this editor keeps every attribute +it does not know (see *Unrecognised data* in [spec.md](spec.md)). + +## Format additions + +| Attribute | Applies to | Added | +| --- | --- | --- | +| `label` | Link type nodes | 2026-09-09 | + +### `label` on link type nodes + +`label` (optional, string) is a text label shown in place of the URL when the +node is displayed. + +A canvas is read at a glance, and a URL is rarely what a reader wants to see: an +address is long, it repeats the same prefix on every node, and the part that says +what the page actually is tends to be at the end, past where the node is wide +enough to show. The label lets the node say `The JSON Canvas spec` instead of +`https://jsoncanvas.org/spec/1.0/`. + +- The name is `label` because group nodes and edges already use `label` for + exactly this — a piece of text that names the thing. +- The URL is not hidden: the node still shows the host, and the full address is + the link's tooltip. +- Without the attribute, or with a blank one, the node displays its URL, which is + what a JSON Canvas 1.0 file gets. +- The label is display text only. Clicking the node still opens `url`. + +```json +{ + "id": "spec", + "type": "link", + "url": "https://jsoncanvas.org/spec/1.0/", + "label": "The JSON Canvas spec", + "x": 220, "y": -250, "width": 320, "height": 140 +} +``` diff --git a/spec/spec.md b/spec/spec.md new file mode 100644 index 0000000..d1790dc --- /dev/null +++ b/spec/spec.md @@ -0,0 +1,165 @@ +# Canvas Spec + +Based on JSON Canvas 1.0 — 2024-03-11 + +The file format `jsoncanvas-desktop` reads and writes, in full. + +It is [JSON Canvas 1.0](1.0.md) with a small number of additions. Everything the +original defines is implemented, unchanged and under the same names, and the +sections below follow its wording. Attributes and rules this editor adds are +marked **(addition)** and are collected, with the reasoning behind them, in +[extensions.md](extensions.md). + +Files use the `.canvas` extension. + +## Top level + +The top level of JSON Canvas contains two arrays: + +- `nodes` (optional, array of nodes) +- `edges` (optional, array of edges) + +## Nodes + +Nodes are objects within the canvas. Nodes may be text, files, links, or groups. + +Nodes are placed in the array in ascending order by z-index. The first node in +the array should be displayed below all other nodes, and the last node in the +array should be displayed on top of all other nodes. + +### Generic node + +All nodes include the following attributes: + +- `id` (required, string) is a unique ID for the node. +- `type` (required, string) is the node type. + - `text` + - `file` + - `link` + - `group` +- `x` (required, integer) is the `x` position of the node in pixels. +- `y` (required, integer) is the `y` position of the node in pixels. +- `width` (required, integer) is the width of the node in pixels. +- `height` (required, integer) is the height of the node in pixels. +- `color` (optional, `canvasColor`) is the color of the node, see the Color section. + +### Text type nodes + +Text type nodes store text. Along with generic node attributes, text nodes +include the following attribute: + +- `text` (required, string) in plain text with Markdown syntax. + +### File type nodes + +File type nodes reference other files or attachments, such as images, videos, +etc. Along with generic node attributes, file nodes include the following +attributes: + +- `file` (required, string) is the path to the file within the system. +- `subpath` (optional, string) is a subpath that may link to a heading or a block. Always starts with a `#`. + +### Link type nodes + +Link type nodes reference a URL. Along with generic node attributes, link nodes +include the following attributes: + +- `url` (required, string) +- `label` (optional, string) is the text displayed for the node in place of its + URL. A label that is empty or only whitespace is treated as no label. **(addition)** + +### Group type nodes + +Group type nodes are used as a visual container for nodes within it. Along with +generic node attributes, group nodes include the following attributes: + +- `label` (optional, string) is a text label for the group. +- `background` (optional, string) is the path to the background image. +- `backgroundStyle` (optional, string) is the rendering style of the background image. Valid values: + - `cover` fills the entire width and height of the node. + - `ratio` maintains the aspect ratio of the background image. + - `repeat` repeats the image as a pattern in both x/y directions. + +## Edges + +Edges are lines that connect one node to another. + +- `id` (required, string) is a unique ID for the edge. +- `fromNode` (required, string) is the node `id` where the connection starts. +- `fromSide` (optional, string) is the side where this edge starts. Valid values: + - `top` + - `right` + - `bottom` + - `left` +- `fromEnd` (optional, string) is the shape of the endpoint at the edge start. Defaults to `none` if not specified. Valid values: + - `none` + - `arrow` +- `toNode` (required, string) is the node `id` where the connection ends. +- `toSide` (optional, string) is the side where this edge ends. Valid values: + - `top` + - `right` + - `bottom` + - `left` +- `toEnd` (optional, string) is the shape of the endpoint at the edge end. Defaults to `arrow` if not specified. Valid values: + - `none` + - `arrow` +- `color` (optional, `canvasColor`) is the color of the line, see the Color section. +- `label` (optional, string) is a text label for the edge. + +## Color + +The `canvasColor` type is used to encode color data for nodes and edges. Colors +attributes expect a string. Colors can be specified in hex format e.g. +`"#FF0000"`, or using one of the preset colors, e.g. `"1"` for red. Six preset +colors exist, mapped to the following numbers: + +- `"1"` red +- `"2"` orange +- `"3"` yellow +- `"4"` green +- `"5"` cyan +- `"6"` purple + +Specific values for the preset colors are intentionally not defined so that +applications can tailor the presets to their specific brand colors or color +scheme. This editor uses one set of six shades for both of its themes, and blends +them into a node's fill with more of the shade in the dark theme than in the +light one. + +## Unrecognised data **(addition)** + +Anything in the file this spec does not describe is kept as it was: + +- unknown top-level keys, +- unknown attributes on a node or an edge, +- nodes whose `type` is none of the four above — the whole node is kept, and the + canvas displays a placeholder in its place. + +Unrecognised data is written back unchanged, after the attributes this spec +defines. This makes a file written by another application survive a load and save +untouched. + +## Reading **(addition)** + +A file that does not follow this spec exactly is still read, as far as it can be: + +- a missing `x` or `y` is `0`; a missing `width` is `250` and a missing `height` + is `60`; a size below `1` is raised to `1`, +- a position or size given as a fractional number is rounded to an integer, +- a missing `text`, `file` or `url` is the empty string, +- a `canvasColor` given as a JSON number, e.g. `1`, is read as the preset of that + number, and the three-digit hex form `"#f0a"` is accepted, +- an attribute whose JSON type is not the one this spec asks for — including a + `canvasColor` that is neither a preset nor a hex string — is left untouched as + unrecognised data, and the attribute counts as absent, +- an edge naming a `fromNode` or `toNode` that no node has is kept; the canvas + reports how many there are and offers to remove them. + +## Writing **(addition)** + +- `nodes` and `edges` are always written, as arrays, even when empty. +- Attributes are written in the order this spec lists them, so a save produces a + small diff rather than a reshuffle. +- Optional attributes that were absent stay absent. +- Hex colors are written in lowercase. +- The file is pretty-printed with two-space indentation and ends with a newline. diff --git a/src/app.rs b/src/app.rs index 8acae1d..7861802 100644 --- a/src/app.rs +++ b/src/app.rs @@ -267,11 +267,15 @@ impl App { &mut self.doc, NodeKind::Link { url: "https://".to_owned(), + label: None, }, at, ); self.show_inspector = true; - self.set_status("Added a link node — set its URL in the inspector", ctx); + self.set_status( + "Added a link node — set its URL and display text in the inspector", + ctx, + ); } fn insert_group(&mut self, ctx: &Context) { diff --git a/src/inspector.rs b/src/inspector.rs index 35b442c..023ff2c 100644 --- a/src/inspector.rs +++ b/src/inspector.rs @@ -107,18 +107,43 @@ fn node_section( set_file(doc, id, Some(path), Some(sub)); } } - NodeKind::Link { url } => { - let mut buffer = url.clone(); + NodeKind::Link { url, label } => { + let mut url_buffer = url.clone(); ui.label(field_label(palette, "URL")); if ui - .add(TextEdit::singleline(&mut buffer).desired_width(f32::INFINITY)) + .add(TextEdit::singleline(&mut url_buffer).desired_width(f32::INFINITY)) .changed() { doc.begin_change("Edit URL"); - if let Some(NodeKind::Link { url }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) { - *url = buffer; + if let Some(NodeKind::Link { url, .. }) = + doc.canvas.node_mut(id).map(|n| &mut n.kind) + { + *url = url_buffer; } } + + ui.add_space(6.0); + ui.label(field_label(palette, "Display text")); + let mut label_buffer = label.clone().unwrap_or_default(); + if ui + .add( + TextEdit::singleline(&mut label_buffer) + .desired_width(f32::INFINITY) + .hint_text("Shown instead of the URL"), + ) + .changed() + { + doc.begin_change("Edit display text"); + if let Some(NodeKind::Link { label, .. }) = + doc.canvas.node_mut(id).map(|n| &mut n.kind) + { + // Kept verbatim so a space typed mid-word is not trimmed + // away under the cursor; blank labels simply go away. + *label = (!label_buffer.is_empty()).then_some(label_buffer); + } + } + + ui.add_space(6.0); if ui.button("Open in browser").clicked() { out.open_target = Some(url.clone()); } diff --git a/src/model.rs b/src/model.rs index f29144b..844123b 100644 --- a/src/model.rs +++ b/src/model.rs @@ -165,6 +165,10 @@ pub enum NodeKind { }, Link { url: String, + /// Text shown in place of the URL when the node is drawn on the canvas. + /// Not part of JSON Canvas 1.0; written as a `label` attribute, the name + /// groups and edges already use for the same idea. + label: Option, }, Group { label: Option, @@ -242,7 +246,10 @@ impl Node { } } NodeKind::File { file, .. } => file_name(file).to_owned(), - NodeKind::Link { url } => truncate(url.trim_start_matches("https://"), 40), + NodeKind::Link { url, label } => match display_text(label.as_deref()) { + Some(label) => truncate(label, 40), + None => truncate(url.trim_start_matches("https://"), 40), + }, NodeKind::Group { label, .. } => label.clone().unwrap_or_else(|| "Group".to_owned()), NodeKind::Unknown { type_name } => format!("<{type_name}>"), } @@ -265,8 +272,9 @@ impl Serialize for Node { map.insert("file".into(), Value::String(file.clone())); insert_opt_str(&mut map, "subpath", subpath.as_deref()); } - NodeKind::Link { url } => { + NodeKind::Link { url, label } => { map.insert("url".into(), Value::String(url.clone())); + insert_opt_str(&mut map, "label", label.as_deref()); } NodeKind::Group { label, @@ -315,6 +323,7 @@ impl<'de> Deserialize<'de> for Node { }, "link" => NodeKind::Link { url: take_string(&mut map, "url").unwrap_or_default(), + label: take_string(&mut map, "label"), }, "group" => NodeKind::Group { label: take_string(&mut map, "label"), @@ -638,6 +647,11 @@ fn truncate(s: &str, max: usize) -> String { } } +/// A label that carries something to show: present, and not just whitespace. +pub fn display_text(label: Option<&str>) -> Option<&str> { + label.map(str::trim).filter(|s| !s.is_empty()) +} + /// The last component of a `/`- or `\`-separated path. pub fn file_name(path: &str) -> &str { path.rsplit(['/', '\\']).next().unwrap_or(path) @@ -731,6 +745,37 @@ mod tests { assert!(canvas.to_json().contains("nope")); } + #[test] + fn a_link_node_can_carry_a_display_text() { + let json = r#"{"nodes":[{"id":"a","type":"link","url":"https://jsoncanvas.org", + "label":"The spec","x":0,"y":0,"width":400,"height":400}]}"#; + let canvas = Canvas::from_json(json).unwrap(); + let NodeKind::Link { label, .. } = &canvas.nodes[0].kind else { + panic!("expected a link node"); + }; + assert_eq!(label.as_deref(), Some("The spec")); + assert_eq!(canvas.nodes[0].title(), "The spec"); + let out = canvas.to_json(); + assert!(out.contains("\"label\": \"The spec\""), "{out}"); + + // Without one, the node still describes itself by its URL and no + // `label` attribute is written. + let plain = Canvas::from_json(SAMPLE).unwrap(); + assert_eq!(plain.nodes[3].title(), "jsoncanvas.org"); + let out = serde_json::to_string(&plain.nodes[3]).unwrap(); + assert!(!out.contains("label"), "{out}"); + } + + #[test] + fn a_blank_display_text_falls_back_to_the_url() { + assert_eq!(display_text(Some(" ")), None); + assert_eq!(display_text(Some(" Docs ")), Some("Docs")); + let json = r#"{"nodes":[{"id":"a","type":"link","url":"https://example.com", + "label":" "}]}"#; + let canvas = Canvas::from_json(json).unwrap(); + assert_eq!(canvas.nodes[0].title(), "example.com"); + } + #[test] fn removing_a_node_removes_its_edges() { let mut canvas = Canvas::from_json(SAMPLE).unwrap(); diff --git a/src/nodes.rs b/src/nodes.rs index 3af1146..6ec8dc0 100644 --- a/src/nodes.rs +++ b/src/nodes.rs @@ -7,7 +7,7 @@ use std::path::{Path, PathBuf}; use eframe::egui::{self, Align, Layout, RichText, Ui}; use crate::markdown; -use crate::model::{Node, NodeKind, file_name}; +use crate::model::{Node, NodeKind, display_text, file_name}; use crate::theme::Palette; /// Files larger than this are not previewed as text. @@ -101,7 +101,7 @@ pub fn render_body(ui: &mut Ui, node: &Node, ctx: &mut BodyContext<'_>) -> Optio match &node.kind { NodeKind::Text { text } => render_text(ui, text, ctx), NodeKind::File { file, subpath } => render_file(ui, file, subpath.as_deref(), ctx), - NodeKind::Link { url } => render_link(ui, url, ctx), + NodeKind::Link { url, label } => render_link(ui, url, label.as_deref(), ctx), NodeKind::Group { .. } => None, NodeKind::Unknown { type_name } => { ui.label( @@ -259,8 +259,14 @@ fn excerpt_for(text: &str, subpath: Option<&str>) -> String { } } -fn render_link(ui: &mut Ui, url: &str, ctx: &mut BodyContext<'_>) -> Option { +fn render_link( + ui: &mut Ui, + url: &str, + label: Option<&str>, + ctx: &mut BodyContext<'_>, +) -> Option { let palette = ctx.palette; + let display = display_text(label); let mut activated = None; ui.with_layout(Layout::top_down(Align::Min), |ui| { ui.horizontal(|ui| { @@ -274,9 +280,15 @@ fn render_link(ui: &mut Ui, url: &str, ctx: &mut BodyContext<'_>) -> Option (display, 15.0, url), + None => (url, 12.0, "Open in the default browser"), + }; if ui - .link(RichText::new(url).size(12.0).color(palette.accent)) - .on_hover_text("Open in the default browser") + .link(RichText::new(text).size(size).color(palette.accent)) + .on_hover_text(hover) .clicked() { activated = Some(url.to_owned()); diff --git a/src/view.rs b/src/view.rs index 15acf12..3cd6c4a 100644 --- a/src/view.rs +++ b/src/view.rs @@ -1337,7 +1337,7 @@ impl CanvasView { match &node.kind { NodeKind::Text { .. } | NodeKind::Group { .. } => self.edit_node(id), NodeKind::File { file, .. } => output.open_target = Some(file.clone()), - NodeKind::Link { url } => output.open_target = Some(url.clone()), + NodeKind::Link { url, .. } => output.open_target = Some(url.clone()), NodeKind::Unknown { .. } => {} } }