@divlet/web 0.0.0-stage → 0.0.1
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/README.md +12 -2
- package/dist/LICENSE +21 -0
- package/dist/board.css +948 -0
- package/dist/board.js +427 -0
- package/dist/index.d.ts +4 -0
- package/dist/index.html +73 -0
- package/dist/index.js +3 -0
- package/dist/lib/actions.js +472 -0
- package/dist/lib/activity.js +311 -0
- package/dist/lib/artboards.js +56 -0
- package/dist/lib/ask.js +350 -0
- package/dist/lib/attrs.js +144 -0
- package/dist/lib/board-host.d.ts +14 -0
- package/dist/lib/board-host.js +2 -0
- package/dist/lib/board-session.d.ts +53 -0
- package/dist/lib/board-session.js +24 -0
- package/dist/lib/canvas.js +1594 -0
- package/dist/lib/chat.js +156 -0
- package/dist/lib/clip.js +231 -0
- package/dist/lib/crumbbar.js +56 -0
- package/dist/lib/crumbs.js +63 -0
- package/dist/lib/css.js +238 -0
- package/dist/lib/expandbar.js +64 -0
- package/dist/lib/file-picker.js +103 -0
- package/dist/lib/help.js +273 -0
- package/dist/lib/host.js +16 -0
- package/dist/lib/http-services.js +24 -0
- package/dist/lib/http-session.js +45 -0
- package/dist/lib/http.js +17 -0
- package/dist/lib/inspector.js +857 -0
- package/dist/lib/layers.js +298 -0
- package/dist/lib/menu.js +119 -0
- package/dist/lib/palette.js +292 -0
- package/dist/lib/panels.js +27 -0
- package/dist/lib/project.js +178 -0
- package/dist/lib/resize.js +114 -0
- package/dist/lib/shortcuts.js +91 -0
- package/dist/lib/sidebar.js +85 -0
- package/dist/lib/store.js +249 -0
- package/dist/lib/suggest.js +130 -0
- package/dist/lib/toolbar.js +54 -0
- package/dist/lib/typography.js +120 -0
- package/dist/lib/util.js +177 -0
- package/package.json +37 -4
package/dist/lib/css.js
ADDED
|
@@ -0,0 +1,238 @@
|
|
|
1
|
+
// Custom CSS for people: a formatter (one declaration per line, nested blocks indented) and a
|
|
2
|
+
// highlighter. Both are small hand-written scanners — the server still validates with postcss.
|
|
3
|
+
|
|
4
|
+
/* ---- scanning helpers ----------------------------------------------------------- */
|
|
5
|
+
|
|
6
|
+
/** End index of a string literal or comment starting at i (exclusive), or -1 if i starts neither. */
|
|
7
|
+
function skipQuoted(text, i) {
|
|
8
|
+
const ch = text[i];
|
|
9
|
+
if (ch === '"' || ch === "'") {
|
|
10
|
+
for (let j = i + 1; j < text.length; j++) {
|
|
11
|
+
if (text[j] === '\\') j++;
|
|
12
|
+
else if (text[j] === ch) return j + 1;
|
|
13
|
+
}
|
|
14
|
+
return text.length;
|
|
15
|
+
}
|
|
16
|
+
if (ch === '/' && text[i + 1] === '*') {
|
|
17
|
+
const end = text.indexOf('*/', i + 2);
|
|
18
|
+
return end < 0 ? text.length : end + 2;
|
|
19
|
+
}
|
|
20
|
+
return -1;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
/* ---- formatter ------------------------------------------------------------------- */
|
|
24
|
+
|
|
25
|
+
/** Parse into [{ comment }, { prelude, children }, { text }] — braces and semicolons at depth 0 only. */
|
|
26
|
+
function parse(text, start, stop) {
|
|
27
|
+
const nodes = [];
|
|
28
|
+
let i = start;
|
|
29
|
+
while (i < text.length) {
|
|
30
|
+
while (i < text.length && /\s/.test(text[i])) i++;
|
|
31
|
+
if (i >= text.length) break;
|
|
32
|
+
if (text[i] === '}') { if (stop) return { nodes, end: i + 1 }; throw new Error('Unbalanced }'); }
|
|
33
|
+
if (text[i] === '/' && text[i + 1] === '*') { const end = skipQuoted(text, i); nodes.push({ comment: text.slice(i, end) }); i = end; continue; }
|
|
34
|
+
let depth = 0;
|
|
35
|
+
let j = i;
|
|
36
|
+
let found = '';
|
|
37
|
+
for (; j < text.length; j++) {
|
|
38
|
+
const quoted = skipQuoted(text, j);
|
|
39
|
+
if (quoted > 0) { j = quoted - 1; continue; }
|
|
40
|
+
const ch = text[j];
|
|
41
|
+
if (ch === '(' || ch === '[') depth++;
|
|
42
|
+
else if (ch === ')' || ch === ']') depth = Math.max(0, depth - 1);
|
|
43
|
+
else if (!depth && (ch === '{' || ch === ';' || ch === '}')) { found = ch; break; }
|
|
44
|
+
}
|
|
45
|
+
const head = text.slice(i, j).trim();
|
|
46
|
+
if (found === '{') {
|
|
47
|
+
const inner = parse(text, j + 1, true);
|
|
48
|
+
nodes.push({ prelude: head, children: inner.nodes });
|
|
49
|
+
i = inner.end;
|
|
50
|
+
} else {
|
|
51
|
+
if (head) nodes.push({ text: head });
|
|
52
|
+
i = found === ';' ? j + 1 : j;
|
|
53
|
+
if (found === '' ) break;
|
|
54
|
+
}
|
|
55
|
+
}
|
|
56
|
+
if (stop) throw new Error('Unbalanced {');
|
|
57
|
+
return { nodes, end: i };
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** Collapse whitespace and add the spaces people expect, leaving strings and url() alone. */
|
|
61
|
+
function tidyValue(value) {
|
|
62
|
+
const parts = [];
|
|
63
|
+
let i = 0;
|
|
64
|
+
let plain = '';
|
|
65
|
+
const flush = () => { if (plain) { parts.push(plain.replace(/\s+/g, ' ').replace(/\s*,\s*/g, ', ').replace(/\s*!\s*important/gi, ' !important')); plain = ''; } };
|
|
66
|
+
while (i < value.length) {
|
|
67
|
+
const quoted = skipQuoted(value, i);
|
|
68
|
+
if (quoted > 0) { flush(); parts.push(value.slice(i, quoted)); i = quoted; continue; }
|
|
69
|
+
const url = /^url\(/i.exec(value.slice(i, i + 4)) ? value.indexOf(')', i) : -1;
|
|
70
|
+
if (url > 0) { flush(); parts.push(value.slice(i, url + 1)); i = url + 1; continue; }
|
|
71
|
+
plain += value[i++];
|
|
72
|
+
}
|
|
73
|
+
flush();
|
|
74
|
+
return parts.join('').trim();
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
function tidyPrelude(prelude) {
|
|
78
|
+
const at = /^@([\w-]+)\s*([\s\S]*)$/.exec(prelude);
|
|
79
|
+
if (at) {
|
|
80
|
+
const params = tidyValue(at[2]).replace(/\s*:\s*/g, ': ').replace(/\(\s+/g, '(').replace(/\s+\)/g, ')');
|
|
81
|
+
return `@${at[1]}${params ? ` ${params}` : ''}`;
|
|
82
|
+
}
|
|
83
|
+
return prelude;
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function printNodes(nodes, depth, out) {
|
|
87
|
+
const pad = ' '.repeat(depth);
|
|
88
|
+
nodes.forEach((node, index) => {
|
|
89
|
+
if (node.comment !== undefined) { out.push(...node.comment.split('\n').map((line, n) => (n ? line : pad + line.trimStart()))); return; }
|
|
90
|
+
if (node.text !== undefined) {
|
|
91
|
+
const colon = node.text.indexOf(':');
|
|
92
|
+
const declaration = colon > 0 && !node.text.startsWith('@');
|
|
93
|
+
out.push(`${pad}${declaration ? `${node.text.slice(0, colon).trim()}: ${tidyValue(node.text.slice(colon + 1))}` : tidyPrelude(node.text)};`);
|
|
94
|
+
return;
|
|
95
|
+
}
|
|
96
|
+
if (index > 0 && depth === 0) out.push('');
|
|
97
|
+
// A selector list breaks after each top-level comma.
|
|
98
|
+
const selector = /^@/.test(node.prelude) ? tidyPrelude(node.prelude) : splitSelectors(node.prelude).join(`,\n${pad}`);
|
|
99
|
+
if (!node.children.length) { out.push(`${pad}${selector} {}`); return; }
|
|
100
|
+
out.push(`${pad}${selector} {`);
|
|
101
|
+
printNodes(node.children, depth + 1, out);
|
|
102
|
+
out.push(`${pad}}`);
|
|
103
|
+
});
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function splitSelectors(prelude) {
|
|
107
|
+
const parts = [];
|
|
108
|
+
let depth = 0;
|
|
109
|
+
let from = 0;
|
|
110
|
+
for (let i = 0; i < prelude.length; i++) {
|
|
111
|
+
const quoted = skipQuoted(prelude, i);
|
|
112
|
+
if (quoted > 0) { i = quoted - 1; continue; }
|
|
113
|
+
const ch = prelude[i];
|
|
114
|
+
if (ch === '(' || ch === '[') depth++;
|
|
115
|
+
else if (ch === ')' || ch === ']') depth--;
|
|
116
|
+
else if (ch === ',' && !depth) { parts.push(prelude.slice(from, i)); from = i + 1; }
|
|
117
|
+
}
|
|
118
|
+
parts.push(prelude.slice(from));
|
|
119
|
+
return parts.map(part => part.replace(/\s+/g, ' ').trim()).filter(Boolean);
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** Pretty-printed CSS, or the input unchanged if it cannot be parsed (the server will say why). */
|
|
123
|
+
export function formatCss(text) {
|
|
124
|
+
if (!text || !text.trim()) return text || '';
|
|
125
|
+
try {
|
|
126
|
+
const out = [];
|
|
127
|
+
printNodes(parse(text, 0, false).nodes, 0, out);
|
|
128
|
+
return `${out.join('\n')}\n`;
|
|
129
|
+
} catch { return text; }
|
|
130
|
+
}
|
|
131
|
+
|
|
132
|
+
/* ---- highlighter ----------------------------------------------------------------- */
|
|
133
|
+
|
|
134
|
+
const esc = value => value.replace(/&/g, '&').replace(/</g, '<').replace(/>/g, '>');
|
|
135
|
+
const span = (kind, value) => (value ? `<span class="syn-${kind}">${esc(value)}</span>` : '');
|
|
136
|
+
// Blocks whose contents are declarations rather than nested rules.
|
|
137
|
+
const DECLARATION_AT = /^@(font-face|page|property|counter-style|starting-style|view-transition|position-try)\b/i;
|
|
138
|
+
|
|
139
|
+
function selectorHtml(text) {
|
|
140
|
+
let out = '';
|
|
141
|
+
for (let i = 0; i < text.length;) {
|
|
142
|
+
const rest = text.slice(i);
|
|
143
|
+
let match;
|
|
144
|
+
if ((match = /^\.(?:\\.|[\w-])+/.exec(rest))) out += span('class', match[0]);
|
|
145
|
+
else if ((match = /^#(?:\\.|[\w-])+/.exec(rest))) out += span('id', match[0]);
|
|
146
|
+
else if ((match = /^::?[\w-]+/.exec(rest))) out += span('pseudo', match[0]);
|
|
147
|
+
else if ((match = /^\[[^\]]*\]/.exec(rest))) out += span('attr', match[0]);
|
|
148
|
+
else if ((match = /^[a-zA-Z][\w-]*/.exec(rest))) out += span('tag', match[0]);
|
|
149
|
+
else if ((match = /^[\s\S]/.exec(rest))) out += /[>+~*,()]/.test(match[0]) ? span('punct', match[0]) : esc(match[0]);
|
|
150
|
+
i += match[0].length;
|
|
151
|
+
}
|
|
152
|
+
return out;
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
function valueHtml(text) {
|
|
156
|
+
let out = '';
|
|
157
|
+
for (let i = 0; i < text.length;) {
|
|
158
|
+
const quoted = skipQuoted(text, i);
|
|
159
|
+
if (quoted > 0) { out += span(text[i] === '/' ? 'comment' : 'string', text.slice(i, quoted)); i = quoted; continue; }
|
|
160
|
+
const rest = text.slice(i);
|
|
161
|
+
let match;
|
|
162
|
+
if ((match = /^!\s*important/i.exec(rest))) out += span('important', match[0]);
|
|
163
|
+
else if ((match = /^#[0-9a-fA-F]{3,8}\b/.exec(rest))) out += span('color', match[0]);
|
|
164
|
+
else if ((match = /^--[\w-]+/.exec(rest))) out += span('var', match[0]);
|
|
165
|
+
else if ((match = /^[a-zA-Z_-][\w-]*(?=\()/.exec(rest))) out += span('func', match[0]);
|
|
166
|
+
else if ((match = /^[+-]?(?:\d+\.?\d*|\.\d+)(?:[a-zA-Z%]+)?/.exec(rest))) out += span('number', match[0]);
|
|
167
|
+
else if ((match = /^[\w-]+/.exec(rest))) out += esc(match[0]);
|
|
168
|
+
else { match = [rest[0]]; out += /[(),/]/.test(match[0]) ? span('punct', match[0]) : esc(match[0]); }
|
|
169
|
+
i += match[0].length;
|
|
170
|
+
}
|
|
171
|
+
return out;
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
/** HTML for `text` with the same characters, in <span class="syn-…"> pieces. */
|
|
175
|
+
export function highlightCss(text) {
|
|
176
|
+
let out = '';
|
|
177
|
+
// Each open block remembers whether its contents are declarations.
|
|
178
|
+
const blocks = [];
|
|
179
|
+
let prelude = '';
|
|
180
|
+
let prop = null; // inside a declaration: the property name was read
|
|
181
|
+
let buffer = '';
|
|
182
|
+
const inDeclarations = () => blocks.at(-1) === 'decls';
|
|
183
|
+
const flushPrelude = () => { out += prelude.startsWith('@') || /^\s*@/.test(prelude) ? atHtml(prelude) : selectorHtml(prelude); prelude = ''; };
|
|
184
|
+
const atHtml = value => { const match = /^(\s*)(@[\w-]+)([\s\S]*)$/.exec(value); return match ? `${esc(match[1])}${span('atrule', match[2])}${valueHtml(match[3])}` : esc(value); };
|
|
185
|
+
const flushValue = () => { out += valueHtml(buffer); buffer = ''; };
|
|
186
|
+
for (let i = 0; i < text.length;) {
|
|
187
|
+
const quoted = skipQuoted(text, i);
|
|
188
|
+
if (quoted > 0) {
|
|
189
|
+
const piece = text.slice(i, quoted);
|
|
190
|
+
if (text[i] === '/') {
|
|
191
|
+
if (inDeclarations() && prop === null) { out += esc(prelude); prelude = ''; } else if (prelude) flushPrelude();
|
|
192
|
+
out += span('comment', inDeclarations() && prop !== null ? '' : piece);
|
|
193
|
+
if (inDeclarations() && prop !== null) buffer += piece;
|
|
194
|
+
}
|
|
195
|
+
else if (inDeclarations() && prop !== null) buffer += piece;
|
|
196
|
+
else prelude += piece;
|
|
197
|
+
i = quoted;
|
|
198
|
+
continue;
|
|
199
|
+
}
|
|
200
|
+
const ch = text[i];
|
|
201
|
+
if (inDeclarations()) {
|
|
202
|
+
if (prop === null) {
|
|
203
|
+
if (ch === ':') { out += span('prop', prelude) + span('punct', ':'); prelude = ''; prop = ''; }
|
|
204
|
+
else if (ch === '}') { out += esc(prelude); prelude = ''; out += span('punct', '}'); blocks.pop(); }
|
|
205
|
+
else if (ch === ';') { out += esc(prelude) + span('punct', ';'); prelude = ''; }
|
|
206
|
+
else if (ch === '{') { flushPrelude(); out += span('punct', '{'); blocks.push('rules'); }
|
|
207
|
+
else prelude += ch;
|
|
208
|
+
} else if (ch === ';' || ch === '}') {
|
|
209
|
+
flushValue();
|
|
210
|
+
prop = null;
|
|
211
|
+
out += span('punct', ch);
|
|
212
|
+
if (ch === '}') blocks.pop();
|
|
213
|
+
} else if (ch === '(' ) {
|
|
214
|
+
// url(...) and other parens may hold ; and } — copy to the matching paren.
|
|
215
|
+
let depth = 0; let j = i;
|
|
216
|
+
for (; j < text.length; j++) { const q = skipQuoted(text, j); if (q > 0) { j = q - 1; continue; } if (text[j] === '(') depth++; else if (text[j] === ')' && !--depth) break; }
|
|
217
|
+
buffer += text.slice(i, j + 1);
|
|
218
|
+
i = j + 1;
|
|
219
|
+
continue;
|
|
220
|
+
} else buffer += ch;
|
|
221
|
+
} else if (ch === '{') {
|
|
222
|
+
const at = /^\s*@/.test(prelude);
|
|
223
|
+
const declarations = at ? DECLARATION_AT.test(prelude.trim()) : true;
|
|
224
|
+
flushPrelude();
|
|
225
|
+
out += span('punct', '{');
|
|
226
|
+
blocks.push(declarations ? 'decls' : 'rules');
|
|
227
|
+
} else if (ch === '}') {
|
|
228
|
+
flushPrelude();
|
|
229
|
+
out += span('punct', '}');
|
|
230
|
+
blocks.pop();
|
|
231
|
+
} else if (ch === ';') { flushPrelude(); out += span('punct', ';'); }
|
|
232
|
+
else prelude += ch;
|
|
233
|
+
i++;
|
|
234
|
+
}
|
|
235
|
+
if (prop !== null && buffer) flushValue();
|
|
236
|
+
if (prelude) { if (inDeclarations() && prop === null) out += span('prop', prelude); else flushPrelude(); }
|
|
237
|
+
return out;
|
|
238
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
// A page opened full screen has one control: its name, at the top, which is the way back. It can be dragged
|
|
2
|
+
// anywhere (to feel the whole page); a click — not a drag — goes back. Everything else is hidden.
|
|
3
|
+
import { $, clamp, uiScale } from './util.js';
|
|
4
|
+
import { store, on } from './store.js';
|
|
5
|
+
import { closeExpanded } from './canvas.js';
|
|
6
|
+
|
|
7
|
+
const bar = $('#expanded-preview');
|
|
8
|
+
const back = $('#expanded-back');
|
|
9
|
+
let spot = null; // where it was left, in screen pixels (kept while the page is open)
|
|
10
|
+
let drag = null;
|
|
11
|
+
let dragged = false;
|
|
12
|
+
|
|
13
|
+
function place() {
|
|
14
|
+
if (!spot) { bar.classList.remove('moved'); bar.style.left = bar.style.top = ''; return; }
|
|
15
|
+
const scale = uiScale();
|
|
16
|
+
const rect = bar.getBoundingClientRect();
|
|
17
|
+
const x = clamp(spot.x, 0, Math.max(0, innerWidth - rect.width));
|
|
18
|
+
const y = clamp(spot.y, 0, Math.max(0, innerHeight - rect.height));
|
|
19
|
+
bar.classList.add('moved');
|
|
20
|
+
bar.style.left = `${x / scale}px`;
|
|
21
|
+
bar.style.top = `${y / scale}px`;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
// While dragging the pointer may cross the page (an iframe, which would swallow the events): listen on the
|
|
25
|
+
// document and let the page ignore the pointer until the drag ends.
|
|
26
|
+
function move(event) {
|
|
27
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
28
|
+
if (!drag.moved && Math.hypot(event.clientX - drag.x, event.clientY - drag.y) < 5) return;
|
|
29
|
+
drag.moved = true;
|
|
30
|
+
bar.classList.add('dragging');
|
|
31
|
+
spot = { x: drag.left + event.clientX - drag.x, y: drag.top + event.clientY - drag.y };
|
|
32
|
+
place();
|
|
33
|
+
}
|
|
34
|
+
function finish(event) {
|
|
35
|
+
if (!drag || drag.id !== event.pointerId) return;
|
|
36
|
+
if (drag.moved) { dragged = true; setTimeout(() => { dragged = false; }, 0); }
|
|
37
|
+
drag = null;
|
|
38
|
+
bar.classList.remove('dragging');
|
|
39
|
+
document.body.classList.remove('bar-dragging');
|
|
40
|
+
document.removeEventListener('pointermove', move, true);
|
|
41
|
+
document.removeEventListener('pointerup', finish, true);
|
|
42
|
+
document.removeEventListener('pointercancel', finish, true);
|
|
43
|
+
}
|
|
44
|
+
back.addEventListener('pointerdown', event => {
|
|
45
|
+
if (event.button !== 0) return;
|
|
46
|
+
const rect = bar.getBoundingClientRect();
|
|
47
|
+
drag = { id: event.pointerId, x: event.clientX, y: event.clientY, left: rect.left, top: rect.top, moved: false };
|
|
48
|
+
document.body.classList.add('bar-dragging');
|
|
49
|
+
document.addEventListener('pointermove', move, true);
|
|
50
|
+
document.addEventListener('pointerup', finish, true);
|
|
51
|
+
document.addEventListener('pointercancel', finish, true);
|
|
52
|
+
});
|
|
53
|
+
back.addEventListener('click', () => { if (!dragged) closeExpanded(); });
|
|
54
|
+
back.addEventListener('keydown', event => {
|
|
55
|
+
// The arrows nudge it when it has focus (the keyboard way to look underneath).
|
|
56
|
+
const step = { ArrowLeft: [-24, 0], ArrowRight: [24, 0], ArrowUp: [0, -24], ArrowDown: [0, 24] }[event.key];
|
|
57
|
+
if (!step || !event.altKey) return;
|
|
58
|
+
event.preventDefault();
|
|
59
|
+
const rect = bar.getBoundingClientRect();
|
|
60
|
+
spot = { x: rect.left + step[0], y: rect.top + step[1] };
|
|
61
|
+
place();
|
|
62
|
+
});
|
|
63
|
+
on('expanded', open => { if (open) requestAnimationFrame(place); else spot = null; });
|
|
64
|
+
addEventListener('resize', () => { if (store.expanded) place(); });
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
// Filesystem navigation UI. The host supplies a read-only listing; consumers own selection policy, writes and modal lifetime.
|
|
2
|
+
import { el, icon } from './util.js';
|
|
3
|
+
|
|
4
|
+
export const directoryPath = value => value.endsWith('/') || value.endsWith('\\') ? value : value + (value.includes('\\') && !value.includes('/') ? '\\' : '/');
|
|
5
|
+
|
|
6
|
+
export function filePicker({ read, includeFiles = true, label, onChange = () => {}, onSubmit = () => {} }) {
|
|
7
|
+
const element = el('div', 'file-picker');
|
|
8
|
+
const bar = el('div', 'file-picker-path');
|
|
9
|
+
const up = el('button', 'tool');
|
|
10
|
+
up.type = 'button';
|
|
11
|
+
up.setAttribute('aria-label', 'Parent folder');
|
|
12
|
+
up.dataset.tip = 'Parent folder';
|
|
13
|
+
up.append(icon('folderUp'));
|
|
14
|
+
const input = el('input', 'field mono');
|
|
15
|
+
input.type = 'text';
|
|
16
|
+
input.spellcheck = false;
|
|
17
|
+
input.autocomplete = 'off';
|
|
18
|
+
input.setAttribute('aria-label', label);
|
|
19
|
+
const list = el('div', 'dir-list');
|
|
20
|
+
bar.append(up, input);
|
|
21
|
+
element.append(bar, list);
|
|
22
|
+
let state = { info: null, error: '', pending: false };
|
|
23
|
+
let sequence = 0, timer = null, disposed = false, reading = Promise.resolve();
|
|
24
|
+
let rows = new Map();
|
|
25
|
+
const update = next => {
|
|
26
|
+
state = next;
|
|
27
|
+
element.setAttribute('aria-busy', String(state.pending));
|
|
28
|
+
up.disabled = !state.info?.parent;
|
|
29
|
+
if (up.disabled && document.activeElement === up) input.focus({ preventScroll: true });
|
|
30
|
+
onChange(state);
|
|
31
|
+
};
|
|
32
|
+
function paint() {
|
|
33
|
+
const info = state.info;
|
|
34
|
+
const nextRows = new Map();
|
|
35
|
+
const nodes = [...(info?.dirs ?? []), ...(includeFiles ? info?.files ?? [] : [])].map(entry => {
|
|
36
|
+
const target = entry.file || directoryPath(info.directory || info.path) + entry.name;
|
|
37
|
+
const key = `${target}\0${!!entry.project}`;
|
|
38
|
+
let row = rows.get(key);
|
|
39
|
+
if (!row) {
|
|
40
|
+
row = el('button', `dir${entry.project ? ' is-project' : ''}`);
|
|
41
|
+
row.type = 'button';
|
|
42
|
+
row.dataset.name = entry.name;
|
|
43
|
+
row.append(icon(entry.project ? 'divlet' : 'folder'), el('span', 'dir-name', entry.name));
|
|
44
|
+
if (entry.project) row.append(el('span', 'dir-tag', 'project'));
|
|
45
|
+
row.addEventListener('click', () => go(target));
|
|
46
|
+
row.addEventListener('dblclick', () => { if (entry.project && includeFiles) activate(); });
|
|
47
|
+
}
|
|
48
|
+
nextRows.set(key, row);
|
|
49
|
+
return row;
|
|
50
|
+
});
|
|
51
|
+
rows = nextRows;
|
|
52
|
+
if (!nodes.length) nodes.push(el('div', 'dir-empty', state.error ? 'Unable to list this folder' : info?.kind === 'missing' ? 'Nothing here yet' : includeFiles ? 'No project files or sub-folders' : 'No sub-folders here'));
|
|
53
|
+
// Selecting a file in the same directory must not detach its row between clicks or lose keyboard focus.
|
|
54
|
+
if (nodes.length !== list.children.length || nodes.some((node, index) => node !== list.children[index])) list.replaceChildren(...nodes);
|
|
55
|
+
}
|
|
56
|
+
function load(path) {
|
|
57
|
+
if (disposed) return Promise.resolve();
|
|
58
|
+
clearTimeout(timer); timer = null;
|
|
59
|
+
input.value = path;
|
|
60
|
+
const mine = ++sequence, target = input.value;
|
|
61
|
+
update({ info: null, error: '', pending: true });
|
|
62
|
+
reading = (async () => {
|
|
63
|
+
try {
|
|
64
|
+
const info = await read(target);
|
|
65
|
+
if (disposed || mine !== sequence) return;
|
|
66
|
+
update({ info, error: '', pending: false });
|
|
67
|
+
} catch (error) {
|
|
68
|
+
if (disposed || mine !== sequence) return;
|
|
69
|
+
update({ info: null, error: error.message, pending: false });
|
|
70
|
+
}
|
|
71
|
+
paint();
|
|
72
|
+
})();
|
|
73
|
+
return reading;
|
|
74
|
+
}
|
|
75
|
+
function go(path) { load(path); input.focus(); }
|
|
76
|
+
async function activate() {
|
|
77
|
+
const pending = timer || !state.info && !state.pending ? load(input.value) : reading;
|
|
78
|
+
const mine = sequence;
|
|
79
|
+
await pending;
|
|
80
|
+
if (!disposed && mine === sequence && state.info && !state.pending && !state.error) onSubmit(state.info);
|
|
81
|
+
}
|
|
82
|
+
up.addEventListener('click', () => { if (state.info?.parent) go(directoryPath(state.info.parent)); });
|
|
83
|
+
input.addEventListener('input', () => {
|
|
84
|
+
if (disposed) return;
|
|
85
|
+
++sequence;
|
|
86
|
+
clearTimeout(timer);
|
|
87
|
+
update({ info: null, error: '', pending: true });
|
|
88
|
+
timer = setTimeout(() => load(input.value), 140);
|
|
89
|
+
});
|
|
90
|
+
input.addEventListener('keydown', event => {
|
|
91
|
+
if (event.key === 'Enter') { event.preventDefault(); event.stopPropagation(); activate(); }
|
|
92
|
+
else if (event.key === 'ArrowDown') { event.preventDefault(); event.stopPropagation(); list.querySelector('.dir')?.focus(); }
|
|
93
|
+
});
|
|
94
|
+
list.addEventListener('keydown', event => {
|
|
95
|
+
if (event.key !== 'ArrowDown' && event.key !== 'ArrowUp') return;
|
|
96
|
+
const nodes = [...list.querySelectorAll('.dir')], at = nodes.indexOf(document.activeElement);
|
|
97
|
+
event.preventDefault(); event.stopPropagation();
|
|
98
|
+
if (event.key === 'ArrowUp' && at <= 0) input.focus();
|
|
99
|
+
else nodes[Math.max(0, Math.min(nodes.length - 1, at + (event.key === 'ArrowDown' ? 1 : -1)))]?.focus();
|
|
100
|
+
});
|
|
101
|
+
up.disabled = true;
|
|
102
|
+
return { element, input, load, get state() { return state; }, dispose() { disposed = true; ++sequence; clearTimeout(timer); } };
|
|
103
|
+
}
|