Canvas support V1

This commit is contained in:
2026-10-04 15:28:06 +02:00
parent 7749d7c237
commit 2903986f44
20 changed files with 3223 additions and 482 deletions
+243 -1
View File
@@ -46,6 +46,15 @@
The shell itself is edge-to-edge; this keeps prose lines readable on
wide monitors (content stays centered within the center column). */
--reading-width: 60rem;
/* JSON Canvas preset colors "1"–"6": red, orange, yellow, green, cyan,
purple. */
--canvas-1: #e0525b;
--canvas-2: #e08a3a;
--canvas-3: #d6c94d;
--canvas-4: #5fb35b;
--canvas-5: #01b6c4;
--canvas-6: #a46ad6;
}
/* Cross-document view transitions: page navigations stay full reloads, but the
@@ -461,7 +470,7 @@ main > h2 {
Single non-wrapping row that scrolls horizontally (swipe on mobile) rather
than breaking into stacked rows. A horizontal-scroll container also clips
overflow-y, so open dropdown menus are pinned to the viewport via JS
(editor/main.js pinMenu) to escape the clip. */
(editor/core.js pinMenu) to escape the clip. */
.editor-toolbar {
display: flex;
flex-wrap: nowrap;
@@ -929,6 +938,239 @@ label.tree-name { flex: 1; cursor: pointer; position: relative; }
}
.fitness-tip[hidden] { display: none; }
/* === Canvas ===
The canvas view (canvas/main.html) fills the shell. The world is one
transformed layer holding nodes in canvas units; canvas.js sets its
transform and --z (the zoom), which keeps handles and strokes a constant
size on screen. Groups sit below the edges, everything else above. */
.canvas-app {
flex: 1 1 auto;
min-width: 0;
min-height: 0;
display: flex;
flex-direction: column;
}
.canvas-bar {
display: flex;
flex-wrap: wrap;
align-items: center;
gap: var(--space-1) var(--space-4);
padding: var(--space-2) var(--space-4);
border-bottom: var(--border-dashed);
font-size: var(--font-sm);
}
.canvas-title { min-width: 0; max-width: 30rem; }
.canvas-title a { color: var(--link); }
.canvas-title a:hover { color: var(--link-hover); }
.canvas-tools { display: flex; flex-wrap: wrap; align-items: center; gap: var(--space-1); }
.canvas-status { margin-left: auto; }
.canvas-status.is-error { color: var(--danger); }
.canvas-zoom { min-width: 4.5em; text-align: center; }
.canvas-color-input {
width: 1.6rem;
height: 1.2rem;
padding: 0;
border: var(--border);
background: none;
cursor: pointer;
}
.canvas-swatch {
display: inline-block;
width: 0.8em;
height: 0.8em;
margin-right: var(--space-2);
background: var(--node-color);
}
.canvas-colors .btn-block { justify-content: flex-start; align-items: center; }
.canvas-error { padding: var(--space-2) var(--space-4); color: var(--danger); }
/* The stage holds the viewport and, floating over its top edge while a note
is open, the page editor's toolbar, so opening it moves nothing. */
.canvas-stage { position: relative; flex: 1 1 auto; min-height: 0; }
.canvas-node-toolbar { position: absolute; top: 0; left: 0; right: 0; z-index: 10; }
.canvas-node-toolbar[hidden] { display: none; }
.canvas-node-toolbar .editor-toolbar { position: static; border-left: none; border-right: none; }
.canvas-viewport {
position: absolute;
inset: 0;
overflow: hidden;
touch-action: none;
cursor: grab;
outline: none;
background-image: radial-gradient(circle, var(--bg-panel-hover) 1px, transparent 1.5px);
}
.canvas-viewport.is-far { background-image: none; }
.canvas-viewport.is-panning { cursor: grabbing; }
.canvas-world, .canvas-nodes, .canvas-edge-labels { position: absolute; left: 0; top: 0; }
.canvas-world { transform-origin: 0 0; }
.canvas-hint {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
color: var(--text-muted);
pointer-events: none;
}
.canvas-hint[hidden] { display: none; }
.canvas-marquee {
position: absolute;
border: 1px dashed var(--primary-hover);
background: color-mix(in srgb, var(--primary-hover) 10%, transparent);
pointer-events: none;
}
/* Preset colors, for nodes, edges and the color menu's swatches. A hex color
arrives as an inline --node-color / --edge-color. */
.canvas-app [data-color="1"] { --node-color: var(--canvas-1); --edge-color: var(--canvas-1); }
.canvas-app [data-color="2"] { --node-color: var(--canvas-2); --edge-color: var(--canvas-2); }
.canvas-app [data-color="3"] { --node-color: var(--canvas-3); --edge-color: var(--canvas-3); }
.canvas-app [data-color="4"] { --node-color: var(--canvas-4); --edge-color: var(--canvas-4); }
.canvas-app [data-color="5"] { --node-color: var(--canvas-5); --edge-color: var(--canvas-5); }
.canvas-app [data-color="6"] { --node-color: var(--canvas-6); --edge-color: var(--canvas-6); }
.canvas-node {
--node-color: var(--secondary);
position: absolute;
z-index: 2;
display: flex;
flex-direction: column;
border: 1px solid var(--node-color);
background: color-mix(in srgb, var(--node-color) var(--node-tint, 0%), var(--bg-panel));
cursor: default;
user-select: none;
-webkit-user-select: none;
}
.canvas-node[data-color],
.canvas-node[style*="--node-color"] { --node-tint: 14%; }
.canvas-node.is-selected { outline: calc(2px / var(--z, 1)) solid var(--primary-hover); outline-offset: calc(3px / var(--z, 1)); }
.canvas-node.is-pending { opacity: 0.6; }
.canvas-node-group {
z-index: 0;
border-style: dashed;
background: color-mix(in srgb, var(--node-color) var(--node-tint, 0%), transparent);
isolation: isolate;
}
.canvas-node-group[data-color],
.canvas-node-group[style*="--node-color"] { --node-tint: 8%; }
.canvas-node-label {
position: absolute;
bottom: 100%;
left: 0;
max-width: 100%;
padding-bottom: 0.15em;
font-size: var(--font-sm);
color: var(--node-color);
}
.canvas-node-group > .canvas-node-label { font-size: 1.1rem; }
.canvas-node-label a { color: inherit; }
.canvas-node-label a:hover { color: var(--link-hover); }
.canvas-node-body {
flex: 1 1 auto;
min-height: 0;
overflow: auto;
padding: var(--space-2) var(--space-3);
word-break: break-word;
}
.canvas-node-body.content { margin: 0; }
.canvas-node-body > :first-child { margin-top: 0; }
.canvas-node-body > :last-child { margin-bottom: 0; }
.canvas-node-body[hidden] { display: none; }
.canvas-node-group > .canvas-node-body { padding: 0; overflow: hidden; }
.canvas-node-file > .canvas-node-body:has(> .canvas-media) { padding: 0; overflow: hidden; }
.canvas-media { display: block; width: 100%; height: 100%; }
.canvas-media img { display: block; width: 100%; height: 100%; object-fit: contain; }
video.canvas-media { object-fit: contain; background: #000; }
audio.canvas-media { height: auto; }
.canvas-card {
display: flex;
align-items: center;
gap: var(--space-2);
height: 100%;
min-width: 0;
color: var(--link);
}
.canvas-card:hover { color: var(--link-hover); }
.canvas-card-icon { flex: none; }
.canvas-card-text { display: flex; flex-direction: column; min-width: 0; }
.canvas-missing { color: var(--danger); font-size: var(--font-sm); }
.canvas-group-bg { position: absolute; inset: 0; z-index: -1; background-position: center; opacity: 0.5; }
.canvas-group-bg-cover { background-size: cover; background-repeat: no-repeat; }
.canvas-group-bg-ratio { background-size: contain; background-repeat: no-repeat; }
.canvas-group-bg-repeat { background-repeat: repeat; }
/* The note editor inside a text node (editor/core.js). */
.canvas-node.is-editing { z-index: 5; cursor: auto; user-select: text; -webkit-user-select: text; }
.canvas-node-editor { flex: 1 1 auto; min-height: 0; display: flex; }
.canvas-node-editor .cm-editor { flex: 1 1 auto; min-width: 0; border: none; }
.canvas-node-editor .cm-editor .cm-scroller { min-height: 0; }
.canvas-node-editor .cm-editor .cm-content { padding: var(--space-2) var(--space-3); }
/* Handles: connect from a side, resize from the corner. */
.canvas-handle, .canvas-resize { position: absolute; display: none; z-index: 1; }
.canvas-handle {
width: calc(14px / var(--z, 1));
height: calc(14px / var(--z, 1));
border-radius: 50%;
border: calc(2px / var(--z, 1)) solid var(--primary-hover);
background: var(--bg);
transform: translate(-50%, -50%);
cursor: crosshair;
}
.canvas-handle[data-side="top"] { left: 50%; top: 0; }
.canvas-handle[data-side="right"] { left: 100%; top: 50%; }
.canvas-handle[data-side="bottom"] { left: 50%; top: 100%; }
.canvas-handle[data-side="left"] { left: 0; top: 50%; }
.canvas-resize {
right: 0;
bottom: 0;
width: calc(16px / var(--z, 1));
height: calc(16px / var(--z, 1));
background: linear-gradient(135deg, transparent 55%, var(--primary-hover) 55%);
cursor: nwse-resize;
}
.canvas-node.is-selected > .canvas-handle,
.canvas-node.is-selected > .canvas-resize { display: block; }
@media (hover: hover) {
.canvas-node:hover > .canvas-handle { display: block; }
}
.canvas-node.is-editing > .canvas-handle,
.canvas-node.is-editing > .canvas-resize { display: none; }
/* Edges: an SVG sized to their bounding box (canvas.js). The wide invisible
hit path makes thin lines easy to click. */
.canvas-edges { position: absolute; z-index: 1; overflow: visible; pointer-events: none; }
.canvas-edge { --edge-color: var(--text-muted); }
.canvas-edge-line {
fill: none;
stroke: var(--edge-color);
stroke-width: max(2px, calc(1.5px / var(--z, 1)));
}
.canvas-edge-hit {
fill: none;
stroke: transparent;
stroke-width: calc(16px / var(--z, 1));
pointer-events: stroke;
cursor: pointer;
}
.canvas-edge-arrow { fill: var(--edge-color); }
.canvas-edge.is-selected .canvas-edge-line { stroke: var(--primary-hover); }
.canvas-edge.is-selected .canvas-edge-arrow { fill: var(--primary-hover); }
.canvas-edge.is-draft .canvas-edge-line { stroke: var(--primary-hover); stroke-dasharray: 6 4; }
.canvas-edge-label {
--edge-color: var(--text-muted);
position: absolute;
z-index: 3;
transform: translate(-50%, -50%);
padding: 0 var(--space-1);
background: var(--bg);
color: var(--edge-color);
font-size: var(--font-sm);
white-space: pre;
cursor: pointer;
}
.canvas-edge-label.is-selected { color: var(--primary-hover); }
/* === Responsive === */
@media (max-width: 1100px) {
.tree-sidebar, .sidebar { display: none; }