@bivy/bivy 0.19.4-staging.1 → 0.19.4-staging.2
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/dist/apps/annotate.js +85 -1
- package/dist/apps/inspector.js +82 -6
- package/dist/apps/preview-shell.js +385 -152
- package/dist/apps/review.js +30 -0
- package/dist/apps/screenshot.js +16 -2
- package/dist/apps/service.js +134 -10
- package/dist/apps/styles.css +5 -0
- package/dist/apps/types.js +16 -0
- package/dist/controllers/app-commands.js +4 -0
- package/dist/server.js +32 -2
- package/dist/session/event-log.js +22 -3
- package/package.json +1 -1
package/dist/apps/annotate.js
CHANGED
|
@@ -16,6 +16,29 @@ export function readElementScrolls(input) {
|
|
|
16
16
|
return { selector: raw.selector, x: raw.x, y: raw.y };
|
|
17
17
|
});
|
|
18
18
|
}
|
|
19
|
+
const MAX_NOTES = 20;
|
|
20
|
+
/** Notes from the client (untrusted shape): numbered, bounded, with strokes. */
|
|
21
|
+
export function readNotes(input) {
|
|
22
|
+
if (input === undefined)
|
|
23
|
+
return [];
|
|
24
|
+
if (!Array.isArray(input) || !input.length || input.length > MAX_NOTES)
|
|
25
|
+
throw new Error(`A message carries 1 to ${MAX_NOTES} notes.`);
|
|
26
|
+
return input.map((raw, index) => {
|
|
27
|
+
const note = raw;
|
|
28
|
+
if (!Number.isInteger(note?.n) || note.n < 1 || note.n > MAX_NOTES)
|
|
29
|
+
throw new Error("Invalid note number.");
|
|
30
|
+
if (note.words !== undefined && typeof note.words !== "string")
|
|
31
|
+
throw new Error("Invalid note text.");
|
|
32
|
+
const selectors = Array.isArray(note.selectors) ? note.selectors.filter((s) => typeof s === "string" && !!s && s.length <= 300).slice(0, 8) : [];
|
|
33
|
+
return { n: index + 1, words: typeof note.words === "string" ? note.words.trim().slice(0, 2000) : "", selectors, strokes: readStrokes(raw.strokes) };
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
/** Where a note's number sits: the top-left of everything it covers. */
|
|
37
|
+
export function noteAnchor(note) {
|
|
38
|
+
const xs = note.strokes.flatMap((stroke) => stroke.points.map(([x]) => x));
|
|
39
|
+
const ys = note.strokes.flatMap((stroke) => stroke.points.map(([, y]) => y));
|
|
40
|
+
return { x: Math.min(...xs), y: Math.min(...ys) };
|
|
41
|
+
}
|
|
19
42
|
const MAX_STROKES = 50;
|
|
20
43
|
const MAX_POINTS = 10_000;
|
|
21
44
|
/** Validates strokes from the client (untrusted shape): bounded, finite. */
|
|
@@ -62,10 +85,57 @@ const HALO = tokenColor("annotate-halo", "#ffffff");
|
|
|
62
85
|
/** Mark width in CSS pixels, and its halo on each side. */
|
|
63
86
|
const WIDTH = 4;
|
|
64
87
|
const HALO_WIDTH = 1.5;
|
|
88
|
+
/** A note's number, in CSS pixels: the pill's half-height, and the digits. */
|
|
89
|
+
const PIP = { radius: 11, height: 12, width: 6, gap: 3, stroke: 1.6 };
|
|
90
|
+
/** Seven-segment digits, drawn rather than typeset: the picture has to carry
|
|
91
|
+
* the numbers, and a PNG writer has no font. Adding a glyph is adding a row.
|
|
92
|
+
*
|
|
93
|
+
* aaa
|
|
94
|
+
* f b
|
|
95
|
+
* ggg
|
|
96
|
+
* e c
|
|
97
|
+
* ddd
|
|
98
|
+
*/
|
|
99
|
+
const SEGMENTS = {
|
|
100
|
+
"0": "abcdef", "1": "bc", "2": "abged", "3": "abgcd", "4": "fgbc",
|
|
101
|
+
"5": "afgcd", "6": "afgecd", "7": "abc", "8": "abcdefg", "9": "abcdfg",
|
|
102
|
+
};
|
|
103
|
+
function digit(glyph, x, y, w, h) {
|
|
104
|
+
const middle = y + h / 2;
|
|
105
|
+
const lines = {
|
|
106
|
+
a: [[x, y], [x + w, y]], b: [[x + w, y], [x + w, middle]], c: [[x + w, middle], [x + w, y + h]],
|
|
107
|
+
d: [[x, y + h], [x + w, y + h]], e: [[x, middle], [x, y + h]], f: [[x, y], [x, middle]], g: [[x, middle], [x + w, middle]],
|
|
108
|
+
};
|
|
109
|
+
return [...(SEGMENTS[glyph] ?? "")].map((name) => lines[name]);
|
|
110
|
+
}
|
|
111
|
+
/** Cuts out the part of a picture a pin is about, with room around it so the
|
|
112
|
+
* mark is read in its surroundings rather than in isolation. `scale` maps CSS
|
|
113
|
+
* pixels to image pixels. Returns the whole picture when the cut would be
|
|
114
|
+
* empty or nearly all of it. */
|
|
115
|
+
export function crop(png, region, scale, padding = 24) {
|
|
116
|
+
const image = decodePng(png);
|
|
117
|
+
const { width, height, channels } = image;
|
|
118
|
+
const pad = padding * scale;
|
|
119
|
+
const left = Math.max(0, Math.floor((region.x - padding) * scale));
|
|
120
|
+
const top = Math.max(0, Math.floor((region.y - padding) * scale));
|
|
121
|
+
const right = Math.min(width, Math.ceil((region.x + region.width) * scale + pad));
|
|
122
|
+
const bottom = Math.min(height, Math.ceil((region.y + region.height) * scale + pad));
|
|
123
|
+
const w = right - left, h = bottom - top;
|
|
124
|
+
if (w < 8 || h < 8 || (w >= width * 0.9 && h >= height * 0.9))
|
|
125
|
+
return png;
|
|
126
|
+
const rgb = Buffer.alloc(w * h * 3);
|
|
127
|
+
for (let y = 0; y < h; y++) {
|
|
128
|
+
for (let x = 0; x < w; x++) {
|
|
129
|
+
const from = ((y + top) * width + (x + left)) * channels;
|
|
130
|
+
image.pixels.copy(rgb, (y * w + x) * 3, from, from + 3);
|
|
131
|
+
}
|
|
132
|
+
}
|
|
133
|
+
return encodePng(w, h, rgb);
|
|
134
|
+
}
|
|
65
135
|
/** Draws marks onto a PNG. `scale` maps CSS pixels to image pixels;
|
|
66
136
|
* `offset` (CSS px) is subtracted first — the scroll position the picture
|
|
67
137
|
* was taken at. Halos go under every mark first, so crossings stay clean. */
|
|
68
|
-
export function composite(png, strokes, map) {
|
|
138
|
+
export function composite(png, strokes, map, badges = []) {
|
|
69
139
|
const image = decodePng(png);
|
|
70
140
|
const { width, height, channels } = image;
|
|
71
141
|
const rgb = Buffer.alloc(width * height * 3);
|
|
@@ -103,5 +173,19 @@ export function composite(png, strokes, map) {
|
|
|
103
173
|
trace(path, r + HALO_WIDTH * map.scale, HALO);
|
|
104
174
|
for (const path of paths)
|
|
105
175
|
trace(path, r, INK);
|
|
176
|
+
// The numbers last, over the marks, so a note and the thing it is about stay
|
|
177
|
+
// paired in the picture as well as in the words.
|
|
178
|
+
for (const badge of badges) {
|
|
179
|
+
const glyphs = [...String(badge.n)];
|
|
180
|
+
const [pw, ph, gap, pr] = [PIP.width, PIP.height, PIP.gap, PIP.radius].map((v) => v * map.scale);
|
|
181
|
+
const span = glyphs.length * pw + (glyphs.length - 1) * gap;
|
|
182
|
+
const [cx, cy] = toImage([badge.x, badge.y]);
|
|
183
|
+
// A traced line of radius `pr` is a pill: a disc at each end, filled between.
|
|
184
|
+
trace([[cx - span / 2, cy], [cx + span / 2, cy]], pr, INK);
|
|
185
|
+
glyphs.forEach((glyph, index) => {
|
|
186
|
+
for (const segment of digit(glyph, cx - span / 2 + index * (pw + gap), cy - ph / 2, pw, ph))
|
|
187
|
+
trace(segment, PIP.stroke * map.scale, HALO);
|
|
188
|
+
});
|
|
189
|
+
}
|
|
106
190
|
return encodePng(width, height, rgb);
|
|
107
191
|
}
|
package/dist/apps/inspector.js
CHANGED
|
@@ -83,7 +83,10 @@ function startPointing(){
|
|
|
83
83
|
document.documentElement.append(layer,box);
|
|
84
84
|
addEventListener('keydown',key,true);
|
|
85
85
|
}
|
|
86
|
-
|
|
86
|
+
// "before" keeps the value this event changed, so a press that turns out to be
|
|
87
|
+
// a mark (not use of the app) can put it back.
|
|
88
|
+
let interactedBefore=false;
|
|
89
|
+
for(const type of ['pointerdown','keydown'])addEventListener(type,e=>{if(!layer&&!(host&&e.composedPath?.().includes(host))){interactedBefore=interacted;interacted=true;}},true);
|
|
87
90
|
// Draw (in the shell, over this page): where the page is, what state it holds
|
|
88
91
|
// that a fresh browser on the machine wouldn't, and what's under each mark.
|
|
89
92
|
const signals=()=>{
|
|
@@ -135,13 +138,86 @@ addEventListener('message',e=>{
|
|
|
135
138
|
}
|
|
136
139
|
else if(d.type==='bivy:draw')post(drawState());
|
|
137
140
|
else if(d.type==='bivy:scroll'){scrollBy({left:n(d.dx),top:n(d.dy),behavior:'instant'});post({type:'scrolled',scroll:{x:Math.round(scrollX),y:Math.round(scrollY)}});}
|
|
141
|
+
// Where the reader was before they took a new version. The page may still be
|
|
142
|
+
// laying out (or fetching what makes it long enough), so try again briefly.
|
|
143
|
+
else if(d.type==='bivy:restore'){
|
|
144
|
+
const panels=Array.isArray(d.elementScrolls)?d.elementScrolls.slice(0,50):[];
|
|
145
|
+
const put=()=>{
|
|
146
|
+
scrollTo({left:n(d.scroll?.x),top:n(d.scroll?.y),behavior:'instant'});
|
|
147
|
+
for(const p of panels){try{document.querySelector(p.selector)?.scrollTo({left:n(p.x),top:n(p.y),behavior:'instant'});}catch{}}
|
|
148
|
+
};
|
|
149
|
+
for(const ms of [0,150,500])setTimeout(put,ms);
|
|
150
|
+
}
|
|
138
151
|
else if(d.type==='bivy:marks'&&d.rect)post({type:'marked',id:n(d.id),elements:marked({x:n(d.rect.x),y:n(d.rect.y),width:n(d.rect.width),height:n(d.rect.height)})});
|
|
139
152
|
});
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
153
|
+
// Marking, started from inside the app: a long press on what's wrong. A tap is
|
|
154
|
+
// left alone, so the app stays usable; so are presses on a field or on text the
|
|
155
|
+
// reader is selecting, where a long press already means something to them.
|
|
156
|
+
// Once it fires, the rest of the gesture is swallowed here — the shell's marking
|
|
157
|
+
// layer cannot receive a gesture that began in this document — and its points
|
|
158
|
+
// are streamed to the shell.
|
|
159
|
+
if(framed){
|
|
160
|
+
let hold=null,armed=true;
|
|
161
|
+
const page=e=>[Math.round(e.clientX+scrollX),Math.round(e.clientY+scrollY)];
|
|
162
|
+
// A long press already means something where the reader is typing or selecting.
|
|
163
|
+
const theirs=el=>{
|
|
164
|
+
for(let n=el;n&&n.nodeType===1;n=n.parentElement)if(/^(input|textarea|select)$/.test(n.localName)||n.isContentEditable)return true;
|
|
165
|
+
return getSelection?.()?.isCollapsed===false;
|
|
166
|
+
};
|
|
167
|
+
const clear=()=>{clearTimeout(hold?.timer);hold=null;};
|
|
168
|
+
/** A clear layer so nothing new reaches the app while a mark is being made.
|
|
169
|
+
* It cannot take this gesture's events (the app's element already has them,
|
|
170
|
+
* which is what the swallowing below is for), but it stops everything else. */
|
|
171
|
+
const cover=()=>{
|
|
172
|
+
stop();
|
|
173
|
+
layer=document.createElement('div');
|
|
174
|
+
layer.setAttribute('aria-hidden','true');
|
|
175
|
+
Object.assign(layer.style,{position:'fixed',inset:0,zIndex:2147483646,cursor:'crosshair',background:'transparent',touchAction:'none',userSelect:'none',webkitUserSelect:'none',webkitTouchCallout:'none'});
|
|
176
|
+
layer.addEventListener('contextmenu',e=>e.preventDefault());
|
|
177
|
+
document.documentElement.append(layer);
|
|
178
|
+
};
|
|
179
|
+
const begin=()=>{
|
|
180
|
+
const start=hold;if(!start)return;
|
|
181
|
+
// The press was marking, not using the app: the picture is still faithful.
|
|
182
|
+
interacted=start.before;
|
|
183
|
+
hold={marking:true,id:start.id};
|
|
184
|
+
cover();
|
|
185
|
+
const el=under({clientX:start.client[0],clientY:start.client[1]});
|
|
186
|
+
const r=el&&el.getBoundingClientRect();
|
|
187
|
+
post(Object.assign(drawState(),{type:'mark-start',point:start.point,
|
|
188
|
+
element:el?{selector:selector(el),tag:el.localName,text:(el.innerText||el.getAttribute('aria-label')||el.getAttribute('alt')||'').trim().replace(/\\s+/g,' ').slice(0,120),
|
|
189
|
+
rect:{x:Math.round(r.left+scrollX),y:Math.round(r.top+scrollY),width:Math.round(r.width),height:Math.round(r.height)}}:undefined}));
|
|
190
|
+
};
|
|
191
|
+
addEventListener('pointerdown',e=>{
|
|
192
|
+
if(!armed||hold?.marking||layer||(host&&e.composedPath?.().includes(host))||!e.isPrimary||theirs(e.target))return;
|
|
193
|
+
clear();
|
|
194
|
+
hold={id:e.pointerId,client:[e.clientX,e.clientY],point:page(e),before:interactedBefore,timer:setTimeout(begin,450)};
|
|
195
|
+
},true);
|
|
196
|
+
addEventListener('pointermove',e=>{
|
|
197
|
+
if(!hold||e.pointerId!==hold.id)return;
|
|
198
|
+
if(hold.marking){post({type:'mark-move',point:page(e)});return;}
|
|
199
|
+
if(Math.hypot(e.clientX-hold.client[0],e.clientY-hold.client[1])>10)clear();
|
|
200
|
+
},true);
|
|
201
|
+
for(const type of ['pointerup','pointercancel'])addEventListener(type,e=>{
|
|
202
|
+
if(!hold||e.pointerId!==hold.id)return;
|
|
203
|
+
const marking=hold.marking;clear();
|
|
204
|
+
if(!marking)return;
|
|
205
|
+
stop();post({type:'mark-end'});
|
|
206
|
+
},true);
|
|
207
|
+
// Scrolling is never a mark, however still the finger was.
|
|
208
|
+
addEventListener('scroll',()=>{if(hold&&!hold.marking)clear();},true);
|
|
209
|
+
// Hiding Bivy's controls means "let me use the app": an app with its own
|
|
210
|
+
// long press (a canvas, a map) gets it back, and showing them arms it again.
|
|
211
|
+
addEventListener('message',e=>{
|
|
212
|
+
if(e.origin!==SHELL||e.source!==parent||e.data?.type!=='bivy:arm')return;
|
|
213
|
+
armed=e.data.on!==false;
|
|
214
|
+
if(!armed&&hold&&!hold.marking)clear();
|
|
215
|
+
});
|
|
216
|
+
// The app must not act on the gesture it already owns: no click on release,
|
|
217
|
+
// no scroll, no selection, no callout.
|
|
218
|
+
for(const type of ['click','auxclick','dblclick','contextmenu','mousedown','mouseup','touchstart','touchmove','touchend','selectstart'])
|
|
219
|
+
addEventListener(type,e=>{if(hold?.marking){e.preventDefault();e.stopPropagation();}},{capture:true,passive:false});
|
|
220
|
+
}
|
|
145
221
|
if(REVIEWER&&!framed){
|
|
146
222
|
// A shadow root keeps the app's CSS off these controls (and ours off the app).
|
|
147
223
|
host=document.createElement('bivy-review');
|