From 06da56d0a0152586f24f8dc65d9d812e57285bd7 Mon Sep 17 00:00:00 2001 From: luxick Date: Sun, 4 Oct 2026 15:58:27 +0200 Subject: [PATCH] Canvas V2 --- assets/canvas/canvas.js | 1123 ++++++++++++++++++++++++++++++--------- assets/canvas/main.html | 49 +- assets/canvas/node.html | 2 +- assets/editor/core.js | 4 + assets/style.css | 156 ++++-- canvas.go | 3 +- canvas_test.go | 4 + 7 files changed, 1008 insertions(+), 333 deletions(-) diff --git a/assets/canvas/canvas.js b/assets/canvas/canvas.js index 4858ea0..5b30539 100644 --- a/assets/canvas/canvas.js +++ b/assets/canvas/canvas.js @@ -7,14 +7,20 @@ // Only geometry, color and selection are applied locally. Edges are drawn // here, since they follow every drag. // -// Mouse: drag the background to pan (Shift+drag selects), wheel zooms, -// double-click the background for a new note, double-click a node to edit it, -// drag a node's side handle onto another node to connect them. Touch: one -// finger pans or drags, two fingers pinch. +// The handling follows jsoncanvas-desktop. There is no toolbar: everything +// is in the context menus (right-click, or a long press on touch) and the +// edit windows, which are non-modal and write through as you type. +// +// Mouse: drag the background to select, middle- or right-drag (or Space+drag) +// to pan, wheel zooms, double-click the background for a new note, +// double-click a node or edge to edit it, drag a node's side handle onto +// another node to connect them. Touch: one finger pans or drags, two fingers +// pinch, a long press opens the menu. (function () { var app = document.querySelector('.canvas-app'); if (!app) return; + var stage = app.querySelector('.canvas-stage'); var viewport = app.querySelector('.canvas-viewport'); var world = app.querySelector('.canvas-world'); var nodesEl = app.querySelector('.canvas-nodes'); @@ -23,7 +29,8 @@ var hintEl = app.querySelector('.canvas-hint'); var statusEl = app.querySelector('.canvas-status'); var zoomEl = app.querySelector('.canvas-zoom'); - var nodeToolbar = app.querySelector('.canvas-node-toolbar'); + var toolbarHome = app.querySelector('.canvas-editor-toolbar'); + var editorToolbar = toolbarHome && toolbarHome.firstElementChild; var readOnly = app.hasAttribute('data-readonly'); var doc = JSON.parse(document.getElementById('canvas-data').textContent); @@ -36,6 +43,8 @@ var MIN_W = 60, MIN_H = 40; var SIDES = ['top', 'right', 'bottom', 'left']; var NORMALS = { top: [0, -1], right: [1, 0], bottom: [0, 1], left: [-1, 0] }; + var COLORS = ['Red', 'Orange', 'Yellow', 'Green', 'Cyan', 'Purple']; + var LONG_PRESS = 500; var byId = {}; // node id → node var els = {}; // node id → element @@ -43,7 +52,6 @@ var htmlCache = {}; // content key → element HTML from the server var selected = new Set(); // node ids var selectedEdges = new Set(); - var editing = null; // the text node being edited, see startEdit var cam = { x: 0, y: 0, z: 1 }; // === Helpers === @@ -68,6 +76,14 @@ doc.nodes.forEach(function (n) { byId[n.id] = n; }); } + function edgeById(id) { + return doc.edges.find(function (e) { return e.id === id; }); + } + + function selectedNodes() { + return doc.nodes.filter(function (n) { return selected.has(n.id); }); + } + // contentKey covers everything that changes a node's rendered element // other than geometry and color. function contentKey(n) { @@ -80,6 +96,13 @@ return t.content.firstElementChild; } + function el(tag, cls, text) { + var e = document.createElement(tag); + if (cls) e.className = cls; + if (text !== undefined) e.textContent = text; + return e; + } + function setColor(target, color, isEl) { if (/^[1-6]$/.test(color || '')) { target.setAttribute('data-color', color); @@ -117,6 +140,20 @@ return el; } + // title is how a node is named in the edge window. + function title(n) { + if (!n) return 'missing node'; + var t; + switch (n.type) { + case 'text': t = (n.text || '').trim().split('\n')[0].replace(/^#+\s*/, ''); break; + case 'file': t = (n.file || '').split('/').pop(); break; + case 'link': t = n.label || n.url; break; + case 'group': t = n.label; break; + } + t = t || n.type; + return t.length > 40 ? t.slice(0, 39) + '…' : t; + } + function modalOpen() { return !!document.querySelector('.modal-backdrop'); } @@ -170,9 +207,10 @@ return b; } - function fit() { + // fit shows the given nodes (all of them by default), never above 100%. + function fit(nodes) { var w = viewport.clientWidth, h = viewport.clientHeight; - var b = bounds(doc.nodes); + var b = bounds(nodes || doc.nodes); if (!b) { cam = { x: w / 2, y: h / 2, z: 1 }; } else { @@ -187,6 +225,11 @@ applyCam(); } + function zoomToSelection() { + var nodes = selectedNodes(); + fit(nodes.length ? nodes : null); + } + // The point new nodes go to: the middle of the view. function viewCenter() { return { @@ -214,7 +257,7 @@ }).then(function (html) { htmlCache[key] = html; var cur = byId[n.id]; - if (cur && contentKey(cur) === key && elKey[n.id] !== key && !(editing && editing.id === n.id)) { + if (cur && contentKey(cur) === key && elKey[n.id] !== key) { swapElement(cur, key); if (cur.type === 'text') growToFit(cur); renderEdges(); @@ -228,6 +271,12 @@ return p; } + // whenRendered runs fn once n's element shows its current content. + function whenRendered(n, fn) { + if (elKey[n.id] === contentKey(n)) fn(); + else renderNode(n).then(fn); + } + function swapElement(n, key) { var el = decorate(fromHTML(htmlCache[key])); var old = els[n.id]; @@ -239,7 +288,7 @@ } // growToFit makes a text node tall enough for its content. It never - // shrinks a node: the size is the user's. + // shrinks a node: the size is the user's (Fit to text does both). function growToFit(n) { var el = els[n.id]; var body = el && el.querySelector('.canvas-node-body'); @@ -252,6 +301,33 @@ } } + // fitHeight is the height n's card needs to show all of its text and no + // more, or null for a card whose content has no height of its own. + function fitHeight(n) { + var el = els[n.id]; + var body = el && el.querySelector('.canvas-node-body.content'); + if (!body || n.type === 'group') return null; + var h = el.style.height; + el.style.height = 'auto'; + var natural = el.offsetHeight; + el.style.height = h; + return Math.max(MIN_H, Math.ceil(natural / GRID) * GRID); + } + + // fitHeights sizes the given cards to their text; it reports whether any + // of them changed. + function fitHeights(ids) { + var changed = false; + ids.forEach(function (id) { + var n = byId[id], h = n && fitHeight(n); + if (h && h !== n.height) { + n.height = h; + changed = true; + } + }); + return changed; + } + // syncDOM brings the node elements in line with doc.nodes: creates, // removes, reorders (array order is z-order), re-renders changed content. function syncDOM() { @@ -261,7 +337,7 @@ keep[n.id] = true; var key = contentKey(n); var el = els[n.id]; - if (elKey[n.id] !== key && !(editing && editing.id === n.id)) { + if (elKey[n.id] !== key) { if (htmlCache[key] !== undefined) { swapElement(n, key); el = els[n.id]; @@ -286,7 +362,7 @@ selected.delete(id); }); selectedEdges.forEach(function (id) { - if (!doc.edges.some(function (e) { return e.id === id; })) selectedEdges.delete(id); + if (!edgeById(id)) selectedEdges.delete(id); }); if (hintEl) hintEl.hidden = doc.nodes.length > 0; renderEdges(); @@ -435,6 +511,7 @@ // mutate runs fn as one undoable change. function mutate(fn) { if (readOnly) return; + settle(); var before = snapshot(); fn(); pushUndo(before); @@ -452,14 +529,14 @@ } function undo() { - commitEdit(); + closeWindow(); if (!undoStack.length) return; redoStack.push(snapshot()); restore(undoStack.pop()); } function redo() { - commitEdit(); + closeWindow(); if (!redoStack.length) return; undoStack.push(snapshot()); restore(redoStack.pop()); @@ -486,6 +563,12 @@ statusEl.classList.toggle('is-error', state === 'error' || state === 'conflict'); } + // incomplete is true while the document would be refused: a link node + // just added has no URL until its window has one typed in. + function incomplete() { + return doc.nodes.some(function (n) { return n.type === 'link' && !n.url; }); + } + function scheduleSave() { if (readOnly || conflict) return; dirty = true; @@ -497,7 +580,7 @@ function save() { clearTimeout(saveTimer); saveTimer = null; - if (saving || !dirty || conflict) return; + if (saving || !dirty || conflict || incomplete()) return; saving = true; dirty = false; setStatus('saving'); @@ -543,14 +626,14 @@ }); } - // Leaving the page: commit an open editor and send what is unsaved with - // keepalive, which outlives the page (up to 64 KB). Only a save that cannot - // go out that way asks the user to stay. + // Leaving the page: send what is unsaved with keepalive, which outlives + // the page (up to 64 KB). Only a save that cannot go out that way asks + // the user to stay. window.addEventListener('beforeunload', function (e) { - commitEdit(); + settle(); if (!dirty || conflict) return; var body = JSON.stringify(doc); - if (!saving && body.length < 60000) { + if (!saving && !incomplete() && body.length < 60000) { clearTimeout(saveTimer); dirty = false; saving = true; @@ -571,142 +654,366 @@ if (document.visibilityState === 'hidden' && dirty && !saving) save(); }); - // === Editing nodes === + // === Edit windows === + // One window at a time, for one node or edge. It is not modal: the canvas + // stays live behind it, and what is typed shows on the card as it is + // typed. Everything done in one window is one undo step (settle). - // startEdit opens the markdown editor (editor/core.js) inside a text node. - function startEdit(id, isNew) { - var n = byId[id]; - if (readOnly || !n || n.type !== 'text' || !window.MarkdownEditor) return; - if (editing) { - if (editing.id === id) return; - commitEdit(); - } - select([id]); - var el = els[id]; - var body = el.querySelector('.canvas-node-body'); - var mount = document.createElement('div'); - mount.className = 'canvas-node-editor'; - body.hidden = true; - el.appendChild(mount); - el.classList.add('is-editing'); - nodeToolbar.hidden = false; - var commitKey = function () { - if (document.querySelector('.cm-tooltip-autocomplete')) return false; - commitEdit(); - return true; - }; - editing = { - id: id, - el: el, - body: body, - mount: mount, - isNew: !!isNew, - before: snapshot(), - editor: MarkdownEditor.create(mount, { - doc: n.text || '', - keymap: [{ key: 'Escape', run: commitKey }, { key: 'Mod-Enter', run: commitKey }], - }), - }; + var win = null; // { id, edge, el, before, isNew, editor } + var renderTimer = null; + + // settle closes the open window's undo step, so that whatever happens + // next on the canvas is a step of its own. + function settle() { + if (!win) return; + if (win.before !== snapshot()) pushUndo(win.before); + win.before = snapshot(); } - function commitEdit() { - if (!editing) return; - var e = editing; - editing = null; - var text = e.editor.getValue(); - e.editor.destroy(); - e.mount.remove(); - e.body.hidden = false; - e.el.classList.remove('is-editing'); - nodeToolbar.hidden = true; - var n = byId[e.id]; - if (!n) return; - if (e.isNew && !text.trim()) { - // An empty new note was a misclick: drop it without a trace. + // live applies a change made in the window: the card follows shortly, + // the edges at once. + function live() { + renderEdges(); + clearTimeout(renderTimer); + renderTimer = setTimeout(syncDOM, 250); + scheduleSave(); + } + + function inWindow(e) { + return !!(e.target && e.target.closest && e.target.closest('.canvas-window, .canvas-menu')); + } + + // openWindow shows a window beside the card of opts.id (or in the middle + // of the view): opts.title, opts.body, opts.buttons [{ label, run }], + // opts.wide for the text editor, opts.before the undo state it started + // from, opts.isNew for a node that goes away if left empty. + function openWindow(opts) { + closeWindow(); + var w = el('div', 'canvas-window panel panel-floating' + (opts.wide ? ' is-wide' : '')); + w.setAttribute('role', 'dialog'); + var header = el('div', 'panel-header'); + header.appendChild(el('span', 'truncate', opts.title)); + var x = el('button', 'btn btn-tool', '×'); + x.type = 'button'; + x.title = 'Close (Esc)'; + x.addEventListener('click', closeWindow); + header.appendChild(x); + var body = el('div', 'panel-body'); + body.appendChild(opts.body); + var footer = el('div', 'panel-footer'); + (opts.buttons || []).concat([{ label: 'CLOSE', run: closeWindow }]).forEach(function (b) { + var btn = el('button', 'btn', b.label); + btn.type = 'button'; + if (b.title) btn.title = b.title; + btn.addEventListener('click', b.run); + footer.appendChild(btn); + }); + w.appendChild(header); + w.appendChild(body); + w.appendChild(footer); + stage.appendChild(w); + + w.addEventListener('keydown', function (e) { + if (e.key === 'Escape' && !e.defaultPrevented && !modalOpen()) { + e.preventDefault(); + closeWindow(); + } + }); + dragByHeader(w, header); + win = { + id: opts.id, + edge: opts.edge, + el: w, + before: opts.before || snapshot(), + isNew: !!opts.isNew, + editor: null, + }; + placeWindow(w, opts.edge ? null : opts.id); + if (!opts.wide) { + var first = body.querySelector('input, select'); + if (first) { + first.focus(); + if (first.select) first.select(); + } + } + return win; + } + + // placeWindow puts the window beside the card, on whichever side has + // more room, within the stage. Small screens dock it to the bottom (CSS). + function placeWindow(w, id) { + if (window.matchMedia('(max-width: 600px)').matches) return; + var s = stage.getBoundingClientRect(); + var gap = 16; + var ww = w.offsetWidth, wh = w.offsetHeight; + var left = (s.width - ww) / 2, top = gap; + var card = id && els[id] && els[id].getBoundingClientRect(); + if (card) { + var l = card.left - s.left, r = card.right - s.left; + left = s.width - r >= l ? r + gap : l - gap - ww; + top = card.top - s.top; + } + w.style.left = clamp(left, gap, Math.max(gap, s.width - ww - gap)) + 'px'; + w.style.top = clamp(top, gap, Math.max(gap, s.height - wh - gap)) + 'px'; + } + + function dragByHeader(w, header) { + header.addEventListener('pointerdown', function (e) { + if (e.button !== 0 || e.target.closest('button') || window.matchMedia('(max-width: 600px)').matches) return; + e.preventDefault(); + var s = stage.getBoundingClientRect(); + var ox = w.offsetLeft - e.clientX, oy = w.offsetTop - e.clientY; + function move(ev) { + w.style.left = clamp(ev.clientX + ox, 0, s.width - w.offsetWidth) + 'px'; + w.style.top = clamp(ev.clientY + oy, 0, s.height - header.offsetHeight) + 'px'; + } + function up() { + window.removeEventListener('pointermove', move); + window.removeEventListener('pointerup', up); + } + window.addEventListener('pointermove', move); + window.addEventListener('pointerup', up); + }); + } + + function closeWindow() { + if (!win) return; + var w = win; + if (w.editor) { + w.editor.destroy(); + if (editorToolbar) toolbarHome.appendChild(editorToolbar); + } + clearTimeout(renderTimer); + w.el.remove(); + var n = byId[w.id]; + // A new node left empty was a misclick: it goes without a trace. + if (w.isNew && n && (n.type === 'text' ? !(n.text || '').trim() : n.type === 'link' && !n.url)) { doc.nodes = doc.nodes.filter(function (x) { return x.id !== n.id; }); doc.edges = doc.edges.filter(function (x) { return x.fromNode !== n.id && x.toNode !== n.id; }); - syncDOM(); - if (e.before !== snapshot()) pushUndo(e.before); - scheduleSave(); - } else if (text !== (n.text || '') || e.isNew) { - n.text = text; - pushUndo(e.before); - syncDOM(); - scheduleSave(); } + settle(); + win = null; + syncDOM(); + scheduleSave(); viewport.focus({ preventScroll: true }); } - function promptText(title, value, placeholder, onDone) { - var input = document.createElement('input'); - input.type = 'text'; - input.className = 'input'; - input.placeholder = placeholder || ''; - input.value = value || ''; - var handle = openModal({ - title: title, - body: input, - confirm: { - label: 'OK', - onConfirm: function () { - handle.close(); - onDone(input.value.trim()); - } - } - }); + // --- Window parts --- + + function field(label, control) { + var f = el('label', 'canvas-field'); + f.appendChild(el('span', 'canvas-field-label', label)); + f.appendChild(control); + return f; } - // editNode is the double-click / Enter action for every node type. - function editNode(id) { + function textInput(value, placeholder, onInput) { + var i = el('input', 'input'); + i.type = 'text'; + i.value = value || ''; + i.placeholder = placeholder || ''; + i.addEventListener('input', function () { onInput(i.value); }); + return i; + } + + function selectInput(options, value, onChange) { + var s = el('select', 'input'); + options.forEach(function (o) { + var opt = el('option', '', o[1]); + opt.value = o[0]; + s.appendChild(opt); + }); + s.value = value || ''; + s.addEventListener('change', function () { onChange(s.value); }); + return s; + } + + function smallButton(label, run) { + var b = el('button', 'btn btn-tool', label); + b.type = 'button'; + b.addEventListener('click', run); + return b; + } + + // set writes v to n[key], or drops the key for an empty value. + function set(n, key, v) { + if (v) n[key] = v; else delete n[key]; + } + + // --- The windows --- + + // editNode is the double-click / Enter / F2 action for every node type. + function editNode(id, isNew, before) { var n = byId[id]; if (!n || readOnly) return; + if (win && win.id === id && !win.edge) return; switch (n.type) { - case 'text': - startEdit(id); - break; - case 'group': - promptText('Group label', n.label, 'Label (optional)', function (v) { - mutate(function () { if (v) n.label = v; else delete n.label; }); - }); - break; - case 'link': - promptText('Link', n.url, 'https://…', function (v) { - if (v) mutate(function () { n.url = v; }); - }); - break; - case 'file': - pickFile(function (rel) { - mutate(function () { n.file = rel; delete n.subpath; }); - }); - break; + case 'text': editText(n, isNew, before); break; + case 'link': editLink(n, isNew, before); break; + case 'group': editGroup(n); break; + case 'file': editFile(n); break; } } - function editEdgeLabel(id) { - var e = doc.edges.find(function (x) { return x.id === id; }); - if (!e || readOnly) return; - promptText('Connection label', e.label, 'Label (optional)', function (v) { - mutate(function () { if (v) e.label = v; else delete e.label; }); + function editText(n, isNew, before) { + if (!window.MarkdownEditor) return; + var box = el('div', 'canvas-window-editor'); + if (editorToolbar) box.appendChild(editorToolbar); + var mount = el('div', 'canvas-window-cm'); + box.appendChild(mount); + var w = openWindow({ + title: 'Edit text', + id: n.id, + body: box, + wide: true, + isNew: isNew, + before: before, + buttons: [{ + label: 'CLOSE & FIT', + title: 'Close, and size the card to its text', + run: function () { + var cur = byId[w.id]; + if (!cur) return closeWindow(); + clearTimeout(renderTimer); + syncDOM(); + whenRendered(cur, function () { + fitHeights([cur.id]); + closeWindow(); + }); + }, + }], }); + var closeKey = function () { + if (document.querySelector('.cm-tooltip-autocomplete')) return false; + closeWindow(); + return true; + }; + w.editor = MarkdownEditor.create(mount, { + doc: n.text || '', + keymap: [{ key: 'Escape', run: closeKey }, { key: 'Mod-Enter', run: closeKey }], + onChange: function () { + var cur = byId[w.id]; + if (!cur || win !== w) return; + cur.text = w.editor.getValue(); + live(); + }, + }); + } + + function editLink(n, isNew, before) { + var box = el('div', 'canvas-fields'); + box.appendChild(field('URL', textInput(n.url, 'https://', function (v) { + // A link needs its URL: clearing the field keeps the last one. + if (!v.trim()) return; + n.url = v.trim(); + live(); + }))); + box.appendChild(field('Display text', textInput(n.label, 'Shown instead of the URL', function (v) { + set(n, 'label', v); + live(); + }))); + openWindow({ title: 'Edit link', id: n.id, body: box, isNew: isNew, before: before }); + } + + function editGroup(n) { + var box = el('div', 'canvas-fields'); + box.appendChild(field('Label', textInput(n.label, 'Unnamed group', function (v) { + set(n, 'label', v); + live(); + }))); + var path = el('span', 'muted truncate', n.background || 'none'); + var row = el('div', 'canvas-field-row'); + row.appendChild(path); + row.appendChild(smallButton('CHOOSE…', function () { + pickFile('Background image', function (rel) { + n.background = rel; + path.textContent = rel; + live(); + }); + })); + row.appendChild(smallButton('CLEAR', function () { + delete n.background; + path.textContent = 'none'; + live(); + })); + box.appendChild(field('Background image', row)); + box.appendChild(field('Fitting', selectInput( + [['', 'default'], ['cover', 'cover'], ['ratio', 'ratio'], ['repeat', 'repeat']], + n.backgroundStyle, + function (v) { + set(n, 'backgroundStyle', v); + live(); + }))); + openWindow({ title: 'Edit group', id: n.id, body: box }); + } + + function editFile(n) { + var box = el('div', 'canvas-fields'); + var path = el('span', 'truncate', n.file); + var row = el('div', 'canvas-field-row'); + row.appendChild(path); + row.appendChild(smallButton('CHANGE…', function () { + pickFile('Pick a file or page', function (rel) { + n.file = rel; + path.textContent = rel; + live(); + }); + })); + box.appendChild(field('File', row)); + box.appendChild(field('Subpath', textInput(n.subpath, '#heading', function (v) { + set(n, 'subpath', v.trim()); + live(); + }))); + openWindow({ title: 'Edit ' + (n.file || '').split('/').pop(), id: n.id, body: box }); + } + + function editEdge(id) { + var e = edgeById(id); + if (!e || readOnly) return; + select([], [id]); + var box = el('div', 'canvas-fields'); + box.appendChild(el('span', 'muted', title(byId[e.fromNode]) + ' → ' + title(byId[e.toNode]))); + box.appendChild(field('Label', textInput(e.label, 'Shown on the line', function (v) { + set(e, 'label', v); + live(); + }))); + var sides = [['', 'auto'], ['top', 'top'], ['right', 'right'], ['bottom', 'bottom'], ['left', 'left']]; + function ends(def) { return [['', 'default (' + def + ')'], ['none', 'none'], ['arrow', 'arrow']]; } + function pair(a, b) { + var r = el('div', 'canvas-field-pair'); + r.appendChild(a); + r.appendChild(b); + return r; + } + function choice(key, options) { + return selectInput(options, e[key], function (v) { + set(e, key, v); + live(); + }); + } + box.appendChild(pair(field('From side', choice('fromSide', sides)), field('Start', choice('fromEnd', ends('none'))))); + box.appendChild(pair(field('To side', choice('toSide', sides)), field('End', choice('toEnd', ends('arrow'))))); + openWindow({ title: 'Edit edge', id: id, edge: true, body: box }); } function pageFolder() { return decodeURIComponent(baseURL.slice(0, baseURL.lastIndexOf('/'))) || '/'; } - function pickFile(onPick) { + function pickFile(title, onPick) { openTreePicker({ - title: 'Pick a file or page', + title: title, mode: 'any', initialPath: pageFolder(), allowRoot: false, - confirmLabel: 'ADD', + confirmLabel: 'SELECT', onSelect: function (absPath) { onPick(absPath.replace(/^\/+/, '')); } }); } // === Commands === + // addNode puts n centered on at (the middle of the view by default). function addNode(n, at) { n.id = newId(); var c = at || viewCenter(); @@ -719,38 +1026,37 @@ return n; } - // addText adds a note and opens its editor. Creating and writing it is - // one undo step, and a note left empty disappears (commitEdit). + // addText adds a note and opens its window. Creating and writing it is + // one undo step, and a note left empty disappears (closeWindow). function addText(at) { - commitEdit(); + closeWindow(); var before = snapshot(); var n = addNode({ type: 'text', text: '', width: 260, height: 80 }, at); undoStack.pop(); - startEdit(n.id, true); - editing.before = before; - return n; + editNode(n.id, true, before); } - function addFile() { - pickFile(function (rel) { + function addLink(at) { + closeWindow(); + var before = snapshot(); + var n = addNode({ type: 'link', url: '', width: 320, height: 70 }, at); + undoStack.pop(); + editNode(n.id, true, before); + } + + function addFile(at) { + pickFile('Pick a file or page', function (rel) { var image = /\.(png|jpe?g|gif|webp|svg)$/i.test(rel); var card = !image && !/\.md$/i.test(rel) && /\.[^\/]+$/.test(rel); - addNode({ type: 'file', file: rel, width: card ? 300 : 400, height: card ? 60 : image ? 300 : 400 }); + addNode({ type: 'file', file: rel, width: card ? 300 : 400, height: card ? 60 : image ? 300 : 400 }, at); }); } - function addLink() { - promptText('Add link', '', 'https://…', function (v) { - if (v) addNode({ type: 'link', url: v, width: 320, height: 70 }); - }); - } - - // addGroup puts a group around the selection, or an empty one in view. - function addGroup() { - var nodes = Array.from(selected).map(function (id) { return byId[id]; }).filter(Boolean); - var b = bounds(nodes); - var n = { type: 'group', width: 400, height: 300 }; - var at; + // addGroup puts a group around the selection, or an empty one at the + // given point or in view. + function addGroup(at, around) { + var b = bounds(around ? selectedNodes() : []); + var n = { type: 'group', label: 'Group', width: 400, height: 300 }; if (b) { var pad = 30; n.width = b.x2 - b.x1 + 2 * pad; @@ -763,6 +1069,7 @@ function deleteSelection() { if (!selected.size && !selectedEdges.size) return; + if (win && (win.edge ? selectedEdges.has(win.id) : selected.has(win.id))) closeWindow(); mutate(function () { doc.nodes = doc.nodes.filter(function (n) { return !selected.has(n.id); }); doc.edges = doc.edges.filter(function (e) { @@ -772,21 +1079,24 @@ select([]); } - function duplicateSelection() { - if (!selected.size) return; + // insert adds copies of nodes (and the edges between them) with new ids, + // their box centered on at, and selects them. + function insert(nodes, edges, at) { + var b = bounds(nodes); + if (!b) return; + var dx = snap(at.x - (b.x1 + b.x2) / 2), dy = snap(at.y - (b.y1 + b.y2) / 2); var map = {}; - var copies = []; + var copies = nodes.map(function (n) { + var c = JSON.parse(JSON.stringify(n)); + c.id = newId(); + c.x += dx; + c.y += dy; + map[n.id] = c.id; + return c; + }); mutate(function () { - doc.nodes.filter(function (n) { return selected.has(n.id); }).forEach(function (n) { - var c = JSON.parse(JSON.stringify(n)); - c.id = newId(); - c.x += 3 * GRID; - c.y += 3 * GRID; - map[n.id] = c.id; - copies.push(c); - }); doc.nodes = doc.nodes.concat(copies); - doc.edges.filter(function (e) { return map[e.fromNode] && map[e.toNode]; }).forEach(function (e) { + edges.filter(function (e) { return map[e.fromNode] && map[e.toNode]; }).forEach(function (e) { var c = JSON.parse(JSON.stringify(e)); c.id = newId(); c.fromNode = map[e.fromNode]; @@ -797,36 +1107,84 @@ select(copies.map(function (c) { return c.id; })); } - function setSelectionColor(color) { - if (!selected.size && !selectedEdges.size) return; - function paint(x) { - if (color) x.color = color; else delete x.color; + // clip is the selection as a JSON Canvas document: groups take what is + // inside them along, and edges come when both ends do. + function clip() { + var ids = new Set(movedSet(Array.from(selected))); + return { + nodes: doc.nodes.filter(function (n) { return ids.has(n.id); }), + edges: doc.edges.filter(function (e) { return ids.has(e.fromNode) && ids.has(e.toNode); }), + }; + } + + function duplicateSelection() { + if (!selected.size) return; + var c = clip(), b = bounds(c.nodes); + insert(c.nodes, c.edges, { x: (b.x1 + b.x2) / 2 + 3 * GRID, y: (b.y1 + b.y2) / 2 + 3 * GRID }); + } + + // paste takes JSON Canvas (a copy from this editor or another), a URL + // (a link node) or any other text (a note). + function paste(text, at) { + var data = null; + try { data = JSON.parse(text); } catch (e) {} + if (data && Array.isArray(data.nodes) && data.nodes.length && data.nodes.every(function (n) { + return n && typeof n.id === 'string' && typeof n.type === 'string' && isFinite(n.x) && isFinite(n.y); + })) { + insert(data.nodes, Array.isArray(data.edges) ? data.edges : [], at); + } else if (/^https?:\/\/\S+$/.test(text.trim())) { + addNode({ type: 'link', url: text.trim(), width: 320, height: 70 }, at); + } else if (text.trim()) { + addNode({ type: 'text', text: text, width: 260, height: 80 }, at); } + } + + function paint(nodeIds, edgeIds, color) { mutate(function () { - doc.nodes.forEach(function (n) { if (selected.has(n.id)) paint(n); }); - doc.edges.forEach(function (e) { if (selectedEdges.has(e.id)) paint(e); }); + doc.nodes.forEach(function (n) { if (nodeIds.has(n.id)) set(n, 'color', color); }); + doc.edges.forEach(function (e) { if (edgeIds.has(e.id)) set(e, 'color', color); }); }); } - function setEnds(value) { - if (!selectedEdges.size) return; + function reverseEdge(e) { mutate(function () { - doc.edges.forEach(function (e) { - if (!selectedEdges.has(e.id)) return; - if (value === 'reverse') { - var t = [e.fromNode, e.fromSide, e.fromEnd]; - e.fromNode = e.toNode; e.fromSide = e.toSide; e.fromEnd = e.toEnd; - e.toNode = t[0]; e.toSide = t[1]; e.toEnd = t[2]; - ['fromSide', 'fromEnd', 'toSide', 'toEnd'].forEach(function (k) { if (e[k] === undefined) delete e[k]; }); - return; + var t = [e.fromNode, e.fromSide, e.fromEnd]; + e.fromNode = e.toNode; e.fromSide = e.toSide; e.fromEnd = e.toEnd; + e.toNode = t[0]; e.toSide = t[1]; e.toEnd = t[2]; + ['fromSide', 'fromEnd', 'toSide', 'toEnd'].forEach(function (k) { if (e[k] === undefined) delete e[k]; }); + }); + } + + // raise moves the selection to the front (end of the array) or back. + function raise(front) { + mutate(function () { + var sel = doc.nodes.filter(function (n) { return selected.has(n.id); }); + var rest = doc.nodes.filter(function (n) { return !selected.has(n.id); }); + doc.nodes = front ? rest.concat(sel) : sel.concat(rest); + }); + } + + function align(how) { + var nodes = selectedNodes(), b = bounds(nodes); + if (nodes.length < 2) return; + mutate(function () { + nodes.forEach(function (n) { + switch (how) { + case 'left': n.x = b.x1; break; + case 'centre': n.x = Math.round((b.x1 + b.x2 - n.width) / 2); break; + case 'right': n.x = b.x2 - n.width; break; + case 'top': n.y = b.y1; break; + case 'middle': n.y = Math.round((b.y1 + b.y2 - n.height) / 2); break; + case 'bottom': n.y = b.y2 - n.height; break; } - var v = value.split(' '); - if (v[0] === 'none') delete e.fromEnd; else e.fromEnd = v[0]; - if (v[1] === 'arrow') delete e.toEnd; else e.toEnd = v[1]; }); }); } + function fitSelection() { + mutate(function () { fitHeights(Array.from(selected)); }); + } + function nudge(dx, dy) { if (!selected.size) return; mutate(function () { @@ -852,12 +1210,240 @@ return Array.from(out); } + // openTarget is where a file or link node leads: the link the server put + // on its card. + function openTarget(id) { + var a = els[id] && els[id].querySelector('.canvas-node-label a, a.canvas-card, a.canvas-media'); + return a ? a.href : null; + } + + // === Context menus === + + var menu = null; + + // openMenu shows items at a client point. An item is '-' (a separator), + // { label, run, danger, disabled }, { colors: current, run(color) } or + // { row: label, items: [{ label, run }] }. + function openMenu(clientX, clientY, items) { + closeMenu(); + var m = el('div', 'canvas-menu panel panel-floating'); + m.setAttribute('role', 'menu'); + var last = '-'; + items.forEach(function (item) { + if (!item || (item === '-' && last === '-')) return; + last = item; + if (item === '-') { + m.appendChild(el('div', 'canvas-menu-sep')); + } else if (item.colors !== undefined) { + m.appendChild(colorRow(item.colors, item.run)); + } else if (item.row) { + var r = el('div', 'canvas-menu-row'); + r.appendChild(el('span', 'muted', item.row)); + item.items.forEach(function (sub) { r.appendChild(menuButton(sub, 'btn btn-tool')); }); + m.appendChild(r); + } else { + m.appendChild(menuButton(item, 'btn btn-tool btn-block' + (item.danger ? ' danger' : ''))); + } + }); + if (last === '-' && m.lastChild) m.lastChild.remove(); + if (!m.firstChild) return; + app.appendChild(m); + var x = clamp(clientX, 4, window.innerWidth - m.offsetWidth - 4); + var y = clientY + m.offsetHeight > window.innerHeight - 4 ? clientY - m.offsetHeight : clientY; + m.style.left = x + 'px'; + m.style.top = clamp(y, 4, window.innerHeight - m.offsetHeight - 4) + 'px'; + menu = m; + var first = m.querySelector('button:not(:disabled)'); + if (first) first.focus({ preventScroll: true }); + } + + function menuButton(item, cls) { + var b = el('button', cls, item.label); + b.type = 'button'; + b.setAttribute('role', 'menuitem'); + b.disabled = !!item.disabled; + if (item.title) b.title = item.title; + b.addEventListener('click', function () { + closeMenu(); + item.run(); + }); + return b; + } + + // colorRow is the six presets, none, and a picker for any other color. + // current is the color to mark ('' for none), null for no mark. + function colorRow(current, run) { + var r = el('div', 'canvas-menu-row canvas-menu-colors'); + r.appendChild(el('span', 'muted', 'Color')); + function swatch(value, name) { + var b = el('button', 'canvas-swatch' + (current === value ? ' is-current' : '')); + b.type = 'button'; + b.title = name; + b.setAttribute('aria-label', name); + if (value) b.setAttribute('data-color', value); + b.addEventListener('click', function () { + closeMenu(); + run(value); + }); + return b; + } + r.appendChild(swatch('', 'None')); + COLORS.forEach(function (name, i) { r.appendChild(swatch(String(i + 1), name)); }); + var custom = el('input', 'canvas-swatch canvas-swatch-custom' + (/^#/.test(current || '') ? ' is-current' : '')); + custom.type = 'color'; + custom.title = 'Custom color'; + custom.value = /^#[0-9a-fA-F]{6}$/.test(current || '') ? current : '#c48401'; + custom.addEventListener('change', function () { + closeMenu(); + run(custom.value); + }); + r.appendChild(custom); + return r; + } + + function closeMenu() { + if (!menu) return; + menu.remove(); + menu = null; + } + + document.addEventListener('pointerdown', function (e) { + if (menu && !menu.contains(e.target)) closeMenu(); + }, true); + window.addEventListener('resize', closeMenu); + window.addEventListener('blur', closeMenu); + + // contextMenu opens the menu for whatever is at target: a node, an edge + // or the bare canvas. + function contextMenu(clientX, clientY, target) { + if (modalOpen()) return; + var nodeEl = target.closest && target.closest('.canvas-node'); + var edgeEl = target.closest && target.closest('.canvas-edge, .canvas-edge-label'); + if (nodeEl && byId[nodeEl.dataset.id]) { + var id = nodeEl.dataset.id; + if (!selected.has(id)) select([id]); + openMenu(clientX, clientY, nodeMenu(byId[id])); + } else if (edgeEl && edgeById(edgeEl.dataset.id)) { + select([], [edgeEl.dataset.id]); + openMenu(clientX, clientY, edgeMenu(edgeById(edgeEl.dataset.id))); + } else { + openMenu(clientX, clientY, backgroundMenu(toWorld(clientX, clientY))); + } + } + + // backgroundMenu: everything that can be added, landing at p. + function backgroundMenu(p) { + var view = [ + { label: 'Zoom to fit', run: function () { fit(); } }, + { label: 'Actual size', run: function () { zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, 1); } }, + ]; + if (readOnly) return view; + return [ + { label: 'Text', run: function () { addText(p); } }, + { label: 'File…', run: function () { addFile(p); } }, + { label: 'Link', run: function () { addLink(p); } }, + '-', + { label: 'Group', run: function () { addGroup(p); } }, + '-', + { label: 'Undo', run: undo, disabled: !undoStack.length }, + { label: 'Redo', run: redo, disabled: !redoStack.length }, + { label: 'Select all', run: function () { select(doc.nodes.map(function (n) { return n.id; })); }, disabled: !doc.nodes.length }, + '-', + ].concat(view); + } + + // nodeMenu: what can be done to the selection, which n is part of. + function nodeMenu(n) { + var count = selected.size; + var single = count <= 1; + var target = (n.type === 'file' || n.type === 'link') && openTarget(n.id); + var open = target && { + label: n.type === 'link' ? 'Open in new tab' : 'Open', + run: function () { + if (n.type === 'link') window.open(target, '_blank', 'noopener'); + else window.location.href = target; + }, + }; + if (readOnly) return single && open ? [open] : []; + var kind = []; + if (single) { + if (open) kind.push(open); + if (n.type === 'file') { + kind.push({ label: 'Change file…', run: function () { + pickFile('Pick a file or page', function (rel) { + mutate(function () { n.file = rel; delete n.subpath; }); + }); + } }); + } + if (n.type === 'group') { + kind.push({ label: 'Background image…', run: function () { + pickFile('Background image', function (rel) { mutate(function () { n.background = rel; }); }); + } }); + kind.push({ label: 'Clear background', disabled: !n.background, run: function () { + mutate(function () { delete n.background; }); + } }); + } + } + var canFit = selectedNodes().some(function (x) { return fitHeight(x) !== null; }); + return [ + single && { label: 'Edit…', run: function () { editNode(n.id); } }, + ].concat(kind, [ + { label: 'Fit to text', run: fitSelection, disabled: !canFit, title: 'Size the card to show all of its text, and no more' }, + '-', + { colors: single ? n.color || '' : null, run: function (c) { paint(selected, new Set(), c); } }, + '-', + { label: 'Duplicate', run: duplicateSelection }, + { label: 'Put in a group', run: function () { addGroup(null, true); } }, + { label: 'Bring to front', run: function () { raise(true); } }, + { label: 'Send to back', run: function () { raise(false); } }, + count >= 2 && { row: 'Align', items: [ + { label: 'LEFT', run: function () { align('left'); } }, + { label: 'CENTRE', run: function () { align('centre'); } }, + { label: 'RIGHT', run: function () { align('right'); } }, + ] }, + count >= 2 && { row: '', items: [ + { label: 'TOP', run: function () { align('top'); } }, + { label: 'MIDDLE', run: function () { align('middle'); } }, + { label: 'BOTTOM', run: function () { align('bottom'); } }, + ] }, + '-', + { label: single ? 'Delete' : 'Delete ' + count + ' nodes', run: deleteSelection, danger: true }, + ]); + } + + function edgeMenu(e) { + if (readOnly) return []; + return [ + { label: 'Edit…', run: function () { editEdge(e.id); }, title: 'Label, the sides it meets, and the shape of its ends' }, + '-', + { colors: e.color || '', run: function (c) { paint(new Set(), new Set([e.id]), c); } }, + { label: 'Reverse direction', run: function () { reverseEdge(e); } }, + '-', + { label: 'Delete edge', run: deleteSelection, danger: true }, + ]; + } + + // The browser's own menu never shows over the canvas or ours. Where it + // comes on mouseup (Windows), its event lands on our menu, which was + // just opened under the pointer, rather than on the viewport. Fields in + // the edit window keep it, for spelling and paste. + document.addEventListener('contextmenu', function (e) { + if (viewport.contains(e.target) || (menu && menu.contains(e.target))) e.preventDefault(); + }); + // === Pointer input === var gesture = null; var pointers = new Map(); var spaceDown = false; var suppressClick = false; + var pressTimer = null; + var lastPointer = null; // world point of the mouse, where pastes land + + function cancelPress() { + clearTimeout(pressTimer); + pressTimer = null; + } function startPinch() { var pts = Array.from(pointers.values()); @@ -872,29 +1458,44 @@ } viewport.addEventListener('pointerdown', function (e) { - if (editing && editing.el.contains(e.target)) return; pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + cancelPress(); if (pointers.size === 2) { if (gesture && gesture.before) restore(gesture.before); startPinch(); return; } if (pointers.size > 2) return; - commitEdit(); + settle(); viewport.focus({ preventScroll: true }); var base = { startX: e.clientX, startY: e.clientY, moved: false }; var nodeEl = e.target.closest('.canvas-node'); var edgeEl = e.target.closest('.canvas-edge, .canvas-edge-label'); var additive = e.shiftKey || e.ctrlKey || e.metaKey; + var touch = e.pointerType !== 'mouse'; - if (e.button === 1 || (e.button === 0 && spaceDown)) { + // Middle and right drag pan; a right click that does not move opens + // the menu (endPointer). + if (e.button === 1 || e.button === 2 || (e.button === 0 && spaceDown)) { e.preventDefault(); - gesture = Object.assign(base, { type: 'pan', cam: Object.assign({}, cam) }); + gesture = Object.assign(base, { type: 'pan', menu: e.button === 2, target: e.target, cam: Object.assign({}, cam) }); return; } if (e.button !== 0) return; + // A long press on touch opens the menu of what is under the finger. + if (touch && !e.target.closest('.canvas-handle, .canvas-resize')) { + var target = e.target; + pressTimer = setTimeout(function () { + pressTimer = null; + if (!gesture || gesture.moved || pointers.size !== 1) return; + gesture = null; + suppressClick = true; + contextMenu(e.clientX, e.clientY, target); + }, LONG_PRESS); + } + if (nodeEl && !readOnly && e.target.closest('.canvas-handle')) { e.preventDefault(); gesture = Object.assign(base, { type: 'connect', from: nodeEl.dataset.id, side: e.target.dataset.side }); @@ -911,7 +1512,10 @@ var wasSelected = selected.has(id); if (additive) toggleSelected(id); else if (!wasSelected) select([id]); - if (readOnly || (additive && wasSelected)) return; + if (readOnly || (additive && wasSelected)) { + gesture = Object.assign(base, { type: 'none' }); + return; + } var ids = movedSet(Array.from(selected)); var origins = {}; ids.forEach(function (i) { origins[i] = { x: byId[i].x, y: byId[i].y }; }); @@ -927,22 +1531,26 @@ } else { select([], [eid]); } + gesture = Object.assign(base, { type: 'none' }); return; } - // The background. - if (e.shiftKey) { + // The background: a mouse selects with a rubber band, a finger pans. + if (touch) { + if (!additive) select([]); + gesture = Object.assign(base, { type: 'pan', cam: Object.assign({}, cam) }); + } else { + var keep = additive ? Array.from(selected) : []; + select(keep, additive ? Array.from(selectedEdges) : []); var box = document.createElement('div'); box.className = 'canvas-marquee'; viewport.appendChild(box); - gesture = Object.assign(base, { type: 'marquee', box: box, keep: Array.from(selected) }); - } else { - if (!additive) select([]); - gesture = Object.assign(base, { type: 'pan', cam: Object.assign({}, cam) }); + gesture = Object.assign(base, { type: 'marquee', box: box, keep: keep }); } }); window.addEventListener('pointermove', function (e) { if (pointers.has(e.pointerId)) pointers.set(e.pointerId, { x: e.clientX, y: e.clientY }); + if (e.pointerType === 'mouse' && viewport.contains(e.target)) lastPointer = toWorld(e.clientX, e.clientY); if (!gesture) return; if (gesture.type === 'pinch') { if (pointers.size < 2) return; @@ -957,8 +1565,9 @@ } var dxs = e.clientX - gesture.startX, dys = e.clientY - gesture.startY; if (!gesture.moved) { - if (Math.hypot(dxs, dys) < 4) return; + if (Math.hypot(dxs, dys) < (e.pointerType === 'mouse' ? 4 : 8)) return; gesture.moved = true; + cancelPress(); if (gesture.type === 'pan') viewport.classList.add('is-panning'); } var dx = dxs / cam.z, dy = dys / cam.z; @@ -1005,6 +1614,7 @@ function endPointer(e) { pointers.delete(e.pointerId); + cancelPress(); var g = gesture; if (!g) return; if (g.type === 'pinch') { @@ -1015,6 +1625,9 @@ viewport.classList.remove('is-panning'); if (g.moved) suppressClick = true; switch (g.type) { + case 'pan': + if (g.menu && !g.moved && e.type === 'pointerup') contextMenu(e.clientX, e.clientY, g.target); + break; case 'move': case 'resize': if (g.moved) { @@ -1040,8 +1653,9 @@ edge.toSide = target.classList.contains('canvas-handle') ? target.dataset.side : sideToward(to, p); mutate(function () { doc.edges.push(edge); }); select([], [edge.id]); - } else if (!toEl) { + } else if (!toEl && viewport.contains(target)) { // Dropped on empty space: a new note there, connected. + closeWindow(); var before = snapshot(); var n = { id: newId(), type: 'text', text: '', width: 260, height: 80 }; // Its side facing back sits at the drop point. @@ -1054,9 +1668,9 @@ doc.nodes.push(n); doc.edges.push(edge); syncDOM(); - startEdit(n.id, true); - editing.before = before; + select([n.id]); scheduleSave(); + editNode(n.id, true, before); } else { renderEdges(); } @@ -1083,11 +1697,11 @@ // Native drag of images and links would swallow the pointer moves. viewport.addEventListener('dragstart', function (e) { e.preventDefault(); }); + // The wheel zooms around the pointer, anywhere: cards crop what they + // cannot show rather than scroll. viewport.addEventListener('wheel', function (e) { - if (editing && editing.el.contains(e.target)) return; - var body = e.target.closest('.canvas-node-body'); - if (body && !e.ctrlKey && body.scrollHeight > body.clientHeight + 1) return; e.preventDefault(); + closeMenu(); var dy = e.deltaY * (e.deltaMode === 1 ? 16 : e.deltaMode === 2 ? 400 : 1); if (e.shiftKey) { cam.x -= dy; @@ -1098,8 +1712,15 @@ zoomAt(p.x, p.y, cam.z * Math.exp(-dy * (e.ctrlKey ? 0.01 : 0.0015))); }, { passive: false }); + // A double click of the wheel button is back to 100%. + viewport.addEventListener('auxclick', function (e) { + if (e.button !== 1 || e.detail !== 2) return; + var p = viewportPoint(e.clientX, e.clientY); + zoomAt(p.x, p.y, 1); + }); + viewport.addEventListener('dblclick', function (e) { - if (readOnly || (editing && editing.el.contains(e.target))) return; + if (readOnly) return; var nodeEl = e.target.closest('.canvas-node'); var edgeEl = e.target.closest('.canvas-edge, .canvas-edge-label'); if (nodeEl) { @@ -1107,7 +1728,7 @@ e.preventDefault(); editNode(nodeEl.dataset.id); } else if (edgeEl) { - editEdgeLabel(edgeEl.dataset.id); + editEdge(edgeEl.dataset.id); } else { addText(toWorld(e.clientX, e.clientY)); } @@ -1150,59 +1771,91 @@ return null; } - // === Keyboard === + // === Keyboard, clipboard === function typingInField(e) { var t = e.target; return t && (t.tagName === 'INPUT' || t.tagName === 'TEXTAREA' || t.tagName === 'SELECT' || t.isContentEditable); } + // ignored is true for keys and clipboard events meant for something else: + // a field, an edit window, a menu or a modal. + function ignored(e) { + return modalOpen() || typingInField(e) || inWindow(e); + } + // Capture phase, so the page shortcuts (global-shortcuts.js) don't see // the ones that mean something else here: E edits the selected node, M // (move page) does not apply to a canvas. window.addEventListener('keydown', function (e) { - if (!e.altKey || !e.shiftKey || modalOpen()) return; + if (!e.altKey || !e.shiftKey || modalOpen() || inWindow(e)) return; if (e.key === 'E') { e.preventDefault(); e.stopPropagation(); - if (!editing && selected.size === 1) editNode(Array.from(selected)[0]); + editSelected(); } else if (e.key === 'M') { e.stopPropagation(); } }, true); + function editSelected() { + if (selected.size === 1 && !selectedEdges.size) editNode(Array.from(selected)[0]); + else if (selectedEdges.size === 1 && !selected.size) editEdge(Array.from(selectedEdges)[0]); + } + document.addEventListener('keydown', function (e) { if (e.key === ' ' && !typingInField(e)) spaceDown = true; - if (editing || modalOpen() || typingInField(e) || readOnly) return; + if (e.key === 'Escape' && menu) { + closeMenu(); + viewport.focus({ preventScroll: true }); + return; + } + if (ignored(e)) return; var mod = e.ctrlKey || e.metaKey; - var step = e.shiftKey ? 1 : GRID; + var key = e.key.toLowerCase(); + var step = e.shiftKey ? 10 : 1; + // Looking around works on a read-only canvas too. + switch (true) { + case e.key === 'Escape': + if (menu) closeMenu(); + else if (win) closeWindow(); + else select([]); + return; + case mod && key === '0': e.preventDefault(); fit(); return; + case mod && key === '1': e.preventDefault(); zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, 1); return; + case mod && (key === '=' || key === '+'): e.preventDefault(); zoomCenter(1.25); return; + case mod && key === '-': e.preventDefault(); zoomCenter(0.8); return; + case !mod && !e.altKey && key === 'f': e.preventDefault(); zoomToSelection(); return; + case mod && key === 'a': + e.preventDefault(); + select(doc.nodes.map(function (n) { return n.id; })); + return; + } + if (readOnly) return; switch (true) { case e.key === 'Delete' || e.key === 'Backspace': e.preventDefault(); deleteSelection(); break; - case mod && !e.shiftKey && e.key.toLowerCase() === 'z': + case mod && !e.shiftKey && key === 'z': e.preventDefault(); undo(); break; - case mod && (e.key.toLowerCase() === 'y' || (e.shiftKey && e.key.toLowerCase() === 'z')): + case mod && (key === 'y' || (e.shiftKey && key === 'z')): e.preventDefault(); redo(); break; - case mod && e.key.toLowerCase() === 'd': + case mod && key === 'd': e.preventDefault(); duplicateSelection(); break; - case mod && e.key.toLowerCase() === 'a': + case mod && key === 'g': e.preventDefault(); - select(doc.nodes.map(function (n) { return n.id; })); + if (selected.size) addGroup(null, true); break; - case e.key === 'Enter' && selected.size === 1: + case e.key === 'F2' || (e.key === 'Enter' && !mod): e.preventDefault(); - editNode(Array.from(selected)[0]); - break; - case e.key === 'Escape': - select([]); + editSelected(); break; case e.key === 'ArrowLeft': e.preventDefault(); nudge(-step, 0); break; case e.key === 'ArrowRight': e.preventDefault(); nudge(step, 0); break; @@ -1214,40 +1867,32 @@ if (e.key === ' ') spaceDown = false; }); - // === Toolbar === - - var commands = { - text: function () { addText(); }, - file: addFile, - link: addLink, - group: addGroup, - color: function (btn) { setSelectionColor(btn.dataset.value); }, - ends: function (btn) { setEnds(btn.dataset.value); }, - undo: undo, - redo: redo, - 'delete': deleteSelection, - zoomin: function () { zoomCenter(1.25); }, - zoomout: function () { zoomCenter(0.8); }, - zoomreset: function () { zoomAt(viewport.clientWidth / 2, viewport.clientHeight / 2, 1); }, - fit: fit, - }; - - app.querySelectorAll('[data-canvas]').forEach(function (btn) { - btn.addEventListener('click', function () { - commitEdit(); - commands[btn.dataset.canvas](btn); - }); - }); - app.querySelectorAll('.canvas-bar .dropdown-toggle').forEach(wireDropdown); - var colorInput = app.querySelector('.canvas-color-input'); - if (colorInput) colorInput.addEventListener('change', function () { setSelectionColor(colorInput.value); }); - - if (window.MarkdownEditor) { - MarkdownEditor.bind(nodeToolbar, function () { return editing && editing.editor.actions; }); + // A copy goes out as JSON Canvas, so it pastes back as nodes, here or in + // another canvas. + function copy(e) { + if (ignored(e) || !selected.size) return false; + e.clipboardData.setData('text/plain', JSON.stringify(clip(), null, '\t')); + e.preventDefault(); + return true; } + document.addEventListener('copy', copy); + document.addEventListener('cut', function (e) { + if (!readOnly && copy(e)) deleteSelection(); + }); + document.addEventListener('paste', function (e) { + if (readOnly || ignored(e)) return; + var text = e.clipboardData.getData('text/plain'); + if (!text) return; + e.preventDefault(); + paste(text, lastPointer || viewCenter()); + }); // === Start === + if (window.MarkdownEditor && editorToolbar) { + MarkdownEditor.bind(editorToolbar, function () { return win && win.editor && win.editor.actions; }); + } + reindex(); nodesEl.querySelectorAll('.canvas-node').forEach(function (el) { var n = byId[el.dataset.id]; diff --git a/assets/canvas/main.html b/assets/canvas/main.html index 0854746..9f70277 100644 --- a/assets/canvas/main.html +++ b/assets/canvas/main.html @@ -13,61 +13,18 @@ navigation and canvas.js can save on pagehide. */}} {{define "shell"}}
-
- {{.Title}} - {{if not .Error}} - - - - - - - - - - - - - - - - - - - - - - - - - - - {{end}} -
{{if .Error}}

