Add label for links
This commit is contained in:
@@ -12,6 +12,11 @@ no account and no hidden state.
|
|||||||
**The whole spec.** All four node types (`text`, `file`, `link`, `group`) and
|
**The whole spec.** All four node types (`text`, `file`, `link`, `group`) and
|
||||||
every edge attribute (`fromSide`/`toSide`, `fromEnd`/`toEnd`, `label`, `color`),
|
every edge attribute (`fromSide`/`toSide`, `fromEnd`/`toEnd`, `label`, `color`),
|
||||||
plus both `canvasColor` forms — the six presets and `#rrggbb` hex.
|
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
|
**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
|
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
|
asks for (`cover`, `ratio` or `repeat`). Paths are stored relative to the
|
||||||
canvas, so a canvas plus its files stays portable.
|
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,
|
**The usual editor comforts**: undo/redo of every change, copy/paste/duplicate,
|
||||||
grouping, alignment, z-order, a properties inspector, snap-to-grid, light and
|
grouping, alignment, z-order, a properties inspector, snap-to-grid, light and
|
||||||
dark themes, and a warning before you lose unsaved work.
|
dark themes, and a warning before you lose unsaved work.
|
||||||
|
|||||||
@@ -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": "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": "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": "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": "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"}
|
{"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
@@ -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.
|
||||||
@@ -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
@@ -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
@@ -267,11 +267,15 @@ impl App {
|
|||||||
&mut self.doc,
|
&mut self.doc,
|
||||||
NodeKind::Link {
|
NodeKind::Link {
|
||||||
url: "https://".to_owned(),
|
url: "https://".to_owned(),
|
||||||
|
label: None,
|
||||||
},
|
},
|
||||||
at,
|
at,
|
||||||
);
|
);
|
||||||
self.show_inspector = true;
|
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) {
|
fn insert_group(&mut self, ctx: &Context) {
|
||||||
|
|||||||
+30
-5
@@ -107,18 +107,43 @@ fn node_section(
|
|||||||
set_file(doc, id, Some(path), Some(sub));
|
set_file(doc, id, Some(path), Some(sub));
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
NodeKind::Link { url } => {
|
NodeKind::Link { url, label } => {
|
||||||
let mut buffer = url.clone();
|
let mut url_buffer = url.clone();
|
||||||
ui.label(field_label(palette, "URL"));
|
ui.label(field_label(palette, "URL"));
|
||||||
if ui
|
if ui
|
||||||
.add(TextEdit::singleline(&mut buffer).desired_width(f32::INFINITY))
|
.add(TextEdit::singleline(&mut url_buffer).desired_width(f32::INFINITY))
|
||||||
.changed()
|
.changed()
|
||||||
{
|
{
|
||||||
doc.begin_change("Edit URL");
|
doc.begin_change("Edit URL");
|
||||||
if let Some(NodeKind::Link { url }) = doc.canvas.node_mut(id).map(|n| &mut n.kind) {
|
if let Some(NodeKind::Link { url, .. }) =
|
||||||
*url = buffer;
|
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() {
|
if ui.button("Open in browser").clicked() {
|
||||||
out.open_target = Some(url.clone());
|
out.open_target = Some(url.clone());
|
||||||
}
|
}
|
||||||
|
|||||||
+47
-2
@@ -165,6 +165,10 @@ pub enum NodeKind {
|
|||||||
},
|
},
|
||||||
Link {
|
Link {
|
||||||
url: String,
|
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 {
|
Group {
|
||||||
label: Option<String>,
|
label: Option<String>,
|
||||||
@@ -242,7 +246,10 @@ impl Node {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
NodeKind::File { file, .. } => file_name(file).to_owned(),
|
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::Group { label, .. } => label.clone().unwrap_or_else(|| "Group".to_owned()),
|
||||||
NodeKind::Unknown { type_name } => format!("<{type_name}>"),
|
NodeKind::Unknown { type_name } => format!("<{type_name}>"),
|
||||||
}
|
}
|
||||||
@@ -265,8 +272,9 @@ impl Serialize for Node {
|
|||||||
map.insert("file".into(), Value::String(file.clone()));
|
map.insert("file".into(), Value::String(file.clone()));
|
||||||
insert_opt_str(&mut map, "subpath", subpath.as_deref());
|
insert_opt_str(&mut map, "subpath", subpath.as_deref());
|
||||||
}
|
}
|
||||||
NodeKind::Link { url } => {
|
NodeKind::Link { url, label } => {
|
||||||
map.insert("url".into(), Value::String(url.clone()));
|
map.insert("url".into(), Value::String(url.clone()));
|
||||||
|
insert_opt_str(&mut map, "label", label.as_deref());
|
||||||
}
|
}
|
||||||
NodeKind::Group {
|
NodeKind::Group {
|
||||||
label,
|
label,
|
||||||
@@ -315,6 +323,7 @@ impl<'de> Deserialize<'de> for Node {
|
|||||||
},
|
},
|
||||||
"link" => NodeKind::Link {
|
"link" => NodeKind::Link {
|
||||||
url: take_string(&mut map, "url").unwrap_or_default(),
|
url: take_string(&mut map, "url").unwrap_or_default(),
|
||||||
|
label: take_string(&mut map, "label"),
|
||||||
},
|
},
|
||||||
"group" => NodeKind::Group {
|
"group" => NodeKind::Group {
|
||||||
label: take_string(&mut map, "label"),
|
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.
|
/// The last component of a `/`- or `\`-separated path.
|
||||||
pub fn file_name(path: &str) -> &str {
|
pub fn file_name(path: &str) -> &str {
|
||||||
path.rsplit(['/', '\\']).next().unwrap_or(path)
|
path.rsplit(['/', '\\']).next().unwrap_or(path)
|
||||||
@@ -731,6 +745,37 @@ mod tests {
|
|||||||
assert!(canvas.to_json().contains("nope"));
|
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]
|
#[test]
|
||||||
fn removing_a_node_removes_its_edges() {
|
fn removing_a_node_removes_its_edges() {
|
||||||
let mut canvas = Canvas::from_json(SAMPLE).unwrap();
|
let mut canvas = Canvas::from_json(SAMPLE).unwrap();
|
||||||
|
|||||||
+17
-5
@@ -7,7 +7,7 @@ use std::path::{Path, PathBuf};
|
|||||||
use eframe::egui::{self, Align, Layout, RichText, Ui};
|
use eframe::egui::{self, Align, Layout, RichText, Ui};
|
||||||
|
|
||||||
use crate::markdown;
|
use crate::markdown;
|
||||||
use crate::model::{Node, NodeKind, file_name};
|
use crate::model::{Node, NodeKind, display_text, file_name};
|
||||||
use crate::theme::Palette;
|
use crate::theme::Palette;
|
||||||
|
|
||||||
/// Files larger than this are not previewed as text.
|
/// 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 {
|
match &node.kind {
|
||||||
NodeKind::Text { text } => render_text(ui, text, ctx),
|
NodeKind::Text { text } => render_text(ui, text, ctx),
|
||||||
NodeKind::File { file, subpath } => render_file(ui, file, subpath.as_deref(), 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::Group { .. } => None,
|
||||||
NodeKind::Unknown { type_name } => {
|
NodeKind::Unknown { type_name } => {
|
||||||
ui.label(
|
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 palette = ctx.palette;
|
||||||
|
let display = display_text(label);
|
||||||
let mut activated = None;
|
let mut activated = None;
|
||||||
ui.with_layout(Layout::top_down(Align::Min), |ui| {
|
ui.with_layout(Layout::top_down(Align::Min), |ui| {
|
||||||
ui.horizontal(|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);
|
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
|
if ui
|
||||||
.link(RichText::new(url).size(12.0).color(palette.accent))
|
.link(RichText::new(text).size(size).color(palette.accent))
|
||||||
.on_hover_text("Open in the default browser")
|
.on_hover_text(hover)
|
||||||
.clicked()
|
.clicked()
|
||||||
{
|
{
|
||||||
activated = Some(url.to_owned());
|
activated = Some(url.to_owned());
|
||||||
|
|||||||
+1
-1
@@ -1337,7 +1337,7 @@ impl CanvasView {
|
|||||||
match &node.kind {
|
match &node.kind {
|
||||||
NodeKind::Text { .. } | NodeKind::Group { .. } => self.edit_node(id),
|
NodeKind::Text { .. } | NodeKind::Group { .. } => self.edit_node(id),
|
||||||
NodeKind::File { file, .. } => output.open_target = Some(file.clone()),
|
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 { .. } => {}
|
NodeKind::Unknown { .. } => {}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user