@devaccel-labs/app-comments-toolbar 1.0.0 → 1.3.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 +152 -18
- package/dist/react.js +516 -61
- package/dist/server.js +342 -14
- package/dist/types/capture/api.d.ts +30 -0
- package/dist/types/capture/editor.d.ts +10 -0
- package/dist/types/capture/ignore.d.ts +19 -0
- package/dist/types/capture/index.d.ts +1 -0
- package/dist/types/capture/picker.d.ts +9 -0
- package/dist/types/capture/picture.d.ts +29 -0
- package/dist/types/capture/surface.d.ts +39 -0
- package/dist/types/core/index.d.ts +2 -2
- package/dist/types/core/resolve.d.ts +2 -1
- package/dist/types/core/types.d.ts +11 -0
- package/dist/types/debug-context.d.ts +7 -0
- package/dist/types/dictation.d.ts +9 -0
- package/dist/types/embed/controller.d.ts +61 -0
- package/dist/types/embed/index.d.ts +1 -0
- package/dist/types/embed/options.d.ts +35 -0
- package/dist/types/help/index.d.ts +1 -0
- package/dist/types/help/panel-index.d.ts +1 -0
- package/dist/types/help/panel-styles.d.ts +1 -0
- package/dist/types/help/panel.d.ts +4 -0
- package/dist/types/help/pill-styles.d.ts +11 -0
- package/dist/types/help/pill.d.ts +44 -0
- package/dist/types/help/react.d.ts +12 -0
- package/dist/types/help/requests.d.ts +2 -0
- package/dist/types/help/types.d.ts +119 -0
- package/dist/types/help/wire.d.ts +61 -0
- package/dist/types/native-wire.d.ts +63 -0
- package/dist/types/native.d.ts +43 -0
- package/dist/types/react.d.ts +8 -1
- package/dist/types/replay.d.ts +6 -0
- package/dist/types/runtime/api.d.ts +250 -0
- package/dist/types/runtime/attachment-model.d.ts +16 -0
- package/dist/types/runtime/attachments.d.ts +43 -7
- package/dist/types/runtime/boot.d.ts +39 -0
- package/dist/types/runtime/browser-label.d.ts +5 -0
- package/dist/types/runtime/build-id.d.ts +7 -0
- package/dist/types/runtime/capture-ui.d.ts +45 -21
- package/dist/types/runtime/clarity-model.d.ts +260 -0
- package/dist/types/runtime/clarity-ui.d.ts +62 -0
- package/dist/types/runtime/client.d.ts +6 -197
- package/dist/types/runtime/composer-size.d.ts +30 -0
- package/dist/types/runtime/dashboard.d.ts +17 -2
- package/dist/types/runtime/debug-context-core.d.ts +88 -0
- package/dist/types/runtime/debug-context-host.d.ts +39 -0
- package/dist/types/runtime/debug-context-model.d.ts +352 -0
- package/dist/types/runtime/debug-context-redact.d.ts +48 -0
- package/dist/types/runtime/debug-context.d.ts +36 -0
- package/dist/types/runtime/details-model.d.ts +7 -0
- package/dist/types/runtime/details.d.ts +7 -5
- package/dist/types/runtime/dictation-engine.d.ts +98 -0
- package/dist/types/runtime/dictation-model.d.ts +28 -0
- package/dist/types/runtime/dictation-ui.d.ts +56 -0
- package/dist/types/runtime/dictation.d.ts +66 -0
- package/dist/types/runtime/draft-dock.d.ts +32 -0
- package/dist/types/runtime/draft-model.d.ts +104 -0
- package/dist/types/runtime/draft-store.d.ts +15 -0
- package/dist/types/runtime/edits.d.ts +1 -1
- package/dist/types/runtime/font.d.ts +16 -0
- package/dist/types/runtime/format.d.ts +30 -1
- package/dist/types/runtime/host-watch.d.ts +20 -0
- package/dist/types/runtime/icons.d.ts +16 -2
- package/dist/types/runtime/index.d.ts +3 -1
- package/dist/types/runtime/ink.d.ts +8 -0
- package/dist/types/runtime/kind-model.d.ts +14 -0
- package/dist/types/runtime/live-model.d.ts +25 -0
- package/dist/types/runtime/loader.d.ts +5 -11
- package/dist/types/runtime/markup-editor.d.ts +54 -0
- package/dist/types/runtime/markup-model.d.ts +126 -0
- package/dist/types/runtime/markup.d.ts +69 -0
- package/dist/types/runtime/mention-parse.d.ts +18 -0
- package/dist/types/runtime/mentions.d.ts +4 -4
- package/dist/types/runtime/page-background.d.ts +9 -0
- package/dist/types/runtime/page-image.d.ts +123 -0
- package/dist/types/runtime/placement.d.ts +6 -3
- package/dist/types/runtime/record.d.ts +18 -2
- package/dist/types/runtime/related.d.ts +1 -1
- package/dist/types/runtime/replay-model.d.ts +80 -0
- package/dist/types/runtime/replay.d.ts +5 -38
- package/dist/types/runtime/runtime.d.ts +6 -0
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -6
- package/dist/types/runtime/session-source.d.ts +45 -0
- package/dist/types/runtime/session.d.ts +20 -18
- package/dist/types/runtime/shield.d.ts +9 -0
- package/dist/types/runtime/shot.d.ts +45 -0
- package/dist/types/runtime/sketch.d.ts +26 -0
- package/dist/types/runtime/styles.d.ts +1 -1
- package/dist/types/runtime/sync.d.ts +1 -0
- package/dist/types/runtime/thread-model.d.ts +18 -0
- package/dist/types/runtime/ticket-form-model.d.ts +57 -0
- package/dist/types/runtime/ticket-form.d.ts +9 -0
- package/dist/types/runtime/toolbar.d.ts +37 -1
- package/dist/types/runtime/ui-scale.d.ts +17 -0
- package/dist/types/runtime/update.d.ts +37 -0
- package/dist/types/runtime/use-debug-context.d.ts +27 -0
- package/dist/types/server/index.d.ts +22 -0
- package/dist/types/server/support.d.ts +20 -0
- package/dist/types/server.d.ts +2 -0
- package/dist/types/vite/plugin.d.ts +15 -0
- package/dist/vite.js +121 -41
- package/package.json +26 -13
- package/src/capture/api.ts +128 -0
- package/src/capture/editor.tsx +178 -0
- package/src/capture/ignore.ts +38 -0
- package/src/capture/index.ts +27 -0
- package/src/capture/picker.tsx +171 -0
- package/src/capture/picture.ts +90 -0
- package/src/capture/surface.tsx +185 -0
- package/src/compiler/annotate.ts +202 -0
- package/src/compiler/babel.d.ts +11 -0
- package/src/compiler/enabled.ts +55 -0
- package/src/compiler/index.ts +2 -0
- package/src/core/index.ts +8 -0
- package/src/core/resolve.ts +307 -0
- package/src/core/types.ts +58 -0
- package/src/debug-context.ts +47 -0
- package/src/dictation.ts +17 -0
- package/src/embed/controller.ts +215 -0
- package/src/embed/index.ts +68 -0
- package/src/embed/options.ts +100 -0
- package/src/help/index.ts +38 -0
- package/src/help/panel-index.ts +17 -0
- package/src/help/panel-styles.ts +123 -0
- package/src/help/panel.ts +840 -0
- package/src/help/pill-styles.ts +75 -0
- package/src/help/pill.ts +338 -0
- package/src/help/react.tsx +143 -0
- package/src/help/requests.ts +111 -0
- package/src/help/types.ts +124 -0
- package/src/help/wire.ts +55 -0
- package/src/native-wire.ts +124 -0
- package/src/native.ts +282 -0
- package/src/react.ts +27 -0
- package/src/replay.ts +13 -0
- package/src/runtime/api.ts +531 -0
- package/src/runtime/attachment-model.ts +42 -0
- package/src/runtime/attachments.tsx +1260 -0
- package/src/runtime/boot.ts +95 -0
- package/src/runtime/browser-label.ts +31 -0
- package/src/runtime/build-id.ts +10 -0
- package/src/runtime/capture-ui.tsx +306 -0
- package/src/runtime/capture.ts +508 -0
- package/src/runtime/clarity-model.ts +634 -0
- package/src/runtime/clarity-ui.tsx +454 -0
- package/src/runtime/client.ts +151 -0
- package/src/runtime/composer-size.tsx +146 -0
- package/src/runtime/dashboard.tsx +1130 -0
- package/src/runtime/debug-context-core.ts +681 -0
- package/src/runtime/debug-context-host.ts +56 -0
- package/src/runtime/debug-context-model.ts +1319 -0
- package/src/runtime/debug-context-redact.ts +259 -0
- package/src/runtime/debug-context.ts +613 -0
- package/src/runtime/details-model.ts +30 -0
- package/src/runtime/details.tsx +395 -0
- package/src/runtime/dictation-engine.ts +616 -0
- package/src/runtime/dictation-model.ts +79 -0
- package/src/runtime/dictation-ui.tsx +363 -0
- package/src/runtime/dictation.ts +271 -0
- package/src/runtime/dock.ts +108 -0
- package/src/runtime/draft-dock.tsx +124 -0
- package/src/runtime/draft-model.ts +251 -0
- package/src/runtime/draft-store.ts +149 -0
- package/src/runtime/edits.tsx +165 -0
- package/src/runtime/font.ts +42 -0
- package/src/runtime/format.ts +129 -0
- package/src/runtime/freshness.ts +23 -0
- package/src/runtime/handoff.ts +76 -0
- package/src/runtime/host-watch.ts +64 -0
- package/src/runtime/icons.tsx +295 -0
- package/src/runtime/idle.ts +20 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/ink.ts +6 -0
- package/src/runtime/kind-model.ts +20 -0
- package/src/runtime/live-model.ts +32 -0
- package/src/runtime/loader.tsx +87 -0
- package/src/runtime/markup-editor.tsx +1333 -0
- package/src/runtime/markup-model.ts +485 -0
- package/src/runtime/markup.ts +382 -0
- package/src/runtime/members.ts +58 -0
- package/src/runtime/mention-parse.ts +56 -0
- package/src/runtime/mentions.tsx +205 -0
- package/src/runtime/page-background.ts +51 -0
- package/src/runtime/page-image.ts +1592 -0
- package/src/runtime/placement.ts +336 -0
- package/src/runtime/record.ts +402 -0
- package/src/runtime/redact.ts +30 -0
- package/src/runtime/related.ts +34 -0
- package/src/runtime/render-page.ts +2 -0
- package/src/runtime/replay-model.ts +232 -0
- package/src/runtime/replay.ts +173 -0
- package/src/runtime/runtime-version.ts +7 -0
- package/src/runtime/runtime.tsx +51 -0
- package/src/runtime/screen.ts +182 -0
- package/src/runtime/screenshot.ts +260 -0
- package/src/runtime/session-source.ts +112 -0
- package/src/runtime/session.ts +108 -0
- package/src/runtime/shield.ts +34 -0
- package/src/runtime/shot.tsx +422 -0
- package/src/runtime/sketch.ts +139 -0
- package/src/runtime/styles.ts +1541 -0
- package/src/runtime/sync.ts +300 -0
- package/src/runtime/thread-model.ts +71 -0
- package/src/runtime/ticket-form-model.ts +128 -0
- package/src/runtime/ticket-form.tsx +342 -0
- package/src/runtime/toolbar.tsx +4427 -0
- package/src/runtime/ui-scale.ts +75 -0
- package/src/runtime/update.ts +98 -0
- package/src/runtime/use-debug-context.ts +81 -0
- package/src/server/index.ts +133 -0
- package/src/server/support.ts +499 -0
- package/src/server.ts +4 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite.ts +1 -0
- package/dist/chunks/chunk-CSKXLF33.js +0 -153
- package/dist/chunks/chunk-HUM5FQII.js +0 -160
- package/dist/chunks/render-page-76MLIT6M.js +0 -1421
- package/dist/chunks/runtime-JX75TKER.js +0 -9267
- package/dist/chunks/screenshot-66ARTBGR.js +0 -12
|
@@ -0,0 +1,112 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The host session (which comment service, project and environment, and the 15-minute host token) and the
|
|
3
|
+
* cache that reuses it until the token is about to expire. DOM-free: the web toolbar loads the session route
|
|
4
|
+
* with `fetch` from the page (`./session.ts`); native clients load it with the app's own bearer auth.
|
|
5
|
+
*/
|
|
6
|
+
export type Session = {
|
|
7
|
+
apiUrl: string;
|
|
8
|
+
project: string;
|
|
9
|
+
environment: string;
|
|
10
|
+
hostToken: string;
|
|
11
|
+
/**
|
|
12
|
+
* Native clients only: their app origin (`<scheme>://app`), sent as `x-comment-service-origin` on every
|
|
13
|
+
* request. Web leaves it unset; the browser sends `Origin`.
|
|
14
|
+
*/
|
|
15
|
+
clientOrigin?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Web, script-tag loader only (`../embed`): the origin the session route says `embed.js` may be loaded from
|
|
18
|
+
* when that is not `apiUrl`'s origin. Without it the loader mounts nothing for such a session.
|
|
19
|
+
*/
|
|
20
|
+
loaderOrigin?: string;
|
|
21
|
+
};
|
|
22
|
+
|
|
23
|
+
/** Returns this page's session, or null when comments are off. `refresh` skips the cached one. */
|
|
24
|
+
export type GetSession = (options?: { refresh?: boolean }) => Promise<Session | null>;
|
|
25
|
+
|
|
26
|
+
/** A cached host token is replaced this long before it expires, so a request never carries an expired one. */
|
|
27
|
+
const EXPIRY_MARGIN_MS = 60_000;
|
|
28
|
+
/** Host tokens last 15 minutes. If one carries no expiry, reuse it for less than that. */
|
|
29
|
+
const UNKNOWN_EXPIRY_MS = 5 * 60_000;
|
|
30
|
+
|
|
31
|
+
export function readJwt(token: string): {
|
|
32
|
+
exp?: number;
|
|
33
|
+
typ?: string;
|
|
34
|
+
name?: string;
|
|
35
|
+
email?: string;
|
|
36
|
+
memberId?: string;
|
|
37
|
+
origin?: string;
|
|
38
|
+
} | null {
|
|
39
|
+
try {
|
|
40
|
+
const part = token.split(".")[1];
|
|
41
|
+
if (!part) return null;
|
|
42
|
+
const pad = part.length % 4 === 0 ? "" : "=".repeat(4 - (part.length % 4));
|
|
43
|
+
return JSON.parse(atob(part.replaceAll("-", "+").replaceAll("_", "/") + pad)) as {
|
|
44
|
+
exp?: number;
|
|
45
|
+
typ?: string;
|
|
46
|
+
name?: string;
|
|
47
|
+
email?: string;
|
|
48
|
+
memberId?: string;
|
|
49
|
+
origin?: string;
|
|
50
|
+
};
|
|
51
|
+
} catch {
|
|
52
|
+
return null;
|
|
53
|
+
}
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export function sessionFrom(payload: unknown): Session | null {
|
|
57
|
+
const value = payload as Partial<Session> & { enabled?: boolean };
|
|
58
|
+
if (!value?.enabled || !value.apiUrl || !value.project || !value.environment || !value.hostToken)
|
|
59
|
+
return null;
|
|
60
|
+
return {
|
|
61
|
+
apiUrl: value.apiUrl,
|
|
62
|
+
project: value.project,
|
|
63
|
+
environment: value.environment,
|
|
64
|
+
hostToken: value.hostToken,
|
|
65
|
+
...(typeof value.loaderOrigin === "string" ? { loaderOrigin: value.loaderOrigin } : {}),
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* The session route answered, and its answer was "comments are off" with a reason (`{ enabled: false, error }`),
|
|
71
|
+
* most often because nobody is signed in. Not a failure to reach it.
|
|
72
|
+
*/
|
|
73
|
+
export class SessionOff extends Error {}
|
|
74
|
+
|
|
75
|
+
/**
|
|
76
|
+
* Reads the session route at most once at a time and reuses the answer until its host token is about to expire.
|
|
77
|
+
* "Comments are off" is kept for the source's lifetime. A failed request is not kept, so the next call retries.
|
|
78
|
+
*/
|
|
79
|
+
export function sessionSource(
|
|
80
|
+
path: string,
|
|
81
|
+
load: (path: string) => Promise<unknown>,
|
|
82
|
+
now = Date.now,
|
|
83
|
+
): GetSession {
|
|
84
|
+
let kept: { session: Session | null; until: number } | null = null;
|
|
85
|
+
let pending: Promise<Session | null> | null = null;
|
|
86
|
+
return ({ refresh = false } = {}) => {
|
|
87
|
+
if (!refresh && kept && now() < kept.until) return Promise.resolve(kept.session);
|
|
88
|
+
if (pending) return pending;
|
|
89
|
+
pending = load(path)
|
|
90
|
+
.then((payload) => {
|
|
91
|
+
const failed = payload as { enabled?: boolean; error?: unknown };
|
|
92
|
+
if (failed && failed.enabled === false && failed.error) {
|
|
93
|
+
throw new SessionOff(
|
|
94
|
+
typeof failed.error === "string" ? failed.error : "Comment service is unreachable.",
|
|
95
|
+
);
|
|
96
|
+
}
|
|
97
|
+
const session = sessionFrom(payload);
|
|
98
|
+
const expires = session ? readJwt(session.hostToken)?.exp : undefined;
|
|
99
|
+
const until = !session
|
|
100
|
+
? Infinity
|
|
101
|
+
: expires
|
|
102
|
+
? expires * 1000 - EXPIRY_MARGIN_MS
|
|
103
|
+
: now() + UNKNOWN_EXPIRY_MS;
|
|
104
|
+
kept = { session, until };
|
|
105
|
+
return session;
|
|
106
|
+
})
|
|
107
|
+
.finally(() => {
|
|
108
|
+
pending = null;
|
|
109
|
+
});
|
|
110
|
+
return pending;
|
|
111
|
+
};
|
|
112
|
+
}
|
|
@@ -0,0 +1,108 @@
|
|
|
1
|
+
import {
|
|
2
|
+
sessionSource as cachedSession,
|
|
3
|
+
SessionOff,
|
|
4
|
+
type GetSession,
|
|
5
|
+
type Session,
|
|
6
|
+
} from "./session-source.js";
|
|
7
|
+
|
|
8
|
+
// Everything but `sessionSource`, which this file wraps with the page's own fetch.
|
|
9
|
+
export { readJwt, sessionFrom, SessionOff } from "./session-source.js";
|
|
10
|
+
export type { GetSession, Session } from "./session-source.js";
|
|
11
|
+
|
|
12
|
+
/** Where the toolbar keeps a person's sign-in: `devaccel.comment.<apiUrl>.<project>.<environment>`. */
|
|
13
|
+
const SIGN_IN_PREFIX = "devaccel.comment.http";
|
|
14
|
+
|
|
15
|
+
/**
|
|
16
|
+
* Forgets every toolbar sign-in this page's origin holds. Run when the host app says nobody is signed in: a
|
|
17
|
+
* sign-in is kept per browser, not per person of the host app, so the next person to sign in to the app must
|
|
18
|
+
* not find the last one's.
|
|
19
|
+
*/
|
|
20
|
+
export function forgetSignIns(storage: Storage = localStorage): void {
|
|
21
|
+
try {
|
|
22
|
+
for (let index = storage.length - 1; index >= 0; index--) {
|
|
23
|
+
const key = storage.key(index);
|
|
24
|
+
if (key?.startsWith(SIGN_IN_PREFIX)) storage.removeItem(key);
|
|
25
|
+
}
|
|
26
|
+
} catch {
|
|
27
|
+
// Storage the page may not use holds no sign-in either.
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
const reported = new Set<string>();
|
|
32
|
+
|
|
33
|
+
/** One console line per distinct answer per page: what the route said when it did not turn comments on. */
|
|
34
|
+
function report(path: string, status: number, error: unknown, failed: boolean) {
|
|
35
|
+
const said = typeof error === "string" ? `: ${error}` : failed ? "." : " with comments off.";
|
|
36
|
+
const line = `[comment-service] ${path} answered ${status}${said}`;
|
|
37
|
+
if (reported.has(line)) return;
|
|
38
|
+
reported.add(line);
|
|
39
|
+
if (failed) console.warn(line);
|
|
40
|
+
else console.info(line);
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/**
|
|
44
|
+
* The session route's answer. `2xx` is the answer as it is. `401` and `403` are "nobody is signed in" from a
|
|
45
|
+
* route that says so with a status. Anything else is a route that failed, which is not an answer about the
|
|
46
|
+
* person: it rejects, and comments stay as they were.
|
|
47
|
+
*/
|
|
48
|
+
async function fetchSession(path: string): Promise<unknown> {
|
|
49
|
+
const response = await fetch(path, {
|
|
50
|
+
headers: { accept: "application/json", "x-comment-service-origin": window.location.origin },
|
|
51
|
+
cache: "no-store",
|
|
52
|
+
});
|
|
53
|
+
const payload = (await response.json().catch(() => null)) as {
|
|
54
|
+
enabled?: unknown;
|
|
55
|
+
error?: unknown;
|
|
56
|
+
} | null;
|
|
57
|
+
const off = response.status === 401 || response.status === 403;
|
|
58
|
+
if (!response.ok && !off) {
|
|
59
|
+
report(path, response.status, payload?.error, true);
|
|
60
|
+
throw new Error(`The session route answered ${response.status}.`);
|
|
61
|
+
}
|
|
62
|
+
if (off || payload?.enabled !== true) {
|
|
63
|
+
report(path, response.status, payload?.error, false);
|
|
64
|
+
forgetSignIns();
|
|
65
|
+
return { enabled: false };
|
|
66
|
+
}
|
|
67
|
+
return payload;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
/**
|
|
71
|
+
* Reads the session route at most once at a time and reuses the answer until its host token is about to expire.
|
|
72
|
+
* "Comments are off" is kept for the page's lifetime. A failed request is not kept, so the next call retries.
|
|
73
|
+
*/
|
|
74
|
+
export function sessionSource(
|
|
75
|
+
path: string,
|
|
76
|
+
load: (path: string) => Promise<unknown> = fetchSession,
|
|
77
|
+
now = Date.now,
|
|
78
|
+
): GetSession {
|
|
79
|
+
return cachedSession(path, load, now);
|
|
80
|
+
}
|
|
81
|
+
|
|
82
|
+
const sources = new Map<string, GetSession>();
|
|
83
|
+
|
|
84
|
+
/** The page-wide session source for a session route path. */
|
|
85
|
+
export function sessionFor(path: string): GetSession {
|
|
86
|
+
let source = sources.get(path);
|
|
87
|
+
if (!source) {
|
|
88
|
+
source = sessionSource(path);
|
|
89
|
+
sources.set(path, source);
|
|
90
|
+
}
|
|
91
|
+
return source;
|
|
92
|
+
}
|
|
93
|
+
|
|
94
|
+
/** What the host app says now: comments are on (with the session), off, or it could not be asked. */
|
|
95
|
+
export type HostAnswer =
|
|
96
|
+
| { readonly state: "on"; readonly session: Session }
|
|
97
|
+
| { readonly state: "off" }
|
|
98
|
+
| { readonly state: "unknown" };
|
|
99
|
+
|
|
100
|
+
/** Asks the session route again, past any kept answer, or takes the kept one when `fresh` is false. */
|
|
101
|
+
export async function askHost(get: GetSession, fresh = true): Promise<HostAnswer> {
|
|
102
|
+
try {
|
|
103
|
+
const session = await get({ refresh: fresh });
|
|
104
|
+
return session ? { state: "on", session } : { state: "off" };
|
|
105
|
+
} catch (caught) {
|
|
106
|
+
return caught instanceof SessionOff ? { state: "off" } : { state: "unknown" };
|
|
107
|
+
}
|
|
108
|
+
}
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
const TEXT_CONTROL = "input, textarea, select, [contenteditable]";
|
|
2
|
+
|
|
3
|
+
function inToolbar(node: EventTarget | null) {
|
|
4
|
+
return node instanceof Element && Boolean(node.closest("#cs-root"));
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
/**
|
|
8
|
+
* Keeps the page from reacting to the toolbar. Presses on the toolbar never reach the page, so an open menu,
|
|
9
|
+
* popover, or dialog does not treat them as an outside press, and buttons do not pull focus out of it. Focus
|
|
10
|
+
* moving into the toolbar is hidden from document-level focus traps (Radix FocusScope listens for focusin and
|
|
11
|
+
* focusout on document), which would otherwise pull it straight back into an open modal so the composer never
|
|
12
|
+
* takes input. The focus listeners sit on document, so the page's and the toolbar's own React handlers still
|
|
13
|
+
* run; call this before any app dialog opens so stopImmediatePropagation runs ahead of the trap's listeners.
|
|
14
|
+
*/
|
|
15
|
+
export function shieldFromPage() {
|
|
16
|
+
const press = (event: Event) => {
|
|
17
|
+
const target = event.target;
|
|
18
|
+
if (!(target instanceof Element) || !inToolbar(target)) return;
|
|
19
|
+
event.stopPropagation();
|
|
20
|
+
if (event.type === "mousedown" && !target.closest(TEXT_CONTROL)) event.preventDefault();
|
|
21
|
+
};
|
|
22
|
+
const focus = (event: Event) => {
|
|
23
|
+
const into = event.type === "focusin" ? event.target : (event as FocusEvent).relatedTarget;
|
|
24
|
+
if (inToolbar(into)) event.stopImmediatePropagation();
|
|
25
|
+
};
|
|
26
|
+
const presses = ["pointerdown", "mousedown", "touchstart"];
|
|
27
|
+
const focuses = ["focusin", "focusout"];
|
|
28
|
+
for (const type of presses) window.addEventListener(type, press, true);
|
|
29
|
+
for (const type of focuses) document.addEventListener(type, focus);
|
|
30
|
+
return () => {
|
|
31
|
+
for (const type of presses) window.removeEventListener(type, press, true);
|
|
32
|
+
for (const type of focuses) document.removeEventListener(type, focus);
|
|
33
|
+
};
|
|
34
|
+
}
|
|
@@ -0,0 +1,422 @@
|
|
|
1
|
+
import { useEffect, useRef, useState, type ReactNode } from "react";
|
|
2
|
+
import {
|
|
3
|
+
ATTACHMENT_MAX_BYTES,
|
|
4
|
+
isReplayFile,
|
|
5
|
+
type Pending,
|
|
6
|
+
type PreviewFile,
|
|
7
|
+
} from "./attachments.js";
|
|
8
|
+
import * as Icon from "./icons.js";
|
|
9
|
+
import { flattenMarkup, loadImage, type MarkupShape } from "./markup.js";
|
|
10
|
+
import { MarkupEditor, type MarkupResult } from "./markup-editor.js";
|
|
11
|
+
import { formatClock } from "./record.js";
|
|
12
|
+
import {
|
|
13
|
+
parseReplay,
|
|
14
|
+
replayLine,
|
|
15
|
+
replayStamp,
|
|
16
|
+
type ReplayEvent,
|
|
17
|
+
type ReplayFile,
|
|
18
|
+
} from "./replay.js";
|
|
19
|
+
|
|
20
|
+
/** The captures a draft holds: pictures (marked up on the stage) and clips. */
|
|
21
|
+
export type ShotCapture = PreviewFile;
|
|
22
|
+
|
|
23
|
+
function ago(at: number | null) {
|
|
24
|
+
if (!at) return "Saving…";
|
|
25
|
+
const seconds = Math.max(0, Math.round((Date.now() - at) / 1000));
|
|
26
|
+
if (seconds < 8) return "Saved just now";
|
|
27
|
+
if (seconds < 60) return `Saved ${seconds}s ago`;
|
|
28
|
+
return `Saved ${Math.round(seconds / 60)} min ago`;
|
|
29
|
+
}
|
|
30
|
+
|
|
31
|
+
function sameShapes(a: MarkupShape[], b: MarkupShape[]) {
|
|
32
|
+
return JSON.stringify(a) === JSON.stringify(b);
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* The flattened picture `item` needs before it is sent, given the shapes drawn on it: null when the file already
|
|
37
|
+
* shows them, the original when every shape is gone, else a fresh flatten. Rejects when the image can't be read.
|
|
38
|
+
*/
|
|
39
|
+
export async function flattenCapture(
|
|
40
|
+
item: Pending,
|
|
41
|
+
shapes: MarkupShape[],
|
|
42
|
+
): Promise<MarkupResult | null> {
|
|
43
|
+
if (!item.contentType.startsWith("image/")) return null;
|
|
44
|
+
if (sameShapes(shapes, item.markup?.shapes ?? [])) return null;
|
|
45
|
+
const name = item.markup?.name ?? item.name;
|
|
46
|
+
if (!shapes.length) return { blob: null, name, shapes: [] };
|
|
47
|
+
const url = item.markup?.url ?? item.preview;
|
|
48
|
+
if (!url) return null;
|
|
49
|
+
const image = await loadImage(url);
|
|
50
|
+
const flat = await flattenMarkup({
|
|
51
|
+
image,
|
|
52
|
+
width: image.naturalWidth,
|
|
53
|
+
height: image.naturalHeight,
|
|
54
|
+
shapes,
|
|
55
|
+
name,
|
|
56
|
+
maxBytes: ATTACHMENT_MAX_BYTES,
|
|
57
|
+
});
|
|
58
|
+
return { blob: flat.blob, name: flat.name, shapes };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Where an event sits on a clip's timeline, 0..1, or null when it falls outside the clip. */
|
|
62
|
+
export function timelinePosition(
|
|
63
|
+
event: ReplayEvent,
|
|
64
|
+
clip: { startedAt: number; durationMs: number },
|
|
65
|
+
) {
|
|
66
|
+
const at = (event.t - clip.startedAt) / clip.durationMs;
|
|
67
|
+
return at >= 0 && at <= 1 ? at : null;
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
function markerKind(event: ReplayEvent) {
|
|
71
|
+
if (event.type === "console" && event.level === "error") return "error";
|
|
72
|
+
if (event.type === "network") return "error";
|
|
73
|
+
if (event.type === "navigate") return "navigate";
|
|
74
|
+
if (event.type === "console") return "warn";
|
|
75
|
+
if (event.type === "say") return "say";
|
|
76
|
+
return "action";
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* A clip on the stage with what happened in it underneath: clicks, page changes, errors and failed requests
|
|
81
|
+
* from the replay that was kept beside it, on the clip's clock. A marker seeks the clip; the line under the
|
|
82
|
+
* playhead names the latest event.
|
|
83
|
+
*/
|
|
84
|
+
function ClipStage({ clip, replay }: { clip: ShotCapture; replay: ShotCapture | null }) {
|
|
85
|
+
const video = useRef<HTMLVideoElement>(null);
|
|
86
|
+
// Tagged with the replay it came from, so a replay still loading reads as none rather than the last one's.
|
|
87
|
+
const [loaded, setLoaded] = useState<{ id: string; file: ReplayFile | null } | null>(null);
|
|
88
|
+
const [time, setTime] = useState(0);
|
|
89
|
+
useEffect(() => {
|
|
90
|
+
if (!replay) return;
|
|
91
|
+
const id = replay.id;
|
|
92
|
+
let stale = false;
|
|
93
|
+
fetch(replay.url)
|
|
94
|
+
.then((response) => (response.ok ? response.json() : null))
|
|
95
|
+
.then((body: unknown) => {
|
|
96
|
+
if (!stale) setLoaded({ id, file: parseReplay(body) });
|
|
97
|
+
})
|
|
98
|
+
.catch(() => {
|
|
99
|
+
if (!stale) setLoaded({ id, file: null });
|
|
100
|
+
});
|
|
101
|
+
return () => {
|
|
102
|
+
stale = true;
|
|
103
|
+
};
|
|
104
|
+
}, [replay]);
|
|
105
|
+
const file = replay && loaded?.id === replay.id ? loaded.file : null;
|
|
106
|
+
const timeline = file?.clip
|
|
107
|
+
? { startedAt: file.clip.startedAt, durationMs: file.clip.durationMs }
|
|
108
|
+
: null;
|
|
109
|
+
const events = timeline
|
|
110
|
+
? file!.events
|
|
111
|
+
.map((event) => ({ event, at: timelinePosition(event, timeline) }))
|
|
112
|
+
.filter((item): item is { event: ReplayEvent; at: number } => item.at !== null)
|
|
113
|
+
: [];
|
|
114
|
+
const seek = (at: number) => {
|
|
115
|
+
const node = video.current;
|
|
116
|
+
if (!node || !timeline) return;
|
|
117
|
+
const seconds = (at * timeline.durationMs) / 1000;
|
|
118
|
+
node.currentTime = Math.max(0, Math.min(node.duration || seconds, seconds));
|
|
119
|
+
setTime(node.currentTime);
|
|
120
|
+
};
|
|
121
|
+
const played = timeline ? (time * 1000) / timeline.durationMs : 0;
|
|
122
|
+
const latest = timeline
|
|
123
|
+
? [...events].reverse().find((item) => item.at <= played + 0.002)?.event
|
|
124
|
+
: undefined;
|
|
125
|
+
const errors = events.filter((item) => markerKind(item.event) === "error").length;
|
|
126
|
+
return (
|
|
127
|
+
<div className="cs-shot-clip-stage" data-cs-ui="shot-clip">
|
|
128
|
+
<video
|
|
129
|
+
ref={video}
|
|
130
|
+
key={clip.id}
|
|
131
|
+
src={clip.url}
|
|
132
|
+
controls
|
|
133
|
+
playsInline
|
|
134
|
+
preload="metadata"
|
|
135
|
+
onTimeUpdate={(event) => setTime(event.currentTarget.currentTime)}
|
|
136
|
+
/>
|
|
137
|
+
{timeline ? (
|
|
138
|
+
<div className="cs-timeline" data-cs-ui="clip-timeline">
|
|
139
|
+
<div className="cs-timeline-track" role="list" aria-label="What happened in this clip">
|
|
140
|
+
<span
|
|
141
|
+
className="cs-timeline-played"
|
|
142
|
+
style={{ width: `${Math.min(100, played * 100)}%` }}
|
|
143
|
+
/>
|
|
144
|
+
{events.map(({ event, at }, index) => (
|
|
145
|
+
<button
|
|
146
|
+
key={`${event.t}-${index}`}
|
|
147
|
+
type="button"
|
|
148
|
+
role="listitem"
|
|
149
|
+
className={`cs-timeline-mark is-${markerKind(event)}`}
|
|
150
|
+
style={{ left: `${at * 100}%` }}
|
|
151
|
+
title={`${formatClock(at * timeline.durationMs)} · ${replayLine(event)}`}
|
|
152
|
+
aria-label={`${formatClock(at * timeline.durationMs)}: ${replayLine(event)}`}
|
|
153
|
+
data-cs-ui="clip-mark"
|
|
154
|
+
onClick={() => seek(at)}
|
|
155
|
+
/>
|
|
156
|
+
))}
|
|
157
|
+
</div>
|
|
158
|
+
<div className="cs-timeline-line">
|
|
159
|
+
<span className="cs-timeline-time">{formatClock(time * 1000)}</span>
|
|
160
|
+
<span className="cs-timeline-text" data-cs-ui="clip-latest">
|
|
161
|
+
{latest ? replayLine(latest) : "Play, or pick a marker, to follow what happened."}
|
|
162
|
+
</span>
|
|
163
|
+
<span className="cs-timeline-count">
|
|
164
|
+
{events.length} event{events.length === 1 ? "" : "s"}
|
|
165
|
+
{errors ? ` · ${errors} error${errors === 1 ? "" : "s"}` : ""}
|
|
166
|
+
</span>
|
|
167
|
+
</div>
|
|
168
|
+
</div>
|
|
169
|
+
) : replay ? (
|
|
170
|
+
<p className="cs-markup-status">Reading what happened in this clip…</p>
|
|
171
|
+
) : (
|
|
172
|
+
<p className="cs-markup-status">No replay was kept with this clip.</p>
|
|
173
|
+
)}
|
|
174
|
+
</div>
|
|
175
|
+
);
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
/**
|
|
179
|
+
* A draft of captures as a comment: the selected capture fills the surface (a picture with the markup tools live
|
|
180
|
+
* on it, or a clip playing), the others sit in a filmstrip, and the composer (`children`) is a column beside it.
|
|
181
|
+
* Nothing has to be typed, and there is no Done to press: posting flattens whatever was drawn (see the composer's
|
|
182
|
+
* submit in `toolbar.tsx`). "Another page" and "Record" minimise the surface to the dock so the draft can collect
|
|
183
|
+
* more from elsewhere in the app.
|
|
184
|
+
*/
|
|
185
|
+
export function ShotSurface({
|
|
186
|
+
captures,
|
|
187
|
+
selectedId,
|
|
188
|
+
shapes,
|
|
189
|
+
savedAt,
|
|
190
|
+
onSelect,
|
|
191
|
+
onRemove,
|
|
192
|
+
onShapes,
|
|
193
|
+
onAnother,
|
|
194
|
+
onRecord,
|
|
195
|
+
onDictate,
|
|
196
|
+
dictating,
|
|
197
|
+
onMinimise,
|
|
198
|
+
onDiscard,
|
|
199
|
+
confirmDiscard,
|
|
200
|
+
onSubmit,
|
|
201
|
+
children,
|
|
202
|
+
}: {
|
|
203
|
+
captures: ShotCapture[];
|
|
204
|
+
selectedId: string | null;
|
|
205
|
+
/** Shapes drawn on each picture so far, by capture id; a picture not listed shows its flattened shapes. */
|
|
206
|
+
shapes: ReadonlyMap<string, MarkupShape[]>;
|
|
207
|
+
savedAt: number | null;
|
|
208
|
+
onSelect: (id: string) => void;
|
|
209
|
+
onRemove: (id: string) => void;
|
|
210
|
+
onShapes: (id: string, shapes: MarkupShape[]) => void;
|
|
211
|
+
onAnother: () => void;
|
|
212
|
+
onRecord: () => void;
|
|
213
|
+
/** Starts (or stops) typing what the person says into the comment; absent when dictation is not available. */
|
|
214
|
+
onDictate?: (() => void) | undefined;
|
|
215
|
+
dictating?: boolean | undefined;
|
|
216
|
+
onMinimise: () => void;
|
|
217
|
+
onDiscard: () => void;
|
|
218
|
+
confirmDiscard: boolean;
|
|
219
|
+
/** Cmd+Enter on the picture: post, as it does in the comment box. */
|
|
220
|
+
onSubmit: () => void;
|
|
221
|
+
children: ReactNode;
|
|
222
|
+
}) {
|
|
223
|
+
// Replays kept beside a clip are not captures of their own; they ride on the clip's slide.
|
|
224
|
+
const companions = captures.filter(isReplayFile);
|
|
225
|
+
const shown = captures.filter((item) => !isReplayFile(item));
|
|
226
|
+
const selected = shown.find((item) => item.id === selectedId) ?? shown[0] ?? null;
|
|
227
|
+
const pictures = shown.filter((item) => item.contentType.startsWith("image/")).length;
|
|
228
|
+
const clips = shown.filter((item) => item.contentType.startsWith("video/")).length;
|
|
229
|
+
const companionOf = (clip: ShotCapture) => {
|
|
230
|
+
const stamp = replayStamp(clip.name);
|
|
231
|
+
return stamp ? (companions.find((item) => replayStamp(item.name) === stamp) ?? null) : null;
|
|
232
|
+
};
|
|
233
|
+
const ribbon = (
|
|
234
|
+
<>
|
|
235
|
+
<div className="cs-ribbon-group">
|
|
236
|
+
<div className="cs-ribbon-row">
|
|
237
|
+
<button
|
|
238
|
+
className="cs-ribbon-big"
|
|
239
|
+
type="button"
|
|
240
|
+
data-cs-ui="shot-another"
|
|
241
|
+
title="Minimises this; go to any page and press S to add it"
|
|
242
|
+
onClick={onAnother}
|
|
243
|
+
>
|
|
244
|
+
<Icon.Camera size={16} />
|
|
245
|
+
<span>Another page</span>
|
|
246
|
+
</button>
|
|
247
|
+
<button
|
|
248
|
+
className="cs-ribbon-big"
|
|
249
|
+
type="button"
|
|
250
|
+
data-cs-ui="shot-record"
|
|
251
|
+
title="Records this tab into the draft, with what happens on the page"
|
|
252
|
+
onClick={onRecord}
|
|
253
|
+
>
|
|
254
|
+
<Icon.Record />
|
|
255
|
+
<span>Record</span>
|
|
256
|
+
</button>
|
|
257
|
+
{onDictate ? (
|
|
258
|
+
<button
|
|
259
|
+
className="cs-ribbon-big"
|
|
260
|
+
type="button"
|
|
261
|
+
data-cs-ui="shot-dictate"
|
|
262
|
+
aria-pressed={Boolean(dictating)}
|
|
263
|
+
title="Type what you say into the comment"
|
|
264
|
+
onClick={onDictate}
|
|
265
|
+
>
|
|
266
|
+
<Icon.Mic />
|
|
267
|
+
<span>{dictating ? "Listening…" : "Dictate"}</span>
|
|
268
|
+
</button>
|
|
269
|
+
) : null}
|
|
270
|
+
</div>
|
|
271
|
+
<div className="cs-ribbon-name">Add</div>
|
|
272
|
+
</div>
|
|
273
|
+
{selected ? (
|
|
274
|
+
<div className="cs-ribbon-group">
|
|
275
|
+
<div className="cs-ribbon-row cs-ribbon-stack">
|
|
276
|
+
<button
|
|
277
|
+
className="cs-ribbon-small"
|
|
278
|
+
type="button"
|
|
279
|
+
data-cs-ui="shot-remove-selected"
|
|
280
|
+
onClick={() => onRemove(selected.id)}
|
|
281
|
+
>
|
|
282
|
+
<Icon.Trash />
|
|
283
|
+
Remove this capture
|
|
284
|
+
</button>
|
|
285
|
+
<span className="cs-ribbon-text">
|
|
286
|
+
{pictures ? `${pictures} picture${pictures === 1 ? "" : "s"}` : "No pictures"}
|
|
287
|
+
{clips ? ` · ${clips} clip${clips === 1 ? "" : "s"}` : ""}
|
|
288
|
+
{companions.length
|
|
289
|
+
? ` · ${companions.length} replay${companions.length === 1 ? "" : "s"}`
|
|
290
|
+
: ""}
|
|
291
|
+
</span>
|
|
292
|
+
</div>
|
|
293
|
+
<div className="cs-ribbon-name">This draft</div>
|
|
294
|
+
</div>
|
|
295
|
+
) : null}
|
|
296
|
+
</>
|
|
297
|
+
);
|
|
298
|
+
return (
|
|
299
|
+
<>
|
|
300
|
+
<div className="cs-shot-scrim cs-hit" data-cs-ui="shot-scrim" onClick={onMinimise} />
|
|
301
|
+
<section
|
|
302
|
+
className="cs-card cs-shot cs-hit"
|
|
303
|
+
data-cs-ui="shot"
|
|
304
|
+
aria-label="Comment on captures of this app"
|
|
305
|
+
>
|
|
306
|
+
<header className="cs-shot-head">
|
|
307
|
+
<span className="cs-shot-title">
|
|
308
|
+
<Icon.Camera size={12} />
|
|
309
|
+
<strong>Draft</strong>
|
|
310
|
+
<span className="cs-shot-count">
|
|
311
|
+
{pictures ? `${pictures} picture${pictures === 1 ? "" : "s"}` : ""}
|
|
312
|
+
{pictures && clips ? " · " : ""}
|
|
313
|
+
{clips ? `${clips} clip${clips === 1 ? "" : "s"}` : ""}
|
|
314
|
+
</span>
|
|
315
|
+
<span className="cs-shot-saved" data-cs-ui="shot-saved">
|
|
316
|
+
<i aria-hidden="true" />
|
|
317
|
+
{ago(savedAt)}
|
|
318
|
+
</span>
|
|
319
|
+
</span>
|
|
320
|
+
<span className="cs-shot-spacer" />
|
|
321
|
+
<button
|
|
322
|
+
className={`cs-button${confirmDiscard ? " is-danger" : ""}`}
|
|
323
|
+
type="button"
|
|
324
|
+
data-cs-ui="shot-discard"
|
|
325
|
+
title="Throw this draft away"
|
|
326
|
+
onClick={onDiscard}
|
|
327
|
+
>
|
|
328
|
+
{confirmDiscard ? "Discard?" : <Icon.Trash />}
|
|
329
|
+
</button>
|
|
330
|
+
<button
|
|
331
|
+
className="cs-icon-button"
|
|
332
|
+
type="button"
|
|
333
|
+
data-cs-ui="shot-minimise"
|
|
334
|
+
aria-label="Minimise. The draft stays, and follows you."
|
|
335
|
+
title="Minimise (the draft stays)"
|
|
336
|
+
onClick={onMinimise}
|
|
337
|
+
>
|
|
338
|
+
<Icon.ChevronDown />
|
|
339
|
+
</button>
|
|
340
|
+
</header>
|
|
341
|
+
<div className="cs-shot-body">
|
|
342
|
+
{/* The film only earns its width with two or more captures; one is the picture on the stage. */}
|
|
343
|
+
{shown.length > 1 ? (
|
|
344
|
+
<aside
|
|
345
|
+
className="cs-shot-film"
|
|
346
|
+
data-cs-ui="shot-film"
|
|
347
|
+
aria-label="Captures in this draft"
|
|
348
|
+
>
|
|
349
|
+
{shown.map((item, index) => {
|
|
350
|
+
const video = item.contentType.startsWith("video/");
|
|
351
|
+
const on = selected?.id === item.id;
|
|
352
|
+
return (
|
|
353
|
+
<div
|
|
354
|
+
key={item.id}
|
|
355
|
+
className={`cs-shot-slide${on ? " is-on" : ""}`}
|
|
356
|
+
data-cs-ui="shot-slide"
|
|
357
|
+
data-kind={video ? "clip" : "picture"}
|
|
358
|
+
>
|
|
359
|
+
<button
|
|
360
|
+
type="button"
|
|
361
|
+
className="cs-shot-thumb"
|
|
362
|
+
aria-label={`Show ${item.name}`}
|
|
363
|
+
aria-pressed={on}
|
|
364
|
+
onClick={() => onSelect(item.id)}
|
|
365
|
+
>
|
|
366
|
+
{video ? (
|
|
367
|
+
<video src={item.url} muted preload="metadata" />
|
|
368
|
+
) : (
|
|
369
|
+
<img src={item.url} alt="" draggable={false} />
|
|
370
|
+
)}
|
|
371
|
+
{video ? <span className="cs-shot-clip">▶</span> : null}
|
|
372
|
+
</button>
|
|
373
|
+
<span className="cs-shot-cap">
|
|
374
|
+
<b>{index + 1}</b>
|
|
375
|
+
{video ? "Clip" : "Picture"}
|
|
376
|
+
</span>
|
|
377
|
+
<button
|
|
378
|
+
type="button"
|
|
379
|
+
className="cs-shot-remove"
|
|
380
|
+
aria-label={`Remove ${item.name} from the draft`}
|
|
381
|
+
onClick={() => onRemove(item.id)}
|
|
382
|
+
>
|
|
383
|
+
<Icon.Close />
|
|
384
|
+
</button>
|
|
385
|
+
</div>
|
|
386
|
+
);
|
|
387
|
+
})}
|
|
388
|
+
<button type="button" className="cs-shot-add" onClick={onAnother}>
|
|
389
|
+
+ Another page
|
|
390
|
+
</button>
|
|
391
|
+
</aside>
|
|
392
|
+
) : null}
|
|
393
|
+
<div className="cs-shot-stage cs-media">
|
|
394
|
+
{selected?.contentType.startsWith("image/") && selected.markup ? (
|
|
395
|
+
<MarkupEditor
|
|
396
|
+
// Keyed on the original, which outlives every flattened copy, so a save never remounts the tools.
|
|
397
|
+
key={selected.markup.src}
|
|
398
|
+
mode="inline"
|
|
399
|
+
src={selected.markup.src}
|
|
400
|
+
name={selected.markup.name}
|
|
401
|
+
shapes={shapes.get(selected.id) ?? selected.markup.shapes}
|
|
402
|
+
maxBytes={ATTACHMENT_MAX_BYTES}
|
|
403
|
+
ribbon={ribbon}
|
|
404
|
+
onChange={(next) => onShapes(selected.id, next)}
|
|
405
|
+
onCancel={onMinimise}
|
|
406
|
+
onSave={() => {}}
|
|
407
|
+
onSubmit={onSubmit}
|
|
408
|
+
/>
|
|
409
|
+
) : selected?.contentType.startsWith("video/") ? (
|
|
410
|
+
<ClipStage clip={selected} replay={companionOf(selected)} />
|
|
411
|
+
) : (
|
|
412
|
+
<p className="cs-markup-status">Nothing captured yet. Press S on any page.</p>
|
|
413
|
+
)}
|
|
414
|
+
</div>
|
|
415
|
+
<aside className="cs-shot-side" data-cs-ui="shot-side">
|
|
416
|
+
{children}
|
|
417
|
+
</aside>
|
|
418
|
+
</div>
|
|
419
|
+
</section>
|
|
420
|
+
</>
|
|
421
|
+
);
|
|
422
|
+
}
|