@myrtilleio/redline 0.1.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/README.md +281 -0
- package/bin/redline.js +375 -0
- package/client/boot.js +146 -0
- package/client/check.js +926 -0
- package/client/composer.js +384 -0
- package/client/core.js +1610 -0
- package/client/devices.js +273 -0
- package/client/edits.js +389 -0
- package/client/keyboard.js +110 -0
- package/client/panel.js +222 -0
- package/client/pen.js +324 -0
- package/client/references.js +114 -0
- package/client/select.js +180 -0
- package/client/shots.js +357 -0
- package/client/variants.js +117 -0
- package/examples/demo-site/hero.svg +18 -0
- package/examples/demo-site/index.html +96 -0
- package/examples/demo-site/styles.css +64 -0
- package/integrations/claude-code/redline.md +27 -0
- package/package.json +42 -0
- package/src/account.js +101 -0
- package/src/bundle.js +33 -0
- package/src/demo.js +137 -0
- package/src/format.js +346 -0
- package/src/guards.js +108 -0
- package/src/inject.js +15 -0
- package/src/mcp.js +380 -0
- package/src/report.js +199 -0
- package/src/server.js +477 -0
- package/src/shots.js +311 -0
- package/src/store.js +544 -0
- package/src/sync.js +126 -0
- package/src/taste.js +80 -0
- package/src/verify.js +71 -0
|
@@ -0,0 +1,384 @@
|
|
|
1
|
+
// ---------------------------------------------------------------- composer
|
|
2
|
+
|
|
3
|
+
// “ul in div.plan-featured · 100 checks…” — enough to tell a list from its card.
|
|
4
|
+
function targetSummary(el, a) {
|
|
5
|
+
if (!el) return a && a.target ? a.target.selector : 'Page';
|
|
6
|
+
const parent = el.parentElement && el.parentElement !== document.body ? el.parentElement : null;
|
|
7
|
+
const text = asBuilt(() => el.innerText || el.getAttribute('alt') || el.getAttribute('aria-label') || '').replace(/\s+/g, ' ').trim();
|
|
8
|
+
return `${label(el)}${parent ? ' in ' + label(parent) : ''}${text ? ' · ' + (text.length > 40 ? text.slice(0, 40) + '…' : text) : ''}`;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
// Put the composer beside what it's about rather than on top of it: right, left,
|
|
12
|
+
// below or above the element; on narrow screens, dock it to the opposite half.
|
|
13
|
+
function placeComposer(form, rect, point) {
|
|
14
|
+
// In a scaled-down device frame the composer is zoomed back up (uiZoom): measure
|
|
15
|
+
// its on-screen size, and divide positions by the zoom, which CSS applies to them.
|
|
16
|
+
const box = form.getBoundingClientRect();
|
|
17
|
+
const w = box.width, fh = box.height, gap = 12, m = 8, z = uiZoom;
|
|
18
|
+
const fits = (x, y) => x >= m && y >= m && x + w <= innerWidth - m && y + fh <= innerHeight - m;
|
|
19
|
+
const clampY = (y) => Math.min(Math.max(y, m), innerHeight - fh - m);
|
|
20
|
+
const clampX = (x) => Math.min(Math.max(x, m), innerWidth - w - m);
|
|
21
|
+
let pos = null;
|
|
22
|
+
if (innerWidth < 520) {
|
|
23
|
+
const lower = rect ? rect.top + rect.height / 2 > innerHeight / 2 : false;
|
|
24
|
+
Object.assign(form.style, { left: m / z + 'px', right: m / z + 'px', width: 'auto', top: lower ? m / z + 'px' : 'auto', bottom: lower ? 'auto' : m / z + 'px' });
|
|
25
|
+
return;
|
|
26
|
+
}
|
|
27
|
+
if (rect) {
|
|
28
|
+
const options = [
|
|
29
|
+
[rect.right + gap, clampY(rect.top), 'left top'],
|
|
30
|
+
[rect.left - w - gap, clampY(rect.top), 'right top'],
|
|
31
|
+
[clampX(rect.left), rect.bottom + gap, 'top left'],
|
|
32
|
+
[clampX(rect.left), rect.top - fh - gap, 'bottom left'],
|
|
33
|
+
];
|
|
34
|
+
pos = options.find(([x, y]) => fits(x, y));
|
|
35
|
+
}
|
|
36
|
+
// Grow out of the side that faces the element.
|
|
37
|
+
form.style.setProperty('--origin', pos ? pos[2] : 'center');
|
|
38
|
+
if (!pos) {
|
|
39
|
+
const p = point || { x: innerWidth / 2, y: innerHeight / 2 };
|
|
40
|
+
pos = [p.x + gap + w > innerWidth - m ? p.x - w - gap : p.x + gap, p.y - 20];
|
|
41
|
+
}
|
|
42
|
+
form.style.left = clampX(pos[0]) / z + 'px';
|
|
43
|
+
form.style.top = clampY(pos[1]) / z + 'px';
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
function openComposer({ annotation, draft, el, point, compact }) {
|
|
47
|
+
closeComposer();
|
|
48
|
+
hideMarkChip(true);
|
|
49
|
+
const a = annotation || null;
|
|
50
|
+
const target = el || (a && anchorEl(a)) || null;
|
|
51
|
+
let verdict = a ? a.verdict : state.verdict;
|
|
52
|
+
const tags = a ? a.tags || [] : []; // kept as they were; the card no longer asks for them
|
|
53
|
+
// Once the agent has joined in (or asked), the card is a conversation: your first
|
|
54
|
+
// note and the replies above, and the field below becomes a reply.
|
|
55
|
+
const replyMode = !!a && !!a.thread && a.thread.length > 0;
|
|
56
|
+
const original = a && !replyMode ? a.comment || '' : '';
|
|
57
|
+
let touched = false;
|
|
58
|
+
let request = a ? a.request || null : null;
|
|
59
|
+
const changed = () => (touched = true);
|
|
60
|
+
|
|
61
|
+
const text = h('textarea', {
|
|
62
|
+
placeholder: a && a.kind === 'stroke' ? 'Add a note about this mark (optional)…' : 'What should the AI keep or change?',
|
|
63
|
+
'aria-label': 'Comment',
|
|
64
|
+
});
|
|
65
|
+
text.value = original;
|
|
66
|
+
|
|
67
|
+
const grow = () => {
|
|
68
|
+
const before = text.offsetHeight;
|
|
69
|
+
text.style.height = 'auto';
|
|
70
|
+
text.style.height = Math.min(text.scrollHeight + 2, 240) + 'px';
|
|
71
|
+
if (form.isConnected && text.offsetHeight !== before) replace();
|
|
72
|
+
};
|
|
73
|
+
// What the card is about on screen: the drawn area of a box, else the element.
|
|
74
|
+
const focusRect = () => {
|
|
75
|
+
const region = (a && a.region) || (draft && draft.region);
|
|
76
|
+
if (target && region) {
|
|
77
|
+
const r = target.getBoundingClientRect();
|
|
78
|
+
const t = a && a.target && a.target.rect;
|
|
79
|
+
const sx = t && t.w ? r.width / t.w : 1, sy = t && t.h ? r.height / t.h : 1;
|
|
80
|
+
const left = r.left + region.x * sx, top = r.top + region.y * sy;
|
|
81
|
+
return { left, top, width: region.w * sx, height: region.h * sy, right: left + region.w * sx, bottom: top + region.h * sy };
|
|
82
|
+
}
|
|
83
|
+
return target ? target.getBoundingClientRect() : null;
|
|
84
|
+
};
|
|
85
|
+
const replace = () => placeComposer(form, focusRect(), point || pinPoint(a));
|
|
86
|
+
text.addEventListener('input', () => {
|
|
87
|
+
changed();
|
|
88
|
+
grow();
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
// ---- Adjust: live edits on the element (built on first open)
|
|
92
|
+
let editor = null;
|
|
93
|
+
const adjustSlot = h('div', { class: 'adjust-slot', hidden: true });
|
|
94
|
+
const adjustBtn = h('button', { type: 'button', class: 'btn small', 'aria-expanded': 'false', title: 'Change size, spacing, colour or wording right on the page', html: ICONS.sliders + ' Adjust' });
|
|
95
|
+
const adjustCount = h('span', { class: 'adj-count' });
|
|
96
|
+
function openAdjust(show) {
|
|
97
|
+
if (!target) return toast('This element is not on the page right now');
|
|
98
|
+
if (!editor) {
|
|
99
|
+
editor = createEditor(target, a && a.edits, () => {
|
|
100
|
+
changed();
|
|
101
|
+
adjustCount.textContent = editor.count() ? String(editor.count()) : '';
|
|
102
|
+
});
|
|
103
|
+
adjustSlot.append(editor.node);
|
|
104
|
+
adjustCount.textContent = editor.count() ? String(editor.count()) : '';
|
|
105
|
+
}
|
|
106
|
+
adjustSlot.hidden = !show;
|
|
107
|
+
adjustBtn.setAttribute('aria-expanded', String(show));
|
|
108
|
+
form.classList.toggle('wide', show);
|
|
109
|
+
replace();
|
|
110
|
+
}
|
|
111
|
+
adjustBtn.addEventListener('click', () => openAdjust(adjustSlot.hidden));
|
|
112
|
+
adjustBtn.append(adjustCount);
|
|
113
|
+
|
|
114
|
+
// ---- References and Options (built once the form exists)
|
|
115
|
+
let refs = null;
|
|
116
|
+
let options = null;
|
|
117
|
+
|
|
118
|
+
const resolved = a && a.status === 'resolved';
|
|
119
|
+
// The card shows the essentials: why, and the ink (meaning). Everything else
|
|
120
|
+
// (Adjust, Reference, the agent's options, Move, Delete) is one ⋯ away, and opens
|
|
121
|
+
// by itself when this item already uses any of it.
|
|
122
|
+
const kindName = a && a.kind === 'stroke' ? describeMark(a.stroke && a.stroke.paths).replace(/ \(.*\)$/, '')
|
|
123
|
+
: (draft && draft.region) || (a && a.region) ? 'Box' : 'Element';
|
|
124
|
+
const thread = replyMode
|
|
125
|
+
? h('div', { class: 'thread' },
|
|
126
|
+
a.comment ? h('div', { class: 'msg you' }, a.comment) : null,
|
|
127
|
+
(a.thread || []).filter((m) => !(resolved && m.tag === 'resolved' && m === a.thread[a.thread.length - 1])).map((m) => h('div', { class: `msg ${m.by === 'agent' ? 'agent' : 'you'}` }, m.tag === 'resolved' ? h('span', { class: 'tag' }, 'Fixed') : null, m.text)))
|
|
128
|
+
: null;
|
|
129
|
+
const more = h('div', { class: 'more-section' });
|
|
130
|
+
const hasExtras = a && (a.edits || (a.references && a.references.length) || a.variants || a.request);
|
|
131
|
+
more.hidden = !hasExtras;
|
|
132
|
+
const inkChip = h('button', { type: 'button', class: 'ink-chip', 'aria-haspopup': 'menu' });
|
|
133
|
+
const paintInk = () => {
|
|
134
|
+
const ink = inkOf(verdict);
|
|
135
|
+
inkChip.replaceChildren(h('i', { style: `background:${COLORS[verdict]}` }), ink.name);
|
|
136
|
+
inkChip.title = `Meaning: ${ink.name}. Click to change`;
|
|
137
|
+
};
|
|
138
|
+
inkChip.addEventListener('click', () => openMenu(inkChip, INKS.map((i) => ({ label: i.name, key: i.key, swatch: COLORS[i.v], checked: verdict === i.v, run: () => api.setVerdict(i.v) }))));
|
|
139
|
+
const moreBtn = h('button', { type: 'button', class: 'btn icon more-btn', title: 'More: adjust, reference, move, delete', 'aria-label': 'More options', 'aria-expanded': String(!more.hidden), html: ICONS.more });
|
|
140
|
+
moreBtn.addEventListener('click', () => {
|
|
141
|
+
more.hidden = !more.hidden;
|
|
142
|
+
moreBtn.setAttribute('aria-expanded', String(!more.hidden));
|
|
143
|
+
replace();
|
|
144
|
+
});
|
|
145
|
+
text.placeholder = replyMode ? 'Reply…'
|
|
146
|
+
: a && a.kind === 'stroke' ? 'Why? (optional: the mark already says it)' : 'Why? (optional)';
|
|
147
|
+
|
|
148
|
+
const form = h(
|
|
149
|
+
'form',
|
|
150
|
+
{ class: 'composer', 'aria-label': 'Feedback note' },
|
|
151
|
+
h('header', {},
|
|
152
|
+
h('div', { class: 'what' },
|
|
153
|
+
h('b', {}, kindName, a && a.quote ? ` “${a.quote.slice(0, 40)}${a.quote.length > 40 ? '…' : ''}”` : ''),
|
|
154
|
+
h('code', { title: target ? cssPath(target) : '' }, targetSummary(target, a)),
|
|
155
|
+
),
|
|
156
|
+
h('button', { type: 'button', class: 'btn icon', title: 'Close', 'aria-label': 'Close', html: ICONS.close, onclick: () => closeComposer() }),
|
|
157
|
+
),
|
|
158
|
+
resolved ? h('div', { class: 'resolution' }, h('b', {}, 'Fixed'), a.resolution ? ': ' + a.resolution : '') : null,
|
|
159
|
+
a ? thumbStrip(a) : null,
|
|
160
|
+
thread,
|
|
161
|
+
text,
|
|
162
|
+
more,
|
|
163
|
+
);
|
|
164
|
+
fillAppend(more,
|
|
165
|
+
resolved ? null : h('div', { class: 'tools-row' }, adjustBtn),
|
|
166
|
+
resolved ? null : adjustSlot,
|
|
167
|
+
);
|
|
168
|
+
// Attaching an image (paste, drop, picker) shows the section it lives in.
|
|
169
|
+
refs = createReferences(form, a && a.references, (list) => {
|
|
170
|
+
changed();
|
|
171
|
+
if (list.length && more.hidden) {
|
|
172
|
+
more.hidden = false;
|
|
173
|
+
moreBtn.setAttribute('aria-expanded', 'true');
|
|
174
|
+
replace();
|
|
175
|
+
}
|
|
176
|
+
});
|
|
177
|
+
if (!resolved) more.querySelector('.tools-row').append(refs.node);
|
|
178
|
+
|
|
179
|
+
const optionHandlers = {
|
|
180
|
+
onPreview: () => applyPreviews(),
|
|
181
|
+
// Only a request left over from before options became the agent's call can be withdrawn.
|
|
182
|
+
async onRequest(n) {
|
|
183
|
+
request = n ? { variants: n } : null;
|
|
184
|
+
if (a) {
|
|
185
|
+
await store.update(a.id, { request });
|
|
186
|
+
a.request = request || undefined;
|
|
187
|
+
toast(n ? 'Asked your agent for options' : 'Request cancelled');
|
|
188
|
+
} else changed();
|
|
189
|
+
renderOptions();
|
|
190
|
+
},
|
|
191
|
+
// Picking saves everything typed with it, and turns the item into a Fix.
|
|
192
|
+
async onPick(key) {
|
|
193
|
+
await save({ chosen: key, verdict: 'fix' });
|
|
194
|
+
toast(`Picked option ${key}${a.verdict !== 'fix' ? ' and marked it Fix' : ''}. Your agent will build it.`);
|
|
195
|
+
},
|
|
196
|
+
};
|
|
197
|
+
// For a new note, the request is saved with it; for an existing one, right away.
|
|
198
|
+
const optionSubject = () => a || { request };
|
|
199
|
+
options = createOptions(optionSubject(), optionHandlers);
|
|
200
|
+
function renderOptions() {
|
|
201
|
+
const next = createOptions(optionSubject(), optionHandlers);
|
|
202
|
+
options.node.replaceWith(next.node);
|
|
203
|
+
options = next;
|
|
204
|
+
}
|
|
205
|
+
fillAppend(more,
|
|
206
|
+
// Options are the agent's to offer when it isn't sure; they appear here when it does.
|
|
207
|
+
resolved || !(a && (a.variants || []).length) ? null : h('div', { class: 'section-label' }, 'Options'),
|
|
208
|
+
resolved ? null : options.node,
|
|
209
|
+
a ? h('div', { class: 'more-actions' },
|
|
210
|
+
a.kind === 'note' ? h('button', { type: 'button', class: 'btn small', title: 'Point this note at a different element', 'aria-label': 'Move note', html: ICONS.move + ' Move' }) : null,
|
|
211
|
+
h('button', { type: 'button', class: 'btn small danger', title: 'Delete', 'aria-label': 'Delete', html: ICONS.trash + ' Delete' }),
|
|
212
|
+
) : null,
|
|
213
|
+
);
|
|
214
|
+
const moveBtn = more.querySelector('[aria-label="Move note"]');
|
|
215
|
+
if (moveBtn) moveBtn.addEventListener('click', () => startRetarget(a));
|
|
216
|
+
const delBtn = more.querySelector('[aria-label="Delete"]');
|
|
217
|
+
if (delBtn) delBtn.addEventListener('click', () => remove(a));
|
|
218
|
+
// A fixed mark asks one question: is it right? Accept it, or say what's still wrong.
|
|
219
|
+
let redo = false;
|
|
220
|
+
const submitBtn = h('button', { type: 'submit', class: 'btn primary', title: `Save (${MOD_KEY}+Enter)` }, 'Save');
|
|
221
|
+
const checkRow = resolved
|
|
222
|
+
? h('div', { class: 'actions' },
|
|
223
|
+
h('button', {
|
|
224
|
+
type: 'button',
|
|
225
|
+
class: 'btn',
|
|
226
|
+
onclick: (e) => {
|
|
227
|
+
redo = true;
|
|
228
|
+
text.hidden = false;
|
|
229
|
+
text.placeholder = 'What is still wrong?';
|
|
230
|
+
text.focus();
|
|
231
|
+
e.currentTarget.parentElement.replaceWith(saveRow);
|
|
232
|
+
replace();
|
|
233
|
+
},
|
|
234
|
+
}, '↺ Redo'),
|
|
235
|
+
h('span', { class: 'grow' }),
|
|
236
|
+
h('button', {
|
|
237
|
+
type: 'button',
|
|
238
|
+
class: 'btn primary accept',
|
|
239
|
+
onclick: async () => {
|
|
240
|
+
await store.update(a.id, { accepted: true });
|
|
241
|
+
closeComposer(true);
|
|
242
|
+
await load();
|
|
243
|
+
toast(toCheck().length ? `Accepted. ${toCheck().length} left to check` : 'Accepted');
|
|
244
|
+
},
|
|
245
|
+
}, '✓ Accept'),
|
|
246
|
+
)
|
|
247
|
+
: null;
|
|
248
|
+
const saveRow = h('div', { class: 'actions' }, resolved ? null : inkChip, h('span', { class: 'grow' }), resolved ? null : moreBtn, submitBtn);
|
|
249
|
+
if (resolved) text.hidden = true;
|
|
250
|
+
fillAppend(form, checkRow || saveRow);
|
|
251
|
+
paintInk();
|
|
252
|
+
|
|
253
|
+
async function save(extra) {
|
|
254
|
+
// Redo: back to a draft for your next round, with what's still wrong as a reply.
|
|
255
|
+
if (redo && a) {
|
|
256
|
+
extra = { status: 'open', draft: true, accepted: false, reply: text.value.trim() || 'Not quite yet.', ...(target ? { target: describeEl(target) } : {}), ...extra };
|
|
257
|
+
}
|
|
258
|
+
const body = replyMode || redo
|
|
259
|
+
? { verdict, tags, ...(text.value.trim() && !redo ? { reply: text.value.trim() } : {}), ...extra }
|
|
260
|
+
: { verdict, comment: text.value.trim(), tags, ...extra };
|
|
261
|
+
if (editor) body.edits = editor.edits();
|
|
262
|
+
body.references = refs.list().length ? refs.list() : undefined;
|
|
263
|
+
if (a && a.references && !body.references) body.references = []; // removed the last one
|
|
264
|
+
try {
|
|
265
|
+
if (a) await store.update(a.id, body);
|
|
266
|
+
else if (draft) {
|
|
267
|
+
const el2 = target || document.body;
|
|
268
|
+
await store.create({ ...draft, ...body, request, draft: true, target: describeEl(el2), page: PAGE, ...captureContext(el2) });
|
|
269
|
+
}
|
|
270
|
+
closeComposer(true);
|
|
271
|
+
await load();
|
|
272
|
+
if (!a) toast(body.edits ? 'Note saved with your edit' : 'Note saved');
|
|
273
|
+
} catch (err) {
|
|
274
|
+
toast('Could not save: ' + err.message);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
form.addEventListener('submit', (e) => {
|
|
279
|
+
e.preventDefault();
|
|
280
|
+
save();
|
|
281
|
+
});
|
|
282
|
+
form.addEventListener('keydown', (e) => {
|
|
283
|
+
// Enter in a one-line field (Adjust values, uploads) must not submit the note.
|
|
284
|
+
if (e.key === 'Enter' && !(e.metaKey || e.ctrlKey) && /^(input|select)$/i.test(e.composedPath()[0].tagName)) e.preventDefault();
|
|
285
|
+
if (e.key === 'Enter' && !e.shiftKey && e.composedPath()[0] === text && text.value.indexOf('\n') < 0 && text.value.length < 120) {
|
|
286
|
+
e.preventDefault();
|
|
287
|
+
save();
|
|
288
|
+
} else if (e.key === 'Enter' && (e.metaKey || e.ctrlKey)) {
|
|
289
|
+
e.preventDefault();
|
|
290
|
+
save();
|
|
291
|
+
} else if (e.key === 'Escape') {
|
|
292
|
+
e.preventDefault();
|
|
293
|
+
closeComposer(true);
|
|
294
|
+
}
|
|
295
|
+
e.stopPropagation();
|
|
296
|
+
});
|
|
297
|
+
|
|
298
|
+
const api = {
|
|
299
|
+
annotation: a,
|
|
300
|
+
el: target,
|
|
301
|
+
focusRect,
|
|
302
|
+
openedAt: Date.now(),
|
|
303
|
+
untouched: () => !touched && !text.value.trim(),
|
|
304
|
+
// Keep what was typed when the composer closes by clicking elsewhere;
|
|
305
|
+
// only Esc and Cancel discard.
|
|
306
|
+
dirty: () => (a ? text.value.trim() !== original || verdict !== a.verdict || touched : !!text.value.trim() || touched),
|
|
307
|
+
commit: () => save(),
|
|
308
|
+
// Called by applyPreviews(): layer this composer's unsaved edit and shown option on top.
|
|
309
|
+
reapply() {
|
|
310
|
+
if (editor) editor.apply();
|
|
311
|
+
const key = options && options.showing();
|
|
312
|
+
if (a && key) applyVariant(a, key);
|
|
313
|
+
},
|
|
314
|
+
setVerdict(v) {
|
|
315
|
+
verdict = v;
|
|
316
|
+
changed();
|
|
317
|
+
paintInk();
|
|
318
|
+
},
|
|
319
|
+
};
|
|
320
|
+
state.composer = api;
|
|
321
|
+
// After a pen stroke the card starts small (verdict + comment), so it doesn't
|
|
322
|
+
// sit under the hand that's still drawing; "More" brings back the rest.
|
|
323
|
+
if (compact) {
|
|
324
|
+
form.classList.add('compact');
|
|
325
|
+
form.querySelector('.actions').prepend(
|
|
326
|
+
h('button', { type: 'button', class: 'btn small more', onclick: (e) => (form.classList.remove('compact'), e.currentTarget.remove(), replace()) }, 'More'),
|
|
327
|
+
);
|
|
328
|
+
}
|
|
329
|
+
root.append(form);
|
|
330
|
+
api.form = form;
|
|
331
|
+
applyPreviews(); // this item's saved edit/option now comes from the composer
|
|
332
|
+
if (a && a.edits) openAdjust(false);
|
|
333
|
+
|
|
334
|
+
grow();
|
|
335
|
+
replace();
|
|
336
|
+
|
|
337
|
+
if (target) placeBox(focusBox, focusRect());
|
|
338
|
+
text.focus({ preventScroll: true });
|
|
339
|
+
}
|
|
340
|
+
|
|
341
|
+
function closeComposer(discard) {
|
|
342
|
+
const c = state.composer;
|
|
343
|
+
if (!c) return;
|
|
344
|
+
state.composer = null;
|
|
345
|
+
const saving = !discard && c.dirty() ? c.commit() : null;
|
|
346
|
+
// Leave quickly; instantly when closed from the keyboard.
|
|
347
|
+
if (state.viaKeyboard || matchMedia('(prefers-reduced-motion: reduce)').matches) c.form.remove();
|
|
348
|
+
else {
|
|
349
|
+
c.form.classList.replace('composer', 'composer-out'); // styled the same, but no longer "the" note
|
|
350
|
+
c.form.inert = true;
|
|
351
|
+
setTimeout(() => c.form.remove(), 130);
|
|
352
|
+
}
|
|
353
|
+
focusBox.style.display = 'none';
|
|
354
|
+
focusBox.classList.remove('flash');
|
|
355
|
+
if (!saving) applyPreviews(); // drop unsaved edits; a save reloads and re-applies
|
|
356
|
+
if (state.pendingReload) Promise.resolve(saving).finally(() => location.reload());
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
async function remove(a) {
|
|
360
|
+
try {
|
|
361
|
+
await store.remove(a.id);
|
|
362
|
+
closeComposer(true);
|
|
363
|
+
await load();
|
|
364
|
+
toast(`Deleted #${a.n}`, {
|
|
365
|
+
label: 'Undo',
|
|
366
|
+
async run() {
|
|
367
|
+
const { id, status, createdAt, updatedAt, ...rest } = a;
|
|
368
|
+
await store.create(rest);
|
|
369
|
+
await load();
|
|
370
|
+
},
|
|
371
|
+
});
|
|
372
|
+
} catch (err) {
|
|
373
|
+
toast('Could not delete: ' + err.message);
|
|
374
|
+
}
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
// Clicking anywhere outside the composer (in browse/pen mode) closes it.
|
|
378
|
+
addEventListener(
|
|
379
|
+
'pointerdown',
|
|
380
|
+
(e) => {
|
|
381
|
+
if (state.composer && !eventIsOurs(e) && state.mode !== 'select') closeComposer();
|
|
382
|
+
},
|
|
383
|
+
true,
|
|
384
|
+
);
|