This canvas cannot be edited: {{.Error}}

{{end}}
-
{{range .Nodes}}{{template "canvas-node" .}}{{end}}
-

Double-click to add a note

+

Double-click to add a note. Right-click or long-press for more.

+
+ {{if not .Error}}{{end}}
diff --git a/assets/canvas/node.html b/assets/canvas/node.html index 6e43b02..4653202 100644 --- a/assets/canvas/node.html +++ b/assets/canvas/node.html @@ -13,7 +13,7 @@ units; canvas.js keeps style and data-color in sync while editing. */}} {{define "canvas-file"}}{{.Icon}}{{.Name}}{{end}} -{{define "canvas-link"}}{{.Host}}{{.URL}}{{end}} +{{define "canvas-link"}}{{if .Label}}{{.Label}}{{.Host}}{{else}}{{.Host}}{{.URL}}{{end}}{{end}} {{define "canvas-missing"}}

Missing: {{.}}

{{end}} diff --git a/assets/editor/core.js b/assets/editor/core.js index 7345508..325594a 100644 --- a/assets/editor/core.js +++ b/assets/editor/core.js @@ -6,6 +6,7 @@ // MarkdownEditor.create(mount, opts) → { view, actions, getValue, focus, destroy } // opts.doc initial text // opts.keymap extra CM key bindings, ahead of the defaults +// opts.onChange called after every change to the text // MarkdownEditor.bind(root, getActions) // wires root's [data-action] buttons, [data-key] shortcuts and toolbar // dropdowns to whatever getActions() returns (null = no editor open). @@ -89,6 +90,9 @@ window.MarkdownEditor = (function () { CM.closeBrackets(), CM.autocompletion({ override: [WikiComplete.source] }), CM.theme, + opts.onChange ? CM.EditorView.updateListener.of(function (u) { + if (u.docChanged) opts.onChange(); + }) : [], CM.Prec.highest(CM.keymap.of(tableKeymap)), CM.keymap.of([].concat( CM.closeBracketsKeymap, diff --git a/assets/style.css b/assets/style.css index e20256d..9d1ee46 100644 --- a/assets/style.css +++ b/assets/style.css @@ -950,51 +950,31 @@ label.tree-name { flex: 1; cursor: pointer; position: relative; } 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-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. */ +/* The stage holds the viewport and what floats over it: the status line, + the context menu and the edit window. */ .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-statusbar { + position: absolute; + left: var(--space-2); + bottom: var(--space-2); + z-index: 10; + display: flex; + gap: var(--space-3); + padding: 0 var(--space-2); + background: var(--bg); + pointer-events: none; +} +.canvas-status.is-error { color: var(--danger); pointer-events: auto; } +.canvas-editor-toolbar[hidden] { display: none; } .canvas-viewport { position: absolute; inset: 0; overflow: hidden; touch-action: none; - cursor: grab; outline: none; + -webkit-touch-callout: none; background-image: radial-gradient(circle, var(--bg-panel-hover) 1px, transparent 1.5px); } .canvas-viewport.is-far { background-image: none; } @@ -1066,7 +1046,7 @@ label.tree-name { flex: 1; cursor: pointer; position: relative; } .canvas-node-body { flex: 1 1 auto; min-height: 0; - overflow: auto; + overflow: hidden; padding: var(--space-2) var(--space-3); word-break: break-word; } @@ -1097,13 +1077,6 @@ audio.canvas-media { height: auto; } .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 { @@ -1132,8 +1105,6 @@ audio.canvas-media { height: auto; } @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. */ @@ -1169,6 +1140,99 @@ audio.canvas-media { height: auto; } } .canvas-edge-label.is-selected { color: var(--primary-hover); } +/* Context menu (canvas.js openMenu): fixed at the pointer. */ +.canvas-menu { + position: fixed; + z-index: 100; + min-width: 12rem; + padding: var(--space-1) 0; + font-size: var(--font-sm); +} +.canvas-menu .btn-block { padding: 0.3rem var(--space-3); } +.canvas-menu .btn:disabled { color: var(--text-muted); cursor: default; } +.canvas-menu-sep { border-top: var(--border-dashed); margin: var(--space-1) 0; } +.canvas-menu-row { + display: flex; + align-items: center; + gap: var(--space-2); + padding: 0.3rem var(--space-3); +} +.canvas-menu-row > .muted { min-width: 3rem; } +.canvas-swatch { + width: 1.1rem; + height: 1.1rem; + padding: 0; + border: var(--border); + background: var(--node-color, transparent); + cursor: pointer; +} +.canvas-swatch:not([data-color]):not(input) { + background: linear-gradient(to top right, transparent 45%, var(--text-muted) 45%, var(--text-muted) 55%, transparent 55%); +} +.canvas-swatch.is-current { outline: 2px solid var(--primary-hover); outline-offset: 1px; } +input.canvas-swatch { background: none; } + +/* Edit window (canvas.js openWindow): not modal, placed beside its card; + small screens dock it to the bottom. */ +.canvas-window { + position: absolute; + z-index: 20; + width: 24rem; + max-width: calc(100% - 2rem); + max-height: calc(100% - 2rem); + display: flex; + flex-direction: column; + box-shadow: 0 0.5rem 1.5rem rgba(0, 0, 0, 0.4); +} +.canvas-window.is-wide { width: 34rem; height: 24rem; resize: both; overflow: hidden; min-width: 18rem; min-height: 12rem; } +.canvas-window .panel-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--space-2); + font-size: var(--font-sm); + color: var(--text); + padding: 0.4rem var(--space-4); + margin-bottom: 0; + cursor: move; + user-select: none; +} +.canvas-window .panel-body { flex: 1 1 auto; min-height: 0; overflow: auto; } +.canvas-window .panel-footer { justify-content: flex-end; } +.canvas-window .input { background: var(--bg); } +.canvas-field { display: flex; flex-direction: column; gap: var(--space-1); min-width: 0; } +.canvas-field-label { font-size: var(--font-xs); text-transform: uppercase; letter-spacing: 0.05em; color: var(--text-muted); } +.canvas-field-row { display: flex; align-items: baseline; gap: var(--space-2); min-width: 0; } +.canvas-field-row > :first-child { flex: 1 1 auto; min-width: 0; } +.canvas-field-pair { display: grid; grid-template-columns: 1fr 1fr; gap: var(--space-3); } +.canvas-fields { display: flex; flex-direction: column; gap: var(--space-3); } +.canvas-window.is-wide .panel-body { padding: 0; } +.canvas-window-editor { flex: 1 1 auto; min-height: 0; display: flex; flex-direction: column; } +.canvas-window-editor .editor-toolbar { position: static; border-left: none; border-right: none; border-top: none; } +.canvas-window-cm { flex: 1 1 auto; min-height: 0; display: flex; } +.canvas-window-cm .cm-editor { flex: 1 1 auto; min-width: 0; border: none; } +.canvas-window-cm .cm-editor .cm-scroller { min-height: 0; } +.canvas-window-cm .cm-editor .cm-content { padding: var(--space-2) var(--space-3); } +@media (max-width: 600px) { + .canvas-window, .canvas-window.is-wide { + position: fixed; + left: 0; + right: 0; + bottom: 0; + top: auto; + width: auto; + max-width: none; + height: auto; + max-height: 60vh; + resize: none; + padding-bottom: env(safe-area-inset-bottom); + } + .canvas-window.is-wide { height: 50vh; } + .canvas-window .panel-header { cursor: default; } + .canvas-menu .btn-block { min-height: 2.5rem; align-items: center; } + .canvas-swatch { width: 1.6rem; height: 1.6rem; } +} + /* === Responsive === */ @media (max-width: 1100px) { .tree-sidebar, .sidebar { display: none; } diff --git a/canvas.go b/canvas.go index f8136c1..9311480 100644 --- a/canvas.go +++ b/canvas.go @@ -227,6 +227,7 @@ type canvasCardBody struct { Icon template.HTML Name, URL string Host string + Label string // a link's display text, shown instead of its URL } type canvasGroupBody struct { @@ -255,7 +256,7 @@ func (h *handler) renderCanvasNode(n canvasNode) canvasNodeView { if u, err := url.Parse(n.URL); err == nil && u.Host != "" { host = u.Host } - v.Body = execNodeTemplate("canvas-link", canvasCardBody{Name: n.URL, URL: n.URL, Host: host}) + v.Body = execNodeTemplate("canvas-link", canvasCardBody{Name: n.URL, URL: n.URL, Host: host, Label: strings.TrimSpace(n.Label)}) case "group": v.Label = n.Label if n.Background != "" { diff --git a/canvas_test.go b/canvas_test.go index 362d841..48c0c65 100644 --- a/canvas_test.go +++ b/canvas_test.go @@ -209,6 +209,10 @@ func TestCanvasRenderNodes(t *testing.T) { if strings.Contains(link, `href="javascript:`) { t.Errorf("unsafe link rendered:\n%s", link) } + named := render(`{"id":"a","type":"link","url":"https://jsoncanvas.org/spec/1.0/","label":"The spec","x":0,"y":0,"width":1,"height":1}`) + if !strings.Contains(named, `The specjsoncanvas.org`) || !strings.Contains(named, `title="https://jsoncanvas.org/spec/1.0/"`) { + t.Errorf("link with display text:\n%s", named) + } group := render(`{"id":"a","type":"group","label":"G","color":"#abc","x":0,"y":0,"width":1,"height":1}`) if !strings.Contains(group, "<b>G</b>") || !strings.Contains(group, "--node-color: #abc") { t.Errorf("group node:\n%s", group)