@hachej/boring-feedback 0.0.0-stage → 0.2.4
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/INVARIANTS.md +35 -0
- package/LICENSE +21 -0
- package/README.md +20 -2
- package/dist/agent/capability.d.ts +52 -0
- package/dist/agent/capability.js +230 -0
- package/dist/agent/index.d.ts +4 -0
- package/dist/agent/index.js +5 -0
- package/dist/agent/preview.d.ts +10 -0
- package/dist/agent/preview.js +27 -0
- package/dist/format/index.d.ts +7 -0
- package/dist/format/index.js +6 -0
- package/dist/format/json.d.ts +30 -0
- package/dist/format/json.js +193 -0
- package/dist/format/preview.d.ts +28 -0
- package/dist/format/preview.js +45 -0
- package/dist/format/readable.d.ts +10 -0
- package/dist/format/readable.js +19 -0
- package/dist/format/report.d.ts +187 -0
- package/dist/format/report.js +669 -0
- package/dist/format/requests.d.ts +53 -0
- package/dist/format/requests.js +69 -0
- package/dist/page/app-element.d.ts +79 -0
- package/dist/page/app-element.js +451 -0
- package/dist/page/canaries.d.ts +46 -0
- package/dist/page/canaries.js +142 -0
- package/dist/page/index.d.ts +6 -0
- package/dist/page/index.js +8 -0
- package/dist/page/picker/index.d.ts +4 -0
- package/dist/page/picker/index.js +4 -0
- package/dist/page/picker/label.d.ts +16 -0
- package/dist/page/picker/label.js +58 -0
- package/dist/page/picker/overlay.d.ts +42 -0
- package/dist/page/picker/overlay.js +126 -0
- package/dist/page/picker/picker.d.ts +72 -0
- package/dist/page/picker/picker.js +386 -0
- package/dist/page/privacy/digest.d.ts +6 -0
- package/dist/page/privacy/digest.js +24 -0
- package/dist/page/privacy/index.d.ts +6 -0
- package/dist/page/privacy/index.js +5 -0
- package/dist/page/privacy/name.d.ts +8 -0
- package/dist/page/privacy/name.js +106 -0
- package/dist/page/privacy/policy.d.ts +65 -0
- package/dist/page/privacy/policy.js +125 -0
- package/dist/page/privacy/route.d.ts +6 -0
- package/dist/page/privacy/route.js +20 -0
- package/dist/page/privacy/serialize.d.ts +42 -0
- package/dist/page/privacy/serialize.js +90 -0
- package/dist/page/voice/align.d.ts +36 -0
- package/dist/page/voice/align.js +122 -0
- package/dist/page/voice/capture.d.ts +43 -0
- package/dist/page/voice/capture.js +186 -0
- package/dist/page/voice/index.d.ts +3 -0
- package/dist/page/voice/index.js +3 -0
- package/dist/preview/agent.d.ts +34 -0
- package/dist/preview/agent.js +91 -0
- package/dist/preview/index.d.ts +5 -0
- package/dist/preview/index.js +6 -0
- package/dist/preview/page.d.ts +50 -0
- package/dist/preview/page.js +293 -0
- package/dist/preview/session.d.ts +63 -0
- package/dist/preview/session.js +110 -0
- package/dist/source/index.d.ts +24 -0
- package/dist/source/index.js +59 -0
- package/dist/source/jsx-dev-runtime.d.ts +5 -0
- package/dist/source/jsx-dev-runtime.js +29 -0
- package/dist/store/index.d.ts +5 -0
- package/dist/store/index.js +4 -0
- package/dist/store/mentions.d.ts +17 -0
- package/dist/store/mentions.js +27 -0
- package/dist/store/store.d.ts +121 -0
- package/dist/store/store.js +353 -0
- package/dist/tickets/index.d.ts +5 -0
- package/dist/tickets/index.js +5 -0
- package/dist/tickets/sinks.d.ts +27 -0
- package/dist/tickets/sinks.js +84 -0
- package/dist/tickets/ticket.d.ts +93 -0
- package/dist/tickets/ticket.js +148 -0
- package/dist/ui/annotate.d.ts +103 -0
- package/dist/ui/annotate.js +154 -0
- package/dist/ui/index.d.ts +7 -0
- package/dist/ui/index.js +8 -0
- package/dist/ui/report.d.ts +51 -0
- package/dist/ui/report.js +58 -0
- package/dist/ui/save.d.ts +45 -0
- package/dist/ui/save.js +65 -0
- package/dist/ui/session.d.ts +186 -0
- package/dist/ui/session.js +762 -0
- package/dist/ui/show.d.ts +43 -0
- package/dist/ui/show.js +72 -0
- package/dist/ui/state.d.ts +11 -0
- package/dist/ui/state.js +21 -0
- package/package.json +98 -4
|
@@ -0,0 +1,762 @@
|
|
|
1
|
+
// The feedback session (FEEDBACK.md, "UX"): one entry point, **Feedback**, in the chat composer. Pressing it enters feedback mode on the
|
|
2
|
+
// application page. The person hovers (the picker's box and label), clicks to drop a numbered pin and types a note in a bubble beside
|
|
3
|
+
// it (Enter saves, Esc discards the pin); the click never reaches the page (the picker's event blocking). Holding ⌥/Alt, or the "Use
|
|
4
|
+
// app" toggle, suspends the picker so clicks and keys reach the app; those interactions are recorded as steps between the notes
|
|
5
|
+
// (element labels through the privacy policy, route templates, named keys only). An optional voice recording is transcribed on Done
|
|
6
|
+
// and aligned with the pointer trail and the pins (`alignSpeech`), on ONE clock (`performance.now()`). While recording, a click drops
|
|
7
|
+
// a numbered pin WITHOUT the bubble: what the person says around that moment becomes its note (the pin wins in `alignSpeech`); a pin
|
|
8
|
+
// nothing was said about stays as an empty note to type into in the review. Done closes feedback mode and
|
|
9
|
+
// leaves one draft for the composer's chip: notes in order, editable and removable, each able to highlight its element; Send
|
|
10
|
+
// attaches the `feedback@1` report (saved first when the host has a store).
|
|
11
|
+
//
|
|
12
|
+
// Headless and framework-light like the rest of `./ui`: `subscribe`/`getSnapshot`, no view-layer import. The bar, bubble, chip and
|
|
13
|
+
// review are registry components over this state.
|
|
14
|
+
import { FEEDBACK_LIMITS, draftReport, feedbackText, } from '../format/index.js';
|
|
15
|
+
import { alignSpeech, anchorOf, createOverlay, createPicker, isExcluded, pickRefusal, pickerLabel, routeFor, serializePage, } from '../page/index.js';
|
|
16
|
+
import { copyToClipboard } from '@hachej/boring-files/platform';
|
|
17
|
+
import { draftId, observePage, operationId } from './annotate.js';
|
|
18
|
+
import { createState } from './state.js';
|
|
19
|
+
const finiteNumber = (value) => typeof value === 'number' && Number.isFinite(value);
|
|
20
|
+
const timedText = (value) => {
|
|
21
|
+
const item = value;
|
|
22
|
+
if (!item || typeof item !== 'object' || !finiteNumber(item.start) || !finiteNumber(item.end) || typeof item.text !== 'string')
|
|
23
|
+
return null;
|
|
24
|
+
return { start: item.start, end: item.end, text: item.text };
|
|
25
|
+
};
|
|
26
|
+
/** The host's answer read leniently: usable timed segments, else plain text, else null (nothing usable came back). */
|
|
27
|
+
function readTranscription(answer) {
|
|
28
|
+
if (!answer || typeof answer !== 'object')
|
|
29
|
+
return null;
|
|
30
|
+
const { segments, text } = answer;
|
|
31
|
+
const timed = (Array.isArray(segments) ? segments : []).flatMap(raw => {
|
|
32
|
+
const segment = timedText(raw);
|
|
33
|
+
if (!segment || !segment.text.trim())
|
|
34
|
+
return [];
|
|
35
|
+
const words = raw.words;
|
|
36
|
+
const kept = (Array.isArray(words) ? words : []).flatMap(word => { const read = timedText(word); return read ? [read] : []; });
|
|
37
|
+
return [kept.length ? { ...segment, words: kept } : segment];
|
|
38
|
+
});
|
|
39
|
+
if (timed.length)
|
|
40
|
+
return { segments: timed };
|
|
41
|
+
return typeof text === 'string' ? { text } : null;
|
|
42
|
+
}
|
|
43
|
+
const NAMED_KEYS = new Set(['Enter', 'Escape', 'Tab', 'ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight', 'PageUp', 'PageDown', 'Home', 'End']);
|
|
44
|
+
const MAX_ELEMENTS = 500;
|
|
45
|
+
const MAX_TRAIL = 5000;
|
|
46
|
+
/** Point mode is on (or finishing): the host hides its panel while this is true and shows it again when it turns false. */
|
|
47
|
+
export function isPointing(state) {
|
|
48
|
+
return state.mode === 'point' && (state.phase === 'active' || state.phase === 'processing');
|
|
49
|
+
}
|
|
50
|
+
/** A pin number as drawn on the page: ①…⑳, then the plain number. */
|
|
51
|
+
export function pinMark(number) {
|
|
52
|
+
return number >= 1 && number <= 20 ? String.fromCodePoint(0x245f + number) : String(number);
|
|
53
|
+
}
|
|
54
|
+
export function createFeedbackSession(options) {
|
|
55
|
+
const { root, policy } = options;
|
|
56
|
+
const now = options.now ?? (() => performance.now());
|
|
57
|
+
const document = root.ownerDocument;
|
|
58
|
+
const view = document.defaultView;
|
|
59
|
+
const voiceAvailable = options.voice !== undefined && options.transcribe !== undefined;
|
|
60
|
+
const routeNow = () => routeFor({ pathname: (options.location?.() ?? view?.location ?? { pathname: '/' }).pathname }, policy);
|
|
61
|
+
const state = createState({
|
|
62
|
+
phase: 'idle', mode: 'feedback', using: false, useToggle: false, notes: [], general: '', generalOpen: false, route: routeNow(), stepCount: 0,
|
|
63
|
+
voice: { kind: 'off' }, voiceAvailable, transcribing: false, send: { kind: 'idle' }, copy: { kind: 'idle' },
|
|
64
|
+
});
|
|
65
|
+
// --- session data (reset by start) ---------------------------------------------------------------------------------------------
|
|
66
|
+
let elements = [];
|
|
67
|
+
const anchors = new Map();
|
|
68
|
+
/** Each session element's label when it was last hovered or pinned, for notes about elements that later left the page. */
|
|
69
|
+
const labels = new Map();
|
|
70
|
+
let notes = [];
|
|
71
|
+
let timeline = [];
|
|
72
|
+
let pointer = [];
|
|
73
|
+
let pins = [];
|
|
74
|
+
let recordings = [];
|
|
75
|
+
let nextId = 1;
|
|
76
|
+
let bubble;
|
|
77
|
+
/** Text typed in a bubble whose selection was cancelled: offered to the general question, dropped by anything else. */
|
|
78
|
+
let stash = '';
|
|
79
|
+
let alt = false;
|
|
80
|
+
let lastRoute = '';
|
|
81
|
+
let lastHover;
|
|
82
|
+
let observed;
|
|
83
|
+
let problem;
|
|
84
|
+
let hoverLabel;
|
|
85
|
+
// Save: one operation id per draft; kept while it may have reached the store (as in createAnnotation).
|
|
86
|
+
let operation = operationId();
|
|
87
|
+
let mayHaveSent = false;
|
|
88
|
+
let saved;
|
|
89
|
+
let picker;
|
|
90
|
+
let pinsOverlay;
|
|
91
|
+
const removers = [];
|
|
92
|
+
const indexOf = (element) => {
|
|
93
|
+
const found = elements.indexOf(element);
|
|
94
|
+
if (found >= 0)
|
|
95
|
+
return found;
|
|
96
|
+
if (elements.length >= MAX_ELEMENTS)
|
|
97
|
+
return null;
|
|
98
|
+
elements.push(element);
|
|
99
|
+
return elements.length - 1;
|
|
100
|
+
};
|
|
101
|
+
/** The element's anchor against a fresh page snapshot, captured once per session element. */
|
|
102
|
+
const capture = (index) => {
|
|
103
|
+
const known = anchors.get(index);
|
|
104
|
+
if (known)
|
|
105
|
+
return { anchor: known };
|
|
106
|
+
const element = elements[index];
|
|
107
|
+
if (!element)
|
|
108
|
+
return { reason: 'the element is gone' };
|
|
109
|
+
const captured = anchorOf(element, policy, { root, page: serializePage(root, policy) });
|
|
110
|
+
if (captured.kind !== 'captured')
|
|
111
|
+
return { reason: captured.reason };
|
|
112
|
+
const anchor = captured.anchor;
|
|
113
|
+
anchors.set(index, anchor);
|
|
114
|
+
return { anchor };
|
|
115
|
+
};
|
|
116
|
+
const ordered = () => [...notes].sort((a, b) => a.t - b.t || a.id - b.id);
|
|
117
|
+
const labelOf = (note) => note.label ?? (note.element ? pickerLabel(note.element, policy) : note.target !== undefined && anchors.get(note.target) ? String(anchors.get(note.target)['fallback']) : 'the page');
|
|
118
|
+
const publish = (change = {}) => {
|
|
119
|
+
const list = ordered();
|
|
120
|
+
const views = list.map((note, index) => {
|
|
121
|
+
const anchor = note.target !== undefined ? anchors.get(note.target) : undefined;
|
|
122
|
+
const signals = anchor?.['signals'];
|
|
123
|
+
const found = signals !== null && typeof signals === 'object' && !Array.isArray(signals) ? signals.source : undefined;
|
|
124
|
+
const source = typeof found === 'string' ? found : undefined;
|
|
125
|
+
return Object.freeze({ id: note.id, number: index + 1, text: note.text, label: labelOf(note), ...(anchor ? { fallback: String(anchor['fallback']) } : {}),
|
|
126
|
+
...(source !== undefined ? { source } : {}), ...(note.from ? { from: note.from } : {}), ...(note.recorded ? { recorded: true } : {}) });
|
|
127
|
+
});
|
|
128
|
+
const open = bubble ? list.find(note => note.id === bubble.noteId) : undefined;
|
|
129
|
+
const { bubble: _bubble, problem: _problem, hover: _hover, ...base } = { ...state.getSnapshot(), ...change };
|
|
130
|
+
const showHover = hoverLabel !== undefined && base.phase === 'active' && !base.using;
|
|
131
|
+
state.replace({
|
|
132
|
+
...base,
|
|
133
|
+
notes: Object.freeze(views),
|
|
134
|
+
stepCount: timeline.filter(entry => 'step' in entry).length,
|
|
135
|
+
...(open && bubble && open.element ? { bubble: Object.freeze({ noteId: open.id, number: list.indexOf(open) + 1, element: open.element, label: labelOf(open), text: bubble.text, fresh: bubble.fresh }) } : {}),
|
|
136
|
+
...(problem !== undefined ? { problem } : {}),
|
|
137
|
+
...(showHover ? { hover: hoverLabel } : {}),
|
|
138
|
+
});
|
|
139
|
+
drawPins();
|
|
140
|
+
};
|
|
141
|
+
const drawPins = () => {
|
|
142
|
+
if (!pinsOverlay)
|
|
143
|
+
return;
|
|
144
|
+
if (state.getSnapshot().phase !== 'active') {
|
|
145
|
+
pinsOverlay.clear();
|
|
146
|
+
return;
|
|
147
|
+
}
|
|
148
|
+
pinsOverlay.draw(ordered().flatMap((note, index) => note.element ? [{ element: note.element, label: pinMark(index + 1), tone: 'pinned' }] : []));
|
|
149
|
+
};
|
|
150
|
+
const clip = (text) => [...text].slice(0, FEEDBACK_LIMITS.stepCharacters).join('');
|
|
151
|
+
const record = (step) => {
|
|
152
|
+
if (timeline.length >= FEEDBACK_LIMITS.steps * 2)
|
|
153
|
+
return;
|
|
154
|
+
const clipped = step.kind === 'route' ? { kind: 'route', route: clip(step.route) }
|
|
155
|
+
: step.kind === 'click' ? { kind: 'click', target: clip(step.target) }
|
|
156
|
+
: step.kind === 'key' && step.target !== undefined ? { kind: 'key', key: step.key, target: clip(step.target) } : step;
|
|
157
|
+
timeline.push({ t: now(), step: clipped });
|
|
158
|
+
};
|
|
159
|
+
const checkRoute = () => {
|
|
160
|
+
const route = routeNow();
|
|
161
|
+
if (route === lastRoute)
|
|
162
|
+
return;
|
|
163
|
+
lastRoute = route;
|
|
164
|
+
record({ kind: 'route', route });
|
|
165
|
+
publish({ route });
|
|
166
|
+
};
|
|
167
|
+
// --- the bubble ---------------------------------------------------------------------------------------------------------------
|
|
168
|
+
/** An empty note (or a new pin dismissed) is no note: remove it, its pin mark and its place on the timeline. */
|
|
169
|
+
const removeRecord = (note) => {
|
|
170
|
+
notes = notes.filter(item => item !== note);
|
|
171
|
+
timeline = timeline.filter(entry => !('noteId' in entry) || entry.noteId !== note.id);
|
|
172
|
+
pins = pins.filter(mark => !(mark.target === note.target && Math.abs(mark.t - note.t) < 1));
|
|
173
|
+
};
|
|
174
|
+
const closeBubble = (keep) => {
|
|
175
|
+
if (!bubble)
|
|
176
|
+
return;
|
|
177
|
+
const open = bubble;
|
|
178
|
+
bubble = undefined;
|
|
179
|
+
const note = notes.find(item => item.id === open.noteId);
|
|
180
|
+
if (!note)
|
|
181
|
+
return;
|
|
182
|
+
const text = keep ? open.text : open.before;
|
|
183
|
+
// A pin dropped while recording stays even when empty: its note is what was said there, or what the person types in the review.
|
|
184
|
+
if (!text.trim() && !(note.recorded && !open.fresh)) {
|
|
185
|
+
// A new pin dismissed keeps what was typed for a general question instead of losing it.
|
|
186
|
+
if (!keep && open.fresh && open.text.trim())
|
|
187
|
+
stash = open.text;
|
|
188
|
+
removeRecord(note);
|
|
189
|
+
}
|
|
190
|
+
else {
|
|
191
|
+
note.text = text;
|
|
192
|
+
if (open.fresh && !timeline.some(entry => 'noteId' in entry && entry.noteId === note.id))
|
|
193
|
+
timeline.push({ t: note.t, noteId: note.id });
|
|
194
|
+
}
|
|
195
|
+
};
|
|
196
|
+
const cancelSelection = () => {
|
|
197
|
+
if (!bubble)
|
|
198
|
+
return;
|
|
199
|
+
const open = bubble;
|
|
200
|
+
const note = notes.find(item => item.id === open.noteId);
|
|
201
|
+
bubble = undefined;
|
|
202
|
+
if (!note)
|
|
203
|
+
return;
|
|
204
|
+
if (open.text.trim())
|
|
205
|
+
stash = open.text;
|
|
206
|
+
removeRecord(note);
|
|
207
|
+
};
|
|
208
|
+
const withStash = (general) => {
|
|
209
|
+
const carried = stash.trim();
|
|
210
|
+
stash = '';
|
|
211
|
+
return carried && !general.includes(carried) ? [general.trim(), carried].filter(Boolean).join('\n') : general;
|
|
212
|
+
};
|
|
213
|
+
const onPick = (element) => {
|
|
214
|
+
if (state.getSnapshot().phase !== 'active')
|
|
215
|
+
return;
|
|
216
|
+
problem = undefined;
|
|
217
|
+
// Clicking the selected zone again cancels the selection (its bubble is open); a pinned zone whose bubble is closed reopens it.
|
|
218
|
+
const toggled = bubble ? notes.find(note => note.id === bubble.noteId && note.element === element) : undefined;
|
|
219
|
+
if (toggled) {
|
|
220
|
+
cancelSelection();
|
|
221
|
+
publish();
|
|
222
|
+
return;
|
|
223
|
+
}
|
|
224
|
+
stash = '';
|
|
225
|
+
if (state.getSnapshot().generalOpen)
|
|
226
|
+
state.set({ generalOpen: false });
|
|
227
|
+
closeBubble(true);
|
|
228
|
+
const recording = state.getSnapshot().voice.kind === 'recording';
|
|
229
|
+
const existing = notes.find(note => note.element === element);
|
|
230
|
+
// While recording an existing pin is already numbered; speech near it lands on it. No bubble interrupts the person talking.
|
|
231
|
+
if (existing && recording) {
|
|
232
|
+
publish();
|
|
233
|
+
return;
|
|
234
|
+
}
|
|
235
|
+
if (existing) {
|
|
236
|
+
bubble = { noteId: existing.id, text: existing.text, fresh: false, before: existing.text };
|
|
237
|
+
publish();
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
const refused = pickRefusal(element, root);
|
|
241
|
+
const target = refused === undefined ? indexOf(element) : null;
|
|
242
|
+
if (target === null) {
|
|
243
|
+
problem = `This element cannot be pointed at: ${refused ?? 'too many elements in one session'}.`;
|
|
244
|
+
publish();
|
|
245
|
+
return;
|
|
246
|
+
}
|
|
247
|
+
if (new Set(notes.flatMap(note => note.target !== undefined ? [note.target] : [])).size >= FEEDBACK_LIMITS.anchors) {
|
|
248
|
+
problem = `At most ${FEEDBACK_LIMITS.anchors} pinned elements per feedback.`;
|
|
249
|
+
publish();
|
|
250
|
+
return;
|
|
251
|
+
}
|
|
252
|
+
const captured = capture(target);
|
|
253
|
+
if (!captured.anchor) {
|
|
254
|
+
problem = `This element cannot be pointed at: ${captured.reason ?? 'unknown reason'}.`;
|
|
255
|
+
publish();
|
|
256
|
+
return;
|
|
257
|
+
}
|
|
258
|
+
const t = now();
|
|
259
|
+
pins.push({ t, target });
|
|
260
|
+
const note = { id: nextId++, element, target, label: pickerLabel(element, policy), text: '', t, ...(recording ? { recorded: true } : {}) };
|
|
261
|
+
notes.push(note);
|
|
262
|
+
if (recording)
|
|
263
|
+
timeline.push({ t, noteId: note.id });
|
|
264
|
+
else
|
|
265
|
+
bubble = { noteId: note.id, text: '', fresh: true, before: '' };
|
|
266
|
+
checkRoute();
|
|
267
|
+
publish();
|
|
268
|
+
};
|
|
269
|
+
const onHover = (element) => {
|
|
270
|
+
const target = element ? indexOf(element) : null;
|
|
271
|
+
if (target === lastHover)
|
|
272
|
+
return;
|
|
273
|
+
lastHover = target;
|
|
274
|
+
if (pointer.length < MAX_TRAIL)
|
|
275
|
+
pointer.push({ t: now(), target });
|
|
276
|
+
// Hovered elements get anchors too while recording, so speech that lands on them can become a pinned note.
|
|
277
|
+
if (target !== null && state.getSnapshot().voice.kind === 'recording')
|
|
278
|
+
capture(target);
|
|
279
|
+
hoverLabel = element ? pickerLabel(element, policy) : undefined;
|
|
280
|
+
if (target !== null && hoverLabel !== undefined)
|
|
281
|
+
labels.set(target, hoverLabel);
|
|
282
|
+
publish();
|
|
283
|
+
};
|
|
284
|
+
// --- using the app (⌥ / the toggle) -------------------------------------------------------------------------------------------
|
|
285
|
+
const applyUsing = () => {
|
|
286
|
+
const current = state.getSnapshot();
|
|
287
|
+
const using = current.phase === 'active' && (alt || current.useToggle);
|
|
288
|
+
if (using === current.using)
|
|
289
|
+
return;
|
|
290
|
+
if (using) {
|
|
291
|
+
closeBubble(true);
|
|
292
|
+
lastHover = undefined;
|
|
293
|
+
hoverLabel = undefined;
|
|
294
|
+
}
|
|
295
|
+
picker?.suspend(using);
|
|
296
|
+
publish({ using });
|
|
297
|
+
};
|
|
298
|
+
const inApp = (target) => {
|
|
299
|
+
const element = target && target.nodeType === 1 ? target : target?.parentElement ?? undefined;
|
|
300
|
+
if (!element || isExcluded(element) || pickRefusal(element, root) !== undefined)
|
|
301
|
+
return undefined;
|
|
302
|
+
return element;
|
|
303
|
+
};
|
|
304
|
+
const later = (run) => { if (view)
|
|
305
|
+
view.setTimeout(run, 0);
|
|
306
|
+
else
|
|
307
|
+
run(); };
|
|
308
|
+
const onKeyDown = (event) => {
|
|
309
|
+
const key = event;
|
|
310
|
+
if (key.key === 'Alt') {
|
|
311
|
+
if (!alt) {
|
|
312
|
+
alt = true;
|
|
313
|
+
applyUsing();
|
|
314
|
+
}
|
|
315
|
+
return;
|
|
316
|
+
}
|
|
317
|
+
const current = state.getSnapshot();
|
|
318
|
+
if (current.using) {
|
|
319
|
+
if (NAMED_KEYS.has(key.key)) {
|
|
320
|
+
const element = inApp(event.target);
|
|
321
|
+
record({ kind: 'key', key: key.key, ...(element ? { target: pickerLabel(element, policy) } : {}) });
|
|
322
|
+
later(checkRoute);
|
|
323
|
+
publish();
|
|
324
|
+
}
|
|
325
|
+
return;
|
|
326
|
+
}
|
|
327
|
+
// Esc on the page closes the bubble and never ends feedback mode by accident; in the bubble, the bubble handles it.
|
|
328
|
+
if (key.key === 'Escape' && !event.composedPath().some(item => item.hasAttribute?.('data-feedback-ignore'))) {
|
|
329
|
+
event.preventDefault();
|
|
330
|
+
event.stopImmediatePropagation();
|
|
331
|
+
if (bubble) {
|
|
332
|
+
closeBubble(false);
|
|
333
|
+
publish();
|
|
334
|
+
}
|
|
335
|
+
else if (current.generalOpen) {
|
|
336
|
+
state.set({ generalOpen: false });
|
|
337
|
+
publish();
|
|
338
|
+
}
|
|
339
|
+
}
|
|
340
|
+
};
|
|
341
|
+
const onKeyUp = (event) => { if (event.key === 'Alt' && alt) {
|
|
342
|
+
alt = false;
|
|
343
|
+
applyUsing();
|
|
344
|
+
} };
|
|
345
|
+
const onBlur = () => { if (alt) {
|
|
346
|
+
alt = false;
|
|
347
|
+
applyUsing();
|
|
348
|
+
} };
|
|
349
|
+
const onClick = (event) => {
|
|
350
|
+
if (!state.getSnapshot().using)
|
|
351
|
+
return;
|
|
352
|
+
const element = inApp(event.target);
|
|
353
|
+
if (!element)
|
|
354
|
+
return;
|
|
355
|
+
record({ kind: 'click', target: pickerLabel(element, policy) });
|
|
356
|
+
later(checkRoute);
|
|
357
|
+
publish();
|
|
358
|
+
};
|
|
359
|
+
const onRoute = () => { checkRoute(); };
|
|
360
|
+
const listen = (type, handler) => {
|
|
361
|
+
if (!view)
|
|
362
|
+
return;
|
|
363
|
+
view.addEventListener(type, handler, { capture: true });
|
|
364
|
+
removers.push(() => view.removeEventListener(type, handler, { capture: true }));
|
|
365
|
+
};
|
|
366
|
+
const stopPicking = () => {
|
|
367
|
+
for (const remove of removers.splice(0))
|
|
368
|
+
remove();
|
|
369
|
+
picker?.dispose();
|
|
370
|
+
picker = undefined;
|
|
371
|
+
pinsOverlay?.dispose();
|
|
372
|
+
pinsOverlay = undefined;
|
|
373
|
+
alt = false;
|
|
374
|
+
};
|
|
375
|
+
let sessionGeneration = 0;
|
|
376
|
+
const reset = () => {
|
|
377
|
+
sessionGeneration++;
|
|
378
|
+
elements = [];
|
|
379
|
+
anchors.clear();
|
|
380
|
+
labels.clear();
|
|
381
|
+
notes = [];
|
|
382
|
+
timeline = [];
|
|
383
|
+
pointer = [];
|
|
384
|
+
pins = [];
|
|
385
|
+
recordings = [];
|
|
386
|
+
bubble = undefined;
|
|
387
|
+
stash = '';
|
|
388
|
+
lastHover = undefined;
|
|
389
|
+
hoverLabel = undefined;
|
|
390
|
+
observed = undefined;
|
|
391
|
+
problem = undefined;
|
|
392
|
+
saved = undefined;
|
|
393
|
+
operation = operationId();
|
|
394
|
+
mayHaveSent = false;
|
|
395
|
+
};
|
|
396
|
+
const start = (startOptions = {}) => {
|
|
397
|
+
const current = state.getSnapshot();
|
|
398
|
+
const mode = startOptions.mode === 'point' ? 'point' : 'feedback';
|
|
399
|
+
if (current.phase === 'active' || current.phase === 'processing')
|
|
400
|
+
return;
|
|
401
|
+
stopPicking();
|
|
402
|
+
reset();
|
|
403
|
+
lastRoute = routeNow();
|
|
404
|
+
record({ kind: 'route', route: lastRoute });
|
|
405
|
+
// Keys belong to feedback mode from now on: focus left on the Feedback button (an ignored subtree) would keep them from it.
|
|
406
|
+
const focused = document.activeElement;
|
|
407
|
+
if (focused && focused !== document.body && isExcluded(focused) && typeof focused.blur === 'function')
|
|
408
|
+
focused.blur();
|
|
409
|
+
// Listeners first, so they see Alt and Esc before the picker takes them.
|
|
410
|
+
for (const [type, handler] of [['keydown', onKeyDown], ['keyup', onKeyUp], ['click', onClick], ['popstate', onRoute], ['hashchange', onRoute]])
|
|
411
|
+
listen(type, handler);
|
|
412
|
+
if (view) {
|
|
413
|
+
view.addEventListener('blur', onBlur);
|
|
414
|
+
removers.push(() => view.removeEventListener('blur', onBlur));
|
|
415
|
+
}
|
|
416
|
+
pinsOverlay = createOverlay({ document, ...(options.styles !== undefined ? { styles: options.styles } : {}) });
|
|
417
|
+
picker = createPicker({
|
|
418
|
+
root, policy, onPick, wheel: false, keys: 'outside-ignored', ...(mode === 'point' ? { tap: 'pick' } : {}),
|
|
419
|
+
...(options.styles !== undefined ? { styles: options.styles } : {}),
|
|
420
|
+
...(options.hitTest ? { hitTest: options.hitTest } : {}),
|
|
421
|
+
onChange: next => { if (next.mode === 'picking' && !next.suspended)
|
|
422
|
+
onHover(next.current); },
|
|
423
|
+
});
|
|
424
|
+
void picker.start();
|
|
425
|
+
state.set({ phase: 'active', mode, using: false, useToggle: false, general: '', generalOpen: false, voice: { kind: 'off' }, transcribing: false, send: { kind: 'idle' }, copy: { kind: 'idle' }, route: lastRoute });
|
|
426
|
+
publish();
|
|
427
|
+
};
|
|
428
|
+
// --- voice --------------------------------------------------------------------------------------------------------------------
|
|
429
|
+
// Stop shows "stopping" at once, but the capture hands the audio over later (the recorder's last chunk, decoding its duration): Done
|
|
430
|
+
// pressed meanwhile must wait for it, or the recording would be silently lost. One stop at a time; a discard in between drops it.
|
|
431
|
+
let stopping;
|
|
432
|
+
const stopRecording = () => {
|
|
433
|
+
if (stopping)
|
|
434
|
+
return stopping;
|
|
435
|
+
if (!options.voice || state.getSnapshot().voice.kind !== 'recording')
|
|
436
|
+
return Promise.resolve();
|
|
437
|
+
const voice = options.voice;
|
|
438
|
+
const generation = sessionGeneration;
|
|
439
|
+
state.set({ voice: { kind: 'stopping' } });
|
|
440
|
+
stopping = (async () => {
|
|
441
|
+
let recording = null, failed = false;
|
|
442
|
+
try {
|
|
443
|
+
recording = await voice.stop();
|
|
444
|
+
}
|
|
445
|
+
catch {
|
|
446
|
+
failed = true;
|
|
447
|
+
}
|
|
448
|
+
if (generation !== sessionGeneration)
|
|
449
|
+
return;
|
|
450
|
+
if (recording)
|
|
451
|
+
recordings.push(recording);
|
|
452
|
+
else
|
|
453
|
+
problem = failed ? 'The recording could not be finished. Your pins are kept.' : 'Nothing was recorded: the microphone gave no sound. Your pins are kept.';
|
|
454
|
+
if (state.getSnapshot().voice.kind === 'stopping')
|
|
455
|
+
state.set({ voice: { kind: 'off' } });
|
|
456
|
+
publish();
|
|
457
|
+
})().finally(() => { stopping = undefined; });
|
|
458
|
+
return stopping;
|
|
459
|
+
};
|
|
460
|
+
const toggleVoice = async () => {
|
|
461
|
+
const current = state.getSnapshot();
|
|
462
|
+
if (!options.voice || !voiceAvailable || current.phase !== 'active')
|
|
463
|
+
return;
|
|
464
|
+
if (current.voice.kind === 'recording') {
|
|
465
|
+
await stopRecording();
|
|
466
|
+
publish();
|
|
467
|
+
return;
|
|
468
|
+
}
|
|
469
|
+
if (current.voice.kind === 'starting' || current.voice.kind === 'stopping')
|
|
470
|
+
return;
|
|
471
|
+
// Called straight from the click: nothing is awaited before the capture asks for the microphone, so the permission prompt keeps
|
|
472
|
+
// the person's gesture. The bubble closes (its text kept): while recording, pins take no bubble.
|
|
473
|
+
let starting;
|
|
474
|
+
try {
|
|
475
|
+
starting = options.voice.start();
|
|
476
|
+
}
|
|
477
|
+
catch (error) {
|
|
478
|
+
starting = Promise.reject(error);
|
|
479
|
+
}
|
|
480
|
+
closeBubble(true);
|
|
481
|
+
problem = undefined;
|
|
482
|
+
state.set({ voice: { kind: 'starting' } });
|
|
483
|
+
publish();
|
|
484
|
+
let started;
|
|
485
|
+
try {
|
|
486
|
+
started = await starting;
|
|
487
|
+
}
|
|
488
|
+
catch {
|
|
489
|
+
started = { kind: 'refused', code: 'failed', reason: 'The microphone could not be started.' };
|
|
490
|
+
}
|
|
491
|
+
if (state.getSnapshot().phase !== 'active') {
|
|
492
|
+
if (started.kind === 'recording')
|
|
493
|
+
options.voice.cancel();
|
|
494
|
+
return;
|
|
495
|
+
}
|
|
496
|
+
state.set({ voice: started.kind === 'recording' ? { kind: 'recording' } : { kind: 'refused', reason: started.reason, ...(started.code ? { code: started.code } : {}) } });
|
|
497
|
+
// The element under the pointer when recording starts is the first sample of the trail.
|
|
498
|
+
if (started.kind === 'recording' && lastHover !== undefined && lastHover !== null) {
|
|
499
|
+
pointer.push({ t: now(), target: lastHover });
|
|
500
|
+
capture(lastHover);
|
|
501
|
+
}
|
|
502
|
+
publish();
|
|
503
|
+
};
|
|
504
|
+
/** Speech merged into the notes: on a pinned element it extends that note; on a hovered element it becomes a pin note marked voice;
|
|
505
|
+
* anywhere else it becomes part of the general note. A transcription with text but no timed segments cannot be placed in time,
|
|
506
|
+
* so all of it goes to the general note (pins made while recording stay as empty notes to type into). Throws when nothing usable
|
|
507
|
+
* came back. */
|
|
508
|
+
const merge = async (recording) => {
|
|
509
|
+
const answer = readTranscription(await options.transcribe(recording.audio, recording.mimeType));
|
|
510
|
+
if (!answer)
|
|
511
|
+
throw new TypeError(''); // nothing usable: the plain "could not be transcribed" message
|
|
512
|
+
const general = [];
|
|
513
|
+
const aligned = 'segments' in answer ? alignSpeech({ segments: answer.segments, audioStartedAt: recording.startedAt, pointer, pins }) : [];
|
|
514
|
+
if ('text' in answer && answer.text.trim())
|
|
515
|
+
general.push(answer.text.trim());
|
|
516
|
+
for (const piece of aligned) {
|
|
517
|
+
const text = piece.text.trim();
|
|
518
|
+
if (!text)
|
|
519
|
+
continue;
|
|
520
|
+
const pinned = piece.target === null ? undefined : notes.find(note => note.target === piece.target && note.element !== undefined && note.from === undefined);
|
|
521
|
+
if (pinned) {
|
|
522
|
+
// A pin dropped while recording with nothing typed: its note is what was said, so it is a voice note.
|
|
523
|
+
if (pinned.recorded && !pinned.text.trim())
|
|
524
|
+
pinned.from = 'voice';
|
|
525
|
+
pinned.text = pinned.text.trim() ? `${pinned.text}\n${text}` : text;
|
|
526
|
+
continue;
|
|
527
|
+
}
|
|
528
|
+
const anchor = piece.target === null ? undefined : anchors.get(piece.target) ?? capture(piece.target).anchor;
|
|
529
|
+
const pinnedCount = new Set(notes.flatMap(note => note.target !== undefined ? [note.target] : [])).size;
|
|
530
|
+
if (piece.target !== null && anchor && (notes.some(note => note.target === piece.target) || pinnedCount < FEEDBACK_LIMITS.anchors)) {
|
|
531
|
+
const voiced = notes.find(note => note.target === piece.target && note.from === 'voice');
|
|
532
|
+
if (voiced) {
|
|
533
|
+
voiced.text = `${voiced.text}\n${text}`;
|
|
534
|
+
continue;
|
|
535
|
+
}
|
|
536
|
+
const element = elements[piece.target];
|
|
537
|
+
const note = { id: nextId++, target: piece.target, text, t: piece.at, from: 'voice', ...(element ? { element, label: labels.get(piece.target) ?? pickerLabel(element, policy) } : {}) };
|
|
538
|
+
notes.push(note);
|
|
539
|
+
timeline.push({ t: piece.at, noteId: note.id });
|
|
540
|
+
continue;
|
|
541
|
+
}
|
|
542
|
+
general.push(text);
|
|
543
|
+
}
|
|
544
|
+
if (general.length) {
|
|
545
|
+
const current = state.getSnapshot().general;
|
|
546
|
+
state.set({ general: [current.trim(), general.join(' ')].filter(Boolean).join('\n') });
|
|
547
|
+
}
|
|
548
|
+
};
|
|
549
|
+
const done = async () => {
|
|
550
|
+
if (state.getSnapshot().phase !== 'active')
|
|
551
|
+
return;
|
|
552
|
+
closeBubble(true);
|
|
553
|
+
checkRoute();
|
|
554
|
+
await stopRecording();
|
|
555
|
+
stopPicking();
|
|
556
|
+
const transcribing = recordings.length > 0 && options.transcribe !== undefined;
|
|
557
|
+
state.set({ phase: 'processing', using: false, useToggle: false, generalOpen: false, transcribing });
|
|
558
|
+
publish();
|
|
559
|
+
if (transcribing) {
|
|
560
|
+
for (const recording of recordings) {
|
|
561
|
+
try {
|
|
562
|
+
await merge(recording);
|
|
563
|
+
}
|
|
564
|
+
catch (error) {
|
|
565
|
+
const pinned = notes.some(note => note.recorded);
|
|
566
|
+
problem = `Voice could not be transcribed${error instanceof Error && error.message ? ` (${error.message})` : ''}. ${pinned ? 'Your pins are kept: type a note for each in the review.' : 'Your typed notes are kept.'}`;
|
|
567
|
+
}
|
|
568
|
+
}
|
|
569
|
+
}
|
|
570
|
+
recordings = [];
|
|
571
|
+
state.set({ transcribing: false });
|
|
572
|
+
try {
|
|
573
|
+
observed = (await observePage(options)).observed;
|
|
574
|
+
}
|
|
575
|
+
catch {
|
|
576
|
+
problem = 'The page could not be observed.';
|
|
577
|
+
}
|
|
578
|
+
state.set({ phase: notes.length || state.getSnapshot().general.trim() ? 'review' : 'idle', route: observed?.subject.route ?? routeNow() });
|
|
579
|
+
publish();
|
|
580
|
+
};
|
|
581
|
+
const discard = () => {
|
|
582
|
+
if (options.voice && state.getSnapshot().voice.kind === 'recording')
|
|
583
|
+
options.voice.cancel();
|
|
584
|
+
stopPicking();
|
|
585
|
+
reset();
|
|
586
|
+
state.set({ phase: 'idle', using: false, useToggle: false, general: '', generalOpen: false, voice: { kind: 'off' }, transcribing: false, send: { kind: 'idle' }, copy: { kind: 'idle' } });
|
|
587
|
+
publish();
|
|
588
|
+
};
|
|
589
|
+
// --- the draft ----------------------------------------------------------------------------------------------------------------
|
|
590
|
+
const edited = () => {
|
|
591
|
+
// A changed draft may take a fresh operation id unless the current one may already have reached the store.
|
|
592
|
+
if (!mayHaveSent && state.getSnapshot().send.kind !== 'saved')
|
|
593
|
+
operation = operationId();
|
|
594
|
+
state.set({ copy: { kind: 'idle' }, ...(state.getSnapshot().send.kind !== 'saved' ? { send: { kind: 'idle' } } : {}) });
|
|
595
|
+
};
|
|
596
|
+
const draft = () => {
|
|
597
|
+
if (!observed)
|
|
598
|
+
throw new TypeError('Press Done first: there is no feedback draft yet.');
|
|
599
|
+
const list = ordered().filter(note => note.text.trim());
|
|
600
|
+
const targets = [];
|
|
601
|
+
const draftNotes = list.map(note => {
|
|
602
|
+
const anchored = note.target !== undefined && anchors.has(note.target);
|
|
603
|
+
if (anchored && !targets.includes(note.target))
|
|
604
|
+
targets.push(note.target);
|
|
605
|
+
return { text: note.text, ...(anchored ? { anchor: targets.indexOf(note.target) } : {}), ...(note.from ? { from: note.from } : {}) };
|
|
606
|
+
});
|
|
607
|
+
const noteIndex = new Map(list.map((note, index) => [note.id, index]));
|
|
608
|
+
const steps = [...timeline].sort((a, b) => a.t - b.t).flatMap((entry) => {
|
|
609
|
+
if ('step' in entry)
|
|
610
|
+
return [entry.step];
|
|
611
|
+
const index = noteIndex.get(entry.noteId);
|
|
612
|
+
return index === undefined ? [] : [{ kind: 'note', note: index }];
|
|
613
|
+
}).slice(0, FEEDBACK_LIMITS.steps);
|
|
614
|
+
const general = state.getSnapshot().general;
|
|
615
|
+
if (!draftNotes.length && !general.trim())
|
|
616
|
+
throw new TypeError('There is nothing to send: every note is empty.');
|
|
617
|
+
return {
|
|
618
|
+
observed, anchors: targets.map(target => anchors.get(target)), said: general,
|
|
619
|
+
...(draftNotes.length ? { notes: draftNotes } : {}),
|
|
620
|
+
...(steps.some(step => step.kind !== 'route') || steps.length > 1 ? { steps } : {}),
|
|
621
|
+
};
|
|
622
|
+
};
|
|
623
|
+
const reportText = () => feedbackText(draftReport(draft(), { id: draftId(), created: new Date().toISOString() }));
|
|
624
|
+
const copy = async () => {
|
|
625
|
+
let text;
|
|
626
|
+
try {
|
|
627
|
+
text = reportText();
|
|
628
|
+
}
|
|
629
|
+
catch (error) {
|
|
630
|
+
state.set({ copy: { kind: 'refused', reason: error instanceof Error ? error.message : 'The report could not be rendered.' } });
|
|
631
|
+
publish();
|
|
632
|
+
return state.getSnapshot().copy;
|
|
633
|
+
}
|
|
634
|
+
let copied = false;
|
|
635
|
+
try {
|
|
636
|
+
copied = await (options.copyText ?? copyToClipboard)(text);
|
|
637
|
+
}
|
|
638
|
+
catch {
|
|
639
|
+
copied = false;
|
|
640
|
+
}
|
|
641
|
+
state.set({ copy: copied ? { kind: 'copied', text } : { kind: 'manual', text } });
|
|
642
|
+
publish();
|
|
643
|
+
return state.getSnapshot().copy;
|
|
644
|
+
};
|
|
645
|
+
const attach = async () => {
|
|
646
|
+
if (state.getSnapshot().phase !== 'review')
|
|
647
|
+
return { kind: 'refused', reason: 'There is no feedback to send.' };
|
|
648
|
+
let current;
|
|
649
|
+
try {
|
|
650
|
+
current = draft();
|
|
651
|
+
}
|
|
652
|
+
catch (error) {
|
|
653
|
+
return { kind: 'refused', reason: error instanceof Error ? error.message : 'There is nothing to send.' };
|
|
654
|
+
}
|
|
655
|
+
if (!options.save) {
|
|
656
|
+
try {
|
|
657
|
+
return { kind: 'inline', text: reportText() };
|
|
658
|
+
}
|
|
659
|
+
catch (error) {
|
|
660
|
+
return { kind: 'refused', reason: error instanceof Error ? error.message : 'The report could not be rendered.' };
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
if (saved)
|
|
664
|
+
return options.mention ? { kind: 'mention', id: saved.id, path: options.mention(saved.id) } : { kind: 'inline', text: reportText() };
|
|
665
|
+
if (state.getSnapshot().send.kind === 'sending')
|
|
666
|
+
return { kind: 'refused', reason: 'Already sending.' };
|
|
667
|
+
state.set({ send: { kind: 'sending' } });
|
|
668
|
+
mayHaveSent = true;
|
|
669
|
+
let result;
|
|
670
|
+
try {
|
|
671
|
+
result = await options.save({ operationId: operation, draft: current });
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
result = { kind: 'unknown', reason: 'The save did not answer; Send again to reconcile it.' };
|
|
675
|
+
}
|
|
676
|
+
if (result.kind === 'denied' || result.kind === 'unavailable' || result.kind === 'invalid')
|
|
677
|
+
mayHaveSent = false;
|
|
678
|
+
state.set({ send: result });
|
|
679
|
+
if (result.kind !== 'saved')
|
|
680
|
+
return { kind: 'refused', reason: result.kind === 'unknown' ? `${result.reason} Nothing is lost: Send again uses the same operation, so it cannot be stored twice.` : `Not saved: ${result.reason}` };
|
|
681
|
+
saved = { id: result.id };
|
|
682
|
+
return options.mention ? { kind: 'mention', id: result.id, path: options.mention(result.id) } : { kind: 'inline', text: reportText() };
|
|
683
|
+
};
|
|
684
|
+
const highlight = (id) => {
|
|
685
|
+
pinsOverlay ??= createOverlay({ document, ...(options.styles !== undefined ? { styles: options.styles } : {}) });
|
|
686
|
+
if (id === undefined) {
|
|
687
|
+
pinsOverlay.clear();
|
|
688
|
+
return 'none';
|
|
689
|
+
}
|
|
690
|
+
const list = ordered();
|
|
691
|
+
const note = list.find(item => item.id === id);
|
|
692
|
+
if (!note || !note.element) {
|
|
693
|
+
pinsOverlay.clear();
|
|
694
|
+
return note ? 'not-on-page' : 'none';
|
|
695
|
+
}
|
|
696
|
+
if (!note.element.isConnected || !root.contains(note.element)) {
|
|
697
|
+
pinsOverlay.clear();
|
|
698
|
+
return 'not-on-page';
|
|
699
|
+
}
|
|
700
|
+
pinsOverlay.highlight(note.element, `${pinMark(list.indexOf(note) + 1)} ${note.text.split('\n')[0] ?? ''}`.trim());
|
|
701
|
+
return 'shown';
|
|
702
|
+
};
|
|
703
|
+
return {
|
|
704
|
+
getSnapshot: state.getSnapshot,
|
|
705
|
+
subscribe: state.subscribe,
|
|
706
|
+
start,
|
|
707
|
+
done,
|
|
708
|
+
discard,
|
|
709
|
+
setBubbleText: text => { if (bubble) {
|
|
710
|
+
bubble.text = text;
|
|
711
|
+
publish();
|
|
712
|
+
} },
|
|
713
|
+
saveBubble: () => { closeBubble(true); publish(); },
|
|
714
|
+
cancelBubble: () => { closeBubble(false); publish(); },
|
|
715
|
+
cancelSelection: () => { cancelSelection(); publish(); },
|
|
716
|
+
askGeneral: () => {
|
|
717
|
+
if (state.getSnapshot().phase !== 'active')
|
|
718
|
+
return;
|
|
719
|
+
problem = undefined;
|
|
720
|
+
if (bubble?.fresh)
|
|
721
|
+
closeBubble(false);
|
|
722
|
+
else
|
|
723
|
+
closeBubble(true);
|
|
724
|
+
state.set({ general: withStash(state.getSnapshot().general), generalOpen: true });
|
|
725
|
+
publish();
|
|
726
|
+
},
|
|
727
|
+
closeGeneral: () => { if (state.getSnapshot().generalOpen) {
|
|
728
|
+
state.set({ generalOpen: false });
|
|
729
|
+
publish();
|
|
730
|
+
} },
|
|
731
|
+
setUseToggle: on => { state.set({ useToggle: on }); applyUsing(); publish(); },
|
|
732
|
+
toggleVoice,
|
|
733
|
+
setNoteText: (id, text) => { const note = notes.find(item => item.id === id); if (note && note.text !== text) {
|
|
734
|
+
note.text = text;
|
|
735
|
+
edited();
|
|
736
|
+
publish();
|
|
737
|
+
} },
|
|
738
|
+
removeNote: id => {
|
|
739
|
+
notes = notes.filter(item => item.id !== id);
|
|
740
|
+
timeline = timeline.filter(entry => !('noteId' in entry) || entry.noteId !== id);
|
|
741
|
+
edited();
|
|
742
|
+
if (state.getSnapshot().phase === 'review' && !notes.length && !state.getSnapshot().general.trim()) {
|
|
743
|
+
discard();
|
|
744
|
+
return;
|
|
745
|
+
}
|
|
746
|
+
publish();
|
|
747
|
+
},
|
|
748
|
+
setGeneral: text => { if (text !== state.getSnapshot().general) {
|
|
749
|
+
state.set({ general: text });
|
|
750
|
+
edited();
|
|
751
|
+
publish();
|
|
752
|
+
} },
|
|
753
|
+
highlight,
|
|
754
|
+
draft,
|
|
755
|
+
reportText,
|
|
756
|
+
copy,
|
|
757
|
+
attach,
|
|
758
|
+
sent: () => { pinsOverlay?.clear(); discard(); },
|
|
759
|
+
trail: () => ({ pointer: Object.freeze([...pointer]), pins: Object.freeze([...pins]) }),
|
|
760
|
+
dispose: () => { discard(); pinsOverlay?.dispose(); pinsOverlay = undefined; },
|
|
761
|
+
};
|
|
762
|
+
}
|