create-pmos 0.67.0 → 0.68.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/payload/KIT-VERSION +1 -1
- package/payload/planning/evals/eval-calibration.md +1 -1
- package/payload/planning/okrs/health-budgets.md +1 -1
- package/payload/state/discoveries.md +1 -1
- package/workspace/guide/command-palette.md +1 -1
- package/workspace/guide/layout.md +36 -9
- package/workspace/lib/sync.js +32 -4
- package/workspace/package-lock.json +1 -1
- package/workspace/package.json +1 -1
- package/workspace/public/pm/components.css +610 -3
- package/workspace/public/pm/doc-editor.js +473 -0
- package/workspace/public/pm/doc-rail.js +161 -0
- package/workspace/public/pm/doc-remote.js +180 -0
- package/workspace/public/pm/dock-sessions.js +57 -13
- package/workspace/public/pm/frame-copy.js +2 -0
- package/workspace/public/pm/icons.js +1 -1
- package/workspace/public/pm/panel-tabs.js +85 -3
- package/workspace/public/pm/primitives.js +8 -4
- package/workspace/public/pm/state.js +3 -0
- package/workspace/public/pm/tab-doc.js +175 -170
- package/workspace/public/vendor/lucide-sprite.js +15 -2
- package/workspace/replaced-tests.json +43 -0
- package/workspace/server.js +30 -0
- package/workspace/test-lists/workspace-tabs-declutter.json +13 -0
- package/workspace/third-party/README.md +1 -1
|
@@ -0,0 +1,473 @@
|
|
|
1
|
+
/* pm/doc-editor.js — a markdown document, read and edited in the same place (workspace-tabs-declutter).
|
|
2
|
+
*
|
|
3
|
+
* The PM, 2026-10-06: "the document view is neither interactive nor modern, and editing an MD file is difficult. I
|
|
4
|
+
* should be able to edit, but the layout still mirrors the view." So a document the PM can edit is not shown in one
|
|
5
|
+
* mode and edited in another. It is one surface: each block (a heading, a paragraph, a list, a quote, a code fence, a
|
|
6
|
+
* table) is drawn as it reads, and a click turns THAT block into its own markdown, where it stands, with the caret where
|
|
7
|
+
* the click landed. Esc, or a click outside, draws it again.
|
|
8
|
+
*
|
|
9
|
+
* ── The text is never re-written by the editor ──────────────────────────────────────────────────────────────────────
|
|
10
|
+
* The file is split into blocks at blank lines (never inside a code fence), and every separator, the frontmatter and
|
|
11
|
+
* the leading and trailing whitespace are kept as they were. So an untouched file composes back byte for byte, and an
|
|
12
|
+
* edit to one block changes only that block's lines: the diff the PM reads in Changes is the edit and nothing else.
|
|
13
|
+
* The buffer is the same `docEdits` buffer the Markdown toggle edits, and save is tab-doc.js's one save path.
|
|
14
|
+
*
|
|
15
|
+
* ── Why the node outlives the render ────────────────────────────────────────────────────────────────────────────────
|
|
16
|
+
* Every keystroke is a state change and a state change repaints the panel. The editor is one node per tab, keyed on
|
|
17
|
+
* the text it last drew; a repaint whose text is the text this editor itself just wrote hands back the same node, so
|
|
18
|
+
* the textarea being typed in keeps its caret. A text that changed from outside (revert, a save that stamped the file,
|
|
19
|
+
* the Markdown toggle) rebuilds it.
|
|
20
|
+
*/
|
|
21
|
+
|
|
22
|
+
import { el, setChildren } from './dom.js';
|
|
23
|
+
import { mdRender } from './markdown.js';
|
|
24
|
+
import { iconButton, button, menu, SEL } from './primitives.js';
|
|
25
|
+
import { icon } from './icons.js';
|
|
26
|
+
|
|
27
|
+
/* ── the block model ─────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
28
|
+
|
|
29
|
+
const FENCE = /^\s{0,3}(```|~~~)/;
|
|
30
|
+
|
|
31
|
+
/** text → { head, fm, blocks, seps, tail }. compose(parse(t)) === t for every t. */
|
|
32
|
+
export function parse(text) {
|
|
33
|
+
const src = String(text == null ? '' : text);
|
|
34
|
+
let head = '';
|
|
35
|
+
let fm = null;
|
|
36
|
+
let body = src;
|
|
37
|
+
const m = /^---\n([\s\S]*?\n)?---(?:\n|$)/.exec(src);
|
|
38
|
+
if (m) { fm = (m[1] || '').replace(/\n$/, ''); head = m[0]; body = src.slice(m[0].length); }
|
|
39
|
+
const lines = body.split('\n');
|
|
40
|
+
const blocks = [];
|
|
41
|
+
const seps = [];
|
|
42
|
+
let cur = null;
|
|
43
|
+
let blank = [];
|
|
44
|
+
let lead = null;
|
|
45
|
+
let fence = false;
|
|
46
|
+
for (const l of lines) {
|
|
47
|
+
if (!fence && !l.trim()) {
|
|
48
|
+
if (cur) { blocks.push(cur.join('\n')); cur = null; }
|
|
49
|
+
blank.push(l);
|
|
50
|
+
continue;
|
|
51
|
+
}
|
|
52
|
+
if (!cur) {
|
|
53
|
+
if (blocks.length) seps.push('\n' + blank.map((b) => b + '\n').join(''));
|
|
54
|
+
else { lead = blank.map((b) => b + '\n').join(''); seps.push(''); }
|
|
55
|
+
blank = [];
|
|
56
|
+
cur = [];
|
|
57
|
+
}
|
|
58
|
+
cur.push(l);
|
|
59
|
+
if (FENCE.test(l)) fence = !fence;
|
|
60
|
+
}
|
|
61
|
+
if (cur) blocks.push(cur.join('\n'));
|
|
62
|
+
// A body ends in a newline when its last line is empty: the blank lines after the last block are the tail.
|
|
63
|
+
const tail = blocks.length ? (blank.length ? '\n' + blank.slice(0, -1).map((b) => b + '\n').join('') : '') : '';
|
|
64
|
+
return { head, fm, lead: lead == null ? (blocks.length ? '' : body) : lead, blocks, seps, tail };
|
|
65
|
+
}
|
|
66
|
+
|
|
67
|
+
/** The model back to text. An emptied block is left out, and its separator with it. */
|
|
68
|
+
export function compose(d) {
|
|
69
|
+
let out = d.head + d.lead;
|
|
70
|
+
let first = true;
|
|
71
|
+
d.blocks.forEach((b, i) => {
|
|
72
|
+
if (!b.trim()) return;
|
|
73
|
+
out += (first ? '' : (d.seps[i] || '\n\n')) + b;
|
|
74
|
+
first = false;
|
|
75
|
+
});
|
|
76
|
+
return out + (d.blocks.some((b) => b.trim()) ? (d.tail || '') : '');
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/** What a block is, by its first line: it chooses the font the block is edited in and how it is drawn. */
|
|
80
|
+
export function kindOf(src) {
|
|
81
|
+
const s = String(src || '');
|
|
82
|
+
if (FENCE.test(s)) return 'code';
|
|
83
|
+
const h = /^(#{1,6})\s/.exec(s);
|
|
84
|
+
if (h) return 'h' + h[1].length;
|
|
85
|
+
if (/^\s{0,3}>/.test(s)) return 'quote';
|
|
86
|
+
const lines = s.split('\n');
|
|
87
|
+
if (s && lines.every((l) => /^\s*[-*+] \[[ xX]\] /.test(l) || (/^\s{2,}\S/.test(l)))) return 'check';
|
|
88
|
+
if (/^\s*([-*+]|\d+[.)]) /.test(s)) return 'list';
|
|
89
|
+
if (lines.length > 1 && /^\s*\|?\s*:?-{3,}/.test(lines[1]) && s.includes('|')) return 'table';
|
|
90
|
+
return 'p';
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/* Where a click in the drawn text lands in the markdown: the drawn characters, each mapped to its offset in the source.
|
|
94
|
+
* Markup the drawing hides (a heading's #, a list's marker, ** and `, a link's address) maps to nothing. */
|
|
95
|
+
export function plainMap(src, k) {
|
|
96
|
+
const keep = [];
|
|
97
|
+
if (k === 'code' || k === 'table') return keep;
|
|
98
|
+
let i = 0;
|
|
99
|
+
let start = true;
|
|
100
|
+
while (i < src.length) {
|
|
101
|
+
if (start) {
|
|
102
|
+
const m = /^(\s*(#{1,6} |>\s?|[-*+] \[[ xX]\] |[-*+] |\d+[.)] ))/.exec(src.slice(i));
|
|
103
|
+
start = false;
|
|
104
|
+
if (m) { i += m[0].length; continue; }
|
|
105
|
+
}
|
|
106
|
+
const c = src[i];
|
|
107
|
+
if (c === '\n') { if (k === 'p' || k === 'quote') keep.push(i); i++; start = true; continue; }
|
|
108
|
+
if (src.startsWith('**', i) || src.startsWith('__', i)) { i += 2; continue; }
|
|
109
|
+
if (c === '*' || c === '`' || c === '_') { i++; continue; }
|
|
110
|
+
if (c === '[') {
|
|
111
|
+
const m = /^\[([^\]]*)\]\(([^)]*)\)/.exec(src.slice(i));
|
|
112
|
+
if (m) { for (let j = 1; j <= m[1].length; j++) keep.push(i + j); i += m[0].length; continue; }
|
|
113
|
+
}
|
|
114
|
+
keep.push(i);
|
|
115
|
+
i++;
|
|
116
|
+
}
|
|
117
|
+
return keep;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
function offsetAt(node, x, y) {
|
|
121
|
+
const d = node.ownerDocument;
|
|
122
|
+
let at = null;
|
|
123
|
+
let off = 0;
|
|
124
|
+
if (d.caretPositionFromPoint) { const p = d.caretPositionFromPoint(x, y); if (p) { at = p.offsetNode; off = p.offset; } }
|
|
125
|
+
else if (d.caretRangeFromPoint) { const r = d.caretRangeFromPoint(x, y); if (r) { at = r.startContainer; off = r.startOffset; } }
|
|
126
|
+
if (!at || !node.contains(at)) return null;
|
|
127
|
+
const r = d.createRange();
|
|
128
|
+
r.selectNodeContents(node);
|
|
129
|
+
r.setEnd(at, off);
|
|
130
|
+
return r.toString().length;
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
/* ── one editor per tab ──────────────────────────────────────────────────────────────────────────────────────────── */
|
|
134
|
+
|
|
135
|
+
const eds = new Map(); // tab id -> editor record
|
|
136
|
+
|
|
137
|
+
const SLASH = [
|
|
138
|
+
{ id: 'p', label: 'Text', icon: 'type', md: '' },
|
|
139
|
+
{ id: 'h2', label: 'Heading', icon: 'heading', md: '## ' },
|
|
140
|
+
{ id: 'h3', label: 'Subheading', icon: 'heading', md: '### ' },
|
|
141
|
+
{ id: 'list', label: 'Bullet list', icon: 'list', md: '- ' },
|
|
142
|
+
{ id: 'check', label: 'Checklist', icon: 'list-checks', md: '- [ ] ' },
|
|
143
|
+
{ id: 'quote', label: 'Quote', icon: 'text-quote', md: '> ' },
|
|
144
|
+
{ id: 'code', label: 'Code', icon: 'code', md: '```\n\n```' },
|
|
145
|
+
];
|
|
146
|
+
|
|
147
|
+
/**
|
|
148
|
+
* The editing surface for one tab. `o` = { text, ctx (links), onChange(text), onSave(), narrow }.
|
|
149
|
+
* Returns the same node while the text is the text this editor last drew or wrote.
|
|
150
|
+
*/
|
|
151
|
+
export function editorNode(tab, o) {
|
|
152
|
+
wirePress();
|
|
153
|
+
let ed = eds.get(tab.id);
|
|
154
|
+
if (!ed) {
|
|
155
|
+
ed = { tab, node: el('div', { class: 'fr-ed__main', 'data-ed': 'main' }), key: null, d: null, editing: null, caret: 0, o };
|
|
156
|
+
eds.set(tab.id, ed);
|
|
157
|
+
}
|
|
158
|
+
ed.o = o;
|
|
159
|
+
if (ed.key === o.text && ed.d) return ed.node;
|
|
160
|
+
ed.key = o.text;
|
|
161
|
+
ed.d = parse(o.text);
|
|
162
|
+
if (ed.editing != null && ed.editing >= ed.d.blocks.length) ed.editing = null;
|
|
163
|
+
draw(ed);
|
|
164
|
+
return ed.node;
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
export function disposeEditor(id) { eds.delete(id); }
|
|
168
|
+
|
|
169
|
+
/** Whether this tab's editor is mid-edit (the panel then leaves its scroll and focus alone). */
|
|
170
|
+
export const isEditing = (id) => { const ed = eds.get(id); return !!(ed && ed.editing != null); };
|
|
171
|
+
|
|
172
|
+
function write(ed) {
|
|
173
|
+
const text = compose(ed.d);
|
|
174
|
+
ed.key = text;
|
|
175
|
+
ed.o.onChange(text);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function draw(ed) {
|
|
179
|
+
const d = ed.d;
|
|
180
|
+
const kids = [];
|
|
181
|
+
const fmBox = propsBox(ed);
|
|
182
|
+
if (fmBox) kids.push(fmBox);
|
|
183
|
+
d.blocks.forEach((src, i) => { kids.push(i === ed.editing ? editingBlock(ed, i) : shownBlock(ed, i)); });
|
|
184
|
+
kids.push(el('div', { class: 'fr-ed__end' },
|
|
185
|
+
button({ label: 'add a block', variant: 'ghost', size: 'sm', icon: 'plus', attrs: { 'data-ed': 'append' },
|
|
186
|
+
onClick: () => { d.blocks.push(''); d.seps.push('\n\n'); begin(ed, d.blocks.length - 1, 0); } }),
|
|
187
|
+
el('p', { class: 'fr-ed__hint', 'data-ed': 'hint' },
|
|
188
|
+
'Click any text to edit it. Type / on an empty line for a heading, a list or a checklist. Ctrl or ⌘ S saves.')));
|
|
189
|
+
setChildren(ed.node, kids);
|
|
190
|
+
if (ed.editing != null) {
|
|
191
|
+
const ta = ed.node.querySelector('[data-ed="src"]');
|
|
192
|
+
if (ta) { size(ta); ta.focus({ preventScroll: true }); const c = Math.min(ed.caret, ta.value.length); ta.setSelectionRange(c, c); keepInView(ta); }
|
|
193
|
+
}
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
function keepInView(ta) {
|
|
197
|
+
const r = ta.getBoundingClientRect();
|
|
198
|
+
const sc = ta.closest('[data-ed="scroll"]');
|
|
199
|
+
if (!sc) return;
|
|
200
|
+
const s = sc.getBoundingClientRect();
|
|
201
|
+
if (r.bottom > s.bottom - 24) sc.scrollTop += r.bottom - s.bottom + 48;
|
|
202
|
+
else if (r.top < s.top + 24) sc.scrollTop -= s.top - r.top + 48;
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
const size = (ta) => { ta.style.setProperty('--fr-ed-h', '0px'); ta.style.setProperty('--fr-ed-h', ta.scrollHeight + 'px'); };
|
|
206
|
+
|
|
207
|
+
/* ── the frontmatter: a properties box ───────────────────────────────────────────────────────────────────────────── */
|
|
208
|
+
|
|
209
|
+
function propsBox(ed) {
|
|
210
|
+
const d = ed.d;
|
|
211
|
+
if (d.fm == null) return null;
|
|
212
|
+
const lines = d.fm.split('\n');
|
|
213
|
+
const rows = [];
|
|
214
|
+
lines.forEach((l, n) => {
|
|
215
|
+
const m = /^([A-Za-z0-9_.-]+):(\s?)(.*)$/.exec(l);
|
|
216
|
+
if (!m) return;
|
|
217
|
+
const nested = lines[n + 1] != null && /^\s+\S/.test(lines[n + 1]);
|
|
218
|
+
const editable = !nested && !/^[|>]/.test(m[3]) && !/^[[{]/.test(m[3]);
|
|
219
|
+
const quoted = /^"(.*)"$/.exec(m[3]);
|
|
220
|
+
const shown = quoted ? quoted[1].replace(/\\"/g, '"') : m[3];
|
|
221
|
+
rows.push(el('label', { class: 'fr-ed__prop-k', for: 'fr-ed-' + ed.tab.id + '-' + m[1] }, m[1]));
|
|
222
|
+
rows.push(editable
|
|
223
|
+
? el('input', {
|
|
224
|
+
class: 'fr-ed__prop-v', id: 'fr-ed-' + ed.tab.id + '-' + m[1], value: shown, spellcheck: 'false', 'data-ed-prop': m[1],
|
|
225
|
+
onInput: (e) => {
|
|
226
|
+
const v = e.target.value;
|
|
227
|
+
const now = d.fm.split('\n');
|
|
228
|
+
now[n] = m[1] + ':' + (m[2] || ' ') + (quoted ? JSON.stringify(v) : v);
|
|
229
|
+
d.fm = now.join('\n');
|
|
230
|
+
d.head = '---\n' + d.fm + '\n---\n';
|
|
231
|
+
write(ed);
|
|
232
|
+
},
|
|
233
|
+
onKeyDown: (e) => saveKey(ed, e),
|
|
234
|
+
})
|
|
235
|
+
: el('span', { class: 'fr-ed__prop-v fr-ed__prop-v--ro', title: 'A value over several lines: edit it in Markdown.' }, m[3] || '…'));
|
|
236
|
+
});
|
|
237
|
+
if (!rows.length) return null;
|
|
238
|
+
return el('details', { class: 'fr-ed__props', open: true, 'data-ed': 'props' },
|
|
239
|
+
el('summary', { class: 'fr-ed__props-sum' }, icon('chevron-right'), el('span', null, 'Properties'), el('span', { class: 'fr-ed__props-n' }, String(rows.length / 2))),
|
|
240
|
+
el('div', { class: 'fr-ed__props-grid' }, rows));
|
|
241
|
+
}
|
|
242
|
+
|
|
243
|
+
/* ── a block, drawn ──────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
244
|
+
|
|
245
|
+
function shownBlock(ed, i) {
|
|
246
|
+
const src = ed.d.blocks[i];
|
|
247
|
+
const k = kindOf(src);
|
|
248
|
+
let body;
|
|
249
|
+
if (k === 'check') {
|
|
250
|
+
body = el('ul', { class: 'fr-ed__checks' }, src.split('\n').map((l, li) => {
|
|
251
|
+
const c = /^(\s*)[-*+] \[([ xX])\] (.*)$/.exec(l);
|
|
252
|
+
if (!c) return el('li', { class: 'fr-ed__check fr-ed__check--more' }, l.trim());
|
|
253
|
+
const done = c[2] !== ' ';
|
|
254
|
+
const r = mdRender(c[3], ed.o.ctx);
|
|
255
|
+
return el('li', { class: 'fr-ed__check', 'data-done': done ? 'yes' : 'no' },
|
|
256
|
+
el('input', { type: 'checkbox', class: 'fr-ed__box', checked: done, 'aria-label': (done ? 'Done: ' : 'To do: ') + c[3], 'data-ed': 'check',
|
|
257
|
+
onChange: () => {
|
|
258
|
+
const ls = ed.d.blocks[i].split('\n');
|
|
259
|
+
ls[li] = ls[li].replace(/\[([ xX])\]/, done ? '[ ]' : '[x]');
|
|
260
|
+
ed.d.blocks[i] = ls.join('\n');
|
|
261
|
+
write(ed);
|
|
262
|
+
draw(ed);
|
|
263
|
+
} }),
|
|
264
|
+
el('span', { class: 'fr-ed__check-t' }, r.title ? r.title : r.blocks.map((b) => [...b.childNodes]).flat()));
|
|
265
|
+
}));
|
|
266
|
+
} else {
|
|
267
|
+
const r = mdRender(src, ed.o.ctx);
|
|
268
|
+
body = el('div', { class: 'fr-ed__md' }, r.title ? el('h1', { class: 'fr-ed__h1' }, r.title) : null, r.blocks);
|
|
269
|
+
}
|
|
270
|
+
const preview = src.replace(/^[#>\-*+\s\d.)[\]xX]+/, '').slice(0, 60);
|
|
271
|
+
return el('div', {
|
|
272
|
+
class: 'fr-ed__blk', 'data-i': String(i), 'data-k': k, tabindex: '0', 'data-ed': 'blk',
|
|
273
|
+
'aria-label': 'Edit: ' + (preview || 'empty block'),
|
|
274
|
+
onKeyDown: (e) => { if (e.target === e.currentTarget && (e.key === 'Enter' || e.key === 'F2')) { e.preventDefault(); begin(ed, i, src.length); } },
|
|
275
|
+
},
|
|
276
|
+
iconButton({ icon: 'plus', ariaLabel: 'Add a block below', size: 'sm', attrs: { class: 'fr-ed__add', 'data-ed': 'add' },
|
|
277
|
+
onClick: () => { ed.d.blocks.splice(i + 1, 0, ''); ed.d.seps.splice(i + 1, 0, '\n\n'); begin(ed, i + 1, 0); } }),
|
|
278
|
+
body);
|
|
279
|
+
}
|
|
280
|
+
|
|
281
|
+
/* A press on a drawn block starts editing it. Read on the document, not the block: a block is text, not a button (it
|
|
282
|
+
* is reached by Tab and opened with Enter), and the press must be taken before it moves the focus, or the block being
|
|
283
|
+
* edited would close and redraw under the pointer. */
|
|
284
|
+
let pressWired = false;
|
|
285
|
+
function wirePress() {
|
|
286
|
+
if (pressWired) return;
|
|
287
|
+
pressWired = true;
|
|
288
|
+
document.addEventListener('mousedown', (e) => {
|
|
289
|
+
const t = e.target;
|
|
290
|
+
if (!(t instanceof Element)) return;
|
|
291
|
+
// The format buttons and the / menu keep the focus where the text is.
|
|
292
|
+
if (t.closest('.fr-ed__tools') || (t.closest(SEL.menu) && [...eds.values()].some((x) => x.editing != null))) { e.preventDefault(); return; }
|
|
293
|
+
const blk = t.closest('.fr-ed__blk');
|
|
294
|
+
if (!blk || blk.classList.contains('fr-ed__blk--on') || t.closest('a, input, button')) return;
|
|
295
|
+
const ed = [...eds.values()].find((x) => x.node.contains(blk));
|
|
296
|
+
if (!ed || e.button !== 0) return;
|
|
297
|
+
e.preventDefault();
|
|
298
|
+
const i = Number(blk.getAttribute('data-i'));
|
|
299
|
+
const src = ed.d.blocks[i] || '';
|
|
300
|
+
const at = offsetAt(blk.querySelector('.fr-ed__md, .fr-ed__checks') || blk, e.clientX, e.clientY);
|
|
301
|
+
const map = plainMap(src, kindOf(src));
|
|
302
|
+
begin(ed, i, at == null || at >= map.length ? src.length : map[at]);
|
|
303
|
+
});
|
|
304
|
+
}
|
|
305
|
+
|
|
306
|
+
function begin(ed, i, caret) {
|
|
307
|
+
const d = ed.d;
|
|
308
|
+
// Leaving an empty block drops it.
|
|
309
|
+
if (ed.editing != null && ed.editing !== i && !String(d.blocks[ed.editing] || '').trim() && d.blocks.length > 1) {
|
|
310
|
+
d.blocks.splice(ed.editing, 1); d.seps.splice(ed.editing, 1);
|
|
311
|
+
if (ed.editing < i) i--;
|
|
312
|
+
}
|
|
313
|
+
ed.editing = i;
|
|
314
|
+
ed.caret = caret;
|
|
315
|
+
draw(ed);
|
|
316
|
+
}
|
|
317
|
+
|
|
318
|
+
function finish(ed, refocus) {
|
|
319
|
+
const i = ed.editing;
|
|
320
|
+
if (i == null) return;
|
|
321
|
+
ed.editing = null;
|
|
322
|
+
const before = compose(ed.d);
|
|
323
|
+
if (!String(ed.d.blocks[i] || '').trim() && ed.d.blocks.length > 1) { ed.d.blocks.splice(i, 1); ed.d.seps.splice(i, 1); }
|
|
324
|
+
if (compose(ed.d) !== before || compose(ed.d) !== ed.key) write(ed);
|
|
325
|
+
draw(ed);
|
|
326
|
+
if (refocus) { const b = ed.node.querySelector('.fr-ed__blk[data-i="' + Math.min(i, ed.d.blocks.length - 1) + '"]'); if (b) b.focus({ preventScroll: true }); }
|
|
327
|
+
}
|
|
328
|
+
|
|
329
|
+
/* ── a block, edited ─────────────────────────────────────────────────────────────────────────────────────────────── */
|
|
330
|
+
|
|
331
|
+
function editingBlock(ed, i) {
|
|
332
|
+
wirePress();
|
|
333
|
+
const src = ed.d.blocks[i];
|
|
334
|
+
const k = kindOf(src);
|
|
335
|
+
const ta = el('textarea', {
|
|
336
|
+
class: 'fr-ed__src fr-ed__src--' + k, rows: '1', spellcheck: 'true', 'data-ed': 'src',
|
|
337
|
+
'aria-label': 'This block, as markdown', placeholder: 'Type / for a heading, a list or a checklist',
|
|
338
|
+
onInput: (e) => onType(ed, i, e.target),
|
|
339
|
+
onKeyDown: (e) => onKey(ed, i, e),
|
|
340
|
+
onBlur: () => setTimeout(() => {
|
|
341
|
+
if (ed.editing !== i) return;
|
|
342
|
+
const a = ta.ownerDocument.activeElement;
|
|
343
|
+
if (a === ta || (a && (a.closest('.fr-ed__tools') || a.closest(SEL.menu)))) return;
|
|
344
|
+
if (!ta.ownerDocument.hasFocus()) return;
|
|
345
|
+
finish(ed, false);
|
|
346
|
+
}, 0),
|
|
347
|
+
});
|
|
348
|
+
ta.value = src;
|
|
349
|
+
const tool = (name, label, kind) => iconButton({ icon: name, ariaLabel: label, size: 'sm', attrs: { 'data-ed-fmt': kind },
|
|
350
|
+
onClick: () => wrap(ta, kind) });
|
|
351
|
+
return el('div', { class: 'fr-ed__blk fr-ed__blk--on', 'data-i': String(i), 'data-k': k },
|
|
352
|
+
el('div', { class: 'fr-ed__tools', role: 'toolbar', 'aria-label': 'Format' },
|
|
353
|
+
tool('bold', 'Bold (Ctrl or ⌘ B)', 'b'), tool('italic', 'Italic (Ctrl or ⌘ I)', 'i'),
|
|
354
|
+
tool('code', 'Code (Ctrl or ⌘ E)', 'c'), tool('link', 'Link (Ctrl or ⌘ K)', 'l'),
|
|
355
|
+
el('span', { class: 'fr-ed__tools-hint' }, 'Esc to finish')),
|
|
356
|
+
ta);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
function onType(ed, i, ta) {
|
|
360
|
+
ed.d.blocks[i] = ta.value;
|
|
361
|
+
ed.caret = ta.selectionStart;
|
|
362
|
+
const k = kindOf(ta.value);
|
|
363
|
+
ta.className = 'fr-ed__src fr-ed__src--' + k;
|
|
364
|
+
if (ta.parentNode) ta.parentNode.setAttribute('data-k', k);
|
|
365
|
+
size(ta);
|
|
366
|
+
write(ed);
|
|
367
|
+
if (ta.value === '/') {
|
|
368
|
+
menu({ anchor: ta, ariaLabel: 'Turn this block into', items: SLASH.map((s) => ({
|
|
369
|
+
label: s.label, icon: s.icon, attrs: { 'data-ed-slash': s.id },
|
|
370
|
+
onSelect: () => {
|
|
371
|
+
ed.d.blocks[i] = s.md;
|
|
372
|
+
write(ed);
|
|
373
|
+
ed.editing = i;
|
|
374
|
+
ed.caret = s.id === 'code' ? 4 : s.md.length;
|
|
375
|
+
draw(ed);
|
|
376
|
+
},
|
|
377
|
+
})) });
|
|
378
|
+
}
|
|
379
|
+
}
|
|
380
|
+
|
|
381
|
+
function saveKey(ed, e) {
|
|
382
|
+
if ((e.metaKey || e.ctrlKey) && (e.key === 's' || e.key === 'S')) { e.preventDefault(); ed.o.onSave(); return true; }
|
|
383
|
+
return false;
|
|
384
|
+
}
|
|
385
|
+
|
|
386
|
+
function onKey(ed, i, e) {
|
|
387
|
+
const ta = e.target;
|
|
388
|
+
const d = ed.d;
|
|
389
|
+
const v = ta.value;
|
|
390
|
+
const s = ta.selectionStart;
|
|
391
|
+
const end = ta.selectionEnd;
|
|
392
|
+
const k = kindOf(v);
|
|
393
|
+
const mod = e.metaKey || e.ctrlKey;
|
|
394
|
+
const go = (j, caret) => { e.preventDefault(); write(ed); ed.editing = j; ed.caret = caret; draw(ed); };
|
|
395
|
+
if (saveKey(ed, e)) return;
|
|
396
|
+
if (mod && !e.shiftKey && !e.altKey && /^[bike]$/i.test(e.key)) { e.preventDefault(); wrap(ta, { b: 'b', i: 'i', e: 'c', k: 'l' }[e.key.toLowerCase()]); return; }
|
|
397
|
+
if (e.key === 'Escape') { e.preventDefault(); finish(ed, true); return; }
|
|
398
|
+
if (e.key === 'Enter' && !e.shiftKey && !mod && k !== 'code' && k !== 'table') {
|
|
399
|
+
if (k === 'list' || k === 'check') {
|
|
400
|
+
const ls = v.lastIndexOf('\n', s - 1) + 1;
|
|
401
|
+
const nl = v.indexOf('\n', s);
|
|
402
|
+
const le = nl === -1 ? v.length : nl;
|
|
403
|
+
const line = v.slice(ls, le);
|
|
404
|
+
const mk = /^(\s*)([-*+]|\d+[.)]) (\[[ xX]\] )?/.exec(line);
|
|
405
|
+
if (mk && line.trim() === line.slice(0, mk[0].length).trim()) {
|
|
406
|
+
// An empty item ends the list: the lines after it become a new block.
|
|
407
|
+
const before = v.slice(0, ls).replace(/\n$/, '');
|
|
408
|
+
const after = v.slice(le).replace(/^\n/, '');
|
|
409
|
+
d.blocks[i] = before;
|
|
410
|
+
d.blocks.splice(i + 1, 0, '', ...(after ? [after] : []));
|
|
411
|
+
d.seps.splice(i + 1, 0, '\n\n', ...(after ? ['\n\n'] : []));
|
|
412
|
+
return go(i + 1, 0);
|
|
413
|
+
}
|
|
414
|
+
e.preventDefault();
|
|
415
|
+
const n = mk && /^\d+/.test(mk[2]) ? (parseInt(mk[2], 10) + 1) + mk[2].slice(-1) : (mk ? mk[2] : '-');
|
|
416
|
+
ta.setRangeText('\n' + (mk ? mk[1] : '') + n + ' ' + (mk && mk[3] ? '[ ] ' : ''), s, end, 'end');
|
|
417
|
+
onType(ed, i, ta);
|
|
418
|
+
return;
|
|
419
|
+
}
|
|
420
|
+
d.blocks[i] = v.slice(0, s);
|
|
421
|
+
d.blocks.splice(i + 1, 0, v.slice(end));
|
|
422
|
+
d.seps.splice(i + 1, 0, '\n\n');
|
|
423
|
+
return go(i + 1, 0);
|
|
424
|
+
}
|
|
425
|
+
if (e.key === 'Backspace' && s === 0 && end === 0 && i > 0) {
|
|
426
|
+
const prev = d.blocks[i - 1];
|
|
427
|
+
const pk = kindOf(prev);
|
|
428
|
+
if (!v) { d.blocks.splice(i, 1); d.seps.splice(i, 1); return go(i - 1, prev.length); }
|
|
429
|
+
if (pk !== 'code' && k !== 'code' && pk !== 'table' && k !== 'table') {
|
|
430
|
+
const joined = (pk === k && (k === 'list' || k === 'check')) ? '\n' + v : v.replace(/^(#{1,6} |>\s?)/, '');
|
|
431
|
+
d.blocks[i - 1] = prev + joined;
|
|
432
|
+
d.blocks.splice(i, 1); d.seps.splice(i, 1);
|
|
433
|
+
return go(i - 1, prev.length);
|
|
434
|
+
}
|
|
435
|
+
}
|
|
436
|
+
if (e.key === 'ArrowUp' && s === end && !v.slice(0, s).includes('\n') && i > 0) return go(i - 1, d.blocks[i - 1].length);
|
|
437
|
+
if (e.key === 'ArrowDown' && s === end && !v.slice(end).includes('\n') && i < d.blocks.length - 1) return go(i + 1, 0);
|
|
438
|
+
}
|
|
439
|
+
|
|
440
|
+
/** Wrap the selection in a mark (or, with nothing selected, insert one around a placeholder word). */
|
|
441
|
+
function wrap(ta, kind) {
|
|
442
|
+
const s = ta.selectionStart;
|
|
443
|
+
const e = ta.selectionEnd;
|
|
444
|
+
const v = ta.value;
|
|
445
|
+
const sel = v.slice(s, e) || (kind === 'l' ? 'link text' : 'text');
|
|
446
|
+
const [a, b] = kind === 'b' ? ['**', '**'] : kind === 'i' ? ['*', '*'] : kind === 'c' ? ['`', '`'] : ['[', '](https://)'];
|
|
447
|
+
ta.setRangeText(a + sel + b, s, e, 'end');
|
|
448
|
+
if (kind === 'l') ta.setSelectionRange(s + a.length + sel.length + 2, s + a.length + sel.length + 10);
|
|
449
|
+
else ta.setSelectionRange(s + a.length, s + a.length + sel.length);
|
|
450
|
+
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
451
|
+
ta.focus();
|
|
452
|
+
}
|
|
453
|
+
|
|
454
|
+
/** The headings of the document this tab's editor holds, for the outline: [{ i, level, text }]. */
|
|
455
|
+
export function headsOf(id) {
|
|
456
|
+
const ed = eds.get(id);
|
|
457
|
+
if (!ed || !ed.d) return [];
|
|
458
|
+
const out = [];
|
|
459
|
+
ed.d.blocks.forEach((b, i) => {
|
|
460
|
+
const m = /^(#{1,3})\s+(.+)$/.exec(b.split('\n')[0]);
|
|
461
|
+
if (m) out.push({ i, level: m[1].length, text: m[2].replace(/[*_`]/g, '').trim() });
|
|
462
|
+
});
|
|
463
|
+
return out;
|
|
464
|
+
}
|
|
465
|
+
|
|
466
|
+
/** Bring one block to the top of the pane and give it the focus, as the outline's press does. */
|
|
467
|
+
export function goToBlock(id, i) {
|
|
468
|
+
const ed = eds.get(id);
|
|
469
|
+
const b = ed && ed.node.querySelector('.fr-ed__blk[data-i="' + i + '"]');
|
|
470
|
+
if (!b) return;
|
|
471
|
+
b.scrollIntoView({ block: 'start', behavior: matchMedia('(prefers-reduced-motion: reduce)').matches ? 'auto' : 'smooth' });
|
|
472
|
+
b.focus({ preventScroll: true });
|
|
473
|
+
}
|
|
@@ -0,0 +1,161 @@
|
|
|
1
|
+
/* pm/doc-rail.js — beside a document: its outline, and its Connections (workspace-tabs-declutter).
|
|
2
|
+
*
|
|
3
|
+
* Connections is the document graph the PM asked for in the tab. It draws only STATED ties, the ones `pmos kg
|
|
4
|
+
* neighbors` prints (lib/kg.js): a link, a frontmatter path, the initiative, the anchor, a decision id. It adds the
|
|
5
|
+
* file's copies from the Docs store and the findings the last consistency check wrote against the file. Nothing is
|
|
6
|
+
* guessed from wording: a file that talks about the same thing without a tie is not on the graph. It replaces the
|
|
7
|
+
* Alignment drawer, whose ties (the consistency record) are the findings shown here.
|
|
8
|
+
*
|
|
9
|
+
* The graph is a picture of the list below it. The list is the control: every tie is a row that opens the file, so
|
|
10
|
+
* the graph itself is hidden from assistive technology and a click on a square is the same press as its row.
|
|
11
|
+
*/
|
|
12
|
+
|
|
13
|
+
import { el, setChildren } from './dom.js';
|
|
14
|
+
import { tabs, row } from './primitives.js';
|
|
15
|
+
import { setState } from './state.js';
|
|
16
|
+
import { copiesOf, wordsOf, openCopy } from './doc-remote.js';
|
|
17
|
+
|
|
18
|
+
const SVG = 'http://www.w3.org/2000/svg';
|
|
19
|
+
const graphs = new Map(); // ws|path -> { state, data, at }
|
|
20
|
+
const rails = new Map(); // tab id -> { node, key }
|
|
21
|
+
const modes = new Map(); // tab id -> 'outline' | 'graph'
|
|
22
|
+
|
|
23
|
+
const VIA = { link: 'Link', frontmatter: 'Frontmatter', initiative: 'Initiative', anchor: 'Anchor', cites: 'Decision',
|
|
24
|
+
supersedes: 'Supersedes', amended_by: 'Amended by' };
|
|
25
|
+
|
|
26
|
+
function readGraph(ws, rel) {
|
|
27
|
+
const k = ws + '|' + rel;
|
|
28
|
+
const cur = graphs.get(k);
|
|
29
|
+
if (cur && (cur.state === 'loading' || Date.now() - cur.at < 60000)) return cur;
|
|
30
|
+
const next = { state: cur && cur.data ? 'ok' : 'loading', data: cur ? cur.data : null, at: Date.now() };
|
|
31
|
+
graphs.set(k, next);
|
|
32
|
+
fetch('/api/doc/graph?ws=' + encodeURIComponent(ws) + '&path=' + encodeURIComponent(rel))
|
|
33
|
+
.then((r) => r.json())
|
|
34
|
+
.then((d) => { graphs.set(k, d && d.ok ? { state: 'ok', data: d, at: Date.now() } : { state: 'error', error: (d && d.error) || 'refused', at: Date.now() }); setState({}); },
|
|
35
|
+
(e) => { graphs.set(k, { state: 'error', error: (e && e.message) || 'could not reach the workspace server', at: Date.now() }); setState({}); });
|
|
36
|
+
return next;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
/** Read the graph again after a save: a new link is a new tie. */
|
|
40
|
+
export function refreshGraph(ws, rel) { graphs.delete(ws + '|' + rel); }
|
|
41
|
+
|
|
42
|
+
export function disposeRail(id) { rails.delete(id); modes.delete(id); }
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* The rail for one tab. `o` = { ws, rel, heads: [{ i, level, text }], findings: [{ status, sentence, rel? }],
|
|
46
|
+
* onGoto(i), onOpen(rel) }. Returns the same node while what it shows has not changed.
|
|
47
|
+
*/
|
|
48
|
+
export function railNode(tab, o) {
|
|
49
|
+
const g = readGraph(o.ws, o.rel);
|
|
50
|
+
const copies = copiesOf(o.ws, o.rel);
|
|
51
|
+
const ties = g.state === 'ok' && g.data ? g.data.ties || [] : [];
|
|
52
|
+
const flagged = new Set(o.findings.map((f) => f.rel).filter(Boolean));
|
|
53
|
+
const mode = modes.get(tab.id) || (o.heads.length > 1 ? 'outline' : 'graph');
|
|
54
|
+
const key = JSON.stringify([mode, o.heads, g.state, ties, copies.map((c) => [c.kind, wordsOf(c, o.ws, o.rel).text]), o.findings]);
|
|
55
|
+
let r = rails.get(tab.id);
|
|
56
|
+
if (!r) { r = { node: el('aside', { class: 'fr-rail', 'data-rail': '' }), key: null }; rails.set(tab.id, r); }
|
|
57
|
+
if (r.key === key) return r.node;
|
|
58
|
+
r.key = key;
|
|
59
|
+
const n = ties.length + copies.length;
|
|
60
|
+
const kids = [tabs({
|
|
61
|
+
variant: 'segmented', ariaLabel: 'Beside the document', value: mode, full: true,
|
|
62
|
+
items: [{ id: 'outline', label: 'Outline' }, { id: 'graph', label: n ? 'Connections ' + n : 'Connections' }],
|
|
63
|
+
onChange: (m) => { modes.set(tab.id, m); setState({}); },
|
|
64
|
+
attrs: { class: 'fr-rail__tabs', 'data-rail': 'tabs' },
|
|
65
|
+
})];
|
|
66
|
+
if (mode === 'outline') {
|
|
67
|
+
kids.push(o.heads.length
|
|
68
|
+
? el('nav', { class: 'fr-rail__outline', 'aria-label': 'Outline', 'data-rail': 'outline' },
|
|
69
|
+
o.heads.map((h) => row({ title: h.text, compact: true, onOpen: () => o.onGoto(h.i), openLabel: 'Go to ' + h.text,
|
|
70
|
+
attrs: { class: 'fr-rail__head', 'data-level': String(h.level), 'data-rail': 'head' } })))
|
|
71
|
+
: el('p', { class: 'fr-rail__note' }, 'No headings yet. A line that starts with # or ## is a heading.'));
|
|
72
|
+
} else if (g.state === 'loading' && !g.data) {
|
|
73
|
+
kids.push(el('p', { class: 'fr-rail__note', 'data-rail': 'status' }, 'Reading the ties of this file…'));
|
|
74
|
+
} else if (g.state === 'error') {
|
|
75
|
+
kids.push(el('p', { class: 'fr-rail__note fr-rail__note--bad', 'data-rail': 'status' }, 'The ties could not be read: ' + g.error + '. Nothing is shown.'));
|
|
76
|
+
} else {
|
|
77
|
+
const items = [
|
|
78
|
+
...ties.map((t) => ({ kind: 'file', t, flag: flagged.has(t.path) })),
|
|
79
|
+
...copies.map((c) => ({ kind: 'copy', c })),
|
|
80
|
+
];
|
|
81
|
+
if (items.length) kids.push(graphSvg(items, (it) => (it.kind === 'copy' ? openCopy(o.ws, o.rel) : o.onOpen(it.t.path))));
|
|
82
|
+
kids.push(el('p', { class: 'fr-rail__note', 'data-rail': 'rule' }, items.length
|
|
83
|
+
? 'Only stated ties: a link, a frontmatter path, an initiative, an anchor or a decision id.'
|
|
84
|
+
: (g.data && g.data.tracked === false
|
|
85
|
+
? 'This file is not tracked by git yet, so no tie is known. Commit it to see its ties.'
|
|
86
|
+
: 'No file is tied to this one. A link, a frontmatter path or a decision id makes a tie.')));
|
|
87
|
+
if (items.length) {
|
|
88
|
+
kids.push(el('div', { class: 'fr-rail__ties', 'data-rail': 'ties' }, items.map((it, k) => (it.kind === 'copy'
|
|
89
|
+
? row({ title: it.c.title || it.c.target, meta: [it.c.label || 'Copy', wordsOf(it.c, o.ws, o.rel).text], compact: true,
|
|
90
|
+
onOpen: () => openCopy(o.ws, o.rel), openLabel: 'The ' + (it.c.label || 'copy') + ' copy of this file',
|
|
91
|
+
attrs: { class: 'fr-rail__tie', 'data-k': String(k), 'data-rail': 'copy' } })
|
|
92
|
+
: row({ title: it.t.title || it.t.path, compact: true,
|
|
93
|
+
meta: [VIA[it.t.via] || it.t.via, it.t.direction === 'in' ? 'points here' : 'this file points to it', it.t.basis === 'mention' ? 'mentioned' : null].filter(Boolean),
|
|
94
|
+
onOpen: () => o.onOpen(it.t.path), openLabel: 'Open ' + it.t.path,
|
|
95
|
+
attrs: { class: 'fr-rail__tie' + (it.flag ? ' fr-rail__tie--flag' : ''), 'data-k': String(k), 'data-rail': 'tie', 'data-path': it.t.path, title: it.t.path } })))));
|
|
96
|
+
}
|
|
97
|
+
if (o.findings.length) {
|
|
98
|
+
kids.push(el('div', { class: 'fr-rail__label' }, 'Findings'));
|
|
99
|
+
kids.push(el('ul', { class: 'fr-rail__findings', 'data-rail': 'findings' }, o.findings.map((f) =>
|
|
100
|
+
el('li', { class: 'fr-rail__finding', 'data-status': f.status }, el('span', { class: 'fr-rail__finding-s' }, f.status), f.sentence))));
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
setChildren(r.node, kids);
|
|
104
|
+
return r.node;
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/* The picture: this file in the middle, each tie on a ring around it. A copy is drawn in the copy's tone, a tie a
|
|
108
|
+
* finding names in the warning tone, a tie made by a mention (a decision id in prose) as a dashed line. */
|
|
109
|
+
function graphSvg(items, open) {
|
|
110
|
+
const W = 280;
|
|
111
|
+
const H = 220;
|
|
112
|
+
const cx = W / 2;
|
|
113
|
+
const cy = H / 2 - 6;
|
|
114
|
+
const R = Math.min(86, 40 + items.length * 6);
|
|
115
|
+
const s = (tag, attrs, ...kids) => {
|
|
116
|
+
const n = document.createElementNS(SVG, tag);
|
|
117
|
+
for (const a in attrs) if (attrs[a] != null) n.setAttribute(a, String(attrs[a]));
|
|
118
|
+
kids.forEach((c) => { if (c) n.appendChild(typeof c === 'string' ? document.createTextNode(c) : c); });
|
|
119
|
+
return n;
|
|
120
|
+
};
|
|
121
|
+
const shown = items.slice(0, 14);
|
|
122
|
+
const pos = shown.map((_, k) => { const a = -Math.PI / 2 + (2 * Math.PI * k) / shown.length; return [cx + R * Math.cos(a), cy + R * Math.sin(a)]; });
|
|
123
|
+
const label = (it) => {
|
|
124
|
+
const p = it.kind === 'copy' ? (it.c.label || 'Copy') : String(it.t.path).split('/').pop().replace(/\.md$/, '');
|
|
125
|
+
return p.length > 20 ? p.slice(0, 19) + '…' : p;
|
|
126
|
+
};
|
|
127
|
+
const svg = s('svg', { class: 'fr-rail__graph', viewBox: '0 0 ' + W + ' ' + H, 'aria-hidden': 'true', 'data-rail': 'graph' },
|
|
128
|
+
...shown.map((it, k) => s('line', { x1: cx, y1: cy, x2: pos[k][0], y2: pos[k][1], 'data-k': k,
|
|
129
|
+
class: 'fr-rail__edge' + (it.kind === 'copy' ? ' fr-rail__edge--copy' : '') + (it.flag ? ' fr-rail__edge--flag' : '') + (it.t && it.t.basis === 'mention' ? ' fr-rail__edge--mention' : '') })),
|
|
130
|
+
s('rect', { x: cx - 9, y: cy - 9, width: 18, height: 18, class: 'fr-rail__me' }),
|
|
131
|
+
...shown.map((it, k) => {
|
|
132
|
+
const [x, y] = pos[k];
|
|
133
|
+
const below = y >= cy - 1;
|
|
134
|
+
return s('g', { class: 'fr-rail__node' + (it.kind === 'copy' ? ' fr-rail__node--copy' : '') + (it.flag ? ' fr-rail__node--flag' : ''), 'data-k': k },
|
|
135
|
+
s('rect', { x: x - 6, y: y - 6, width: 12, height: 12 }),
|
|
136
|
+
s('text', { x, y: below ? y + 20 : y - 11, 'text-anchor': 'middle' }, label(it)));
|
|
137
|
+
}));
|
|
138
|
+
svg.__open = (k) => { const it = shown[k]; if (it) open(it); };
|
|
139
|
+
wireGraph();
|
|
140
|
+
return svg;
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
/* One listener on the document for every graph: a press on a square opens what its row opens, and pointing at a
|
|
144
|
+
* square or a row lights both. */
|
|
145
|
+
let wired = false;
|
|
146
|
+
function wireGraph() {
|
|
147
|
+
if (wired) return;
|
|
148
|
+
wired = true;
|
|
149
|
+
document.addEventListener('click', (e) => {
|
|
150
|
+
const g = e.target instanceof Element ? e.target.closest('.fr-rail__node') : null;
|
|
151
|
+
const svg = g && g.closest('.fr-rail__graph');
|
|
152
|
+
if (svg && svg.__open) svg.__open(Number(g.getAttribute('data-k')));
|
|
153
|
+
});
|
|
154
|
+
document.addEventListener('mouseover', (e) => {
|
|
155
|
+
const t = e.target instanceof Element ? e.target.closest('.fr-rail__node, .fr-rail__tie') : null;
|
|
156
|
+
const rail = (t || (e.target instanceof Element ? e.target : null))?.closest?.('.fr-rail');
|
|
157
|
+
if (!rail) return;
|
|
158
|
+
const k = t ? t.getAttribute('data-k') : null;
|
|
159
|
+
rail.querySelectorAll('[data-k]').forEach((n) => n.classList.toggle('fr-rail--lit', k != null && n.getAttribute('data-k') === k));
|
|
160
|
+
});
|
|
161
|
+
}
|