Canvas support V1
This commit is contained in:
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,83 @@
|
||||
{{/* The canvas view (canvas.go). It fills the whole shell: no folder tree,
|
||||
no sidebar, no footer. Links are not boosted, so leaving the canvas is a full
|
||||
navigation and canvas.js can save on pagehide. */}}
|
||||
|
||||
{{define "boost"}} hx-boost="false"{{end}}
|
||||
|
||||
{{define "page-actions"}}<div id="page-actions" class="page-actions">{{template "headerActions" .}}</div>{{end}}
|
||||
|
||||
{{define "headerActions"}}
|
||||
<a class="btn btn-block" href="{{.PageURL}}" title="Back to the page"><span class="btn-icon">{{icon "up"}}</span><span class="btn-label">PAGE</span></a>
|
||||
<a class="btn btn-block" href="{{.RawURL}}" download="{{.Name}}" title="Download the canvas file"><span class="btn-icon">{{icon "doc"}}</span><span class="btn-label">DOWNLOAD</span></a>
|
||||
{{end}}
|
||||
|
||||
{{define "shell"}}
|
||||
<div class="canvas-app" data-version="{{.Version}}"{{if .Error}} data-readonly{{end}}>
|
||||
<div class="canvas-bar">
|
||||
<span class="canvas-title truncate"><a href="{{.PageURL}}">{{.PageURL}}</a>{{.Name}}</span>
|
||||
{{if not .Error}}
|
||||
<span class="canvas-tools">
|
||||
<button type="button" class="btn btn-tool" data-canvas="text" title="Add a note (or double-click the background)">TEXT</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="file" title="Add a file or page">FILE</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="link" title="Add a web link">LINK</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="group" title="Group the selection">GROUP</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Color of the selection">COLOR▾</button>
|
||||
<div class="dropdown-menu canvas-colors">
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="">None</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="1"><span class="canvas-swatch" data-color="1"></span>Red</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="2"><span class="canvas-swatch" data-color="2"></span>Orange</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="3"><span class="canvas-swatch" data-color="3"></span>Yellow</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="4"><span class="canvas-swatch" data-color="4"></span>Green</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="5"><span class="canvas-swatch" data-color="5"></span>Cyan</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="color" data-value="6"><span class="canvas-swatch" data-color="6"></span>Purple</button>
|
||||
</div>
|
||||
</span>
|
||||
<input type="color" class="canvas-color-input" title="Custom color of the selection" value="#c48401" />
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Arrows of the selected connections">ARROWS▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="ends" data-value="none arrow">One way →</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="ends" data-value="arrow arrow">Both ways ↔</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="ends" data-value="none none">No arrows —</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-canvas="ends" data-value="reverse">Reverse ⇄</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-canvas="undo" title="Undo (Ctrl+Z)">↶</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="redo" title="Redo (Ctrl+Y)">↷</button>
|
||||
<button type="button" class="btn btn-tool danger" data-canvas="delete" title="Delete the selection (Del)">DEL</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-canvas="zoomout" title="Zoom out">−</button>
|
||||
<button type="button" class="btn btn-tool canvas-zoom" data-canvas="zoomreset" title="Zoom to 100%">100%</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="zoomin" title="Zoom in">+</button>
|
||||
<button type="button" class="btn btn-tool" data-canvas="fit" title="Show everything">FIT</button>
|
||||
</span>
|
||||
<span class="canvas-status muted" aria-live="polite"></span>
|
||||
{{end}}
|
||||
</div>
|
||||
{{if .Error}}<p class="canvas-error">This canvas cannot be edited: {{.Error}}</p>{{end}}
|
||||
<div class="canvas-stage">
|
||||
<div class="canvas-node-toolbar" hidden>{{template "editor-toolbar"}}</div>
|
||||
<div class="canvas-viewport" tabindex="0">
|
||||
<div class="canvas-world">
|
||||
<svg class="canvas-edges" aria-hidden="true"></svg>
|
||||
<div class="canvas-edge-labels"></div>
|
||||
<div class="canvas-nodes">{{range .Nodes}}{{template "canvas-node" .}}{{end}}</div>
|
||||
</div>
|
||||
<p class="canvas-hint"{{if .Nodes}} hidden{{end}}>Double-click to add a note</p>
|
||||
</div>
|
||||
</div>
|
||||
<script type="application/json" id="canvas-data">{{.Doc}}</script>
|
||||
</div>
|
||||
{{if not .Error}}
|
||||
<script src="{{asset "editor/vendor/codemirror.bundle.js"}}"></script>
|
||||
<script src="{{asset "editor/tables.js"}}"></script>
|
||||
<script src="{{asset "editor/dates.js"}}"></script>
|
||||
<script src="{{asset "editor/movie.js"}}"></script>
|
||||
<script src="{{asset "editor/wikicomplete.js"}}"></script>
|
||||
<script src="{{asset "editor/core.js"}}"></script>
|
||||
{{end}}
|
||||
<script src="{{asset "canvas/canvas.js"}}"></script>
|
||||
{{end}}
|
||||
@@ -0,0 +1,20 @@
|
||||
{{/* One canvas node element (canvas.go canvasNodeView). Rendered for the
|
||||
whole canvas on load and for a single node by POST ?render, so the editor
|
||||
(canvas/canvas.js) never builds node markup itself. Geometry is in canvas
|
||||
units; canvas.js keeps style and data-color in sync while editing. */}}
|
||||
|
||||
{{define "canvas-node"}}<div class="canvas-node canvas-node-{{.Type}}" data-id="{{.ID}}"{{with .Preset}} data-color="{{.}}"{{end}} style="left: {{.X}}px; top: {{.Y}}px; width: {{.W}}px; height: {{.H}}px;{{with .Hex}} --node-color: {{.}};{{end}}">
|
||||
{{- if .Label}}<div class="canvas-node-label truncate">{{if .LabelURL}}<a href="{{.LabelURL}}">{{.Label}}</a>{{else}}{{.Label}}{{end}}</div>{{end -}}
|
||||
<div class="canvas-node-body{{if .Markdown}} content{{end}}">{{.Body}}</div></div>{{end}}
|
||||
|
||||
{{define "canvas-media"}}{{if eq .Kind "image"}}<a class="canvas-media" href="{{.URL}}"><img src="{{.Thumb}}" alt="" loading="lazy" draggable="false"></a>
|
||||
{{- else if eq .Kind "video"}}<video class="canvas-media" src="{{.URL}}" controls preload="metadata"></video>
|
||||
{{- else}}<audio class="canvas-media" src="{{.URL}}" controls preload="metadata"></audio>{{end}}{{end}}
|
||||
|
||||
{{define "canvas-file"}}<a class="canvas-card" href="{{.URL}}"><span class="canvas-card-icon">{{.Icon}}</span><span class="truncate">{{.Name}}</span></a>{{end}}
|
||||
|
||||
{{define "canvas-link"}}<a class="canvas-card" href="{{.URL}}" target="_blank" rel="noopener noreferrer"><img class="canvas-card-icon" src="https://icons.duckduckgo.com/ip3/{{.Host}}.ico" alt="" width="16" height="16" draggable="false"><span class="canvas-card-text"><span class="truncate">{{.Host}}</span><span class="muted truncate">{{.URL}}</span></span></a>{{end}}
|
||||
|
||||
{{define "canvas-missing"}}<p class="canvas-missing">Missing: {{.}}</p>{{end}}
|
||||
|
||||
{{define "canvas-group-bg"}}<div class="canvas-group-bg canvas-group-bg-{{.Style}}" style="background-image: url('{{.URL}}')"></div>{{end}}
|
||||
@@ -0,0 +1,430 @@
|
||||
// core.js — the markdown editor: the CodeMirror setup plus the formatting
|
||||
// actions behind the toolbar (editor/toolbar.html) and the Alt+Shift
|
||||
// shortcuts. Shared by the page editor (main.js) and the canvas node editor
|
||||
// (canvas/canvas.js).
|
||||
//
|
||||
// MarkdownEditor.create(mount, opts) → { view, actions, getValue, focus, destroy }
|
||||
// opts.doc initial text
|
||||
// opts.keymap extra CM key bindings, ahead of the defaults
|
||||
// MarkdownEditor.bind(root, getActions)
|
||||
// wires root's [data-action] buttons, [data-key] shortcuts and toolbar
|
||||
// dropdowns to whatever getActions() returns (null = no editor open).
|
||||
window.MarkdownEditor = (function () {
|
||||
var T = EditorTables;
|
||||
var D = EditorDates;
|
||||
var M = EditorMovie;
|
||||
|
||||
// Enter/Tab cell navigation must yield to an open autocomplete popup so those
|
||||
// keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell).
|
||||
// The table keymap sits at Prec.highest, so without this guard it would
|
||||
// swallow the key before the completion keymap ever sees it. Detect the popup
|
||||
// via its tooltip element rather than exporting completionStatus from the
|
||||
// vendored bundle (which would force a bundle rebuild).
|
||||
function completionOpen() {
|
||||
return !!document.querySelector('.cm-tooltip-autocomplete');
|
||||
}
|
||||
|
||||
// pageBase is the folder of the page being edited, slash-terminated: the
|
||||
// page URL itself, or for a canvas (/Page/Board.canvas) the folder it
|
||||
// lives in. Relative embed destinations resolve against it.
|
||||
function pageBase() {
|
||||
var p = decodeURIComponent(window.location.pathname);
|
||||
return p.slice(0, p.lastIndexOf('/') + 1);
|
||||
}
|
||||
|
||||
function create(mount, opts) {
|
||||
opts = opts || {};
|
||||
var doc = opts.doc || '';
|
||||
var view;
|
||||
|
||||
// Shift+Enter (new table row below) / Shift+Delete (delete table row) run at
|
||||
// highest precedence so they win over CM's default newline/forward-delete.
|
||||
// Returning false (no table at cursor) lets CM fall back to its default.
|
||||
function tableKey(fn) {
|
||||
return function (v) {
|
||||
var result = fn(v.state.doc.toString(), v.state.selection.main.head);
|
||||
if (!result) return false;
|
||||
dispatchFullReplace(result);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
function tableNavKey(fn) {
|
||||
var handler = tableKey(fn);
|
||||
return function (v) {
|
||||
if (completionOpen()) return false;
|
||||
return handler(v);
|
||||
};
|
||||
}
|
||||
|
||||
var tableKeymap = [
|
||||
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
|
||||
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
|
||||
{ key: 'Enter', run: tableNavKey(T.nextRowSameColumn) },
|
||||
{ key: 'Tab', run: tableNavKey(T.nextCell) },
|
||||
{ key: 'Shift-Tab', run: tableNavKey(T.prevCell) },
|
||||
];
|
||||
|
||||
var state = CM.EditorState.create({
|
||||
doc: doc,
|
||||
// Start with the caret at the end of the existing content — editing an
|
||||
// existing page almost always means appending, and the default (start
|
||||
// of doc) buries the caret behind the leading heading.
|
||||
selection: { anchor: doc.length },
|
||||
extensions: [
|
||||
// The caller's keys go first, ahead of even autocompletion's
|
||||
// own highest-precedence keys: those swallow an Escape while a
|
||||
// completion query is still pending, with no popup in sight.
|
||||
CM.Prec.highest(CM.keymap.of(opts.keymap || [])),
|
||||
CM.history(),
|
||||
CM.drawSelection(),
|
||||
CM.indentOnInput(),
|
||||
CM.EditorView.lineWrapping,
|
||||
// Enable native browser spellcheck on the contenteditable surface
|
||||
// (CM6 leaves it off by default). autocapitalize helps prose entry
|
||||
// on the Android/mobile path; CM's DOM observer absorbs corrections.
|
||||
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,
|
||||
CM.Prec.highest(CM.keymap.of(tableKeymap)),
|
||||
CM.keymap.of([].concat(
|
||||
CM.closeBracketsKeymap,
|
||||
CM.completionKeymap,
|
||||
CM.markdownKeymap,
|
||||
CM.defaultKeymap,
|
||||
CM.historyKeymap,
|
||||
[CM.indentWithTab]
|
||||
)),
|
||||
],
|
||||
});
|
||||
|
||||
view = new CM.EditorView({ state: state, parent: mount });
|
||||
view.focus();
|
||||
|
||||
// --- CM document helpers ---
|
||||
|
||||
function dispatchFullReplace(result) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: result.text },
|
||||
selection: { anchor: result.cursor },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function insertAtCursor(s) {
|
||||
var sel = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: s },
|
||||
selection: { anchor: sel.from + s.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function wrap(before, after, placeholder) {
|
||||
var sel = view.state.selection.main;
|
||||
var hadSelection = sel.to > sel.from;
|
||||
var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder;
|
||||
var insert = before + selected + after;
|
||||
var anchor, head;
|
||||
if (hadSelection) {
|
||||
anchor = head = sel.from + insert.length;
|
||||
} else {
|
||||
anchor = sel.from + before.length;
|
||||
head = anchor + placeholder.length;
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: insert },
|
||||
selection: { anchor: anchor, head: head },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function linePrefix(prefix) {
|
||||
var sel = view.state.selection.main;
|
||||
var line = view.state.doc.lineAt(sel.from);
|
||||
view.dispatch({
|
||||
changes: { from: line.from, to: line.from, insert: prefix },
|
||||
selection: { anchor: sel.from + prefix.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function applyTableOp(fn, arg) {
|
||||
var text = view.state.doc.toString();
|
||||
var pos = view.state.selection.main.head;
|
||||
var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos);
|
||||
if (result) dispatchFullReplace(result);
|
||||
}
|
||||
|
||||
// Adapter passed to movie.js so it reads/writes the CM document instead of a
|
||||
// textarea (replace() dispatches a transaction; cursor lands after the block).
|
||||
var movieCtx = {
|
||||
getValue: function () { return view.state.doc.toString(); },
|
||||
replace: function (start, end, text) {
|
||||
view.dispatch({
|
||||
changes: { from: start, to: end, insert: text },
|
||||
selection: { anchor: start + text.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
},
|
||||
};
|
||||
|
||||
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
|
||||
// plus an optional display-text field, inserting [[query]] or
|
||||
// [[query::display]] at the cursor. Picking a suggestion only fills in that
|
||||
// page's name — the link resolves through search at click time, so a path
|
||||
// never enters the document. Inline `[[` typing uses the same suggestions
|
||||
// via wikicomplete.js.
|
||||
function insertWikilink() {
|
||||
var sel = view.state.selection.main;
|
||||
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
|
||||
|
||||
var container = document.createElement('div');
|
||||
|
||||
var queryWrap = document.createElement('div');
|
||||
var queryInput = document.createElement('input');
|
||||
queryInput.type = 'text';
|
||||
queryInput.className = 'input';
|
||||
queryInput.placeholder = 'Search query';
|
||||
// A selection is the query, not the label: [[selection]] renders with
|
||||
// the same visible text anyway, and it seeds the typeahead.
|
||||
queryInput.value = selectedText;
|
||||
queryWrap.appendChild(queryInput);
|
||||
|
||||
var displayInput = document.createElement('input');
|
||||
displayInput.type = 'text';
|
||||
displayInput.className = 'input';
|
||||
displayInput.placeholder = 'Display text (optional)';
|
||||
|
||||
container.appendChild(queryWrap);
|
||||
container.appendChild(displayInput);
|
||||
|
||||
var handle = openModal({
|
||||
title: 'Insert link',
|
||||
body: container,
|
||||
confirm: {
|
||||
label: 'INSERT',
|
||||
initiallyDisabled: !selectedText,
|
||||
onConfirm: function () {
|
||||
var query = queryInput.value.trim();
|
||||
if (!query) return;
|
||||
var display = displayInput.value.trim();
|
||||
handle.close();
|
||||
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateConfirm() {
|
||||
handle.setConfirmDisabled(!queryInput.value.trim());
|
||||
}
|
||||
queryInput.addEventListener('input', updateConfirm);
|
||||
|
||||
window.attachSuggestions(queryInput, {
|
||||
showFooter: false,
|
||||
container: queryWrap,
|
||||
onPick: function (r) {
|
||||
queryInput.value = r.name;
|
||||
updateConfirm();
|
||||
displayInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// embedDest turns the absolute wiki path the tree picker hands back into the
|
||||
// destination written to the document: relative to the page being edited
|
||||
// when the file lives under it, absolute otherwise. Pages render at a
|
||||
// slash-terminated URL, so a relative destination resolves against the page
|
||||
// in the browser.
|
||||
function embedDest(absPath) {
|
||||
var base = pageBase();
|
||||
return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath;
|
||||
}
|
||||
|
||||
// embedToken formats a Markdown image. A destination carrying spaces or
|
||||
// brackets goes in <angle brackets> — CommonMark's way of keeping it in one
|
||||
// piece — and the title slot holds the alignment keyword the renderer reads.
|
||||
// Right is the renderer's default, so it is left off.
|
||||
function embedToken(dest, caption, align) {
|
||||
if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>';
|
||||
var title = (align && align !== 'right') ? ' "' + align + '"' : '';
|
||||
return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')';
|
||||
}
|
||||
|
||||
// Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a
|
||||
// caption and an alignment. Images render as a floated figure; anything
|
||||
// else degrades to a link.
|
||||
function insertEmbed() {
|
||||
var sel = view.state.selection.main;
|
||||
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
|
||||
var here = pageBase().replace(/\/+$/, '') || '/';
|
||||
|
||||
openTreePicker({
|
||||
title: 'Embed — pick a file',
|
||||
mode: 'file',
|
||||
initialPath: here,
|
||||
confirmLabel: 'NEXT',
|
||||
onSelect: function (absPath) {
|
||||
var container = document.createElement('div');
|
||||
|
||||
var captionInput = document.createElement('input');
|
||||
captionInput.type = 'text';
|
||||
captionInput.className = 'input';
|
||||
captionInput.placeholder = 'Caption (optional)';
|
||||
captionInput.value = selectedText;
|
||||
|
||||
var alignSelect = document.createElement('select');
|
||||
alignSelect.className = 'input';
|
||||
[['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']]
|
||||
.forEach(function (opt) {
|
||||
var o = document.createElement('option');
|
||||
o.value = opt[0];
|
||||
o.textContent = opt[1];
|
||||
alignSelect.appendChild(o);
|
||||
});
|
||||
|
||||
container.appendChild(captionInput);
|
||||
container.appendChild(alignSelect);
|
||||
|
||||
var handle = openModal({
|
||||
title: 'Embed — caption?',
|
||||
body: container,
|
||||
confirm: {
|
||||
label: 'INSERT',
|
||||
onConfirm: function () {
|
||||
handle.close();
|
||||
insertAtCursor(embedToken(
|
||||
embedDest(absPath),
|
||||
captionInput.value.trim(),
|
||||
alignSelect.value
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
var actions = {
|
||||
undo: function () { CM.undo(view); view.focus(); },
|
||||
redo: function () { CM.redo(view); view.focus(); },
|
||||
deleteline: function () { CM.deleteLine(view); view.focus(); },
|
||||
bold: function () { wrap('**', '**', 'bold text'); },
|
||||
italic: function () { wrap('*', '*', 'italic text'); },
|
||||
h1: function () { linePrefix('# '); },
|
||||
h2: function () { linePrefix('## '); },
|
||||
h3: function () { linePrefix('### '); },
|
||||
code: function () { wrap('`', '`', 'code'); },
|
||||
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
||||
quote: function () { linePrefix('> '); },
|
||||
link: function () { wrap('[', '](url)', 'link text'); },
|
||||
wikilink: insertWikilink,
|
||||
embed: insertEmbed,
|
||||
ul: function () { linePrefix('- '); },
|
||||
ol: function () { linePrefix('1. '); },
|
||||
task: function () { linePrefix('- [ ] '); },
|
||||
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
||||
fmttable: function () { applyTableOp(T.formatTableText); },
|
||||
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
|
||||
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
|
||||
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
|
||||
tblinsertcol: function () { applyTableOp(T.insertColumn); },
|
||||
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
|
||||
tblinsertrow: function () { applyTableOp(T.insertRow); },
|
||||
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
||||
dateiso: function () { insertAtCursor(D.isoDate()); },
|
||||
datelong: function () { insertAtCursor(D.longDate()); },
|
||||
movie: function () { M.run(movieCtx); },
|
||||
};
|
||||
|
||||
return {
|
||||
view: view,
|
||||
actions: actions,
|
||||
getValue: function () { return view.state.doc.toString(); },
|
||||
focus: function () { view.focus(); },
|
||||
destroy: function () { view.destroy(); },
|
||||
};
|
||||
}
|
||||
|
||||
// The toolbar scrolls horizontally (so it clips its absolutely-positioned
|
||||
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
|
||||
// menu to the viewport so it escapes the clip, opening upward when there
|
||||
// isn't room below it (the bottom-toolbar case).
|
||||
function pinMenu(toggle, menu) {
|
||||
if (!menu.classList.contains('is-open')) return;
|
||||
var r = toggle.getBoundingClientRect();
|
||||
var vh = window.innerHeight;
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.overflowY = 'auto';
|
||||
var spaceBelow = vh - r.bottom;
|
||||
var spaceAbove = r.top;
|
||||
if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) {
|
||||
menu.style.top = 'auto';
|
||||
menu.style.bottom = (vh - r.top) + 'px';
|
||||
menu.style.maxHeight = (spaceAbove - 8) + 'px';
|
||||
} else {
|
||||
menu.style.bottom = 'auto';
|
||||
menu.style.top = r.bottom + 'px';
|
||||
menu.style.maxHeight = (spaceBelow - 8) + 'px';
|
||||
}
|
||||
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
|
||||
menu.style.left = Math.max(4, left) + 'px';
|
||||
}
|
||||
|
||||
function bind(root, getActions) {
|
||||
function run(name) {
|
||||
var actions = getActions();
|
||||
var action = actions && actions[name];
|
||||
if (action) action();
|
||||
}
|
||||
|
||||
var keyMap = {};
|
||||
root.querySelectorAll('[data-action]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () { run(btn.dataset.action); });
|
||||
if (btn.dataset.key) keyMap[btn.dataset.key] = btn.dataset.action;
|
||||
});
|
||||
|
||||
// Keep the editor focused when a toolbar button is tapped. Without this the
|
||||
// button steals focus on mousedown, which dismisses the mobile soft keyboard
|
||||
// before the action runs (and view.focus() can't reopen it without a direct
|
||||
// gesture). preventDefault on mousedown blocks the focus shift; click still
|
||||
// fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes
|
||||
// dropdown toggles, which also must not pull focus off the editor.
|
||||
var toolbar = root.classList.contains('editor-toolbar') ? root : root.querySelector('.editor-toolbar');
|
||||
if (toolbar) {
|
||||
toolbar.addEventListener('mousedown', function (e) {
|
||||
if (e.target.closest('.btn')) e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!e.altKey || !e.shiftKey || !getActions()) return;
|
||||
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
||||
// on US, "!" on DE), so fall back to e.code for digit rows.
|
||||
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
|
||||
var name = keyMap[key];
|
||||
if (name) {
|
||||
e.preventDefault();
|
||||
run(name);
|
||||
}
|
||||
});
|
||||
|
||||
root.querySelectorAll('.dropdown-toggle').forEach(function (toggle) {
|
||||
wireDropdown(toggle);
|
||||
var menu = toggle.parentElement.querySelector('.dropdown-menu');
|
||||
if (!menu || !toolbar || !toolbar.contains(toggle)) return;
|
||||
// Runs after wireDropdown's own click handler has toggled is-open.
|
||||
toggle.addEventListener('click', function () { pinMenu(toggle, menu); });
|
||||
});
|
||||
}
|
||||
|
||||
return { create: create, bind: bind };
|
||||
})();
|
||||
+2
-64
@@ -11,70 +11,7 @@
|
||||
{{if ge .SectionIndex 0}}<input type="hidden" name="section" value="{{.SectionIndex}}">{{end}}
|
||||
{{if .Span}}<input type="hidden" name="span" value="1">{{end}}
|
||||
{{if ge .InsertBefore 0}}<input type="hidden" name="insert_before" value="{{.InsertBefore}}">{{end}}
|
||||
<div class="editor-toolbar">
|
||||
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
|
||||
<button type="button" class="btn btn-tool" data-action="redo" title="Redo">↷</button>
|
||||
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">×</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Heading (1/2/3)">H▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="h1" data-key="1" title="Heading 1 (1)">Heading 1</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="h2" data-key="2" title="Heading 2 (2)">Heading 2</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="h3" data-key="3" title="Heading 3 (3)">Heading 3</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">L▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="List (U/O/X)">≡▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="ul" data-key="U" title="Unordered list (U)">Unordered list</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="ol" data-key="O" title="Ordered list (O)">Ordered list</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="task" data-key="X" title="Task list (X)">Task list</button>
|
||||
</div>
|
||||
</span>
|
||||
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="fmttable" data-key="T" title="Format table (T)">Format table</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tblalignleft" title="Align left">Align left</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tblaligncenter" title="Align center">Align center</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tblalignright" title="Align right">Align right</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertcol" title="Insert column">Insert column</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tbldeletecol" title="Delete column">Delete column</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertrow" title="Insert row">Insert row</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tbldeleterow" title="Delete row">Delete row</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Insert date (D/W)">D▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="dateiso" data-key="D" title="YYYY-MM-DD (D)">YYYY-MM-DD</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="datelong" data-key="W" title="DE Long (W)">DE Long</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Special (V)">★▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
|
||||
</div>
|
||||
</span>
|
||||
</div>
|
||||
{{template "editor-toolbar"}}
|
||||
<div id="editor" class="editor-cm"></div>
|
||||
<textarea name="content" id="editor-content" hidden>{{.RawContent}}</textarea>
|
||||
</form>
|
||||
@@ -83,5 +20,6 @@
|
||||
<script src="{{asset "editor/dates.js"}}"></script>
|
||||
<script src="{{asset "editor/movie.js"}}"></script>
|
||||
<script src="{{asset "editor/wikicomplete.js"}}"></script>
|
||||
<script src="{{asset "editor/core.js"}}"></script>
|
||||
<script src="{{asset "editor/main.js"}}"></script>
|
||||
{{end}}
|
||||
|
||||
+7
-394
@@ -1,161 +1,12 @@
|
||||
// The page editor: one markdown editor (core.js) inside the edit form, saved
|
||||
// with the header's SAVE button.
|
||||
(function () {
|
||||
var mount = document.getElementById('editor');
|
||||
var hidden = document.getElementById('editor-content');
|
||||
if (!mount || !hidden || !window.CM) return;
|
||||
|
||||
var form = hidden.closest('form');
|
||||
|
||||
var T = EditorTables;
|
||||
var D = EditorDates;
|
||||
var M = EditorMovie;
|
||||
|
||||
// --- CodeMirror setup ---
|
||||
|
||||
// Shift+Enter (new table row below) / Shift+Delete (delete table row) run at
|
||||
// highest precedence so they win over CM's default newline/forward-delete.
|
||||
// Returning false (no table at cursor) lets CM fall back to its default.
|
||||
function tableKey(fn) {
|
||||
return function (view) {
|
||||
var result = fn(view.state.doc.toString(), view.state.selection.main.head);
|
||||
if (!result) return false;
|
||||
dispatchFullReplace(result);
|
||||
return true;
|
||||
};
|
||||
}
|
||||
|
||||
// Enter/Tab cell navigation must yield to an open autocomplete popup so those
|
||||
// keys still accept a suggestion (e.g. a [[wiki-link]] typed inside a cell).
|
||||
// The table keymap sits at Prec.highest, so without this guard it would
|
||||
// swallow the key before the completion keymap ever sees it. Detect the popup
|
||||
// via its tooltip element rather than exporting completionStatus from the
|
||||
// vendored bundle (which would force a bundle rebuild).
|
||||
function completionOpen() {
|
||||
return !!document.querySelector('.cm-tooltip-autocomplete');
|
||||
}
|
||||
|
||||
function tableNavKey(fn) {
|
||||
var handler = tableKey(fn);
|
||||
return function (view) {
|
||||
if (completionOpen()) return false;
|
||||
return handler(view);
|
||||
};
|
||||
}
|
||||
|
||||
var tableKeymap = [
|
||||
{ key: 'Shift-Enter', run: tableKey(T.insertRowBelow) },
|
||||
{ key: 'Shift-Delete', run: tableKey(T.deleteRow) },
|
||||
{ key: 'Enter', run: tableNavKey(T.nextRowSameColumn) },
|
||||
{ key: 'Tab', run: tableNavKey(T.nextCell) },
|
||||
{ key: 'Shift-Tab', run: tableNavKey(T.prevCell) },
|
||||
];
|
||||
|
||||
var state = CM.EditorState.create({
|
||||
doc: hidden.value,
|
||||
// Start with the caret at the end of the existing content — editing an
|
||||
// existing page almost always means appending, and the default (start
|
||||
// of doc) buries the caret behind the leading heading.
|
||||
selection: { anchor: hidden.value.length },
|
||||
extensions: [
|
||||
CM.history(),
|
||||
CM.drawSelection(),
|
||||
CM.indentOnInput(),
|
||||
CM.EditorView.lineWrapping,
|
||||
// Enable native browser spellcheck on the contenteditable surface
|
||||
// (CM6 leaves it off by default). autocapitalize helps prose entry
|
||||
// on the Android/mobile path; CM's DOM observer absorbs corrections.
|
||||
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,
|
||||
CM.Prec.highest(CM.keymap.of(tableKeymap)),
|
||||
CM.keymap.of([].concat(
|
||||
CM.closeBracketsKeymap,
|
||||
CM.completionKeymap,
|
||||
CM.markdownKeymap,
|
||||
CM.defaultKeymap,
|
||||
CM.historyKeymap,
|
||||
[CM.indentWithTab]
|
||||
)),
|
||||
],
|
||||
});
|
||||
|
||||
var view = new CM.EditorView({ state: state, parent: mount });
|
||||
view.focus();
|
||||
|
||||
// --- CM document helpers ---
|
||||
|
||||
function dispatchFullReplace(result) {
|
||||
view.dispatch({
|
||||
changes: { from: 0, to: view.state.doc.length, insert: result.text },
|
||||
selection: { anchor: result.cursor },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function insertAtCursor(s) {
|
||||
var sel = view.state.selection.main;
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: s },
|
||||
selection: { anchor: sel.from + s.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function wrap(before, after, placeholder) {
|
||||
var sel = view.state.selection.main;
|
||||
var hadSelection = sel.to > sel.from;
|
||||
var selected = hadSelection ? view.state.sliceDoc(sel.from, sel.to) : placeholder;
|
||||
var insert = before + selected + after;
|
||||
var anchor, head;
|
||||
if (hadSelection) {
|
||||
anchor = head = sel.from + insert.length;
|
||||
} else {
|
||||
anchor = sel.from + before.length;
|
||||
head = anchor + placeholder.length;
|
||||
}
|
||||
view.dispatch({
|
||||
changes: { from: sel.from, to: sel.to, insert: insert },
|
||||
selection: { anchor: anchor, head: head },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function linePrefix(prefix) {
|
||||
var sel = view.state.selection.main;
|
||||
var line = view.state.doc.lineAt(sel.from);
|
||||
view.dispatch({
|
||||
changes: { from: line.from, to: line.from, insert: prefix },
|
||||
selection: { anchor: sel.from + prefix.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
}
|
||||
|
||||
function applyTableOp(fn, arg) {
|
||||
var text = view.state.doc.toString();
|
||||
var pos = view.state.selection.main.head;
|
||||
var result = arg !== undefined ? fn(text, pos, arg) : fn(text, pos);
|
||||
if (result) dispatchFullReplace(result);
|
||||
}
|
||||
|
||||
// Adapter passed to movie.js so it reads/writes the CM document instead of a
|
||||
// textarea (replace() dispatches a transaction; cursor lands after the block).
|
||||
var movieCtx = {
|
||||
getValue: function () { return view.state.doc.toString(); },
|
||||
replace: function (start, end, text) {
|
||||
view.dispatch({
|
||||
changes: { from: start, to: end, insert: text },
|
||||
selection: { anchor: start + text.length },
|
||||
scrollIntoView: true,
|
||||
});
|
||||
view.focus();
|
||||
},
|
||||
};
|
||||
var editor = MarkdownEditor.create(mount, { doc: hidden.value });
|
||||
|
||||
// --- Content sync ---
|
||||
|
||||
@@ -163,7 +14,7 @@
|
||||
// must not depend on CM focus or async state (decision 8). requestSubmit
|
||||
// fires this listener even for the ALT+SHIFT+S path.
|
||||
function syncContent() {
|
||||
hidden.value = view.state.doc.toString();
|
||||
hidden.value = editor.getValue();
|
||||
}
|
||||
|
||||
// Save POSTs via fetch and then rewrites the *editor's* history entry with
|
||||
@@ -208,246 +59,8 @@
|
||||
|
||||
// --- Actions ---
|
||||
|
||||
var actions = {
|
||||
save: function () { form.requestSubmit(); },
|
||||
undo: function () { CM.undo(view); view.focus(); },
|
||||
redo: function () { CM.redo(view); view.focus(); },
|
||||
deleteline: function () { CM.deleteLine(view); view.focus(); },
|
||||
bold: function () { wrap('**', '**', 'bold text'); },
|
||||
italic: function () { wrap('*', '*', 'italic text'); },
|
||||
h1: function () { linePrefix('# '); },
|
||||
h2: function () { linePrefix('## '); },
|
||||
h3: function () { linePrefix('### '); },
|
||||
code: function () { wrap('`', '`', 'code'); },
|
||||
codeblock: function () { wrap('```\n', '\n```', 'code'); },
|
||||
quote: function () { linePrefix('> '); },
|
||||
link: function () { wrap('[', '](url)', 'link text'); },
|
||||
wikilink: insertWikilink,
|
||||
embed: insertEmbed,
|
||||
ul: function () { linePrefix('- '); },
|
||||
ol: function () { linePrefix('1. '); },
|
||||
task: function () { linePrefix('- [ ] '); },
|
||||
hr: function () { wrap('\n\n---\n\n', '', ''); },
|
||||
fmttable: function () { applyTableOp(T.formatTableText); },
|
||||
tblalignleft: function () { applyTableOp(T.setColumnAlignment, 'left'); },
|
||||
tblaligncenter: function () { applyTableOp(T.setColumnAlignment, 'center'); },
|
||||
tblalignright: function () { applyTableOp(T.setColumnAlignment, 'right'); },
|
||||
tblinsertcol: function () { applyTableOp(T.insertColumn); },
|
||||
tbldeletecol: function () { applyTableOp(T.deleteColumn); },
|
||||
tblinsertrow: function () { applyTableOp(T.insertRow); },
|
||||
tbldeleterow: function () { applyTableOp(T.deleteRow); },
|
||||
dateiso: function () { insertAtCursor(D.isoDate()); },
|
||||
datelong: function () { insertAtCursor(D.longDate()); },
|
||||
movie: function () { M.run(movieCtx); },
|
||||
};
|
||||
editor.actions.save = function () { form.requestSubmit(); };
|
||||
|
||||
// Wiki link button (ALT+SHIFT+P): a query field backed by /_search typeahead
|
||||
// plus an optional display-text field, inserting [[query]] or
|
||||
// [[query::display]] at the cursor. Picking a suggestion only fills in that
|
||||
// page's name — the link resolves through search at click time, so a path
|
||||
// never enters the document. Inline `[[` typing uses the same suggestions
|
||||
// via wikicomplete.js.
|
||||
function insertWikilink() {
|
||||
var sel = view.state.selection.main;
|
||||
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
|
||||
|
||||
var container = document.createElement('div');
|
||||
|
||||
var queryWrap = document.createElement('div');
|
||||
var queryInput = document.createElement('input');
|
||||
queryInput.type = 'text';
|
||||
queryInput.className = 'input';
|
||||
queryInput.placeholder = 'Search query';
|
||||
// A selection is the query, not the label: [[selection]] renders with
|
||||
// the same visible text anyway, and it seeds the typeahead.
|
||||
queryInput.value = selectedText;
|
||||
queryWrap.appendChild(queryInput);
|
||||
|
||||
var displayInput = document.createElement('input');
|
||||
displayInput.type = 'text';
|
||||
displayInput.className = 'input';
|
||||
displayInput.placeholder = 'Display text (optional)';
|
||||
|
||||
container.appendChild(queryWrap);
|
||||
container.appendChild(displayInput);
|
||||
|
||||
var handle = openModal({
|
||||
title: 'Insert link',
|
||||
body: container,
|
||||
confirm: {
|
||||
label: 'INSERT',
|
||||
initiallyDisabled: !selectedText,
|
||||
onConfirm: function () {
|
||||
var query = queryInput.value.trim();
|
||||
if (!query) return;
|
||||
var display = displayInput.value.trim();
|
||||
handle.close();
|
||||
insertAtCursor(display ? '[[' + query + '::' + display + ']]' : '[[' + query + ']]');
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
function updateConfirm() {
|
||||
handle.setConfirmDisabled(!queryInput.value.trim());
|
||||
}
|
||||
queryInput.addEventListener('input', updateConfirm);
|
||||
|
||||
window.attachSuggestions(queryInput, {
|
||||
showFooter: false,
|
||||
container: queryWrap,
|
||||
onPick: function (r) {
|
||||
queryInput.value = r.name;
|
||||
updateConfirm();
|
||||
displayInput.focus();
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// embedDest turns the absolute wiki path the tree picker hands back into the
|
||||
// destination written to the document: relative to the page being edited
|
||||
// when the file lives under it, absolute otherwise. Pages render at a
|
||||
// slash-terminated URL, so a relative destination resolves against the page
|
||||
// in the browser.
|
||||
function embedDest(absPath) {
|
||||
var base = decodeURIComponent(window.location.pathname);
|
||||
if (base.slice(-1) !== '/') base += '/';
|
||||
return absPath.indexOf(base) === 0 ? absPath.slice(base.length) : absPath;
|
||||
}
|
||||
|
||||
// embedToken formats a Markdown image. A destination carrying spaces or
|
||||
// brackets goes in <angle brackets> — CommonMark's way of keeping it in one
|
||||
// piece — and the title slot holds the alignment keyword the renderer reads.
|
||||
// Right is the renderer's default, so it is left off.
|
||||
function embedToken(dest, caption, align) {
|
||||
if (/[\s()<>]/.test(dest)) dest = '<' + dest + '>';
|
||||
var title = (align && align !== 'right') ? ' "' + align + '"' : '';
|
||||
return '![' + caption.replace(/([\[\]\\])/g, '\\$1') + '](' + dest + title + ')';
|
||||
}
|
||||
|
||||
// Embed button (ALT+SHIFT+G): pick a file from the tree, then give it a
|
||||
// caption and an alignment. Images render as a floated figure; anything
|
||||
// else degrades to a link.
|
||||
function insertEmbed() {
|
||||
var sel = view.state.selection.main;
|
||||
var selectedText = view.state.sliceDoc(sel.from, sel.to).trim();
|
||||
var here = decodeURIComponent(window.location.pathname).replace(/\/+$/, '') || '/';
|
||||
|
||||
openTreePicker({
|
||||
title: 'Embed — pick a file',
|
||||
mode: 'file',
|
||||
initialPath: here,
|
||||
confirmLabel: 'NEXT',
|
||||
onSelect: function (absPath) {
|
||||
var container = document.createElement('div');
|
||||
|
||||
var captionInput = document.createElement('input');
|
||||
captionInput.type = 'text';
|
||||
captionInput.className = 'input';
|
||||
captionInput.placeholder = 'Caption (optional)';
|
||||
captionInput.value = selectedText;
|
||||
|
||||
var alignSelect = document.createElement('select');
|
||||
alignSelect.className = 'input';
|
||||
[['right', 'Float right'], ['left', 'Float left'], ['center', 'Centered']]
|
||||
.forEach(function (opt) {
|
||||
var o = document.createElement('option');
|
||||
o.value = opt[0];
|
||||
o.textContent = opt[1];
|
||||
alignSelect.appendChild(o);
|
||||
});
|
||||
|
||||
container.appendChild(captionInput);
|
||||
container.appendChild(alignSelect);
|
||||
|
||||
var handle = openModal({
|
||||
title: 'Embed — caption?',
|
||||
body: container,
|
||||
confirm: {
|
||||
label: 'INSERT',
|
||||
onConfirm: function () {
|
||||
handle.close();
|
||||
insertAtCursor(embedToken(
|
||||
embedDest(absPath),
|
||||
captionInput.value.trim(),
|
||||
alignSelect.value
|
||||
));
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
// --- Keyboard shortcut registration ---
|
||||
|
||||
var keyMap = {};
|
||||
document.querySelectorAll('[data-action]').forEach(function (btn) {
|
||||
btn.addEventListener('click', function () {
|
||||
var action = actions[btn.dataset.action];
|
||||
if (action) action();
|
||||
});
|
||||
if (btn.dataset.key) {
|
||||
keyMap[btn.dataset.key] = actions[btn.dataset.action];
|
||||
}
|
||||
});
|
||||
|
||||
// Keep the editor focused when a toolbar button is tapped. Without this the
|
||||
// button steals focus on mousedown, which dismisses the mobile soft keyboard
|
||||
// before the action runs (and view.focus() can't reopen it without a direct
|
||||
// gesture). preventDefault on mousedown blocks the focus shift; click still
|
||||
// fires. Scoped to the toolbar so header SAVE/CANCEL are unaffected. Includes
|
||||
// dropdown toggles, which also must not pull focus off the editor.
|
||||
var toolbar = document.querySelector('.editor-toolbar');
|
||||
if (toolbar) {
|
||||
toolbar.addEventListener('mousedown', function (e) {
|
||||
if (e.target.closest('.btn')) e.preventDefault();
|
||||
});
|
||||
}
|
||||
|
||||
document.addEventListener('keydown', function (e) {
|
||||
if (!e.altKey || !e.shiftKey) return;
|
||||
// Shift+digit produces a layout-dependent character in e.key (e.g. "!"
|
||||
// on US, "!" on DE), so fall back to e.code for digit rows.
|
||||
var key = /^Digit[0-9]$/.test(e.code) ? e.code.slice(5) : e.key;
|
||||
var action = keyMap[key];
|
||||
if (action) {
|
||||
e.preventDefault();
|
||||
action();
|
||||
}
|
||||
});
|
||||
|
||||
// --- Dropdowns ---
|
||||
|
||||
// The toolbar scrolls horizontally (so it clips its absolutely-positioned
|
||||
// menus) and on mobile is fixed to the bottom of the viewport. Pin an open
|
||||
// menu to the viewport so it escapes the clip, opening upward when there
|
||||
// isn't room below it (the bottom-toolbar case).
|
||||
function pinMenu(toggle, menu) {
|
||||
if (!menu.classList.contains('is-open')) return;
|
||||
var r = toggle.getBoundingClientRect();
|
||||
var vh = window.innerHeight;
|
||||
menu.style.position = 'fixed';
|
||||
menu.style.overflowY = 'auto';
|
||||
var spaceBelow = vh - r.bottom;
|
||||
var spaceAbove = r.top;
|
||||
if (spaceBelow < menu.offsetHeight + 8 && spaceAbove > spaceBelow) {
|
||||
menu.style.top = 'auto';
|
||||
menu.style.bottom = (vh - r.top) + 'px';
|
||||
menu.style.maxHeight = (spaceAbove - 8) + 'px';
|
||||
} else {
|
||||
menu.style.bottom = 'auto';
|
||||
menu.style.top = r.bottom + 'px';
|
||||
menu.style.maxHeight = (spaceBelow - 8) + 'px';
|
||||
}
|
||||
var left = Math.min(r.left, document.documentElement.clientWidth - menu.offsetWidth - 4);
|
||||
menu.style.left = Math.max(4, left) + 'px';
|
||||
}
|
||||
|
||||
document.querySelectorAll('.dropdown-toggle').forEach(function (toggle) {
|
||||
wireDropdown(toggle);
|
||||
var menu = toggle.parentElement.querySelector('.dropdown-menu');
|
||||
if (!menu || !toolbar || !toolbar.contains(toggle)) return;
|
||||
// Runs after wireDropdown's own click handler has toggled is-open.
|
||||
toggle.addEventListener('click', function () { pinMenu(toggle, menu); });
|
||||
});
|
||||
// The toolbar and the header SAVE button both name actions.
|
||||
MarkdownEditor.bind(document.body, function () { return editor.actions; });
|
||||
})();
|
||||
|
||||
@@ -0,0 +1,67 @@
|
||||
{{/* The markdown editor's toolbar, shared by the page editor and the canvas
|
||||
node editor. Buttons name an action of editor/core.js (data-action) and its
|
||||
Alt+Shift shortcut (data-key). */}}
|
||||
{{define "editor-toolbar"}}<div class="editor-toolbar">
|
||||
<button type="button" class="btn btn-tool" data-action="undo" title="Undo">↶</button>
|
||||
<button type="button" class="btn btn-tool" data-action="redo" title="Redo">↷</button>
|
||||
<button type="button" class="btn btn-tool" data-action="deleteline" data-key="Y" title="Delete line (Y)">×</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="bold" data-key="B" title="Bold (B)">**</button>
|
||||
<button type="button" class="btn btn-tool" data-action="italic" data-key="I" title="Italic (I)">*</button>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Heading (1/2/3)">H▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="h1" data-key="1" title="Heading 1 (1)">Heading 1</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="h2" data-key="2" title="Heading 2 (2)">Heading 2</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="h3" data-key="3" title="Heading 3 (3)">Heading 3</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="toolbar-sep"></span>
|
||||
<button type="button" class="btn btn-tool" data-action="code" data-key="C" title="Inline code (C)">`</button>
|
||||
<button type="button" class="btn btn-tool" data-action="codeblock" data-key="K" title="Code block (K)">```</button>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Link (L/P/G)">L▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="link" data-key="L" title="Link (L)">Link</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="wikilink" data-key="P" title="Wiki link (P)">Wiki link</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="embed" data-key="G" title="Embed file (G)">Embed file</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="List (U/O/X)">≡▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="ul" data-key="U" title="Unordered list (U)">Unordered list</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="ol" data-key="O" title="Ordered list (O)">Ordered list</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="task" data-key="X" title="Task list (X)">Task list</button>
|
||||
</div>
|
||||
</span>
|
||||
<button type="button" class="btn btn-tool" data-action="quote" data-key="Q" title="Blockquote (Q)">></button>
|
||||
<button type="button" class="btn btn-tool" data-action="hr" data-key="R" title="Horizontal rule (R)">---</button>
|
||||
<span class="toolbar-sep"></span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Table (T)">T▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="fmttable" data-key="T" title="Format table (T)">Format table</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tblalignleft" title="Align left">Align left</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tblaligncenter" title="Align center">Align center</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tblalignright" title="Align right">Align right</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertcol" title="Insert column">Insert column</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tbldeletecol" title="Delete column">Delete column</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tblinsertrow" title="Insert row">Insert row</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="tbldeleterow" title="Delete row">Delete row</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Insert date (D/W)">D▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="dateiso" data-key="D" title="YYYY-MM-DD (D)">YYYY-MM-DD</button>
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="datelong" data-key="W" title="DE Long (W)">DE Long</button>
|
||||
</div>
|
||||
</span>
|
||||
<span class="dropdown">
|
||||
<button type="button" class="btn btn-tool dropdown-toggle" title="Special (V)">★▾</button>
|
||||
<div class="dropdown-menu">
|
||||
<button type="button" class="btn btn-tool btn-block" data-action="movie" data-key="V" title="Import movie (V)">Import movie</button>
|
||||
</div>
|
||||
</span>
|
||||
</div>{{end}}
|
||||
+15
-14
@@ -10,14 +10,26 @@
|
||||
return document.body.getAttribute('hx-boost') === 'true';
|
||||
}
|
||||
|
||||
// Only wiki pages are boosted: same origin, a slash-terminated path, no
|
||||
// query except a search (?q=). Files, the editor (?edit), internal
|
||||
// endpoints (/_…) and POST forms navigate normally.
|
||||
function isPageURL(url) {
|
||||
if (url.origin !== location.origin) return false;
|
||||
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
|
||||
var ok = true;
|
||||
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
|
||||
return ok;
|
||||
}
|
||||
|
||||
// A server-sent HX-Redirect follows a mutation (move, delete, …): load the
|
||||
// target into the current history entry rather than pushing a new one, so
|
||||
// Back skips the stale pre-mutation page. Boosted pages swap it in like a
|
||||
// navigation; elsewhere replaceState + reload (not location.replace: a
|
||||
// Back skips the stale pre-mutation page. Boosted pages swap a page target
|
||||
// in like a navigation; anything else (or a target that is not a page,
|
||||
// like a new canvas) gets replaceState + reload (not location.replace: a
|
||||
// target differing only by #fragment would otherwise not re-fetch).
|
||||
htmx.location = {
|
||||
set href(target) {
|
||||
if (boosting()) {
|
||||
if (boosting() && isPageURL(new URL(target, location.href))) {
|
||||
if (window.closeModal) closeModal();
|
||||
htmx.ajax('GET', target, {
|
||||
target: '.shell',
|
||||
@@ -60,17 +72,6 @@
|
||||
// snapshots that could show a page from before a move or delete).
|
||||
htmx.config.scrollIntoViewOnBoost = false;
|
||||
|
||||
// Only wiki pages are boosted: same origin, a slash-terminated path, no
|
||||
// query except a search (?q=). Files, the editor (?edit), internal
|
||||
// endpoints (/_…) and POST forms navigate normally.
|
||||
function isPageURL(url) {
|
||||
if (url.origin !== location.origin) return false;
|
||||
if (!url.pathname.endsWith('/') || url.pathname.indexOf('/_') === 0) return false;
|
||||
var ok = true;
|
||||
url.searchParams.forEach(function (v, key) { if (key !== 'q') ok = false; });
|
||||
return ok;
|
||||
}
|
||||
|
||||
// htmx decides per element whether it is boosted, right after this event.
|
||||
document.addEventListener('htmx:beforeProcessNode', function (e) {
|
||||
var elt = e.target;
|
||||
|
||||
@@ -0,0 +1,4 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16" width="1em" height="1em"
|
||||
fill="none" stroke="currentColor" stroke-width="1.5" stroke-linejoin="miter" shape-rendering="crispEdges">
|
||||
<path d="M1 2h6v4H1zM9 10h6v4H9zM4 6v6h5"/>
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 250 B |
+3
-2
@@ -23,8 +23,9 @@
|
||||
</head>
|
||||
{{/* Outside the editor, links to pages are boosted: htmx swaps .shell
|
||||
(plus title and page actions, see "partial") instead of loading a new
|
||||
document. hx.js decides which links count as pages. */}}
|
||||
<body{{if not .EditMode}} hx-boost="true"{{end}}>
|
||||
document. hx.js decides which links count as pages. A view can opt out by
|
||||
overriding "boost" (the canvas does: it saves on pagehide). */}}
|
||||
<body{{if not .EditMode}}{{block "boost" .}} hx-boost="true"{{end}}{{end}}>
|
||||
<header>
|
||||
<nav class="breadcrumb row">
|
||||
<a href="/" tabindex="-1" title="Home"><svg class="logo" viewBox="0 0 26.052269 26.052269" xmlns="http://www.w3.org/2000/svg"><g fill="none" stroke="currentColor" stroke-linejoin="miter" transform="matrix(0.05463483,8.1519706e-6,-8.1519706e-6,0.05463483,-64.560546,-24.6949)"><rect x="1188.537" y="457.92056" width="461.87488" height="462.15189" stroke-width="20.2288"/><path d="m1348.9955 456.59572.046 309.36839" stroke-width="19.6849"/><path d="m1200.3996 765.80237 441.8362-.0659" stroke-width="19.6849"/><path d="m1648.2897 620.244-299.2012.0446" stroke-width="20.5676"/><path d="m1491.6148 909.24806-.021-136.93117" stroke-width="19.6849"/><rect x="1191.6504" y="461.66092" width="457.09634" height="457.09634" stroke-width="19.6761"/></g></svg><span class="app-name"> datascape</span></a>
|
||||
|
||||
@@ -16,6 +16,19 @@ confirmation), or fail with a plain-text error. */}}
|
||||
</form>
|
||||
</div>{{end}}
|
||||
|
||||
{{define "canvas"}}<div class="modal-backdrop">
|
||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?canvas" hx-target="#modal-host">
|
||||
<div class="panel-header"><span id="modal-title">New canvas — name?</span></div>
|
||||
<div class="panel-body">
|
||||
<input class="input" type="text" name="name" placeholder="Canvas name" required autofocus />
|
||||
</div>
|
||||
<div class="panel-footer">
|
||||
<button class="btn" type="button" data-modal-close>CANCEL</button>
|
||||
<button class="btn" type="submit">CREATE</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>{{end}}
|
||||
|
||||
{{define "move"}}<div class="modal-backdrop">
|
||||
<form class="modal panel panel-floating" role="dialog" aria-modal="true" aria-labelledby="modal-title" hx-post="?move" hx-target="#modal-host">
|
||||
<div class="panel-header"><span id="modal-title">Move — new name?</span></div>
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
{{define "headerActions"}}{{if .CanEdit}}
|
||||
<button class="btn btn-block" onclick="newPage()" title="New page (N)"><span class="btn-icon">{{icon "new"}}</span><span class="btn-label">NEW</span></button>
|
||||
<button class="btn btn-block" hx-get="?dialog=canvas" hx-target="#modal-host" title="New canvas on this page"><span class="btn-icon">{{icon "canvas"}}</span><span class="btn-label">CANVAS</span></button>
|
||||
<button class="btn btn-block" data-companion-reveal hidden title="Reveal in file manager"><span class="btn-icon">{{icon "reveal"}}</span><span class="btn-label">REVEAL</span></button>
|
||||
{{if not .IsRoot}}
|
||||
<button class="btn btn-block" onclick="movePage()" title="Move page (M)"><span class="btn-icon">{{icon "move"}}</span><span class="btn-label">MOVE</span></button>
|
||||
|
||||
+243
-1
@@ -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; }
|
||||
|
||||
Reference in New Issue
Block a user