// 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; } }; })();