Add label for links

This commit is contained in:
2026-09-09 21:47:34 +02:00
parent a64a5327e7
commit e7d8c6081b
10 changed files with 431 additions and 15 deletions
+11
View File
@@ -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.
+1 -1
View File
@@ -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"}
],
+102
View File
@@ -0,0 +1,102 @@
# JSON Canvas Spec
<small>Version 1.0 — 2024-03-11</small>
## 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.
+52
View File
@@ -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
}
```
+165
View File
@@ -0,0 +1,165 @@
# Canvas Spec
<small>Based on JSON Canvas 1.0 — 2024-03-11</small>
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.
+5 -1
View File
@@ -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) {
+30 -5
View File
@@ -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());
}
+47 -2
View File
@@ -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<String>,
},
Group {
label: Option<String>,
@@ -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();
+17 -5
View File
@@ -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<String> {
fn render_link(
ui: &mut Ui,
url: &str,
label: Option<&str>,
ctx: &mut BodyContext<'_>,
) -> Option<String> {
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<Stri
);
});
ui.add_space(2.0);
// With a display text the URL itself moves to the tooltip, so the node
// still says where it goes without spelling the address out.
let (text, size, hover) = match display {
Some(display) => (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());
+1 -1
View File
@@ -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 { .. } => {}
}
}