205 lines
7.0 KiB
JavaScript
205 lines
7.0 KiB
JavaScript
// edit.js — editing a node's content.
|
|
//
|
|
// Text nodes get the vendored CodeMirror editor in place, positioned over the
|
|
// node in *screen* coordinates rather than mounted inside the zoomed world.
|
|
// CodeMirror measures its own layout in device pixels, so a CSS-scaled parent
|
|
// wrecks caret placement and selection maths; a fixed-position panel sidesteps
|
|
// that entirely and stays sharp at every zoom level.
|
|
//
|
|
// The other node types have nothing to type into, so they reuse the existing
|
|
// tree picker and modal instead of an editor.
|
|
window.Canvas = window.Canvas || {};
|
|
|
|
Canvas.Edit = (function () {
|
|
var Doc = Canvas.Doc;
|
|
var View = Canvas.View;
|
|
|
|
var MIN_WIDTH = 360;
|
|
var MIN_HEIGHT = 200;
|
|
|
|
var panel = null;
|
|
var view = null;
|
|
var currentId = null;
|
|
var original = '';
|
|
|
|
function destroy() {
|
|
if (view) { view.destroy(); view = null; }
|
|
if (panel) { panel.remove(); panel = null; }
|
|
currentId = null;
|
|
original = '';
|
|
document.removeEventListener('pointerdown', onOutside, true);
|
|
}
|
|
|
|
function commit() {
|
|
if (!panel || !view) return;
|
|
var text = view.state.doc.toString();
|
|
var id = currentId;
|
|
destroy();
|
|
var n = Doc.node(id);
|
|
if (!n || text === original) return;
|
|
Doc.begin('edit text');
|
|
n.text = text;
|
|
Doc.commit();
|
|
}
|
|
|
|
function onOutside(e) {
|
|
if (!panel || panel.contains(e.target)) return;
|
|
commit();
|
|
}
|
|
|
|
// screenRect places the editor over the node it edits, clamped to a usable
|
|
// minimum and kept inside the viewport — a 60x60 node still needs a panel
|
|
// big enough to type in.
|
|
function screenRect(n) {
|
|
var s = View.scale();
|
|
var tl = View.toScreen(n.x, n.y);
|
|
var w = Math.max(MIN_WIDTH, n.width * s);
|
|
var h = Math.max(MIN_HEIGHT, n.height * s);
|
|
w = Math.min(w, window.innerWidth - 16);
|
|
h = Math.min(h, window.innerHeight - 16);
|
|
var left = Math.max(8, Math.min(tl.x, window.innerWidth - w - 8));
|
|
var top = Math.max(8, Math.min(tl.y, window.innerHeight - h - 8));
|
|
return { left: left, top: top, width: w, height: h };
|
|
}
|
|
|
|
function openText(n) {
|
|
var rect = screenRect(n);
|
|
panel = document.createElement('div');
|
|
panel.className = 'canvas-editor panel panel-floating';
|
|
panel.style.left = rect.left + 'px';
|
|
panel.style.top = rect.top + 'px';
|
|
panel.style.width = rect.width + 'px';
|
|
panel.style.height = rect.height + 'px';
|
|
|
|
var hint = document.createElement('div');
|
|
hint.className = 'panel-header';
|
|
hint.textContent = 'Ctrl+Enter or click away to apply · Escape to cancel';
|
|
panel.appendChild(hint);
|
|
|
|
var mount = document.createElement('div');
|
|
mount.className = 'canvas-editor-mount';
|
|
panel.appendChild(mount);
|
|
document.body.appendChild(panel);
|
|
|
|
currentId = n.id;
|
|
original = n.text || '';
|
|
|
|
view = new CM.EditorView({
|
|
state: CM.EditorState.create({
|
|
doc: original,
|
|
selection: { anchor: original.length },
|
|
extensions: [
|
|
CM.history(),
|
|
CM.drawSelection(),
|
|
CM.indentOnInput(),
|
|
CM.EditorView.lineWrapping,
|
|
CM.EditorView.contentAttributes.of({ spellcheck: 'true', autocapitalize: 'sentences' }),
|
|
CM.markdown({ base: CM.markdownLanguage }),
|
|
CM.syntaxHighlighting(CM.highlightStyle),
|
|
CM.closeBrackets(),
|
|
CM.autocompletion({ override: [WikiComplete.source] }),
|
|
CM.theme,
|
|
// Mod-Enter goes first (nothing else claims it); Escape goes
|
|
// last so an open completion popup still gets first refusal
|
|
// on it and closes itself instead of discarding the edit.
|
|
CM.keymap.of([{ key: 'Mod-Enter', run: function () { commit(); return true; } }]
|
|
.concat(
|
|
CM.closeBracketsKeymap,
|
|
CM.completionKeymap,
|
|
CM.markdownKeymap,
|
|
CM.defaultKeymap,
|
|
CM.historyKeymap,
|
|
[CM.indentWithTab],
|
|
[{ key: 'Escape', run: function () { destroy(); return true; } }]
|
|
))
|
|
]
|
|
}),
|
|
parent: mount
|
|
});
|
|
view.focus();
|
|
// Capture phase: a click on a toolbar button should apply the edit
|
|
// before that button's own handler reads the document.
|
|
document.addEventListener('pointerdown', onOutside, true);
|
|
}
|
|
|
|
function openFile(n) {
|
|
var start = (n.file || '/').replace(/\/+$/, '') || '/';
|
|
openTreePicker({
|
|
title: 'File node — pick a target',
|
|
mode: 'any',
|
|
initialPath: start,
|
|
preselect: start,
|
|
confirmLabel: 'USE',
|
|
onSelect: function (p) {
|
|
if (!p || p === n.file) return;
|
|
Doc.begin('retarget file node');
|
|
n.file = p;
|
|
n.subpath = '';
|
|
Doc.commit();
|
|
}
|
|
});
|
|
}
|
|
|
|
function promptValue(title, label, value, onValue) {
|
|
var input = document.createElement('input');
|
|
input.type = 'text';
|
|
input.className = 'input';
|
|
input.placeholder = label;
|
|
input.value = value || '';
|
|
openModal({
|
|
title: title,
|
|
body: input,
|
|
confirm: {
|
|
label: 'OK',
|
|
onConfirm: function () {
|
|
closeModal();
|
|
onValue(input.value.trim());
|
|
}
|
|
}
|
|
});
|
|
}
|
|
|
|
function openLink(n) {
|
|
promptValue('Link node', 'https://example.com', n.url, function (v) {
|
|
if (!v || v === n.url) return;
|
|
Doc.begin('edit link');
|
|
n.url = v;
|
|
Doc.commit();
|
|
});
|
|
}
|
|
|
|
function openGroup(n) {
|
|
promptValue('Group label', 'Label', n.label, function (v) {
|
|
if (v === (n.label || '')) return;
|
|
Doc.begin('edit group label');
|
|
if (v) n.label = v; else delete n.label;
|
|
Doc.commit();
|
|
});
|
|
}
|
|
|
|
// close applies whatever is currently open, if anything.
|
|
function close() {
|
|
if (panel) commit();
|
|
}
|
|
|
|
return {
|
|
open: function (id) {
|
|
var n = Doc.node(id);
|
|
if (!n) return;
|
|
close();
|
|
switch (n.type) {
|
|
case 'text': openText(n); break;
|
|
case 'file': openFile(n); break;
|
|
case 'link': openLink(n); break;
|
|
case 'group': openGroup(n); break;
|
|
}
|
|
},
|
|
|
|
// Called from the gesture layer on any pointerdown on the surface, so
|
|
// starting a drag never silently drops an edit.
|
|
close: close,
|
|
|
|
isOpen: function () { return panel !== null; }
|
|
};
|
|
})();
|