@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
|
@@ -0,0 +1,311 @@
|
|
|
1
|
+
import { $, el, icon, ago, pressGuard } from './util.js';
|
|
2
|
+
import { store, on, documents, descriptor, selectArtboards, selectNodes, loadModel } from './store.js';
|
|
3
|
+
import { historyService } from './host.js';
|
|
4
|
+
import { reveal, flashNodes } from './canvas.js';
|
|
5
|
+
import * as sidebar from './sidebar.js';
|
|
6
|
+
import * as chat from './chat.js';
|
|
7
|
+
|
|
8
|
+
const panel = $('#activity');
|
|
9
|
+
const statusButton = $('#status');
|
|
10
|
+
const dot = $('#status-dot');
|
|
11
|
+
const diag = $('#diag-count');
|
|
12
|
+
const historyButton = $('#open-history');
|
|
13
|
+
const ACTOR = { __proto__: null, agent: ['agent', 'Pi'], user: ['user', 'You'], file: ['file', 'File'], system: ['divlet', 'divlet'] };
|
|
14
|
+
const KIND = { __proto__: null, undo: 'Undo', redo: 'Redo', create: 'New', delete: 'Removed', external: 'File edit' };
|
|
15
|
+
const CHAT = new Set(['ask', 'reply']);
|
|
16
|
+
let focusSeq = null;
|
|
17
|
+
let unread = 0;
|
|
18
|
+
let timelineRead = null;
|
|
19
|
+
// Server point IDs may recur after Undo; each observed transition invalidates in-flight snapshots, including A → B → A.
|
|
20
|
+
let observedHead = '', stateGeneration = 0, requestedGeneration = -1;
|
|
21
|
+
let reading = false, readAgain = false, navigating = null, readError = '', navigationError = '', auditOpen = false, restorePoint = null;
|
|
22
|
+
const supportsNavigation = () => !!historyService?.navigate && store.state?.history?.navigation === true;
|
|
23
|
+
const timelineReady = () => supportsNavigation() && timelineRead?.generation === stateGeneration && timelineRead.projectId === store.state?.projectId && timelineRead.timeline?.current === store.state?.history?.pointId;
|
|
24
|
+
|
|
25
|
+
export const isOpen = () => sidebar.isOpen('activity');
|
|
26
|
+
export function toggle(force) {
|
|
27
|
+
if (force === undefined) sidebar.toggle('activity');
|
|
28
|
+
else if (force) sidebar.show('activity');
|
|
29
|
+
else if (isOpen()) sidebar.close();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function paintStatus() {
|
|
33
|
+
const diagnostics = store.state?.diagnostics || [];
|
|
34
|
+
const unsaved = documents().some(doc => Number.isInteger(doc.savedVersion) && doc.savedVersion < doc.version);
|
|
35
|
+
const kind = store.connection !== 'connected' ? store.connection : diagnostics.length ? 'attention' : unsaved ? 'saving' : 'live';
|
|
36
|
+
dot.dataset.kind = kind;
|
|
37
|
+
// The History button carries the count: issues in red, otherwise what Pi and files changed unseen.
|
|
38
|
+
diag.hidden = !diagnostics.length && !unread;
|
|
39
|
+
diag.textContent = diagnostics.length ? String(diagnostics.length) : String(Math.min(unread, 99));
|
|
40
|
+
diag.classList.toggle('bad', diagnostics.length > 0);
|
|
41
|
+
const count = documents().length;
|
|
42
|
+
const text = { connecting: 'Connecting…', offline: 'Offline — reconnecting; the preview is kept', attention: `${diagnostics.length} issue${diagnostics.length === 1 ? '' : 's'} need attention`, saving: 'Saving…', live: `Live · all changes saved · ${count} artboard${count === 1 ? '' : 's'}` }[kind];
|
|
43
|
+
statusButton.dataset.tip = `${statusButton.dataset.project || 'project'} · ${text} · click for project actions`;
|
|
44
|
+
statusButton.setAttribute('aria-label', `Project menu. ${text}`);
|
|
45
|
+
historyButton.dataset.tip = `History${diagnostics.length ? ` · ${diagnostics.length} issue${diagnostics.length === 1 ? '' : 's'}` : unread ? ` · ${unread} new` : ''} · H`;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
export async function loadRecent() {
|
|
49
|
+
if (!historyService) return;
|
|
50
|
+
if (reading) { readAgain = true; return; }
|
|
51
|
+
reading = true;
|
|
52
|
+
const project = store.state?.projectId;
|
|
53
|
+
const generation = stateGeneration;
|
|
54
|
+
requestedGeneration = generation;
|
|
55
|
+
try {
|
|
56
|
+
const result = await historyService.readRecent();
|
|
57
|
+
if (project && project !== store.state?.projectId) return;
|
|
58
|
+
store.recent = [...new Map([...result.changes, ...store.recent].map(change => [change.seq, change])).values()].sort((a, b) => a.seq - b.seq).slice(-300);
|
|
59
|
+
timelineRead = { projectId: store.state?.projectId, generation, timeline: result.navigation === true && Array.isArray(result.timeline?.points) ? result.timeline : null };
|
|
60
|
+
readError = supportsNavigation() && !timelineRead.timeline ? 'This board did not supply a navigable timeline.' : '';
|
|
61
|
+
} catch (error) { readError = `History could not be read: ${error.message}. Reopen History to retry.`; }
|
|
62
|
+
finally {
|
|
63
|
+
reading = false;
|
|
64
|
+
render();
|
|
65
|
+
chat.render();
|
|
66
|
+
if (readAgain) { readAgain = false; if (isOpen() && supportsNavigation() && !timelineReady() && !readError) void loadRecent(); }
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
export function pushChange(change) {
|
|
71
|
+
if (store.recent.some(item => item.seq === change.seq)) return;
|
|
72
|
+
store.recent.push(change);
|
|
73
|
+
if (store.recent.length > 300) store.recent.splice(0, store.recent.length - 300);
|
|
74
|
+
if (!isOpen() && change.actor !== 'user' && !CHAT.has(change.kind)) { unread++; paintStatus(); }
|
|
75
|
+
render();
|
|
76
|
+
chat.note(change);
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
async function focusChange(change, documentIds = [change.documentId]) {
|
|
80
|
+
const present = documentIds.filter(id => descriptor(id));
|
|
81
|
+
if (present.length > 1) { selectArtboards(present); reveal(present[0]); return; }
|
|
82
|
+
if (!descriptor(change.documentId)) return;
|
|
83
|
+
selectArtboards([change.documentId]);
|
|
84
|
+
reveal(change.documentId);
|
|
85
|
+
if (!change.touched?.length) return;
|
|
86
|
+
const model = await loadModel(change.documentId).catch(() => null);
|
|
87
|
+
const alive = change.touched.filter(id => model?.nodes[id]);
|
|
88
|
+
if (alive.length) {
|
|
89
|
+
selectNodes(change.documentId, alive.slice(0, 1), { reveal: true });
|
|
90
|
+
flashNodes(change.documentId, descriptor(change.documentId)?.render ?? 0, alive);
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** Three or more issues of one kind (ten missing images after an import) read as one line that lists them on hover. */
|
|
95
|
+
function groupDiagnostics(issues) {
|
|
96
|
+
const byCode = new Map();
|
|
97
|
+
for (const item of issues) byCode.set(item.code, [...(byCode.get(item.code) || []), item]);
|
|
98
|
+
return [...byCode.values()].flatMap(group => (group.length < 3 ? group : [{ ...group[0], message: `${group[0].message} — and ${group.length - 1} more of this kind`, group }]));
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
function diagnosticRow(item) {
|
|
102
|
+
const line = el('div', 'diag');
|
|
103
|
+
line.append(icon('alertDot'));
|
|
104
|
+
const text = el('div', 'text');
|
|
105
|
+
const where = [(item.path || item.documentId || '').split(/[\\/]/).slice(-2).join('/'), item.line ? `line ${item.line}:${item.column}` : ''].filter(Boolean).join(' · ');
|
|
106
|
+
text.append(el('div', 'msg', item.message || 'Issue'), el('div', 'sub', [item.code, where].filter(Boolean).join(' · ')));
|
|
107
|
+
line.append(text);
|
|
108
|
+
if (item.code === 'MISSING_ASSET') text.append(el('div', 'sub', item.path ? 'Copy the file to the path shown on hover, or fix the path in the element.' : 'Fix the path in the element.'));
|
|
109
|
+
// The hover names every expected path: the row itself shows only the last two segments.
|
|
110
|
+
if (item.group) {
|
|
111
|
+
line.dataset.tip = `${item.group.length} issues of this kind — expected path of each`;
|
|
112
|
+
line.dataset.tipCode = item.group.slice(0, 12).map(entry => `${entry.message}${entry.path ? `\nexpected at ${entry.path}` : ''}`).join('\n') + (item.group.length > 12 ? `\n… ${item.group.length - 12} more` : '');
|
|
113
|
+
} else if (item.code === 'MISSING_ASSET' && item.path) {
|
|
114
|
+
line.dataset.tip = 'Expected path';
|
|
115
|
+
line.dataset.tipCode = item.path;
|
|
116
|
+
}
|
|
117
|
+
if (item.documentId && descriptor(item.documentId)) {
|
|
118
|
+
const show = el('button', 'link-button', 'Show');
|
|
119
|
+
show.type = 'button';
|
|
120
|
+
show.addEventListener('click', () => { selectArtboards([item.documentId]); reveal(item.documentId); });
|
|
121
|
+
line.append(show);
|
|
122
|
+
}
|
|
123
|
+
return line;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
/** Consecutive changes of one gesture (same undo group, actor and kind) read as one line. */
|
|
127
|
+
function collapse(changes) {
|
|
128
|
+
const rows = [];
|
|
129
|
+
for (const change of changes) {
|
|
130
|
+
const previous = rows.at(-1);
|
|
131
|
+
if (previous && !CHAT.has(change.kind) && change.group && previous.change.group === change.group && previous.change.actor === change.actor && previous.change.kind === change.kind) {
|
|
132
|
+
previous.count++;
|
|
133
|
+
previous.documents.add(change.documentId);
|
|
134
|
+
previous.touched = [...new Set([...previous.touched, ...(change.touched || [])])];
|
|
135
|
+
previous.change = change;
|
|
136
|
+
previous.seqs.push(change.seq);
|
|
137
|
+
continue;
|
|
138
|
+
}
|
|
139
|
+
rows.push({ change, count: 1, documents: new Set([change.documentId]), touched: [...(change.touched || [])], seqs: [change.seq] });
|
|
140
|
+
}
|
|
141
|
+
return rows;
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function changeRow(row, readOnly = false) {
|
|
145
|
+
const { change, count } = row;
|
|
146
|
+
const [iconName, who] = ACTOR[change.actor] || ACTOR.system;
|
|
147
|
+
const line = el(readOnly ? 'div' : 'button', `change actor-${change.actor} kind-${change.kind}${readOnly ? ' history-audit' : ''}`);
|
|
148
|
+
if (!readOnly) line.type = 'button';
|
|
149
|
+
const kind = KIND[change.kind];
|
|
150
|
+
const title = change.label && change.label !== change.summary ? change.label : change.summary;
|
|
151
|
+
const text = el('div', 'text');
|
|
152
|
+
const msg = el('div', 'msg');
|
|
153
|
+
if (kind) msg.append(el('span', 'kind', kind));
|
|
154
|
+
msg.append(document.createTextNode(title));
|
|
155
|
+
if (count > 1) msg.append(el('span', 'times', `×${count}`));
|
|
156
|
+
const where = row.documents.size > 1 ? `${row.documents.size} artboards` : change.name || change.documentId;
|
|
157
|
+
const details = [where, who, title !== change.summary ? change.summary : ''].filter(Boolean).join(' · ');
|
|
158
|
+
text.append(msg, el('div', 'sub', details));
|
|
159
|
+
const when = el('time', 'when', ago(change.at));
|
|
160
|
+
when.dateTime = change.at;
|
|
161
|
+
when.title = new Date(change.at).toLocaleString();
|
|
162
|
+
line.append(icon(change.kind === 'undo' || change.kind === 'redo' ? change.kind : iconName), text, when);
|
|
163
|
+
line.dataset.tip = count > 1 ? `${count} steps undone together · #${row.seqs[0]}–#${change.seq}` : `#${change.seq} · v${change.fromVersion ?? '—'} → v${change.toVersion ?? '—'}${change.touched?.length ? ` · ${change.touched.length} node${change.touched.length === 1 ? '' : 's'}` : ''}`;
|
|
164
|
+
line.dataset.seq = String(change.seq);
|
|
165
|
+
if (!readOnly) {
|
|
166
|
+
line.disabled = ![...row.documents].some(id => descriptor(id));
|
|
167
|
+
line.dataset.tip += ' · Show affected elements in their current state';
|
|
168
|
+
line.addEventListener('click', () => focusChange({ ...change, touched: row.touched }, [...row.documents]));
|
|
169
|
+
}
|
|
170
|
+
return line;
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
async function navigate(pointId, expectedPoint) {
|
|
174
|
+
if (navigating || !supportsNavigation() || store.connection !== 'connected' || pointId === expectedPoint) return;
|
|
175
|
+
navigating = pointId;
|
|
176
|
+
navigationError = '';
|
|
177
|
+
render();
|
|
178
|
+
try { await historyService.navigate({ pointId, expectedPoint }); }
|
|
179
|
+
catch (error) { navigationError = error.message; }
|
|
180
|
+
finally { navigating = null; await loadRecent(); render(); }
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function stateRow(point, index, timeline) {
|
|
184
|
+
const current = point.id === timeline.current;
|
|
185
|
+
const future = index > timeline.index;
|
|
186
|
+
const line = el('button', `change history-state${current ? ' is-current' : ''}${future ? ' is-future' : ''}`);
|
|
187
|
+
line.type = 'button';
|
|
188
|
+
line.dataset.historyPoint = point.id;
|
|
189
|
+
line.setAttribute('aria-label', `${current ? 'Current' : 'Restore'} project state: ${point.label}`);
|
|
190
|
+
line.setAttribute('aria-disabled', String(current || !!navigating || store.connection !== 'connected'));
|
|
191
|
+
if (current) line.setAttribute('aria-current', 'step');
|
|
192
|
+
if (navigating === point.id) line.setAttribute('aria-busy', 'true');
|
|
193
|
+
const text = el('div', 'text');
|
|
194
|
+
text.append(el('div', 'msg', point.label));
|
|
195
|
+
const range = point.seq == null ? 'Session baseline' : point.firstSeq !== point.seq ? `Grouped edit · #${point.firstSeq}–#${point.seq}` : `After edit #${point.seq}`;
|
|
196
|
+
text.append(el('div', 'sub', range));
|
|
197
|
+
line.append(icon('clock'), text, el('span', 'history-tag', current ? 'Current' : future ? 'Ahead' : 'Restore'));
|
|
198
|
+
line.dataset.tip = current ? 'The project is at this state' : 'Restore the whole project after this grouped edit, including frame placement and project settings';
|
|
199
|
+
const expectedPoint = timeline.current;
|
|
200
|
+
line.addEventListener('click', () => navigate(point.id, expectedPoint));
|
|
201
|
+
line.addEventListener('keydown', event => {
|
|
202
|
+
if (!['ArrowDown', 'ArrowUp', 'Home', 'End'].includes(event.key)) return;
|
|
203
|
+
event.preventDefault(); event.stopPropagation();
|
|
204
|
+
const rows = [...panel.querySelectorAll('[data-history-point]')], at = rows.indexOf(line);
|
|
205
|
+
const target = event.key === 'Home' ? rows[0] : event.key === 'End' ? rows.at(-1) : rows[Math.max(0, Math.min(rows.length - 1, at + (event.key === 'ArrowDown' ? 1 : -1)))];
|
|
206
|
+
target?.focus();
|
|
207
|
+
});
|
|
208
|
+
return line;
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
const BOUNDARY = {
|
|
212
|
+
opened: 'Session history starts here. Restarting the board clears these state addresses.',
|
|
213
|
+
untracked: 'Earlier states cannot be reached across an untracked source change.',
|
|
214
|
+
nonlinear: 'Selective Undo/Redo separated this segment from earlier states.',
|
|
215
|
+
retained: 'Only the retained states are available.',
|
|
216
|
+
};
|
|
217
|
+
|
|
218
|
+
let pending = false, keyboardPress = false;
|
|
219
|
+
const pressing = pressGuard(panel, () => { if (pending) render(); });
|
|
220
|
+
panel.addEventListener('keydown', event => { if (['Enter', ' '].includes(event.key) && event.target.closest('[data-history-point]')) keyboardPress = true; }, true);
|
|
221
|
+
const releaseKey = () => { if (!keyboardPress) return; keyboardPress = false; setTimeout(() => { if (pending) render(); }, 0); };
|
|
222
|
+
window.addEventListener('keyup', releaseKey, true);
|
|
223
|
+
window.addEventListener('blur', releaseKey);
|
|
224
|
+
|
|
225
|
+
export function render() {
|
|
226
|
+
paintStatus();
|
|
227
|
+
if (!isOpen()) return;
|
|
228
|
+
if (pressing() || keyboardPress) { pending = true; return; }
|
|
229
|
+
pending = false;
|
|
230
|
+
unread = 0;
|
|
231
|
+
paintStatus();
|
|
232
|
+
const issues = store.state?.diagnostics || [];
|
|
233
|
+
const body = el('div', 'panel-body');
|
|
234
|
+
for (const item of groupDiagnostics(issues)) body.append(diagnosticRow(item));
|
|
235
|
+
const changes = store.recent.filter(change => !CHAT.has(change.kind));
|
|
236
|
+
const rows = collapse(changes).reverse();
|
|
237
|
+
const supported = supportsNavigation();
|
|
238
|
+
if (supported) {
|
|
239
|
+
const states = el('div', 'history-states');
|
|
240
|
+
states.setAttribute('role', 'group'); states.setAttribute('aria-label', 'Project states'); states.tabIndex = -1;
|
|
241
|
+
states.append(el('div', 'caption history-caption', 'Project states'));
|
|
242
|
+
if (timelineReady()) {
|
|
243
|
+
const timeline = timelineRead.timeline;
|
|
244
|
+
for (let i = timeline.points.length - 1; i >= 0; i--) states.append(stateRow(timeline.points[i], i, timeline));
|
|
245
|
+
const limited = timeline.pastLimited || timeline.futureLimited ? ' Some legacy Undo/Redo entries lie outside this connected timeline.' : '';
|
|
246
|
+
states.append(el('p', 'history-boundary', (BOUNDARY[timeline.boundary] || BOUNDARY.retained) + limited));
|
|
247
|
+
} else states.append(el('div', 'quiet', readError ? 'Project states are unavailable.' : 'Reading the current project states…'));
|
|
248
|
+
body.append(states);
|
|
249
|
+
const audit = el('details', 'history-log');
|
|
250
|
+
audit.open = auditOpen;
|
|
251
|
+
audit.dataset.historyAudit = 'true';
|
|
252
|
+
audit.addEventListener('toggle', () => { if (audit.isConnected) auditOpen = audit.open; });
|
|
253
|
+
const summary = el('summary'); summary.append(icon('chevron'), el('span', 'text', 'Activity log'), el('span', 'value', String(rows.length)));
|
|
254
|
+
audit.append(summary, el('p', 'history-boundary', 'Read-only activity, not state addresses. The conversation is in Agent.'));
|
|
255
|
+
for (const row of rows) audit.append(changeRow(row, true));
|
|
256
|
+
body.append(audit);
|
|
257
|
+
} else {
|
|
258
|
+
body.append(el('p', 'history-boundary', historyService ? 'This board does not advertise state navigation. Rows only show affected elements in their current state.' : 'This host does not provide change history.'));
|
|
259
|
+
if (historyService && !rows.length && !issues.length) body.append(el('div', 'quiet', 'No changes yet. Every edit by you, Pi or external files appears here; the conversation with Pi is in the Agent tab.'));
|
|
260
|
+
for (const row of rows) body.append(changeRow(row));
|
|
261
|
+
}
|
|
262
|
+
const foot = el('div', 'panel-foot');
|
|
263
|
+
const revision = el('span', 'value');
|
|
264
|
+
revision.append(el('i', '', 'rev'), document.createTextNode(String(store.state?.revision ?? 0)));
|
|
265
|
+
const frames = el('span', 'value');
|
|
266
|
+
frames.dataset.tip = 'Live previews / artboards';
|
|
267
|
+
frames.append(el('i', '', 'live'), document.createTextNode($('#live-count').textContent));
|
|
268
|
+
if (supported) {
|
|
269
|
+
foot.classList.add('history-foot');
|
|
270
|
+
const feedback = el('div', 'history-feedback', navigationError || readError || (navigating ? 'Restoring the project…' : ''));
|
|
271
|
+
feedback.setAttribute('role', 'status'); feedback.setAttribute('aria-live', 'polite');
|
|
272
|
+
feedback.classList.toggle('is-error', !!(navigationError || readError));
|
|
273
|
+
const counters = el('div', 'history-counters'); counters.append(revision, frames);
|
|
274
|
+
foot.append(feedback, counters);
|
|
275
|
+
} else foot.append(revision, frames);
|
|
276
|
+
const scroll = panel.querySelector('.panel-body')?.scrollTop ?? 0;
|
|
277
|
+
const focused = panel.contains(document.activeElement) ? document.activeElement : null;
|
|
278
|
+
const focusedPoint = focused?.dataset.historyPoint;
|
|
279
|
+
if (focusedPoint) restorePoint = focusedPoint;
|
|
280
|
+
const mayRestorePoint = !!focusedPoint || restorePoint && (document.activeElement === document.body || document.activeElement?.matches('.history-states'));
|
|
281
|
+
if (!mayRestorePoint) restorePoint = null;
|
|
282
|
+
const focusedAudit = focused?.parentElement?.matches('.history-log');
|
|
283
|
+
panel.replaceChildren(body, foot);
|
|
284
|
+
body.scrollTop = scroll;
|
|
285
|
+
if (restorePoint && mayRestorePoint) {
|
|
286
|
+
const target = [...body.querySelectorAll('[data-history-point]')].find(node => node.dataset.historyPoint === restorePoint) ?? body.querySelector('[aria-current="step"]');
|
|
287
|
+
if (target) { target.focus({ preventScroll: true }); restorePoint = null; }
|
|
288
|
+
else body.querySelector('.history-states')?.focus({ preventScroll: true });
|
|
289
|
+
} else if (focusedAudit) body.querySelector('.history-log summary')?.focus({ preventScroll: true });
|
|
290
|
+
if (focusSeq !== null) {
|
|
291
|
+
const target = [...body.querySelectorAll('[data-seq]')].find(node => node.dataset.seq === String(focusSeq));
|
|
292
|
+
focusSeq = null;
|
|
293
|
+
if (target) { target.scrollIntoView({ block: 'nearest' }); target.classList.add('flash'); setTimeout(() => target.classList.remove('flash'), 1200); }
|
|
294
|
+
}
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
on('focus-change', seq => { if (store.recent.some(change => change.seq === seq && CHAT.has(change.kind))) return; focusSeq = seq; render(); });
|
|
298
|
+
|
|
299
|
+
on('state', () => {
|
|
300
|
+
const supported = supportsNavigation();
|
|
301
|
+
const head = JSON.stringify([store.state?.projectId, store.state?.history?.pointId, supported]);
|
|
302
|
+
if (head !== observedHead) { observedHead = head; stateGeneration++; }
|
|
303
|
+
if (isOpen() && supported && !timelineReady() && requestedGeneration !== stateGeneration) {
|
|
304
|
+
if (reading) readAgain = true; else void loadRecent();
|
|
305
|
+
}
|
|
306
|
+
render();
|
|
307
|
+
});
|
|
308
|
+
on('expanded', paintStatus);
|
|
309
|
+
on('connection', render);
|
|
310
|
+
on('sidebar', ({ open, tab }) => { if (open && tab === 'activity') { void loadRecent(); render(); } else restorePoint = null; });
|
|
311
|
+
setInterval(() => { if (isOpen()) for (const node of panel.querySelectorAll('time.when')) node.textContent = ago(node.dateTime); }, 15000);
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
// Artboard lifecycle commands; selection, reveal, grouping and failure feedback stay with each accepted operation.
|
|
2
|
+
import { descriptor, post, undo, toast, newGroup, selectArtboards, whenDocument, leave } from './store.js';
|
|
3
|
+
import * as canvas from './canvas.js';
|
|
4
|
+
|
|
5
|
+
export async function newArtboard({ x, y, width, height, name } = {}) {
|
|
6
|
+
const narrow = document.documentElement.clientWidth < 720;
|
|
7
|
+
const body = { label: 'new artboard', width: width ?? (narrow ? 390 : 1280), height: height ?? (narrow ? 844 : 800) };
|
|
8
|
+
if (name) body.name = name;
|
|
9
|
+
if (Number.isFinite(x) && Number.isFinite(y)) Object.assign(body, { x: Math.round(x), y: Math.round(y) });
|
|
10
|
+
try {
|
|
11
|
+
const result = await post('api/artboards', body);
|
|
12
|
+
await whenDocument(result.documentId);
|
|
13
|
+
selectArtboards([result.documentId]);
|
|
14
|
+
canvas.reveal(result.documentId);
|
|
15
|
+
return result.documentId;
|
|
16
|
+
} catch (error) { toast(error.message); return null; }
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export async function duplicateArtboards(ids) {
|
|
20
|
+
if (!ids.length) return;
|
|
21
|
+
const group = newGroup();
|
|
22
|
+
const created = [];
|
|
23
|
+
for (const from of ids) {
|
|
24
|
+
try { created.push((await post('api/artboards', { from, group, expectedVersion: descriptor(from)?.version, label: ids.length > 1 ? `duplicate ${ids.length} artboards` : 'duplicate artboard' })).documentId); }
|
|
25
|
+
catch (error) { toast(error.message); }
|
|
26
|
+
}
|
|
27
|
+
if (!created.length) return;
|
|
28
|
+
await whenDocument(created.at(-1));
|
|
29
|
+
selectArtboards(created);
|
|
30
|
+
canvas.reveal(created[0]);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export async function deleteArtboards(ids) {
|
|
34
|
+
if (!ids.length) return;
|
|
35
|
+
const pending = new Map();
|
|
36
|
+
for (const id of ids) {
|
|
37
|
+
const doc = descriptor(id);
|
|
38
|
+
// Absent or already-hidden frames are not a fresh destructive intent.
|
|
39
|
+
if (doc) pending.set(id, { id, name: doc.name || id, version: doc.version, usedBy: doc.usedBy || [] });
|
|
40
|
+
}
|
|
41
|
+
if (!pending.size) return;
|
|
42
|
+
const group = newGroup();
|
|
43
|
+
// Capture targets before optimistic hiding; component consumers go before their definitions.
|
|
44
|
+
const order = [], removed = [];
|
|
45
|
+
while (pending.size) {
|
|
46
|
+
const next = [...pending.values()].find(doc => !doc.usedBy.some(user => user !== doc.id && pending.has(user))) ?? pending.values().next().value;
|
|
47
|
+
order.push(next);
|
|
48
|
+
pending.delete(next.id);
|
|
49
|
+
}
|
|
50
|
+
leave(order.map(doc => doc.id));
|
|
51
|
+
for (const doc of order) {
|
|
52
|
+
try { await post('api/artboards/delete', { documentId: doc.id, group, expectedVersion: doc.version, label: order.length > 1 ? `delete ${order.length} artboards` : `delete “${doc.name}”` }); removed.push(doc); }
|
|
53
|
+
catch (error) { leave([doc.id], true); toast(error.message); }
|
|
54
|
+
}
|
|
55
|
+
if (removed.length) toast(removed.length > 1 ? `${removed.length} artboards deleted` : `“${removed[0].name}” deleted`, { label: 'Undo', run: () => undo() });
|
|
56
|
+
}
|