@miadi/stateloom-web 0.3.1 → 0.3.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/package.json +1 -1
- package/server/.next/BUILD_ID +1 -1
- package/server/.next/build-manifest.json +5 -5
- package/server/.next/server/app/_global-error/page/build-manifest.json +3 -3
- package/server/.next/server/app/_global-error/page.js.nft.json +1 -1
- package/server/.next/server/app/_global-error/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_global-error.html +2 -2
- package/server/.next/server/app/_global-error.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_full.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_index.segment.rsc +1 -1
- package/server/.next/server/app/_global-error.segments/_tree.segment.rsc +1 -1
- package/server/.next/server/app/_not-found/page/build-manifest.json +3 -3
- package/server/.next/server/app/_not-found/page.js.nft.json +1 -1
- package/server/.next/server/app/_not-found/page_client-reference-manifest.js +1 -1
- package/server/.next/server/app/_not-found.html +1 -1
- package/server/.next/server/app/_not-found.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_full.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/_not-found.segments/_not-found/__PAGE__.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_not-found.segment.rsc +1 -1
- package/server/.next/server/app/_not-found.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/api/docs/route.js.nft.json +1 -1
- package/server/.next/server/app/api/file/route.js.nft.json +1 -1
- package/server/.next/server/app/api/marks/route.js.nft.json +1 -1
- package/server/.next/server/app/api/rispec/route.js.nft.json +1 -1
- package/server/.next/server/app/api/watch/route.js.nft.json +1 -1
- package/server/.next/server/app/index.html +1 -1
- package/server/.next/server/app/index.rsc +3 -3
- package/server/.next/server/app/index.segments/__PAGE__.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_full.segment.rsc +3 -3
- package/server/.next/server/app/index.segments/_head.segment.rsc +1 -1
- package/server/.next/server/app/index.segments/_index.segment.rsc +2 -2
- package/server/.next/server/app/index.segments/_tree.segment.rsc +2 -2
- package/server/.next/server/app/page/build-manifest.json +3 -3
- package/server/.next/server/app/page.js.nft.json +1 -1
- package/server/.next/server/app/page_client-reference-manifest.js +1 -1
- package/server/.next/server/chunks/ssr/{2374f_next_dist_0c464f98._.js → 2374f_next_dist_491bc9ca._.js} +2 -2
- package/server/.next/server/chunks/ssr/{[root-of-the-server]__385c85a7._.js → [root-of-the-server]__0d719a8a._.js} +2 -2
- package/server/.next/server/chunks/ssr/[root-of-the-server]__199d50b6._.js +3 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__5c28bab6._.js +3 -0
- package/server/.next/server/chunks/ssr/[root-of-the-server]__edbb995c._.js +6 -6
- package/server/.next/server/middleware-build-manifest.js +3 -3
- package/server/.next/server/pages/404.html +1 -1
- package/server/.next/server/pages/500.html +2 -2
- package/server/.next/static/chunks/33df24ad2d3a04e7.js +1 -0
- package/server/.next/static/chunks/6052c4bfdd66ef5c.css +2 -0
- package/server/.next/static/chunks/b0af2862e9bb3e18.js +14 -0
- package/server/.next/static/chunks/b71bf89a3bbb72e1.js +5 -0
- package/server/.next/static/chunks/{turbopack-b3cae9131ae5086f.js → turbopack-3a774f5a5a8a9023.js} +1 -1
- package/server/package-lock.json +2 -2
- package/server/package.json +1 -1
- package/server/src/app/globals.css +60 -2
- package/server/src/app/page.tsx +8 -6
- package/server/src/components/ChromeIcon.tsx +106 -0
- package/server/src/components/PropertiesPanel.tsx +3 -2
- package/server/src/components/Toolbar.tsx +37 -20
- package/server/src/components/erd/ErdWorkspace.tsx +2 -1
- package/server/src/components/sequence/SequenceWorkspace.tsx +10 -1
- package/server/src/components/stage/CompanionLayer.tsx +41 -6
- package/server/src/components/stage/StageLayer.tsx +287 -28
- package/server/src/components/stage/StageProvider.tsx +149 -25
- package/server/src/components/stage/useEpisodePresence.ts +71 -0
- package/server/src/components/system/SystemWorkspace.tsx +26 -12
- package/server/src/lib/markRequest.ts +24 -0
- package/server/src/lib/useNarrow.ts +30 -0
- package/server/stateloom-web.json +2 -2
- package/server/tsconfig.tsbuildinfo +1 -1
- package/server/.next/server/chunks/ssr/[root-of-the-server]__9e661f75._.js +0 -3
- package/server/.next/server/chunks/ssr/[root-of-the-server]__f25b51be._.js +0 -3
- package/server/.next/static/chunks/0d24f3e3dbdef25e.css +0 -2
- package/server/.next/static/chunks/662d1b0ce457c16b.js +0 -1
- package/server/.next/static/chunks/96818f5620f039a1.js +0 -5
- package/server/.next/static/chunks/ec729c54327cc597.js +0 -14
- /package/server/.next/static/{pbn7-8jVWdRn8FLy4fHnU → ZoijkpoWD861NG-cIGJrO}/_buildManifest.js +0 -0
- /package/server/.next/static/{pbn7-8jVWdRn8FLy4fHnU → ZoijkpoWD861NG-cIGJrO}/_clientMiddlewareManifest.json +0 -0
- /package/server/.next/static/{pbn7-8jVWdRn8FLy4fHnU → ZoijkpoWD861NG-cIGJrO}/_ssgManifest.js +0 -0
|
@@ -12,16 +12,54 @@
|
|
|
12
12
|
* marks an area and what is inside it — and the composer asks what the agent
|
|
13
13
|
* should know. Answer on the caption opens the composer on what is lit.
|
|
14
14
|
* - What the person is looking at goes back to the agents through the stage.
|
|
15
|
+
* - Who answers a mark: under the composer, the panel, and a thread whose last
|
|
16
|
+
* word is the person's, one line says whether a Mia seat listens in the
|
|
17
|
+
* drawing's chronicle episode (Spec 84's presence). No line when the drawing
|
|
18
|
+
* is not in an episode or no companion is configured.
|
|
19
|
+
* - When an agent answers: the speech bubble counts what waits on the person
|
|
20
|
+
* ("1 for you") before what waits on the agent (quiet); a thread with an
|
|
21
|
+
* agent's word not yet opened is "new" on its pin and first in the list
|
|
22
|
+
* (read state per drawing in this browser); and a `marks:changed` carrying
|
|
23
|
+
* the answer shows one line by the controls for a few seconds ("Mia answered
|
|
24
|
+
* mark 3"), which opens the thread when tapped. Nothing takes focus and no
|
|
25
|
+
* card opens by itself.
|
|
26
|
+
* - Pins: on opening a drawing with open marks and nothing lit, the view frames
|
|
27
|
+
* the marks (never below a legible scale); pins stay inside the board, fan out
|
|
28
|
+
* when they share a point, and never sit under the board's buttons or under the
|
|
29
|
+
* caption (the phone's sheet), where they could not be tapped.
|
|
30
|
+
* - On a phone the cards open in the sheet at the bottom of the board, in the
|
|
31
|
+
* caption's place (StageProvider); the Talk conversation and a card take turns.
|
|
32
|
+
* - Elements are named as the person sees them; a board that knows words only it
|
|
33
|
+
* can give (a sequence's messages) passes `labels`.
|
|
34
|
+
* - The Talk dock's list of marks opens a mark here (`requestMark`): the thread
|
|
35
|
+
* opens and the view frames its mark.
|
|
15
36
|
*
|
|
16
37
|
* The sidecar is written through /api/marks; the hub is told after.
|
|
17
38
|
*/
|
|
18
39
|
import { useCallback, useEffect, useMemo, useRef, useState, type RefObject } from "react";
|
|
19
|
-
import {
|
|
40
|
+
import { createPortal } from "react-dom";
|
|
20
41
|
import {
|
|
42
|
+
fitLegibly,
|
|
43
|
+
markAnchor,
|
|
44
|
+
MarkComposer,
|
|
45
|
+
MarkIcon,
|
|
46
|
+
MarkNotice,
|
|
47
|
+
MarksIcon,
|
|
48
|
+
MarkThread,
|
|
49
|
+
MarksPanel,
|
|
50
|
+
StageOverlay,
|
|
51
|
+
useMarksRead,
|
|
52
|
+
type MarkDraft,
|
|
53
|
+
} from "@miadi/stateloom-canvas";
|
|
54
|
+
import {
|
|
55
|
+
agentWordIn,
|
|
56
|
+
agentWordText,
|
|
21
57
|
boxesOf,
|
|
22
|
-
|
|
58
|
+
markCounts,
|
|
59
|
+
unreadMarks,
|
|
23
60
|
formatReview,
|
|
24
61
|
screenToWorld,
|
|
62
|
+
type Box,
|
|
25
63
|
type DocKind,
|
|
26
64
|
type ElementBox,
|
|
27
65
|
type Mark,
|
|
@@ -29,7 +67,10 @@ import {
|
|
|
29
67
|
type Viewport,
|
|
30
68
|
} from "@miadi/stateloom-protocol";
|
|
31
69
|
import { useStageContext } from "./StageProvider";
|
|
70
|
+
import { episodeOf } from "./CompanionLayer";
|
|
71
|
+
import { presenceLine, useEpisodePresence } from "./useEpisodePresence";
|
|
32
72
|
import { urlParam } from "@/lib/systemParam";
|
|
73
|
+
import { MARK_REQUEST_EVENT, clearMarkRequest, markRequested } from "@/lib/markRequest";
|
|
33
74
|
|
|
34
75
|
export interface StageLayerProps {
|
|
35
76
|
/** The drawing this board shows, absolute. Null until the workspace has read it. */
|
|
@@ -48,10 +89,25 @@ export interface StageLayerProps {
|
|
|
48
89
|
* level, a sequence shown as a list. The workspace brings it on.
|
|
49
90
|
*/
|
|
50
91
|
onReveal?: (focuses: readonly string[]) => void;
|
|
92
|
+
/** Words for elements only this board can name (a sequence's messages), by focus key. */
|
|
93
|
+
labels?: Readonly<Record<string, string>>;
|
|
51
94
|
}
|
|
52
95
|
|
|
96
|
+
/** How long the line that says an agent answered stays by the controls. */
|
|
97
|
+
const NOTICE_MS = 7000;
|
|
98
|
+
|
|
53
99
|
const quietButton =
|
|
54
|
-
"flex h-9 min-w-9 items-center justify-center gap-1 rounded-lg border border-gray-700 bg-gray-900/90 px-2 text-sm text-gray-200 shadow-lg backdrop-blur hover:bg-gray-800";
|
|
100
|
+
"stage-quiet-btn flex h-9 min-w-9 items-center justify-center gap-1 rounded-lg border border-gray-700 bg-gray-900/90 px-2 text-sm text-gray-200 shadow-lg backdrop-blur hover:bg-gray-800";
|
|
101
|
+
|
|
102
|
+
/** How much of the board's bottom the caption, or the phone's sheet, covers. */
|
|
103
|
+
function coveredBottom(rect: DOMRect): number {
|
|
104
|
+
let top = Infinity;
|
|
105
|
+
for (const sel of [".stage-sheet", ".stage-caption-host .slc-stage-caption"]) {
|
|
106
|
+
const r = document.querySelector(sel)?.getBoundingClientRect();
|
|
107
|
+
if (r && r.height > 0 && r.top < rect.bottom) top = Math.min(top, r.top);
|
|
108
|
+
}
|
|
109
|
+
return top === Infinity ? 0 : Math.max(0, rect.bottom - top + 8);
|
|
110
|
+
}
|
|
55
111
|
|
|
56
112
|
async function postMarks(docPath: string, body: unknown): Promise<{ marks: Mark[]; change: { action: "add" | "reply" | "resolve" | "reopen" | "remove"; id: string } }> {
|
|
57
113
|
const r = await fetch(`/api/marks?doc=${encodeURIComponent(docPath)}`, {
|
|
@@ -64,7 +120,7 @@ async function postMarks(docPath: string, body: unknown): Promise<{ marks: Mark[
|
|
|
64
120
|
return data;
|
|
65
121
|
}
|
|
66
122
|
|
|
67
|
-
export default function StageLayer({ docPath, kind, viewport, setViewport, boxes, boardRef, focus = null, level, onReveal }: StageLayerProps) {
|
|
123
|
+
export default function StageLayer({ docPath, kind, viewport, setViewport, boxes, boardRef, focus = null, level, onReveal, labels }: StageLayerProps) {
|
|
68
124
|
const stage = useStageContext();
|
|
69
125
|
const [marks, setMarks] = useState<Mark[]>([]);
|
|
70
126
|
const [marking, setMarking] = useState(false);
|
|
@@ -74,6 +130,31 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
74
130
|
const [busy, setBusy] = useState(false);
|
|
75
131
|
const [error, setError] = useState<string | null>(null);
|
|
76
132
|
const [copied, setCopied] = useState(false);
|
|
133
|
+
const [notice, setNotice] = useState<{ id: string; text: string } | null>(null);
|
|
134
|
+
const noticeTimer = useRef<ReturnType<typeof setTimeout> | null>(null);
|
|
135
|
+
const selectedRef = useRef<string | null>(null);
|
|
136
|
+
selectedRef.current = selected;
|
|
137
|
+
const { read, markRead } = useMarksRead(docPath);
|
|
138
|
+
|
|
139
|
+
// ── The drawing on this board, for the caption ──────────────────────
|
|
140
|
+
const setBoard = stage?.setBoard;
|
|
141
|
+
useEffect(() => {
|
|
142
|
+
if (!setBoard || !docPath) return;
|
|
143
|
+
setBoard(docPath);
|
|
144
|
+
return () => setBoard(null);
|
|
145
|
+
}, [setBoard, docPath]);
|
|
146
|
+
|
|
147
|
+
/** Frame `targets` in what the caption (or the phone's sheet) leaves of the board, legibly. */
|
|
148
|
+
const frame = useCallback(
|
|
149
|
+
(targets: readonly Box[], opts: { floor?: number } = {}) => {
|
|
150
|
+
const rect = boardRef.current?.getBoundingClientRect();
|
|
151
|
+
if (!rect?.width || !targets.length) return;
|
|
152
|
+
const covered = coveredBottom(rect);
|
|
153
|
+
const height = Math.max(rect.height * 0.4, rect.height - covered);
|
|
154
|
+
setViewport(fitLegibly(targets, rect.width, height, { padding: 48, maxScale: 1, floor: opts.floor ?? 0.2 }));
|
|
155
|
+
},
|
|
156
|
+
[boardRef, setViewport],
|
|
157
|
+
);
|
|
77
158
|
|
|
78
159
|
// ── This drawing's marks ─────────────────────────────────────────────
|
|
79
160
|
const load = useCallback(() => {
|
|
@@ -94,10 +175,23 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
94
175
|
useEffect(
|
|
95
176
|
() =>
|
|
96
177
|
stage?.onMarks((e) => {
|
|
97
|
-
if (docPath
|
|
178
|
+
if (!docPath || e.docId !== docPath || !Array.isArray(e.marks)) return;
|
|
179
|
+
setMarks(e.marks);
|
|
180
|
+
// An agent's answer (or question) reaches a person who is not reading that thread: one line, for a while.
|
|
181
|
+
const word = agentWordIn(e);
|
|
182
|
+
if (!word || word.mark.id === selectedRef.current) return;
|
|
183
|
+
setNotice({ id: word.mark.id, text: agentWordText(word) });
|
|
184
|
+
if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
|
185
|
+
noticeTimer.current = setTimeout(() => setNotice(null), NOTICE_MS);
|
|
98
186
|
}),
|
|
99
187
|
[stage, docPath],
|
|
100
188
|
);
|
|
189
|
+
useEffect(() => {
|
|
190
|
+
setNotice(null);
|
|
191
|
+
return () => {
|
|
192
|
+
if (noticeTimer.current) clearTimeout(noticeTimer.current);
|
|
193
|
+
};
|
|
194
|
+
}, [docPath]);
|
|
101
195
|
|
|
102
196
|
const write = async (body: unknown): Promise<boolean> => {
|
|
103
197
|
if (!docPath) return false;
|
|
@@ -137,26 +231,62 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
137
231
|
return;
|
|
138
232
|
}
|
|
139
233
|
fitted.current = litHere.nonce;
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
if (!rect?.width) return;
|
|
143
|
-
// The caption covers the bottom of the board: fit into what it leaves.
|
|
144
|
-
const caption = document.querySelector(".stage-caption-host .slc-stage-caption")?.getBoundingClientRect();
|
|
145
|
-
const covered = caption && caption.top < rect.bottom ? Math.max(0, rect.bottom - caption.top + 8) : 0;
|
|
146
|
-
const height = Math.max(rect.height * 0.4, rect.height - covered);
|
|
147
|
-
setViewport(fitToBoxes(targets, rect.width, height, { padding: 48, maxScale: 1 }));
|
|
148
|
-
};
|
|
234
|
+
// The caption covers the bottom of the board: fit into what it leaves.
|
|
235
|
+
const fit = (): void => frame(targets);
|
|
149
236
|
fit();
|
|
150
237
|
// Again after the workspace's own fit or centring has landed. Not cleared
|
|
151
238
|
// when the board's boxes move under it (a live update arrives just after a
|
|
152
239
|
// drawing opens) — only when the layer goes.
|
|
153
240
|
if (refit.current) clearTimeout(refit.current);
|
|
154
241
|
refit.current = setTimeout(fit, 520);
|
|
155
|
-
}, [litHere, litBoxes, region, spotlight,
|
|
242
|
+
}, [litHere, litBoxes, region, spotlight, frame, onReveal]);
|
|
156
243
|
useEffect(() => () => {
|
|
157
244
|
if (refit.current) clearTimeout(refit.current);
|
|
158
245
|
}, []);
|
|
159
246
|
|
|
247
|
+
// ── Opening a drawing with marks: the view frames them ──────────────
|
|
248
|
+
// Once per drawing, when nothing is lit here (a lit step decides the view): what the marks
|
|
249
|
+
// point at on this level, and the element in hand. Looked at again after the workspace's
|
|
250
|
+
// own first view and this browser's remembered view have landed, and framed again only
|
|
251
|
+
// if that view hides some of them.
|
|
252
|
+
const marksFramed = useRef<string | null>(null);
|
|
253
|
+
const marksRefit = useRef<ReturnType<typeof setTimeout>[]>([]);
|
|
254
|
+
const viewportRef = useRef(viewport);
|
|
255
|
+
viewportRef.current = viewport;
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (!docPath || marksFramed.current === docPath) return;
|
|
258
|
+
if (litHere) {
|
|
259
|
+
marksFramed.current = docPath;
|
|
260
|
+
return;
|
|
261
|
+
}
|
|
262
|
+
const open = marks.filter((m) => m.status !== "resolved" && (!m.level || !level || m.level === level));
|
|
263
|
+
if (!open.length || !boxes.length) return;
|
|
264
|
+
// Marks that name elements wait for the board to draw them (a board still showing the
|
|
265
|
+
// layout it had before this drawing arrived draws none of them); an area stands alone.
|
|
266
|
+
const named = open.filter((m) => m.elements?.length);
|
|
267
|
+
const drawn = named.filter((m) => boxesOf(boxes, m.elements!).length > 0);
|
|
268
|
+
if (named.length && !drawn.length) return;
|
|
269
|
+
const anchors = [...drawn, ...open.filter((m) => !m.elements?.length)].map((m) => markAnchor(m, boxes)).filter((b): b is Box => !!b);
|
|
270
|
+
marksFramed.current = docPath;
|
|
271
|
+
if (!anchors.length) return;
|
|
272
|
+
const targets = [...anchors, ...(focus ? boxesOf(boxes, [focus]) : [])];
|
|
273
|
+
const fitIfHidden = (): void => {
|
|
274
|
+
const rect = boardRef.current?.getBoundingClientRect();
|
|
275
|
+
if (!rect?.width) return;
|
|
276
|
+
const vp = viewportRef.current;
|
|
277
|
+
const a = screenToWorld(vp, { x: 0, y: 0 });
|
|
278
|
+
const b = screenToWorld(vp, { x: rect.width, y: rect.height - coveredBottom(rect) });
|
|
279
|
+
const seen = targets.every((t) => t.x >= a.x && t.y >= a.y && t.x + t.width <= b.x && t.y + t.height <= b.y);
|
|
280
|
+
if (!seen) frame(targets, { floor: 0.55 });
|
|
281
|
+
};
|
|
282
|
+
fitIfHidden();
|
|
283
|
+
for (const t of marksRefit.current) clearTimeout(t);
|
|
284
|
+
marksRefit.current = [setTimeout(fitIfHidden, 520), setTimeout(fitIfHidden, 1400)];
|
|
285
|
+
}, [docPath, marks, boxes, litHere, level, focus, frame, boardRef]);
|
|
286
|
+
useEffect(() => () => {
|
|
287
|
+
for (const t of marksRefit.current) clearTimeout(t);
|
|
288
|
+
}, []);
|
|
289
|
+
|
|
160
290
|
// Answer on the caption: a composer on what is lit (or the element in hand).
|
|
161
291
|
const answered = useRef(stage?.answerNonce ?? 0);
|
|
162
292
|
useEffect(() => {
|
|
@@ -193,10 +323,118 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
193
323
|
});
|
|
194
324
|
};
|
|
195
325
|
|
|
196
|
-
|
|
197
|
-
const
|
|
198
|
-
const
|
|
326
|
+
// ── Who answers a mark ───────────────────────────────────────────────
|
|
327
|
+
const episode = stage?.companion ? episodeOf(docPath) : null;
|
|
328
|
+
const presence = useEpisodePresence(episode, !!(draft || selected || panel));
|
|
329
|
+
const note = presenceLine(presence, episode);
|
|
330
|
+
|
|
331
|
+
// ── What waits on whom, and what is new ─────────────────────────────
|
|
332
|
+
const unread = useMemo(() => unreadMarks(marks, read).map((m) => m.id), [marks, read]);
|
|
333
|
+
const counts = markCounts(marks, read);
|
|
199
334
|
const selectedMark = marks.find((m) => m.id === selected) ?? null;
|
|
335
|
+
// A thread on screen is read, and stays read as replies land in it.
|
|
336
|
+
const selectedReplies = selectedMark?.replies?.length ?? 0;
|
|
337
|
+
useEffect(() => {
|
|
338
|
+
if (selectedMark) markRead(selectedMark);
|
|
339
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
340
|
+
}, [selectedMark?.id, selectedReplies, markRead]);
|
|
341
|
+
const openThread = useCallback((id: string): void => {
|
|
342
|
+
setDraft(null);
|
|
343
|
+
setPanel(false);
|
|
344
|
+
setMarking(false);
|
|
345
|
+
setSelected(id);
|
|
346
|
+
setNotice(null);
|
|
347
|
+
}, []);
|
|
348
|
+
|
|
349
|
+
// ── A mark asked for from outside the board (the Talk dock's list) ──
|
|
350
|
+
useEffect(() => {
|
|
351
|
+
const take = (): void => {
|
|
352
|
+
const id = markRequested(docPath);
|
|
353
|
+
const mark = id ? marks.find((m) => m.id === id) : null;
|
|
354
|
+
if (!mark) return;
|
|
355
|
+
clearMarkRequest();
|
|
356
|
+
openThread(mark.id);
|
|
357
|
+
const at = markAnchor(mark, boxes);
|
|
358
|
+
if (at) setTimeout(() => frame([at], { floor: 0.55 }), 60);
|
|
359
|
+
};
|
|
360
|
+
take();
|
|
361
|
+
window.addEventListener(MARK_REQUEST_EVENT, take);
|
|
362
|
+
return () => window.removeEventListener(MARK_REQUEST_EVENT, take);
|
|
363
|
+
}, [docPath, marks, boxes, openThread, frame]);
|
|
364
|
+
|
|
365
|
+
// ── The phone's sheet: a card holds it in the caption's place ───────
|
|
366
|
+
const cardOpen = !!(draft || selectedMark || panel);
|
|
367
|
+
const claimSheet = stage?.claimSheet;
|
|
368
|
+
const releaseSheet = stage?.releaseSheet;
|
|
369
|
+
useEffect(() => {
|
|
370
|
+
if (cardOpen) claimSheet?.("card");
|
|
371
|
+
else releaseSheet?.("card");
|
|
372
|
+
}, [cardOpen, claimSheet, releaseSheet]);
|
|
373
|
+
useEffect(() => () => releaseSheet?.("card"), [releaseSheet]);
|
|
374
|
+
// The conversation took the sheet: the card gives way to it.
|
|
375
|
+
const narrow = !!stage?.narrow;
|
|
376
|
+
const sheetOwner = stage?.sheet ?? null;
|
|
377
|
+
const [seenOwner, setSeenOwner] = useState(sheetOwner);
|
|
378
|
+
if (seenOwner !== sheetOwner) {
|
|
379
|
+
setSeenOwner(sheetOwner);
|
|
380
|
+
if (narrow && sheetOwner === "talk") {
|
|
381
|
+
setDraft(null);
|
|
382
|
+
setSelected(null);
|
|
383
|
+
setPanel(false);
|
|
384
|
+
}
|
|
385
|
+
}
|
|
386
|
+
|
|
387
|
+
// ── The board's buttons: no pin sits under them ─────────────────────
|
|
388
|
+
const controlsRef = useRef<HTMLDivElement | null>(null);
|
|
389
|
+
const [controlsBox, setControlsBox] = useState<Box | null>(null);
|
|
390
|
+
useEffect(() => {
|
|
391
|
+
const el = controlsRef.current;
|
|
392
|
+
if (!el) return;
|
|
393
|
+
const measure = (): void => {
|
|
394
|
+
const next = { x: el.offsetLeft, y: el.offsetTop, width: el.offsetWidth, height: el.offsetHeight };
|
|
395
|
+
setControlsBox((b) => (b && b.x === next.x && b.y === next.y && b.width === next.width && b.height === next.height ? b : next));
|
|
396
|
+
};
|
|
397
|
+
measure();
|
|
398
|
+
window.addEventListener("resize", measure);
|
|
399
|
+
if (typeof ResizeObserver === "undefined") return () => window.removeEventListener("resize", measure);
|
|
400
|
+
const watch = new ResizeObserver(measure);
|
|
401
|
+
watch.observe(el);
|
|
402
|
+
return () => {
|
|
403
|
+
watch.disconnect();
|
|
404
|
+
window.removeEventListener("resize", measure);
|
|
405
|
+
};
|
|
406
|
+
}, [docPath]);
|
|
407
|
+
// ── And none under the caption, or the phone's sheet: what it covers of the board ──
|
|
408
|
+
const [coverBox, setCoverBox] = useState<Box | null>(null);
|
|
409
|
+
const shownNow = stage?.shown ?? null;
|
|
410
|
+
useEffect(() => {
|
|
411
|
+
const board = boardRef.current;
|
|
412
|
+
if (!board) return;
|
|
413
|
+
const find = () => document.querySelector(".stage-sheet") ?? document.querySelector(".stage-caption-host .slc-stage-caption");
|
|
414
|
+
let frameId = 0;
|
|
415
|
+
const measure = (): void => {
|
|
416
|
+
cancelAnimationFrame(frameId);
|
|
417
|
+
frameId = requestAnimationFrame(() => {
|
|
418
|
+
const b = board.getBoundingClientRect();
|
|
419
|
+
const r = find()?.getBoundingClientRect();
|
|
420
|
+
const next = r && r.height > 0 && r.top < b.bottom ? { x: r.left - b.left, y: r.top - b.top, width: r.width, height: Math.min(r.bottom, b.bottom) - r.top } : null;
|
|
421
|
+
setCoverBox((c) => (c && next && c.x === next.x && c.y === next.y && c.width === next.width && c.height === next.height ? c : next));
|
|
422
|
+
});
|
|
423
|
+
};
|
|
424
|
+
measure();
|
|
425
|
+
const el = find();
|
|
426
|
+
const watch = el && typeof ResizeObserver !== "undefined" ? new ResizeObserver(measure) : null;
|
|
427
|
+
if (el) watch?.observe(el);
|
|
428
|
+
window.addEventListener("resize", measure);
|
|
429
|
+
return () => {
|
|
430
|
+
cancelAnimationFrame(frameId);
|
|
431
|
+
watch?.disconnect();
|
|
432
|
+
window.removeEventListener("resize", measure);
|
|
433
|
+
};
|
|
434
|
+
}, [boardRef, shownNow, cardOpen, sheetOwner, narrow, docPath]);
|
|
435
|
+
const avoid = useMemo(() => [controlsBox, coverBox].filter((b): b is Box => !!b), [controlsBox, coverBox]);
|
|
436
|
+
|
|
437
|
+
if (!docPath) return null;
|
|
200
438
|
|
|
201
439
|
return (
|
|
202
440
|
<>
|
|
@@ -206,6 +444,7 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
206
444
|
spotlight={spotlight}
|
|
207
445
|
region={region}
|
|
208
446
|
marks={marks}
|
|
447
|
+
unread={unread}
|
|
209
448
|
selectedMark={selected}
|
|
210
449
|
onSelectMark={(id) => {
|
|
211
450
|
setDraft(null);
|
|
@@ -217,12 +456,19 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
217
456
|
setMarking(false);
|
|
218
457
|
setSelected(null);
|
|
219
458
|
setPanel(false);
|
|
459
|
+
setNotice(null);
|
|
220
460
|
setDraft(d);
|
|
221
461
|
}}
|
|
222
462
|
draft={draft}
|
|
463
|
+
avoid={avoid}
|
|
223
464
|
/>
|
|
224
465
|
|
|
225
|
-
<div className="stage-board-controls">
|
|
466
|
+
<div className="stage-board-controls" ref={controlsRef}>
|
|
467
|
+
{notice && (
|
|
468
|
+
<MarkNotice className="stage-board-notice" onOpen={() => openThread(notice.id)} onDismiss={() => setNotice(null)}>
|
|
469
|
+
{notice.text}
|
|
470
|
+
</MarkNotice>
|
|
471
|
+
)}
|
|
226
472
|
<button
|
|
227
473
|
type="button"
|
|
228
474
|
className={`${quietButton} ${marking ? "!border-amber-400 !text-amber-300" : ""}`}
|
|
@@ -243,24 +489,30 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
243
489
|
onClick={() => {
|
|
244
490
|
setDraft(null);
|
|
245
491
|
setSelected(null);
|
|
492
|
+
setNotice(null);
|
|
246
493
|
setPanel((p) => !p);
|
|
247
494
|
}}
|
|
248
|
-
title=
|
|
495
|
+
title={`Marks on this drawing${counts.forYou ? ` · ${counts.forYou} for you` : ""}${counts.forAgent ? ` · ${counts.forAgent} waiting on the agent` : ""}`}
|
|
496
|
+
aria-label={`Marks on this drawing: ${counts.forYou} for you${counts.unread ? ` (${counts.unread} new)` : ""}, ${counts.forAgent} waiting on the agent`}
|
|
249
497
|
>
|
|
250
498
|
<MarksIcon />
|
|
251
|
-
{
|
|
499
|
+
{counts.forYou > 0 && (
|
|
500
|
+
<span className={`stage-for-you${counts.unread ? " stage-for-you--new" : ""}`}>
|
|
501
|
+
{counts.forYou} for you
|
|
502
|
+
</span>
|
|
503
|
+
)}
|
|
504
|
+
{counts.forAgent > 0 && <span className="text-xs text-gray-400">{counts.forAgent}</span>}
|
|
252
505
|
</button>
|
|
253
506
|
</div>
|
|
254
507
|
|
|
255
|
-
{marking && !draft && (
|
|
508
|
+
{marking && !draft && !notice && (
|
|
256
509
|
<div className="stage-board-hint" role="status">
|
|
257
510
|
Tap an element, or drag around several, to say something about it.
|
|
258
511
|
</div>
|
|
259
512
|
)}
|
|
260
513
|
|
|
261
|
-
{
|
|
262
|
-
|
|
263
|
-
{draft ? (
|
|
514
|
+
{cardOpen && (() => {
|
|
515
|
+
const card = draft ? (
|
|
264
516
|
<MarkComposer
|
|
265
517
|
key={`${draft.elements.join(",")}|${draft.region ? JSON.stringify(draft.region) : ""}`}
|
|
266
518
|
elements={draft.elements}
|
|
@@ -271,6 +523,8 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
271
523
|
listenAtOnce={!!draft.listen}
|
|
272
524
|
busy={busy}
|
|
273
525
|
error={error}
|
|
526
|
+
note={note}
|
|
527
|
+
labels={labels}
|
|
274
528
|
onCancel={() => {
|
|
275
529
|
setDraft(null);
|
|
276
530
|
setError(null);
|
|
@@ -291,6 +545,8 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
291
545
|
listener={stage?.listener}
|
|
292
546
|
busy={busy}
|
|
293
547
|
error={error}
|
|
548
|
+
note={note}
|
|
549
|
+
labels={labels}
|
|
294
550
|
onClose={() => setSelected(null)}
|
|
295
551
|
onReply={(text) => void write({ action: "reply", id: selectedMark.id, text, author: { name: stage?.author || "someone" } })}
|
|
296
552
|
onResolve={() => void write({ action: "resolve", id: selectedMark.id })}
|
|
@@ -302,6 +558,7 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
302
558
|
) : (
|
|
303
559
|
<MarksPanel
|
|
304
560
|
marks={marks}
|
|
561
|
+
unread={unread}
|
|
305
562
|
selected={selected}
|
|
306
563
|
onSelect={(id) => {
|
|
307
564
|
setPanel(false);
|
|
@@ -309,11 +566,13 @@ export default function StageLayer({ docPath, kind, viewport, setViewport, boxes
|
|
|
309
566
|
}}
|
|
310
567
|
onCopy={copy}
|
|
311
568
|
copied={copied}
|
|
569
|
+
note={note}
|
|
570
|
+
labels={labels}
|
|
312
571
|
onClose={() => setPanel(false)}
|
|
313
572
|
/>
|
|
314
|
-
)
|
|
315
|
-
</div
|
|
316
|
-
)}
|
|
573
|
+
);
|
|
574
|
+
return narrow && stage?.sheetBody ? createPortal(card, stage.sheetBody) : <div className="stage-board-card">{card}</div>;
|
|
575
|
+
})()}
|
|
317
576
|
</>
|
|
318
577
|
);
|
|
319
578
|
}
|