@brass-build/client 0.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/AGENTS.md +255 -0
- package/CHANGELOG.md +15 -0
- package/LICENSE +21 -0
- package/README.md +836 -0
- package/dist/api-fetch.d.ts +16 -0
- package/dist/api-fetch.d.ts.map +1 -0
- package/dist/api-fetch.js +97 -0
- package/dist/api-fetch.js.map +1 -0
- package/dist/api-headers.d.ts +3 -0
- package/dist/api-headers.d.ts.map +1 -0
- package/dist/api-headers.js +25 -0
- package/dist/api-headers.js.map +1 -0
- package/dist/auth-flow.d.ts +23 -0
- package/dist/auth-flow.d.ts.map +1 -0
- package/dist/auth-flow.js +76 -0
- package/dist/auth-flow.js.map +1 -0
- package/dist/client.d.ts +106 -0
- package/dist/client.d.ts.map +1 -0
- package/dist/client.js +1278 -0
- package/dist/client.js.map +1 -0
- package/dist/doc-url-sync.d.ts +19 -0
- package/dist/doc-url-sync.d.ts.map +1 -0
- package/dist/doc-url-sync.js +181 -0
- package/dist/doc-url-sync.js.map +1 -0
- package/dist/document-branches.d.ts +32 -0
- package/dist/document-branches.d.ts.map +1 -0
- package/dist/document-branches.js +89 -0
- package/dist/document-branches.js.map +1 -0
- package/dist/document-checkpoints.d.ts +24 -0
- package/dist/document-checkpoints.d.ts.map +1 -0
- package/dist/document-checkpoints.js +115 -0
- package/dist/document-checkpoints.js.map +1 -0
- package/dist/document-commands.d.ts +93 -0
- package/dist/document-commands.d.ts.map +1 -0
- package/dist/document-commands.js +423 -0
- package/dist/document-commands.js.map +1 -0
- package/dist/document-signals.d.ts +53 -0
- package/dist/document-signals.d.ts.map +1 -0
- package/dist/document-signals.js +114 -0
- package/dist/document-signals.js.map +1 -0
- package/dist/document-streams.d.ts +22 -0
- package/dist/document-streams.d.ts.map +1 -0
- package/dist/document-streams.js +98 -0
- package/dist/document-streams.js.map +1 -0
- package/dist/document-thumbnail-capture.d.ts +27 -0
- package/dist/document-thumbnail-capture.d.ts.map +1 -0
- package/dist/document-thumbnail-capture.js +160 -0
- package/dist/document-thumbnail-capture.js.map +1 -0
- package/dist/documents-namespace.d.ts +134 -0
- package/dist/documents-namespace.d.ts.map +1 -0
- package/dist/documents-namespace.js +190 -0
- package/dist/documents-namespace.js.map +1 -0
- package/dist/documents.d.ts +146 -0
- package/dist/documents.d.ts.map +1 -0
- package/dist/documents.js +2045 -0
- package/dist/documents.js.map +1 -0
- package/dist/emitter.d.ts +15 -0
- package/dist/emitter.d.ts.map +1 -0
- package/dist/emitter.js +90 -0
- package/dist/emitter.js.map +1 -0
- package/dist/errors.d.ts +18 -0
- package/dist/errors.d.ts.map +1 -0
- package/dist/errors.js +22 -0
- package/dist/errors.js.map +1 -0
- package/dist/export-guest.d.ts +9 -0
- package/dist/export-guest.d.ts.map +1 -0
- package/dist/export-guest.js +225 -0
- package/dist/export-guest.js.map +1 -0
- package/dist/fetch-retry.d.ts +16 -0
- package/dist/fetch-retry.d.ts.map +1 -0
- package/dist/fetch-retry.js +90 -0
- package/dist/fetch-retry.js.map +1 -0
- package/dist/guest-link.d.ts +3 -0
- package/dist/guest-link.d.ts.map +1 -0
- package/dist/guest-link.js +13 -0
- package/dist/guest-link.js.map +1 -0
- package/dist/handles.d.ts +57 -0
- package/dist/handles.d.ts.map +1 -0
- package/dist/handles.js +1375 -0
- package/dist/handles.js.map +1 -0
- package/dist/hosted-runtime.d.ts +33 -0
- package/dist/hosted-runtime.d.ts.map +1 -0
- package/dist/hosted-runtime.js +99 -0
- package/dist/hosted-runtime.js.map +1 -0
- package/dist/hosted-ui.d.ts +84 -0
- package/dist/hosted-ui.d.ts.map +1 -0
- package/dist/hosted-ui.js +673 -0
- package/dist/hosted-ui.js.map +1 -0
- package/dist/importer.d.ts +22 -0
- package/dist/importer.d.ts.map +1 -0
- package/dist/importer.js +54 -0
- package/dist/importer.js.map +1 -0
- package/dist/index.d.ts +54 -0
- package/dist/index.d.ts.map +1 -0
- package/dist/index.js +132 -0
- package/dist/index.js.map +1 -0
- package/dist/init-keys.d.ts +3 -0
- package/dist/init-keys.d.ts.map +1 -0
- package/dist/init-keys.js +13 -0
- package/dist/init-keys.js.map +1 -0
- package/dist/internal.d.ts +6 -0
- package/dist/internal.d.ts.map +1 -0
- package/dist/internal.js +17 -0
- package/dist/internal.js.map +1 -0
- package/dist/jwt.d.ts +3 -0
- package/dist/jwt.d.ts.map +1 -0
- package/dist/jwt.js +22 -0
- package/dist/jwt.js.map +1 -0
- package/dist/launch-context.d.ts +4 -0
- package/dist/launch-context.d.ts.map +1 -0
- package/dist/launch-context.js +44 -0
- package/dist/launch-context.js.map +1 -0
- package/dist/login-hint.d.ts +4 -0
- package/dist/login-hint.d.ts.map +1 -0
- package/dist/login-hint.js +63 -0
- package/dist/login-hint.js.map +1 -0
- package/dist/mutate-batching.d.ts +14 -0
- package/dist/mutate-batching.d.ts.map +1 -0
- package/dist/mutate-batching.js +65 -0
- package/dist/mutate-batching.js.map +1 -0
- package/dist/open-document.d.ts +127 -0
- package/dist/open-document.d.ts.map +1 -0
- package/dist/open-document.js +2121 -0
- package/dist/open-document.js.map +1 -0
- package/dist/page-title.d.ts +3 -0
- package/dist/page-title.d.ts.map +1 -0
- package/dist/page-title.js +32 -0
- package/dist/page-title.js.map +1 -0
- package/dist/perf.d.ts +9 -0
- package/dist/perf.d.ts.map +1 -0
- package/dist/perf.js +39 -0
- package/dist/perf.js.map +1 -0
- package/dist/presence.d.ts +62 -0
- package/dist/presence.d.ts.map +1 -0
- package/dist/presence.js +331 -0
- package/dist/presence.js.map +1 -0
- package/dist/record-paging.d.ts +99 -0
- package/dist/record-paging.d.ts.map +1 -0
- package/dist/record-paging.js +220 -0
- package/dist/record-paging.js.map +1 -0
- package/dist/schema-conflict-dialog.d.ts +10 -0
- package/dist/schema-conflict-dialog.d.ts.map +1 -0
- package/dist/schema-conflict-dialog.js +164 -0
- package/dist/schema-conflict-dialog.js.map +1 -0
- package/dist/selection-persistence.d.ts +5 -0
- package/dist/selection-persistence.d.ts.map +1 -0
- package/dist/selection-persistence.js +90 -0
- package/dist/selection-persistence.js.map +1 -0
- package/dist/serializer.d.ts +3 -0
- package/dist/serializer.d.ts.map +1 -0
- package/dist/serializer.js +2 -0
- package/dist/serializer.js.map +1 -0
- package/dist/session-persistence.d.ts +4 -0
- package/dist/session-persistence.d.ts.map +1 -0
- package/dist/session-persistence.js +68 -0
- package/dist/session-persistence.js.map +1 -0
- package/dist/session-toolbar.d.ts +62 -0
- package/dist/session-toolbar.d.ts.map +1 -0
- package/dist/session-toolbar.js +1205 -0
- package/dist/session-toolbar.js.map +1 -0
- package/dist/session.d.ts +69 -0
- package/dist/session.d.ts.map +1 -0
- package/dist/session.js +926 -0
- package/dist/session.js.map +1 -0
- package/dist/tail-ops.d.ts +28 -0
- package/dist/tail-ops.d.ts.map +1 -0
- package/dist/tail-ops.js +46 -0
- package/dist/tail-ops.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +41 -0
- package/dist/topic-lifecycle.d.ts.map +1 -0
- package/dist/topic-lifecycle.js +169 -0
- package/dist/topic-lifecycle.js.map +1 -0
- package/dist/types.d.ts +22 -0
- package/dist/types.d.ts.map +1 -0
- package/dist/types.js +2 -0
- package/dist/types.js.map +1 -0
- package/dist/undo.d.ts +18 -0
- package/dist/undo.d.ts.map +1 -0
- package/dist/undo.js +110 -0
- package/dist/undo.js.map +1 -0
- package/dist/version-bar.d.ts +22 -0
- package/dist/version-bar.d.ts.map +1 -0
- package/dist/version-bar.js +169 -0
- package/dist/version-bar.js.map +1 -0
- package/dist/version.d.ts +3 -0
- package/dist/version.d.ts.map +1 -0
- package/dist/version.js +8 -0
- package/dist/version.js.map +1 -0
- package/dist/vite.d.ts +15 -0
- package/dist/vite.d.ts.map +1 -0
- package/dist/vite.js +36 -0
- package/dist/vite.js.map +1 -0
- package/dist/ws-client.d.ts +53 -0
- package/dist/ws-client.d.ts.map +1 -0
- package/dist/ws-client.js +604 -0
- package/dist/ws-client.js.map +1 -0
- package/dist/ws-frame-limit.d.ts +21 -0
- package/dist/ws-frame-limit.d.ts.map +1 -0
- package/dist/ws-frame-limit.js +40 -0
- package/dist/ws-frame-limit.js.map +1 -0
- package/docs/app-manifest.md +65 -0
- package/docs/custom-chrome.md +147 -0
- package/docs/hosted-ui-advanced.md +77 -0
- package/docs/imported-documents.md +261 -0
- package/docs/presence.md +129 -0
- package/docs/schemas.md +135 -0
- package/docs/streams.md +248 -0
- package/package.json +60 -0
- package/scripts/prepare.mjs +50 -0
- package/src/api-fetch.ts +127 -0
- package/src/api-headers.ts +28 -0
- package/src/auth-flow.ts +125 -0
- package/src/client.ts +1457 -0
- package/src/doc-url-sync.ts +183 -0
- package/src/document-branches.ts +124 -0
- package/src/document-checkpoints.ts +135 -0
- package/src/document-commands.ts +651 -0
- package/src/document-signals.ts +234 -0
- package/src/document-streams.ts +132 -0
- package/src/document-thumbnail-capture.ts +191 -0
- package/src/documents-namespace.ts +516 -0
- package/src/documents.ts +2426 -0
- package/src/emitter.ts +129 -0
- package/src/errors.ts +45 -0
- package/src/export-guest.ts +282 -0
- package/src/fetch-retry.ts +119 -0
- package/src/guest-link.ts +14 -0
- package/src/handles.ts +1731 -0
- package/src/hosted-runtime.ts +109 -0
- package/src/hosted-ui.ts +1020 -0
- package/src/importer.ts +89 -0
- package/src/index.ts +267 -0
- package/src/init-keys.ts +15 -0
- package/src/internal.ts +23 -0
- package/src/jwt.ts +18 -0
- package/src/launch-context.ts +43 -0
- package/src/login-hint.ts +60 -0
- package/src/mutate-batching.ts +63 -0
- package/src/open-document.ts +2869 -0
- package/src/page-title.ts +33 -0
- package/src/perf.ts +46 -0
- package/src/presence.ts +471 -0
- package/src/record-paging.ts +270 -0
- package/src/schema-conflict-dialog.ts +176 -0
- package/src/selection-persistence.ts +98 -0
- package/src/serializer.ts +15 -0
- package/src/session-persistence.ts +69 -0
- package/src/session-toolbar.ts +1457 -0
- package/src/session.ts +1262 -0
- package/src/tail-ops.ts +63 -0
- package/src/topic-lifecycle.ts +217 -0
- package/src/types.ts +75 -0
- package/src/undo.ts +125 -0
- package/src/version-bar.ts +221 -0
- package/src/version.ts +8 -0
- package/src/vite.ts +49 -0
- package/src/ws-client.ts +678 -0
- package/src/ws-frame-limit.ts +52 -0
|
@@ -0,0 +1,1205 @@
|
|
|
1
|
+
// The SDK-rendered document chrome, as composable pieces. The SDK owns the
|
|
2
|
+
// DOM, look, and behaviour; the app provides a container and (optionally) its
|
|
3
|
+
// own tool buttons.
|
|
4
|
+
//
|
|
5
|
+
// Three granularities from one implementation:
|
|
6
|
+
// - `createSessionToolbar` mounts the whole bar.
|
|
7
|
+
// - `createSessionHeader` mounts the empty shell and hands back its three
|
|
8
|
+
// slots (`start` / `tools` / `end`) for an app that fills them itself.
|
|
9
|
+
// - the per-piece mounts each render one cluster into the app's element.
|
|
10
|
+
//
|
|
11
|
+
// The header layout has ONE definition (`buildBarShell`), shared by the
|
|
12
|
+
// whole-bar and slot-only mounts, so a composable app gets the same
|
|
13
|
+
// avatar-stays-top-right wrapping the full bar has.
|
|
14
|
+
//
|
|
15
|
+
// These widgets only render chrome and route to the hosted dialogs; they
|
|
16
|
+
// never mint a grant themselves.
|
|
17
|
+
import { createVersionBarFrame, } from './version-bar.js';
|
|
18
|
+
const STYLE_ID = 'brass-toolbar-styles';
|
|
19
|
+
// Scoped, theme-aware styles, injected once per document. Every piece root
|
|
20
|
+
// carries `brass-ui` (theme tokens + base font, so a standalone piece styles
|
|
21
|
+
// itself); `.brass-bar` only adds the flex header layout. Light/dark follows
|
|
22
|
+
// the OS.
|
|
23
|
+
const CSS = `
|
|
24
|
+
.brass-ui { --brass-surface:#161b22; --brass-surface-2:#1f2730;
|
|
25
|
+
--brass-fg:#e6edf3; --brass-fg-muted:#9da7b3; --brass-border:#2a313b;
|
|
26
|
+
--brass-accent:#3b82f6; --brass-accent-fg:#fff; --brass-danger:#e5534b;
|
|
27
|
+
color:var(--brass-fg);
|
|
28
|
+
font:14px system-ui,-apple-system,Segoe UI,Roboto,sans-serif; box-sizing:border-box; }
|
|
29
|
+
@media (prefers-color-scheme: light) { .brass-ui {
|
|
30
|
+
--brass-surface:#fff; --brass-surface-2:#f1f3f5; --brass-fg:#1b1f24;
|
|
31
|
+
--brass-fg-muted:#5c636a; --brass-border:#d8dee4; --brass-danger:#cf222e; } }
|
|
32
|
+
.brass-ui *, .brass-bar * { box-sizing:border-box; }
|
|
33
|
+
/* Host apps commonly set a global button min-height for touch targets, which
|
|
34
|
+
overrides the avatar's height regardless of specificity and stretches the
|
|
35
|
+
32px circle into an oval. Zero the inherited sizing constraints so each
|
|
36
|
+
widget's explicit dimensions hold. */
|
|
37
|
+
.brass-ui button, .brass-bar button { min-width:0; min-height:0; }
|
|
38
|
+
/* width:100% is load-bearing: mounted as a flex item the bar would otherwise
|
|
39
|
+
shrink to content width (flex stretches only the cross axis). */
|
|
40
|
+
.brass-bar { width:100%; display:flex; flex-wrap:wrap; align-items:center; gap:10px;
|
|
41
|
+
padding:8px 12px; background:var(--brass-surface);
|
|
42
|
+
border-bottom:1px solid var(--brass-border); }
|
|
43
|
+
/* flex-wrap breaks lines on each item's basis/content size, not its
|
|
44
|
+
shrunk-to-min size, so a wide left cluster would push the avatar onto its
|
|
45
|
+
own line even though both fit after shrinking. flex-basis:0 on the left
|
|
46
|
+
removes it from that line-fill calc, so the avatar stays on row 1 top-right
|
|
47
|
+
and the left ellipsizes instead; the toolbar commands wrap via
|
|
48
|
+
flex-basis:100%. */
|
|
49
|
+
.brass-bar .b-left { display:flex; align-items:center; gap:10px; order:1;
|
|
50
|
+
min-width:0; flex:1 1 0; overflow:hidden; }
|
|
51
|
+
.brass-bar .b-right { display:flex; align-items:center; gap:8px; order:2;
|
|
52
|
+
flex:0 0 auto; }
|
|
53
|
+
.brass-bar .b-tools { display:flex; align-items:center; gap:6px; flex-wrap:wrap;
|
|
54
|
+
order:3; flex-basis:100%; }
|
|
55
|
+
/* margin-left:auto pins the avatar right independent of the tools cluster:
|
|
56
|
+
with no doc open the empty tools cluster is hidden (updateToolsVisibility),
|
|
57
|
+
so nothing grows to push the avatar over and it would bunch up by the title.
|
|
58
|
+
It doesn't fight the centered tools: grow resolves before auto margins, so a
|
|
59
|
+
visible flex:1 tools cluster consumes the slack and the margin collapses. */
|
|
60
|
+
@media (min-width:760px) { .brass-bar .b-left { flex:0 1 auto; }
|
|
61
|
+
.brass-bar .b-tools { order:2; flex:1; flex-basis:auto;
|
|
62
|
+
justify-content:center; } .brass-bar .b-right { order:3; margin-left:auto; } }
|
|
63
|
+
.b-menu { display:inline-flex; }
|
|
64
|
+
/* A bare hamburger glyph, no button chrome (background / border / radius), so it
|
|
65
|
+
reads as an icon sitting beside the document title. The 36px box is only an
|
|
66
|
+
invisible tap target; the glyph is sized up so it holds its weight next to the
|
|
67
|
+
14px title. Hover tints the glyph since there's no border to highlight. */
|
|
68
|
+
.b-burger { cursor:pointer; width:36px; height:36px;
|
|
69
|
+
display:inline-flex; align-items:center; justify-content:center; border-radius:0;
|
|
70
|
+
border:none; background:none;
|
|
71
|
+
color:var(--brass-fg); font-size:24px; line-height:1; padding:0; font-family:inherit; }
|
|
72
|
+
.b-burger:hover { color:var(--brass-accent); }
|
|
73
|
+
/* Top-layer dropdown for the file and account menus: rendered via the Popover
|
|
74
|
+
API with fixed JS coords so an ancestor's overflow can't clip it (the file
|
|
75
|
+
menu lives in the bar's overflow:hidden left cluster). */
|
|
76
|
+
.b-pop { position:fixed; inset:auto; margin:0; padding:6px;
|
|
77
|
+
list-style:none; background:var(--brass-surface); border:1px solid var(--brass-border);
|
|
78
|
+
border-radius:8px; display:none; flex-direction:column; gap:2px; min-width:200px;
|
|
79
|
+
z-index:2147483646; box-shadow:0 8px 24px rgba(0,0,0,.35); color:var(--brass-fg); }
|
|
80
|
+
.b-pop:popover-open { display:flex; }
|
|
81
|
+
.b-pop button, .b-pop a { display:block; width:100%; text-align:left;
|
|
82
|
+
background:transparent; border:none; color:var(--brass-fg); padding:8px 10px;
|
|
83
|
+
border-radius:6px; cursor:pointer; font:inherit; text-decoration:none; }
|
|
84
|
+
.b-pop button:hover:not(:disabled), .b-pop a:hover { background:var(--brass-surface-2); }
|
|
85
|
+
.b-pop button:disabled { opacity:.5; cursor:default; }
|
|
86
|
+
/* The rule between command families. A host app's global hr styling would
|
|
87
|
+
otherwise land a thick default rule in a 200px menu, so every side is
|
|
88
|
+
declared here. */
|
|
89
|
+
.b-pop .b-sep { height:0; border:none; border-top:1px solid var(--brass-border);
|
|
90
|
+
margin:4px 2px; }
|
|
91
|
+
/* [hidden] must beat the block rule above (the account menu swaps its body
|
|
92
|
+
between signed-in actions and the guest's "Sign in"). */
|
|
93
|
+
.b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden] { display:none; }
|
|
94
|
+
.b-identity { display:flex; flex-direction:column; align-items:flex-start;
|
|
95
|
+
gap:1px; min-width:0; }
|
|
96
|
+
/* Cancels the horizontal padding the title and location carry for their focus
|
|
97
|
+
rings, so the identity block's text still lines up where it would with none.
|
|
98
|
+
Safe inside the bar, where the burger and its 10px gap sit to the left. */
|
|
99
|
+
.brass-bar .b-identity { margin-left:-3px; }
|
|
100
|
+
.b-titlerow { display:flex; align-items:center; gap:6px;
|
|
101
|
+
max-width:44vw; min-width:0; overflow:hidden; }
|
|
102
|
+
/* The title and location are chrome-stripped text buttons (no background /
|
|
103
|
+
border). A host app's global button border-radius still bleeds in, and the
|
|
104
|
+
title's overflow:hidden (for ellipsis) makes that radius clip the glyph
|
|
105
|
+
corners into a rounded nibble; zero it so the text renders whole. */
|
|
106
|
+
.b-title { font-weight:600; font-size:14px; line-height:1.2; color:var(--brass-fg);
|
|
107
|
+
flex:0 1 auto; min-width:3em; overflow:hidden; text-overflow:ellipsis; white-space:nowrap;
|
|
108
|
+
background:none; border:none; border-radius:0; padding:1px 3px; cursor:pointer; font-family:inherit; }
|
|
109
|
+
.b-title:hover { text-decoration:underline; text-underline-offset:2px; }
|
|
110
|
+
.b-branch { flex:0 0 auto; max-width:12em; font-size:11px; font-weight:600;
|
|
111
|
+
line-height:1.4; color:var(--brass-accent); background:var(--brass-surface-2);
|
|
112
|
+
border:1px solid var(--brass-accent); border-radius:6px; padding:0 6px;
|
|
113
|
+
margin:0; cursor:pointer; font-family:inherit; overflow:hidden;
|
|
114
|
+
text-overflow:ellipsis; white-space:nowrap; }
|
|
115
|
+
.b-branch:hover { background:var(--brass-accent); color:var(--brass-accent-fg); }
|
|
116
|
+
.b-loc { font-size:11px; line-height:1.2; color:var(--brass-fg-muted);
|
|
117
|
+
background:none; border:none; border-radius:0; padding:1px 3px; min-height:0; cursor:pointer; white-space:nowrap;
|
|
118
|
+
font-family:inherit; }
|
|
119
|
+
.b-loc:hover { color:var(--brass-fg); }
|
|
120
|
+
.b-loc.filed::before { content:'📁 '; }
|
|
121
|
+
.b-loc.loose { text-decoration:underline dashed; text-underline-offset:2px; }
|
|
122
|
+
.b-loc.loose::before { content:'+ '; }
|
|
123
|
+
.b-undo-redo { display:inline-flex; align-items:center; gap:6px; }
|
|
124
|
+
.b-tool { background:var(--brass-surface-2); color:var(--brass-fg);
|
|
125
|
+
border:1px solid var(--brass-border); border-radius:8px; padding:8px 12px; cursor:pointer; font:inherit; }
|
|
126
|
+
.b-tool:disabled { opacity:.5; cursor:default; }
|
|
127
|
+
.b-roster { display:flex; gap:4px; }
|
|
128
|
+
.b-pip { flex:0 0 auto; width:24px; height:24px; border-radius:50%; display:inline-flex;
|
|
129
|
+
align-items:center; justify-content:center; color:#fff; font-size:11px; font-weight:600;
|
|
130
|
+
overflow:hidden; }
|
|
131
|
+
/* A collaborator's avatar fills the pip; object-fit:cover keeps it
|
|
132
|
+
undistorted. Absent or broken, the pip falls back to its coloured initial. */
|
|
133
|
+
.b-pip-img { width:100%; height:100%; object-fit:cover; display:block; }
|
|
134
|
+
/* flex:0 0 auto so a tight header row never shrinks the avatar's width below
|
|
135
|
+
its fixed height; pairing that with the host-button reset above keeps it a
|
|
136
|
+
true circle (equal width + height) regardless of host CSS or available
|
|
137
|
+
width. */
|
|
138
|
+
.b-avatar { flex:0 0 auto; cursor:pointer; width:32px; height:32px; border-radius:50%; border:none;
|
|
139
|
+
display:inline-flex; align-items:center; justify-content:center; background:var(--brass-accent);
|
|
140
|
+
color:var(--brass-accent-fg); font-size:13px; font-weight:600; padding:0; font-family:inherit;
|
|
141
|
+
overflow:hidden; }
|
|
142
|
+
/* The uploaded avatar image fills the circle; object-fit:cover keeps it
|
|
143
|
+
undistorted. It sits over the monogram, which stays in the DOM as the
|
|
144
|
+
fallback shown when no image is set or the image fails to load. */
|
|
145
|
+
.b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
|
|
146
|
+
.b-avatar-img[hidden], .b-avatar-initial[hidden] { display:none; }
|
|
147
|
+
.b-email { color:var(--brass-fg-muted); font-size:12px; padding:2px 4px; word-break:break-all; }
|
|
148
|
+
/* Drive sync: a dim link icon at rest, accent (or danger) with a dot when
|
|
149
|
+
there is something to do. Clicking opens a top-layer Popover (never clipped
|
|
150
|
+
by the header overflow, light-dismissed on outside tap). */
|
|
151
|
+
.b-drive { display:inline-flex; }
|
|
152
|
+
.b-drive[hidden] { display:none; }
|
|
153
|
+
.b-drive-icon { position:relative; cursor:pointer; width:30px; height:30px;
|
|
154
|
+
display:inline-flex; align-items:center; justify-content:center; border-radius:8px;
|
|
155
|
+
background:transparent; border:none; padding:0; color:var(--brass-fg-muted); }
|
|
156
|
+
.b-drive-icon:hover { background:var(--brass-surface-2); color:var(--brass-fg); }
|
|
157
|
+
.b-drive.has-changes .b-drive-icon { color:var(--brass-accent); }
|
|
158
|
+
.b-drive.is-broken .b-drive-icon { color:var(--brass-danger); }
|
|
159
|
+
.b-drive-dot { display:none; position:absolute; top:5px; right:5px; width:7px; height:7px;
|
|
160
|
+
border-radius:50%; background:var(--brass-accent); border:1.5px solid var(--brass-surface); }
|
|
161
|
+
.b-drive.has-changes .b-drive-dot, .b-drive.is-broken .b-drive-dot { display:block; }
|
|
162
|
+
.b-drive.is-broken .b-drive-dot { background:var(--brass-danger); }
|
|
163
|
+
/* Top-layer popover, fixed JS coords clamped to the viewport (inset:auto
|
|
164
|
+
undoes the UA centering). Default display:none keeps it hidden on the
|
|
165
|
+
fallback path; the two show rules stay SEPARATE so an engine without
|
|
166
|
+
:popover-open still honors .b-drive-open. */
|
|
167
|
+
.b-drive-pop { position:fixed; margin:0; inset:auto; padding:6px;
|
|
168
|
+
background:var(--brass-surface); border:1px solid var(--brass-border); border-radius:8px;
|
|
169
|
+
display:none; flex-direction:column; gap:2px; z-index:2147483646;
|
|
170
|
+
min-width:220px; max-width:min(320px, calc(100vw - 16px));
|
|
171
|
+
box-shadow:0 8px 24px rgba(0,0,0,.35); color:var(--brass-fg); }
|
|
172
|
+
.b-drive-pop:popover-open { display:flex; }
|
|
173
|
+
.b-drive-pop.b-drive-open { display:flex; }
|
|
174
|
+
.b-drive-pop button { width:100%; text-align:left; background:transparent; border:none;
|
|
175
|
+
color:var(--brass-fg); padding:8px 10px; border-radius:6px; cursor:pointer;
|
|
176
|
+
font:inherit; font-size:13px; }
|
|
177
|
+
.b-drive-pop button:hover { background:var(--brass-surface-2); }
|
|
178
|
+
.b-drive-head { display:flex; align-items:baseline; gap:8px; padding:6px 10px 2px; }
|
|
179
|
+
.b-drive-source { font-size:12px; color:var(--brass-accent); text-decoration:none; white-space:nowrap; }
|
|
180
|
+
.b-drive-source:hover { text-decoration:underline; }
|
|
181
|
+
.b-drive-detail { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-fg-muted); }
|
|
182
|
+
/* Historical-version preview bar: a full-width strip the toolbar shows below
|
|
183
|
+
the bar whenever the active doc is previewing an earlier version. The strip
|
|
184
|
+
itself is the platform-served version-bar iframe (the SDK owns only this
|
|
185
|
+
container + the load placeholder); it is collapsed (display:none) unless
|
|
186
|
+
previewing. The placeholder reserves the strip's height during the iframe
|
|
187
|
+
load so there is no white flash or layout jump. Neutral surface tones
|
|
188
|
+
(theme-aware via the toolbar tokens) so the load moment reads as chrome,
|
|
189
|
+
not an alert. */
|
|
190
|
+
.b-version { width:100%; display:none; }
|
|
191
|
+
.b-version.b-version-on { display:block; }
|
|
192
|
+
.b-version-loading { display:flex; align-items:center; width:100%;
|
|
193
|
+
min-height:44px; padding:0 14px; font-size:13px;
|
|
194
|
+
background:var(--brass-surface-2); color:var(--brass-fg-muted);
|
|
195
|
+
border-bottom:1px solid var(--brass-border); }
|
|
196
|
+
.b-version iframe { display:block; }
|
|
197
|
+
/* Keyboard focus. The bar's controls draw the ring INSIDE their own border box
|
|
198
|
+
(negative outline-offset), because the left cluster and the title row clip
|
|
199
|
+
their overflow so a long name ellipsizes: a ring outside the box falls in
|
|
200
|
+
that clipped strip, leaving the burger without its top and bottom edges, the
|
|
201
|
+
location button without its right edge, and the title, which fills the row,
|
|
202
|
+
without any. The chrome-stripped text buttons carry the padding the inset
|
|
203
|
+
ring needs to clear their glyphs. */
|
|
204
|
+
.b-burger:focus-visible, .b-title:focus-visible, .b-branch:focus-visible,
|
|
205
|
+
.b-loc:focus-visible, .b-tool:focus-visible, .b-avatar:focus-visible,
|
|
206
|
+
.b-drive-icon:focus-visible {
|
|
207
|
+
outline:2px solid var(--brass-accent); outline-offset:-2px; }
|
|
208
|
+
/* The avatar is filled with the accent and the branch chip is bordered with it
|
|
209
|
+
and fills with it on hover, so an accent ring reads there as part of the
|
|
210
|
+
control. Both take the foreground tone, which holds against the accent fill
|
|
211
|
+
and against their own surface. */
|
|
212
|
+
.b-avatar:focus-visible, .b-branch:focus-visible { outline-color:var(--brass-fg); }
|
|
213
|
+
/* A top-layer popover is clipped by nothing, so its rows keep the ring outside
|
|
214
|
+
the box, clear of the label. */
|
|
215
|
+
.b-pop button:focus-visible, .b-pop a:focus-visible,
|
|
216
|
+
.b-drive-pop button:focus-visible, .b-drive-source:focus-visible {
|
|
217
|
+
outline:2px solid var(--brass-accent); outline-offset:2px; }
|
|
218
|
+
`;
|
|
219
|
+
function injectStyles(doc) {
|
|
220
|
+
if (doc.getElementById(STYLE_ID))
|
|
221
|
+
return;
|
|
222
|
+
const style = doc.createElement('style');
|
|
223
|
+
style.id = STYLE_ID;
|
|
224
|
+
style.textContent = CSS;
|
|
225
|
+
doc.head.appendChild(style);
|
|
226
|
+
}
|
|
227
|
+
function el(tag, className) {
|
|
228
|
+
const node = document.createElement(tag);
|
|
229
|
+
if (className)
|
|
230
|
+
node.className = className;
|
|
231
|
+
return node;
|
|
232
|
+
}
|
|
233
|
+
function initialOf(user) {
|
|
234
|
+
const label = user.display_name ?? user.email ?? user.sub;
|
|
235
|
+
return label.trim().charAt(0).toUpperCase() || '?';
|
|
236
|
+
}
|
|
237
|
+
function renderCommandButton(cmd, asTool) {
|
|
238
|
+
const b = el('button', asTool ? 'b-tool' : undefined);
|
|
239
|
+
b.type = 'button';
|
|
240
|
+
b.textContent = cmd.label;
|
|
241
|
+
b.disabled = !cmd.enabled || cmd.running;
|
|
242
|
+
return b;
|
|
243
|
+
}
|
|
244
|
+
// Place a top-layer (position:fixed) popover under its anchor, clamped to the
|
|
245
|
+
// viewport so a wide one near an edge never runs off-screen. `align` picks the
|
|
246
|
+
// edge it lines up with the anchor: `start` (left) or `end` (right).
|
|
247
|
+
function positionPopover(anchor, pop, align) {
|
|
248
|
+
const r = anchor.getBoundingClientRect();
|
|
249
|
+
const margin = 8;
|
|
250
|
+
const pw = pop.offsetWidth || 200;
|
|
251
|
+
const ph = pop.offsetHeight || 0;
|
|
252
|
+
let left = align === 'end' ? r.right - pw : r.left;
|
|
253
|
+
left = Math.min(left, window.innerWidth - pw - margin);
|
|
254
|
+
left = Math.max(margin, left);
|
|
255
|
+
let top = r.bottom + 4;
|
|
256
|
+
// Flip above the anchor when there is no room below but room above.
|
|
257
|
+
if (top + ph > window.innerHeight - margin && r.top - ph - 4 >= margin) {
|
|
258
|
+
top = r.top - ph - 4;
|
|
259
|
+
}
|
|
260
|
+
top = Math.max(margin, Math.min(top, window.innerHeight - ph - margin));
|
|
261
|
+
pop.style.left = `${left}px`;
|
|
262
|
+
pop.style.top = `${top}px`;
|
|
263
|
+
}
|
|
264
|
+
// Unique ids per mounted menu popover (one element per id per document).
|
|
265
|
+
let menuPopSeq = 0;
|
|
266
|
+
function buildPopoverMenu(spec) {
|
|
267
|
+
const root = el('span', spec.rootClass);
|
|
268
|
+
const trigger = el('button', spec.triggerClass);
|
|
269
|
+
trigger.type = 'button';
|
|
270
|
+
const menuSemantics = spec.menuSemantics ?? true;
|
|
271
|
+
if (menuSemantics)
|
|
272
|
+
trigger.setAttribute('aria-haspopup', 'menu');
|
|
273
|
+
trigger.setAttribute('aria-label', spec.label);
|
|
274
|
+
const pop = el(spec.popTag ?? 'menu', spec.popClass ?? 'brass-ui b-pop');
|
|
275
|
+
pop.id = `${spec.idPrefix ?? 'brass-menu-pop'}-${++menuPopSeq}`;
|
|
276
|
+
pop.setAttribute('popover', 'auto');
|
|
277
|
+
if (menuSemantics)
|
|
278
|
+
pop.setAttribute('role', 'menu');
|
|
279
|
+
root.append(trigger, pop);
|
|
280
|
+
const popApi = pop;
|
|
281
|
+
const fallbackClass = spec.fallbackOpenClass;
|
|
282
|
+
const nativePopover = fallbackClass === undefined || typeof popApi.showPopover === 'function';
|
|
283
|
+
const reposition = () => positionPopover(trigger, pop, spec.align);
|
|
284
|
+
const inFallbackClass = () => fallbackClass !== undefined && pop.classList.contains(fallbackClass);
|
|
285
|
+
const isOpen = () => {
|
|
286
|
+
if (!nativePopover)
|
|
287
|
+
return inFallbackClass();
|
|
288
|
+
try {
|
|
289
|
+
return pop.matches(':popover-open');
|
|
290
|
+
}
|
|
291
|
+
catch {
|
|
292
|
+
return inFallbackClass();
|
|
293
|
+
}
|
|
294
|
+
};
|
|
295
|
+
function onOutside(e) {
|
|
296
|
+
if (!root.contains(e.target))
|
|
297
|
+
close();
|
|
298
|
+
}
|
|
299
|
+
function open() {
|
|
300
|
+
if (isOpen())
|
|
301
|
+
return;
|
|
302
|
+
if (nativePopover)
|
|
303
|
+
popApi.showPopover?.();
|
|
304
|
+
else if (fallbackClass !== undefined) {
|
|
305
|
+
pop.classList.add(fallbackClass);
|
|
306
|
+
document.addEventListener('click', onOutside, true);
|
|
307
|
+
}
|
|
308
|
+
reposition();
|
|
309
|
+
window.addEventListener('resize', reposition);
|
|
310
|
+
window.addEventListener('scroll', reposition, true);
|
|
311
|
+
}
|
|
312
|
+
// Guarded: hidePopover() throws InvalidStateError on a popover that is
|
|
313
|
+
// not showing (incl. a disconnected element on destroy).
|
|
314
|
+
function close() {
|
|
315
|
+
if (!isOpen())
|
|
316
|
+
return;
|
|
317
|
+
if (nativePopover)
|
|
318
|
+
popApi.hidePopover?.();
|
|
319
|
+
else if (fallbackClass !== undefined) {
|
|
320
|
+
pop.classList.remove(fallbackClass);
|
|
321
|
+
document.removeEventListener('click', onOutside, true);
|
|
322
|
+
}
|
|
323
|
+
window.removeEventListener('resize', reposition);
|
|
324
|
+
window.removeEventListener('scroll', reposition, true);
|
|
325
|
+
}
|
|
326
|
+
// The trigger is wired as the popover's invoker. The UA exempts an
|
|
327
|
+
// invoker from light-dismiss, so a click on the trigger of an OPEN
|
|
328
|
+
// popover closes it; a manual onclick toggle instead races light-dismiss
|
|
329
|
+
// (pointerdown hides the popover, then click sees it closed and reopens
|
|
330
|
+
// it), leaving a menu that can't be closed from its own trigger. The
|
|
331
|
+
// manual toggle exists only on the no-native-Popover fallback path.
|
|
332
|
+
if (nativePopover)
|
|
333
|
+
trigger.setAttribute('popovertarget', pop.id);
|
|
334
|
+
else
|
|
335
|
+
trigger.onclick = () => (isOpen() ? close() : open());
|
|
336
|
+
// The UA drives native open/close (invoker click, outside tap, Escape)
|
|
337
|
+
// without routing through open()/close(), so position on every open and
|
|
338
|
+
// detach the reposition listeners on close. (No-op on the fallback path,
|
|
339
|
+
// which has no native toggle event.)
|
|
340
|
+
const onToggle = (event) => {
|
|
341
|
+
const state = event.newState;
|
|
342
|
+
if (state === 'open') {
|
|
343
|
+
reposition();
|
|
344
|
+
window.addEventListener('resize', reposition);
|
|
345
|
+
window.addEventListener('scroll', reposition, true);
|
|
346
|
+
}
|
|
347
|
+
else if (state === 'closed') {
|
|
348
|
+
window.removeEventListener('resize', reposition);
|
|
349
|
+
window.removeEventListener('scroll', reposition, true);
|
|
350
|
+
}
|
|
351
|
+
};
|
|
352
|
+
pop.addEventListener('toggle', onToggle);
|
|
353
|
+
return {
|
|
354
|
+
root,
|
|
355
|
+
trigger,
|
|
356
|
+
pop,
|
|
357
|
+
close,
|
|
358
|
+
destroy() {
|
|
359
|
+
close();
|
|
360
|
+
pop.removeEventListener('toggle', onToggle);
|
|
361
|
+
document.removeEventListener('click', onOutside, true);
|
|
362
|
+
window.removeEventListener('resize', reposition);
|
|
363
|
+
window.removeEventListener('scroll', reposition, true);
|
|
364
|
+
},
|
|
365
|
+
};
|
|
366
|
+
}
|
|
367
|
+
// --- Pieces ----------------------------------------------------------------
|
|
368
|
+
function buildFileMenu(deps) {
|
|
369
|
+
injectStyles(document);
|
|
370
|
+
const menu = buildPopoverMenu({
|
|
371
|
+
rootClass: 'brass-ui b-menu',
|
|
372
|
+
triggerClass: 'b-burger',
|
|
373
|
+
label: 'Menu',
|
|
374
|
+
align: 'start',
|
|
375
|
+
});
|
|
376
|
+
menu.trigger.textContent = '☰';
|
|
377
|
+
const render = () => {
|
|
378
|
+
const rows = [];
|
|
379
|
+
let group = null;
|
|
380
|
+
for (const c of deps.session.commands) {
|
|
381
|
+
if (c.surface !== 'menu')
|
|
382
|
+
continue;
|
|
383
|
+
// A rule wherever the family changes: the entries that put a
|
|
384
|
+
// different document on screen, then the ones about this document.
|
|
385
|
+
if (group !== null && c.group !== group) {
|
|
386
|
+
const sep = el('hr', 'b-sep');
|
|
387
|
+
sep.setAttribute('role', 'separator');
|
|
388
|
+
rows.push(sep);
|
|
389
|
+
}
|
|
390
|
+
group = c.group;
|
|
391
|
+
const b = renderCommandButton(c, false);
|
|
392
|
+
b.onclick = () => {
|
|
393
|
+
menu.close();
|
|
394
|
+
void c.run();
|
|
395
|
+
};
|
|
396
|
+
rows.push(b);
|
|
397
|
+
}
|
|
398
|
+
menu.pop.replaceChildren(...rows);
|
|
399
|
+
};
|
|
400
|
+
render();
|
|
401
|
+
const unsub = deps.session.subscribe(render);
|
|
402
|
+
return {
|
|
403
|
+
root: menu.root,
|
|
404
|
+
destroy() {
|
|
405
|
+
unsub();
|
|
406
|
+
menu.destroy();
|
|
407
|
+
},
|
|
408
|
+
};
|
|
409
|
+
}
|
|
410
|
+
function buildDocumentTitle(deps) {
|
|
411
|
+
injectStyles(document);
|
|
412
|
+
const root = el('div', 'brass-ui b-identity');
|
|
413
|
+
const titleRow = el('div', 'b-titlerow');
|
|
414
|
+
const title = el('button', 'b-title');
|
|
415
|
+
title.type = 'button';
|
|
416
|
+
// A chip qualifying the title with the non-default branch. Hidden on the
|
|
417
|
+
// default branch, and dropped (not truncated) when the title row can't fit
|
|
418
|
+
// it, so the document name keeps priority. Opens the branches dialog.
|
|
419
|
+
const branch = el('button', 'b-branch');
|
|
420
|
+
branch.type = 'button';
|
|
421
|
+
branch.hidden = true;
|
|
422
|
+
branch.onclick = () => void deps.session.run('branches');
|
|
423
|
+
titleRow.append(title, branch);
|
|
424
|
+
const loc = el('button', 'b-loc');
|
|
425
|
+
loc.type = 'button';
|
|
426
|
+
loc.hidden = true;
|
|
427
|
+
root.append(titleRow, loc);
|
|
428
|
+
let lastDocId = null;
|
|
429
|
+
// The non-default branch label (null on the default), read by `fitBranch`
|
|
430
|
+
// so a resize re-evaluates the drop without a full re-render.
|
|
431
|
+
let branchLabel = null;
|
|
432
|
+
// Show the branch chip, then drop it if the title row can't fit it. Reading
|
|
433
|
+
// `scrollWidth` forces a sync layout, so the overflow test reflects the
|
|
434
|
+
// just-applied text.
|
|
435
|
+
function fitBranch() {
|
|
436
|
+
if (!branchLabel) {
|
|
437
|
+
branch.hidden = true;
|
|
438
|
+
return;
|
|
439
|
+
}
|
|
440
|
+
branch.hidden = false;
|
|
441
|
+
if (titleRow.scrollWidth > titleRow.clientWidth)
|
|
442
|
+
branch.hidden = true;
|
|
443
|
+
}
|
|
444
|
+
// The active doc's branch name when it is NOT the default, else null. Reads
|
|
445
|
+
// the doc's internal branch state, so it settles once the branch list loads
|
|
446
|
+
// (a `'branches'` subscribe tick re-renders the chip).
|
|
447
|
+
function activeBranchLabel() {
|
|
448
|
+
return deps.session.document?._currentBranchName() ?? null;
|
|
449
|
+
}
|
|
450
|
+
async function refreshLocation() {
|
|
451
|
+
const doc = deps.session.document;
|
|
452
|
+
const docId = doc?.id ?? null;
|
|
453
|
+
if (!docId) {
|
|
454
|
+
loc.hidden = true;
|
|
455
|
+
return;
|
|
456
|
+
}
|
|
457
|
+
try {
|
|
458
|
+
const record = await deps.getDocument(docId);
|
|
459
|
+
if (deps.session.document?.id !== docId)
|
|
460
|
+
return; // superseded
|
|
461
|
+
// The server hands back an opaque location label (rendered verbatim);
|
|
462
|
+
// the toolbar never composes it from project / org ids, so the
|
|
463
|
+
// hierarchy above the document can evolve without a client change. A
|
|
464
|
+
// null label is the loose (unfiled) state.
|
|
465
|
+
if (record.location_label !== null) {
|
|
466
|
+
loc.textContent = record.location_label;
|
|
467
|
+
loc.className = 'b-loc filed';
|
|
468
|
+
loc.title = 'Move or rename this document';
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
loc.textContent = 'Add to project';
|
|
472
|
+
loc.className = 'b-loc loose';
|
|
473
|
+
loc.title = 'File this document in a project';
|
|
474
|
+
}
|
|
475
|
+
loc.hidden = false;
|
|
476
|
+
}
|
|
477
|
+
catch {
|
|
478
|
+
if (deps.session.document?.id === docId)
|
|
479
|
+
loc.hidden = true;
|
|
480
|
+
}
|
|
481
|
+
}
|
|
482
|
+
function runManage() {
|
|
483
|
+
void (async () => {
|
|
484
|
+
await deps.session.run('manage');
|
|
485
|
+
void refreshLocation();
|
|
486
|
+
})();
|
|
487
|
+
}
|
|
488
|
+
title.onclick = runManage;
|
|
489
|
+
loc.onclick = runManage;
|
|
490
|
+
const render = () => {
|
|
491
|
+
const session = deps.session;
|
|
492
|
+
const doc = session.document;
|
|
493
|
+
// Three states, not two. A bound doc whose name has not resolved yet
|
|
494
|
+
// (a fresh create the server just numbered, a create/open still settling)
|
|
495
|
+
// and an open in flight (`status` 'loading', `document` still null) both
|
|
496
|
+
// leave the name UNKNOWN: render nothing rather than assert "No document",
|
|
497
|
+
// the same way the session reports 'unknown' auth as a neutral state
|
|
498
|
+
// instead of flashing 'signed-out'. Only the genuine empty state (no doc,
|
|
499
|
+
// not loading) reads "No document".
|
|
500
|
+
const name = doc
|
|
501
|
+
? doc.name
|
|
502
|
+
: session.status === 'loading'
|
|
503
|
+
? null
|
|
504
|
+
: 'No document';
|
|
505
|
+
if (name === null) {
|
|
506
|
+
title.hidden = true;
|
|
507
|
+
}
|
|
508
|
+
else {
|
|
509
|
+
title.hidden = false;
|
|
510
|
+
title.textContent = name;
|
|
511
|
+
title.disabled = !doc;
|
|
512
|
+
}
|
|
513
|
+
if (!doc)
|
|
514
|
+
loc.hidden = true;
|
|
515
|
+
branchLabel = activeBranchLabel();
|
|
516
|
+
if (branchLabel) {
|
|
517
|
+
branch.textContent = branchLabel;
|
|
518
|
+
branch.title = `On branch "${branchLabel}"`;
|
|
519
|
+
}
|
|
520
|
+
fitBranch();
|
|
521
|
+
// Re-resolve location only when the active document changes.
|
|
522
|
+
const docId = doc?.id ?? null;
|
|
523
|
+
if (docId !== lastDocId) {
|
|
524
|
+
lastDocId = docId;
|
|
525
|
+
void refreshLocation();
|
|
526
|
+
}
|
|
527
|
+
};
|
|
528
|
+
render();
|
|
529
|
+
const unsub = deps.session.subscribe(render);
|
|
530
|
+
// Re-evaluate the branch-chip drop on width changes (resize, tools reflow).
|
|
531
|
+
// Guarded for hosts without ResizeObserver.
|
|
532
|
+
const ro = typeof ResizeObserver !== 'undefined'
|
|
533
|
+
? new ResizeObserver(() => fitBranch())
|
|
534
|
+
: null;
|
|
535
|
+
ro?.observe(titleRow);
|
|
536
|
+
return {
|
|
537
|
+
root,
|
|
538
|
+
destroy() {
|
|
539
|
+
unsub();
|
|
540
|
+
ro?.disconnect();
|
|
541
|
+
},
|
|
542
|
+
};
|
|
543
|
+
}
|
|
544
|
+
function buildUndoRedo(deps) {
|
|
545
|
+
injectStyles(document);
|
|
546
|
+
const root = el('div', 'brass-ui b-undo-redo');
|
|
547
|
+
const render = () => {
|
|
548
|
+
root.replaceChildren(...deps.session.commands
|
|
549
|
+
.filter((c) => c.surface === 'toolbar')
|
|
550
|
+
.map((c) => {
|
|
551
|
+
const b = renderCommandButton(c, true);
|
|
552
|
+
b.onclick = () => void c.run();
|
|
553
|
+
return b;
|
|
554
|
+
}));
|
|
555
|
+
};
|
|
556
|
+
render();
|
|
557
|
+
const unsub = deps.session.subscribe(render);
|
|
558
|
+
return {
|
|
559
|
+
root,
|
|
560
|
+
destroy() {
|
|
561
|
+
unsub();
|
|
562
|
+
},
|
|
563
|
+
};
|
|
564
|
+
}
|
|
565
|
+
function buildPresenceRoster(deps) {
|
|
566
|
+
injectStyles(document);
|
|
567
|
+
const root = el('div', 'brass-ui b-roster');
|
|
568
|
+
let lastDocId = null;
|
|
569
|
+
let presenceOff = null;
|
|
570
|
+
const renderRoster = (participants) => {
|
|
571
|
+
root.replaceChildren(...participants.map((p) => {
|
|
572
|
+
const pip = el('span', 'b-pip');
|
|
573
|
+
pip.style.background = p.color;
|
|
574
|
+
const label = p.displayName ?? p.sub ?? '?';
|
|
575
|
+
pip.title = label;
|
|
576
|
+
const initial = label.slice(0, 1).toUpperCase() || '?';
|
|
577
|
+
const url = p.avatarUrl?.trim() ?? '';
|
|
578
|
+
if (url) {
|
|
579
|
+
// The collaborator's avatar fills the pip; the coloured initial
|
|
580
|
+
// stays the fallback, restored if the image fails to load (a
|
|
581
|
+
// broken / expired URL shows the initial, not a broken-image
|
|
582
|
+
// glyph). The pip keeps its per-sub colour as the image backdrop.
|
|
583
|
+
const img = el('img', 'b-pip-img');
|
|
584
|
+
img.alt = '';
|
|
585
|
+
img.addEventListener('error', () => {
|
|
586
|
+
pip.textContent = initial;
|
|
587
|
+
});
|
|
588
|
+
img.src = url;
|
|
589
|
+
pip.appendChild(img);
|
|
590
|
+
}
|
|
591
|
+
else {
|
|
592
|
+
pip.textContent = initial;
|
|
593
|
+
}
|
|
594
|
+
return pip;
|
|
595
|
+
}));
|
|
596
|
+
};
|
|
597
|
+
function bindPresence() {
|
|
598
|
+
presenceOff?.();
|
|
599
|
+
presenceOff = null;
|
|
600
|
+
root.replaceChildren();
|
|
601
|
+
const presence = deps.session.presence;
|
|
602
|
+
if (!presence)
|
|
603
|
+
return;
|
|
604
|
+
renderRoster(presence.roster ?? []);
|
|
605
|
+
const off = presence.on('change', renderRoster);
|
|
606
|
+
presenceOff =
|
|
607
|
+
typeof off === 'function' ? off : () => presence.off('change', renderRoster);
|
|
608
|
+
}
|
|
609
|
+
const render = () => {
|
|
610
|
+
const docId = deps.session.document?.id ?? null;
|
|
611
|
+
if (docId !== lastDocId) {
|
|
612
|
+
lastDocId = docId;
|
|
613
|
+
bindPresence();
|
|
614
|
+
}
|
|
615
|
+
};
|
|
616
|
+
render();
|
|
617
|
+
const unsub = deps.session.subscribe(render);
|
|
618
|
+
return {
|
|
619
|
+
root,
|
|
620
|
+
destroy() {
|
|
621
|
+
unsub();
|
|
622
|
+
presenceOff?.();
|
|
623
|
+
},
|
|
624
|
+
};
|
|
625
|
+
}
|
|
626
|
+
// A monochrome chain-link glyph (inherits `color` via `currentColor`). Built
|
|
627
|
+
// in the SVG namespace because `createElement('svg')` yields an HTML element
|
|
628
|
+
// that won't render.
|
|
629
|
+
function chainLinkIcon() {
|
|
630
|
+
const ns = 'http://www.w3.org/2000/svg';
|
|
631
|
+
const svg = document.createElementNS(ns, 'svg');
|
|
632
|
+
svg.setAttribute('width', '16');
|
|
633
|
+
svg.setAttribute('height', '16');
|
|
634
|
+
svg.setAttribute('viewBox', '0 0 24 24');
|
|
635
|
+
svg.setAttribute('fill', 'none');
|
|
636
|
+
svg.setAttribute('stroke', 'currentColor');
|
|
637
|
+
svg.setAttribute('stroke-width', '2');
|
|
638
|
+
svg.setAttribute('stroke-linecap', 'round');
|
|
639
|
+
svg.setAttribute('stroke-linejoin', 'round');
|
|
640
|
+
svg.setAttribute('aria-hidden', 'true');
|
|
641
|
+
for (const d of [
|
|
642
|
+
'M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71',
|
|
643
|
+
'M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.72-1.71',
|
|
644
|
+
]) {
|
|
645
|
+
const path = document.createElementNS(ns, 'path');
|
|
646
|
+
path.setAttribute('d', d);
|
|
647
|
+
svg.append(path);
|
|
648
|
+
}
|
|
649
|
+
return svg;
|
|
650
|
+
}
|
|
651
|
+
// How long the popover holds its "Publishing..." affordance after a push
|
|
652
|
+
// before giving up on the synced confirmation. Long enough to cover a normal
|
|
653
|
+
// drive webhook round-trip.
|
|
654
|
+
const PUBLISH_HOLD_MS = 30000;
|
|
655
|
+
// The drive-sync indicator: a link icon (dim at rest, accent / danger with a
|
|
656
|
+
// dot when there is something to do) whose popover describes the state
|
|
657
|
+
// (server-authored copy, verbatim) and offers publish / resolve. The receiver
|
|
658
|
+
// half of `doc.pushToDrive` / `doc.resolveDrive`. Hidden only when the doc is
|
|
659
|
+
// not drive-linked, so a non-drive app mounts it harmlessly. A plain panel,
|
|
660
|
+
// not a menu, with the `.b-drive-open` class fallback for engines without
|
|
661
|
+
// the native Popover API.
|
|
662
|
+
function buildDriveSync(deps) {
|
|
663
|
+
injectStyles(document);
|
|
664
|
+
const menu = buildPopoverMenu({
|
|
665
|
+
rootClass: 'brass-ui b-drive',
|
|
666
|
+
triggerClass: 'b-drive-icon',
|
|
667
|
+
label: 'Drive sync',
|
|
668
|
+
align: 'start',
|
|
669
|
+
popTag: 'div',
|
|
670
|
+
popClass: 'brass-ui b-drive-pop',
|
|
671
|
+
idPrefix: 'brass-drive-pop',
|
|
672
|
+
menuSemantics: false,
|
|
673
|
+
fallbackOpenClass: 'b-drive-open',
|
|
674
|
+
});
|
|
675
|
+
const { root, pop } = menu;
|
|
676
|
+
root.hidden = true;
|
|
677
|
+
const button = menu.trigger;
|
|
678
|
+
button.append(chainLinkIcon());
|
|
679
|
+
const dot = el('span', 'b-drive-dot');
|
|
680
|
+
dot.setAttribute('aria-hidden', 'true');
|
|
681
|
+
button.append(dot);
|
|
682
|
+
let lastDocId = null;
|
|
683
|
+
let statusOff = null;
|
|
684
|
+
// A publish runs on a server worker and reaches `synced` only after the
|
|
685
|
+
// worker (and, on a file-roundtrip, the drive's importer) confirms. In that
|
|
686
|
+
// gap the drive state still reads `ahead`, which would flash "unpublished
|
|
687
|
+
// changes" / re-offer Publish. The server computes "a push is in flight"
|
|
688
|
+
// once and exposes it as `driveStatus.publishing` (with the progress copy on
|
|
689
|
+
// `detail`), so hold a steady "Publishing..." while it is true, regardless
|
|
690
|
+
// of which surface started the push. `publishing` is only a short local hold
|
|
691
|
+
// covering the gap between a click here and the first `publishing` frame
|
|
692
|
+
// arriving; the two combine as `isPublishing` in `render`.
|
|
693
|
+
let publishing = false;
|
|
694
|
+
let publishError = false;
|
|
695
|
+
let publishTimer = null;
|
|
696
|
+
let currentStatus = null;
|
|
697
|
+
const clearPublishing = () => {
|
|
698
|
+
publishing = false;
|
|
699
|
+
if (publishTimer !== null) {
|
|
700
|
+
clearTimeout(publishTimer);
|
|
701
|
+
publishTimer = null;
|
|
702
|
+
}
|
|
703
|
+
};
|
|
704
|
+
const render = (status) => {
|
|
705
|
+
currentStatus = status;
|
|
706
|
+
// Hidden only when the doc is not drive-linked.
|
|
707
|
+
if (status === null) {
|
|
708
|
+
root.hidden = true;
|
|
709
|
+
menu.close();
|
|
710
|
+
clearPublishing();
|
|
711
|
+
root.classList.remove('has-changes', 'is-broken');
|
|
712
|
+
pop.replaceChildren();
|
|
713
|
+
return;
|
|
714
|
+
}
|
|
715
|
+
// The publish settles once the doc actually reaches synced; drop the
|
|
716
|
+
// local hold (the server frame already has `publishing: false`).
|
|
717
|
+
if (status.state === 'synced') {
|
|
718
|
+
clearPublishing();
|
|
719
|
+
publishError = false;
|
|
720
|
+
}
|
|
721
|
+
root.hidden = false;
|
|
722
|
+
// Either a local click hold or the server's own `publishing` flag means a
|
|
723
|
+
// push is in flight; both suppress the `ahead` affordance.
|
|
724
|
+
const isPublishing = publishing || status.publishing;
|
|
725
|
+
const driveName = status.driveName;
|
|
726
|
+
const broken = status.broken;
|
|
727
|
+
// The icon brightens (and the dot shows) only when there is something
|
|
728
|
+
// to act on; a synced / unhydrated doc stays dim.
|
|
729
|
+
const hasChanges = !broken &&
|
|
730
|
+
(status.state === 'ahead' ||
|
|
731
|
+
status.state === 'behind' ||
|
|
732
|
+
status.state === 'diverged');
|
|
733
|
+
root.classList.toggle('is-broken', broken);
|
|
734
|
+
root.classList.toggle('has-changes', hasChanges);
|
|
735
|
+
button.title =
|
|
736
|
+
status.detail ??
|
|
737
|
+
(status.state === 'synced'
|
|
738
|
+
? `In sync with ${driveName}`
|
|
739
|
+
: `Linked to ${driveName}`);
|
|
740
|
+
// Popover: a "view source" link (the drive name is the link label, so no
|
|
741
|
+
// separate header), a one-line state description (server-authored where
|
|
742
|
+
// there is one), then the commands. Rebuilt on every status change, so an
|
|
743
|
+
// open popover updates live.
|
|
744
|
+
const rows = [];
|
|
745
|
+
if (status.itemUrl) {
|
|
746
|
+
const head = el('div', 'b-drive-head');
|
|
747
|
+
const link = el('a', 'b-drive-source');
|
|
748
|
+
link.href = status.itemUrl;
|
|
749
|
+
link.target = '_blank';
|
|
750
|
+
link.rel = 'noopener noreferrer';
|
|
751
|
+
link.textContent = `View in ${driveName}`;
|
|
752
|
+
head.append(link);
|
|
753
|
+
rows.push(head);
|
|
754
|
+
}
|
|
755
|
+
// While the server reports the push in flight, `detail` carries its
|
|
756
|
+
// phase-by-phase copy (exporting, uploading, waiting on the source's
|
|
757
|
+
// importer); render it verbatim so the popover tracks the worker. The
|
|
758
|
+
// fixed line covers only the local click-hold gap before the first
|
|
759
|
+
// `publishing` frame arrives, when `detail` still holds resting copy.
|
|
760
|
+
const description = status.publishing
|
|
761
|
+
? (status.detail ?? 'Publishing changes...')
|
|
762
|
+
: publishing
|
|
763
|
+
? 'Publishing changes...'
|
|
764
|
+
: (status.detail ??
|
|
765
|
+
(status.state === 'synced'
|
|
766
|
+
? `In sync with ${driveName}.`
|
|
767
|
+
: (status.label ?? '')));
|
|
768
|
+
if (description) {
|
|
769
|
+
const p = el('p', 'b-drive-detail');
|
|
770
|
+
p.textContent = description;
|
|
771
|
+
rows.push(p);
|
|
772
|
+
}
|
|
773
|
+
if (isPublishing) {
|
|
774
|
+
// Held while the publish is in flight (a local click hold, or the doc's
|
|
775
|
+
// pending work-status), until it reaches synced (see `isPublishing`).
|
|
776
|
+
const pub = el('button');
|
|
777
|
+
pub.type = 'button';
|
|
778
|
+
pub.textContent = 'Publishing...';
|
|
779
|
+
pub.disabled = true;
|
|
780
|
+
rows.push(pub);
|
|
781
|
+
}
|
|
782
|
+
else if (status.canPush) {
|
|
783
|
+
// `canPush` already encodes two-way + not broken + local edits, so this
|
|
784
|
+
// never offers a no-op publish.
|
|
785
|
+
const pub = el('button');
|
|
786
|
+
pub.type = 'button';
|
|
787
|
+
pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
|
|
788
|
+
pub.onclick = () => {
|
|
789
|
+
const doc = deps.session.document;
|
|
790
|
+
if (!doc)
|
|
791
|
+
return;
|
|
792
|
+
publishError = false;
|
|
793
|
+
publishing = true;
|
|
794
|
+
// Safety net: if the publish neither errors nor reaches synced (the
|
|
795
|
+
// webhook never confirms), release the held state so the popover
|
|
796
|
+
// recovers instead of hanging.
|
|
797
|
+
publishTimer = setTimeout(() => {
|
|
798
|
+
clearPublishing();
|
|
799
|
+
render(currentStatus);
|
|
800
|
+
}, PUBLISH_HOLD_MS);
|
|
801
|
+
render(currentStatus);
|
|
802
|
+
void doc.pushToDrive().catch(() => {
|
|
803
|
+
clearPublishing();
|
|
804
|
+
publishError = true;
|
|
805
|
+
render(currentStatus);
|
|
806
|
+
});
|
|
807
|
+
};
|
|
808
|
+
rows.push(pub);
|
|
809
|
+
}
|
|
810
|
+
// Resolve a divergence via the hosted take-source / keep-both surface. Not
|
|
811
|
+
// on a broken link (the connection is gone, so re-import can't run).
|
|
812
|
+
if (!isPublishing &&
|
|
813
|
+
!broken &&
|
|
814
|
+
(status.state === 'behind' || status.state === 'diverged')) {
|
|
815
|
+
const res = el('button');
|
|
816
|
+
res.type = 'button';
|
|
817
|
+
res.textContent = 'Resolve sync...';
|
|
818
|
+
res.onclick = () => {
|
|
819
|
+
// The hosted resolution surface is a modal, so close this popover.
|
|
820
|
+
menu.close();
|
|
821
|
+
void deps.session.document?.resolveDrive().catch(() => { });
|
|
822
|
+
};
|
|
823
|
+
rows.push(res);
|
|
824
|
+
}
|
|
825
|
+
pop.replaceChildren(...rows);
|
|
826
|
+
};
|
|
827
|
+
function bind() {
|
|
828
|
+
statusOff?.();
|
|
829
|
+
statusOff = null;
|
|
830
|
+
const doc = deps.session.document;
|
|
831
|
+
if (!doc) {
|
|
832
|
+
render(null);
|
|
833
|
+
return;
|
|
834
|
+
}
|
|
835
|
+
render(doc.driveStatus);
|
|
836
|
+
// `driveStatus.publishing` carries the "push in flight" signal, so a push
|
|
837
|
+
// started from any surface (the dashboard card, the app) flips it and the
|
|
838
|
+
// indicator holds "Publishing..." without a local click.
|
|
839
|
+
statusOff = doc.onDriveStatus(render);
|
|
840
|
+
}
|
|
841
|
+
const onSession = () => {
|
|
842
|
+
const docId = deps.session.document?.id ?? null;
|
|
843
|
+
if (docId !== lastDocId) {
|
|
844
|
+
lastDocId = docId;
|
|
845
|
+
bind();
|
|
846
|
+
}
|
|
847
|
+
};
|
|
848
|
+
onSession();
|
|
849
|
+
const unsub = deps.session.subscribe(onSession);
|
|
850
|
+
return {
|
|
851
|
+
root,
|
|
852
|
+
destroy() {
|
|
853
|
+
unsub();
|
|
854
|
+
statusOff?.();
|
|
855
|
+
clearPublishing();
|
|
856
|
+
menu.destroy();
|
|
857
|
+
},
|
|
858
|
+
};
|
|
859
|
+
}
|
|
860
|
+
function buildAccountMenu(deps) {
|
|
861
|
+
injectStyles(document);
|
|
862
|
+
const menu = buildPopoverMenu({
|
|
863
|
+
rootClass: 'brass-ui b-menu b-account',
|
|
864
|
+
triggerClass: 'b-avatar',
|
|
865
|
+
label: 'Account',
|
|
866
|
+
align: 'end',
|
|
867
|
+
});
|
|
868
|
+
const emailEl = el('div', 'b-email');
|
|
869
|
+
// Opens the dashboard in a new tab, so the user keeps the app open in the
|
|
870
|
+
// current one. `/home` is the authenticated landing (resolves the active
|
|
871
|
+
// org); the bare origin is the sign-in page.
|
|
872
|
+
const dashboardLink = el('a');
|
|
873
|
+
dashboardLink.textContent = 'Open dashboard';
|
|
874
|
+
dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
|
|
875
|
+
dashboardLink.target = '_blank';
|
|
876
|
+
dashboardLink.rel = 'noopener noreferrer';
|
|
877
|
+
dashboardLink.onclick = () => menu.close();
|
|
878
|
+
const signOutBtn = el('button');
|
|
879
|
+
signOutBtn.textContent = 'Sign out';
|
|
880
|
+
signOutBtn.onclick = () => {
|
|
881
|
+
menu.close();
|
|
882
|
+
deps.signOut();
|
|
883
|
+
};
|
|
884
|
+
// Anonymous case: a guest opened the document through a public link with no
|
|
885
|
+
// Brass session. The signed-in actions (email / dashboard / sign out) are
|
|
886
|
+
// meaningless, so the menu offers a single "Sign in" that takes the top
|
|
887
|
+
// window through the app's normal sign-in and returns to this URL.
|
|
888
|
+
const signInBtn = el('button');
|
|
889
|
+
signInBtn.textContent = 'Sign in';
|
|
890
|
+
signInBtn.onclick = () => {
|
|
891
|
+
menu.close();
|
|
892
|
+
deps.signIn();
|
|
893
|
+
};
|
|
894
|
+
menu.pop.append(emailEl, dashboardLink, signOutBtn, signInBtn);
|
|
895
|
+
// The avatar trigger holds both the uploaded image and the monogram. The
|
|
896
|
+
// monogram is the fallback: shown when the user has no avatar image, and
|
|
897
|
+
// restored if the image fails to load (a broken / unreachable URL renders
|
|
898
|
+
// the initial, never a broken-image glyph).
|
|
899
|
+
const avatarImg = el('img', 'b-avatar-img');
|
|
900
|
+
avatarImg.alt = '';
|
|
901
|
+
const initialEl = el('span', 'b-avatar-initial');
|
|
902
|
+
menu.trigger.append(avatarImg, initialEl);
|
|
903
|
+
const showMonogram = () => {
|
|
904
|
+
avatarImg.hidden = true;
|
|
905
|
+
initialEl.hidden = false;
|
|
906
|
+
};
|
|
907
|
+
avatarImg.addEventListener('error', () => {
|
|
908
|
+
avatarImg.removeAttribute('src');
|
|
909
|
+
showMonogram();
|
|
910
|
+
});
|
|
911
|
+
const renderUser = (user) => {
|
|
912
|
+
initialEl.textContent = user ? initialOf(user) : '?';
|
|
913
|
+
emailEl.textContent = user ? (user.email ?? user.display_name ?? user.sub) : '';
|
|
914
|
+
// Swap the menu body between the signed-in actions and the guest's lone
|
|
915
|
+
// "Sign in": a null user is an anonymous link holder.
|
|
916
|
+
const signedIn = user !== null;
|
|
917
|
+
emailEl.hidden = !signedIn;
|
|
918
|
+
dashboardLink.hidden = !signedIn;
|
|
919
|
+
signOutBtn.hidden = !signedIn;
|
|
920
|
+
signInBtn.hidden = signedIn;
|
|
921
|
+
const url = user?.avatar_url?.trim() ?? '';
|
|
922
|
+
if (url) {
|
|
923
|
+
if (avatarImg.getAttribute('src') !== url)
|
|
924
|
+
avatarImg.src = url;
|
|
925
|
+
avatarImg.hidden = false;
|
|
926
|
+
initialEl.hidden = true;
|
|
927
|
+
}
|
|
928
|
+
else {
|
|
929
|
+
avatarImg.removeAttribute('src');
|
|
930
|
+
showMonogram();
|
|
931
|
+
}
|
|
932
|
+
};
|
|
933
|
+
renderUser(deps.getUser());
|
|
934
|
+
const unsub = deps.onUserChange(renderUser);
|
|
935
|
+
return {
|
|
936
|
+
root: menu.root,
|
|
937
|
+
destroy() {
|
|
938
|
+
unsub();
|
|
939
|
+
menu.destroy();
|
|
940
|
+
},
|
|
941
|
+
};
|
|
942
|
+
}
|
|
943
|
+
// The historical-version preview bar. When the active doc is previewing an
|
|
944
|
+
// earlier version (a hosted `history`-initiated checkout: `doc.readOnly` is
|
|
945
|
+
// true and `doc.version` is set), the SDK mounts the platform-served
|
|
946
|
+
// `/embed/version-bar` inline as a strip and routes its intents (Prev / Next
|
|
947
|
+
// checkout, return-to-latest, browse, branch-from) to the doc's internal
|
|
948
|
+
// methods. Delivered as a server embed so the platform owns and grows the
|
|
949
|
+
// bar's features with no SDK or app change; the SDK stays a thin bridge.
|
|
950
|
+
// Version navigation itself is hosted; this piece owns the whole preview
|
|
951
|
+
// surface. Apps still gate their own edit controls on `doc.readOnly`.
|
|
952
|
+
function buildVersionPreview(deps) {
|
|
953
|
+
injectStyles(document);
|
|
954
|
+
const root = el('div', 'brass-ui b-version');
|
|
955
|
+
root.setAttribute('role', 'group');
|
|
956
|
+
root.setAttribute('aria-label', 'Document version');
|
|
957
|
+
let frame = null;
|
|
958
|
+
// Once a preview starts (a checkout), the bar owns a browsing session that
|
|
959
|
+
// stays open, including after returning to the live head, until the user
|
|
960
|
+
// explicitly closes it. So the bar is never dismissed as a side effect of
|
|
961
|
+
// navigation (stepping to latest, returning to head). Reset when the user
|
|
962
|
+
// closes it, when the active document changes, or when no doc is open.
|
|
963
|
+
let browsing = false;
|
|
964
|
+
let lastDocId = null;
|
|
965
|
+
const session = deps.session;
|
|
966
|
+
const hideBar = () => {
|
|
967
|
+
root.classList.remove('b-version-on');
|
|
968
|
+
if (frame) {
|
|
969
|
+
frame.destroy();
|
|
970
|
+
frame = null;
|
|
971
|
+
}
|
|
972
|
+
};
|
|
973
|
+
const frameDeps = {
|
|
974
|
+
dashboardBaseUrl: deps.dashboardBaseUrl,
|
|
975
|
+
...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme } : {}),
|
|
976
|
+
...(deps.getSessionToken ? { getSessionToken: deps.getSessionToken } : {}),
|
|
977
|
+
// Read the live active document at call time: a bridge intent lands
|
|
978
|
+
// async, so it must not close over a doc that may have been superseded.
|
|
979
|
+
checkoutVersion: (version) => {
|
|
980
|
+
void session.document?._checkoutVersion(version).catch(() => { });
|
|
981
|
+
},
|
|
982
|
+
returnToHead: () => {
|
|
983
|
+
// Return to the live head but keep the bar open (the browsing session
|
|
984
|
+
// persists); the bar re-renders in its "at latest" state with a Close
|
|
985
|
+
// button. Dismissal is the explicit `close` intent only.
|
|
986
|
+
try {
|
|
987
|
+
session.document?.returnToHead();
|
|
988
|
+
}
|
|
989
|
+
catch {
|
|
990
|
+
// frozen / no live head: leave the preview as-is
|
|
991
|
+
}
|
|
992
|
+
},
|
|
993
|
+
fork: (fromVersion, name) => {
|
|
994
|
+
void session.document?._forkFromVersion(fromVersion, name).catch(() => { });
|
|
995
|
+
},
|
|
996
|
+
browse: () => {
|
|
997
|
+
void session.run('history');
|
|
998
|
+
},
|
|
999
|
+
close: () => {
|
|
1000
|
+
// Explicit dismissal: end the browsing session and hide the bar
|
|
1001
|
+
// synchronously so the tap is instant. The document's own browsing
|
|
1002
|
+
// flag is cleared too, or the next render would latch the bar back on.
|
|
1003
|
+
browsing = false;
|
|
1004
|
+
session.document?._setVersionBrowsing(false);
|
|
1005
|
+
hideBar();
|
|
1006
|
+
},
|
|
1007
|
+
};
|
|
1008
|
+
const render = () => {
|
|
1009
|
+
const doc = session.document;
|
|
1010
|
+
const docId = doc?.id ?? null;
|
|
1011
|
+
// A new active document (or none) ends any prior browsing session.
|
|
1012
|
+
if (docId !== lastDocId) {
|
|
1013
|
+
lastDocId = docId;
|
|
1014
|
+
browsing = false;
|
|
1015
|
+
}
|
|
1016
|
+
// A live checkout (`doc.version !== null`) starts / keeps the browsing
|
|
1017
|
+
// session. Other read-only states (a view-only share link, a still-loading
|
|
1018
|
+
// doc) are not this bar's concern, and `doc.id` is null only on the offline
|
|
1019
|
+
// / test path the embed can't serve.
|
|
1020
|
+
if (doc != null &&
|
|
1021
|
+
docId !== null &&
|
|
1022
|
+
(doc.version !== null || doc.versionBrowsing)) {
|
|
1023
|
+
browsing = true;
|
|
1024
|
+
}
|
|
1025
|
+
// The bar stays open for the whole session, including at the live head
|
|
1026
|
+
// (`doc.version === null`, browsing still true), until `close`.
|
|
1027
|
+
if (doc != null && docId !== null && browsing) {
|
|
1028
|
+
root.classList.add('b-version-on');
|
|
1029
|
+
if (!frame)
|
|
1030
|
+
frame = createVersionBarFrame(root, frameDeps);
|
|
1031
|
+
frame.update({
|
|
1032
|
+
docId,
|
|
1033
|
+
branch: doc.branchId ?? undefined,
|
|
1034
|
+
version: doc.version,
|
|
1035
|
+
branchName: doc._currentBranchName() ?? undefined,
|
|
1036
|
+
});
|
|
1037
|
+
}
|
|
1038
|
+
else {
|
|
1039
|
+
hideBar();
|
|
1040
|
+
}
|
|
1041
|
+
};
|
|
1042
|
+
render();
|
|
1043
|
+
const unsub = session.subscribe(render);
|
|
1044
|
+
return {
|
|
1045
|
+
root,
|
|
1046
|
+
destroy() {
|
|
1047
|
+
unsub();
|
|
1048
|
+
frame?.destroy();
|
|
1049
|
+
frame = null;
|
|
1050
|
+
},
|
|
1051
|
+
};
|
|
1052
|
+
}
|
|
1053
|
+
// --- Standalone piece mounts -----------------------------------------------
|
|
1054
|
+
function mountPiece(container, piece) {
|
|
1055
|
+
container.replaceChildren(piece.root);
|
|
1056
|
+
return {
|
|
1057
|
+
destroy() {
|
|
1058
|
+
piece.destroy();
|
|
1059
|
+
container.replaceChildren();
|
|
1060
|
+
},
|
|
1061
|
+
};
|
|
1062
|
+
}
|
|
1063
|
+
// The hamburger file menu (the `session.commands` menu-surface), mounted
|
|
1064
|
+
// standalone into the app's own header.
|
|
1065
|
+
export function createFileMenu(container, deps) {
|
|
1066
|
+
return mountPiece(container, buildFileMenu(deps));
|
|
1067
|
+
}
|
|
1068
|
+
// The document title plus a location chip; both open the hosted location
|
|
1069
|
+
// editor (rename / move / file).
|
|
1070
|
+
export function createDocumentTitle(container, deps) {
|
|
1071
|
+
return mountPiece(container, buildDocumentTitle(deps));
|
|
1072
|
+
}
|
|
1073
|
+
// The toolbar-surface commands (undo / redo) as prominent buttons.
|
|
1074
|
+
export function createUndoRedo(container, deps) {
|
|
1075
|
+
return mountPiece(container, buildUndoRedo(deps));
|
|
1076
|
+
}
|
|
1077
|
+
// The presence roster (coloured initials of who's on the active doc).
|
|
1078
|
+
// Receiver half only; the app still publishes its own state.
|
|
1079
|
+
export function createPresenceRoster(container, deps) {
|
|
1080
|
+
return mountPiece(container, buildPresenceRoster(deps));
|
|
1081
|
+
}
|
|
1082
|
+
// The drive-sync indicator (tracking state + Publish / Resolve). Renders
|
|
1083
|
+
// nothing until a drive-linked doc drifts from its source, so a non-drive app
|
|
1084
|
+
// mounts it harmlessly.
|
|
1085
|
+
export function createDriveSync(container, deps) {
|
|
1086
|
+
return mountPiece(container, buildDriveSync(deps));
|
|
1087
|
+
}
|
|
1088
|
+
// The account avatar menu (email + sign out).
|
|
1089
|
+
export function createAccountMenu(container, deps) {
|
|
1090
|
+
return mountPiece(container, buildAccountMenu(deps));
|
|
1091
|
+
}
|
|
1092
|
+
// The historical-version preview banner (return-to-live during a hosted
|
|
1093
|
+
// `history` preview). Renders nothing until the active doc is previewing an
|
|
1094
|
+
// earlier version, so an app mounts it harmlessly. The full toolbar and the
|
|
1095
|
+
// bare header shell both include it, so this standalone mount is only for an
|
|
1096
|
+
// app that builds fully custom chrome and wants the correct preview affordance.
|
|
1097
|
+
export function createVersionPreview(container, deps) {
|
|
1098
|
+
return mountPiece(container, buildVersionPreview(deps));
|
|
1099
|
+
}
|
|
1100
|
+
function buildBarShell() {
|
|
1101
|
+
injectStyles(document);
|
|
1102
|
+
const bar = el('header', 'brass-bar brass-ui');
|
|
1103
|
+
const start = el('div', 'b-left');
|
|
1104
|
+
const tools = el('div', 'b-tools');
|
|
1105
|
+
const end = el('div', 'b-right');
|
|
1106
|
+
bar.append(start, tools, end);
|
|
1107
|
+
// Hide the tools strip when it holds no visible content: on mobile it wraps
|
|
1108
|
+
// to its own row, and an empty strip would still reserve that row plus the
|
|
1109
|
+
// row-gap, leaving asymmetric padding. "Empty" = every child is blank (no
|
|
1110
|
+
// text, no element children), so an empty save-indicator span or a
|
|
1111
|
+
// command-less undo/redo piece both count.
|
|
1112
|
+
function updateToolsVisibility() {
|
|
1113
|
+
const allBlank = Array.from(tools.children).every((c) => (c.textContent ?? '').trim() === '' && c.childElementCount === 0);
|
|
1114
|
+
tools.style.display = allBlank ? 'none' : '';
|
|
1115
|
+
}
|
|
1116
|
+
// The app (and the undo/redo subscription) mutates `tools` after mount, so
|
|
1117
|
+
// re-evaluate visibility on any change.
|
|
1118
|
+
const toolsObserver = new MutationObserver(() => updateToolsVisibility());
|
|
1119
|
+
toolsObserver.observe(tools, {
|
|
1120
|
+
childList: true,
|
|
1121
|
+
subtree: true,
|
|
1122
|
+
characterData: true,
|
|
1123
|
+
});
|
|
1124
|
+
updateToolsVisibility();
|
|
1125
|
+
return {
|
|
1126
|
+
bar,
|
|
1127
|
+
start,
|
|
1128
|
+
tools,
|
|
1129
|
+
end,
|
|
1130
|
+
destroy() {
|
|
1131
|
+
toolsObserver.disconnect();
|
|
1132
|
+
},
|
|
1133
|
+
};
|
|
1134
|
+
}
|
|
1135
|
+
// --- The composed bar ------------------------------------------------------
|
|
1136
|
+
export function createSessionToolbar(container, deps, options = {}) {
|
|
1137
|
+
const showPresence = options.presence !== false;
|
|
1138
|
+
const showAccount = options.account !== false;
|
|
1139
|
+
const showDrive = options.drive !== false;
|
|
1140
|
+
const shell = buildBarShell();
|
|
1141
|
+
// Left cluster: file menu + title/location identity + the drive-sync
|
|
1142
|
+
// indicator (by the title, where storage provenance belongs).
|
|
1143
|
+
const fileMenu = buildFileMenu(deps);
|
|
1144
|
+
const titlePiece = buildDocumentTitle(deps);
|
|
1145
|
+
shell.start.append(fileMenu.root, titlePiece.root);
|
|
1146
|
+
const pieces = [fileMenu, titlePiece];
|
|
1147
|
+
if (showDrive) {
|
|
1148
|
+
const drivePiece = buildDriveSync(deps);
|
|
1149
|
+
shell.start.append(drivePiece.root);
|
|
1150
|
+
pieces.push(drivePiece);
|
|
1151
|
+
}
|
|
1152
|
+
// Middle: SDK toolbar-surface commands (undo/redo) + the app's slot.
|
|
1153
|
+
const undoRedo = buildUndoRedo(deps);
|
|
1154
|
+
shell.tools.append(undoRedo.root);
|
|
1155
|
+
pieces.push(undoRedo);
|
|
1156
|
+
// Right cluster: presence roster + account menu.
|
|
1157
|
+
if (showPresence) {
|
|
1158
|
+
const rosterPiece = buildPresenceRoster(deps);
|
|
1159
|
+
shell.end.append(rosterPiece.root);
|
|
1160
|
+
pieces.push(rosterPiece);
|
|
1161
|
+
}
|
|
1162
|
+
if (showAccount) {
|
|
1163
|
+
const accountPiece = buildAccountMenu(deps);
|
|
1164
|
+
shell.end.append(accountPiece.root);
|
|
1165
|
+
pieces.push(accountPiece);
|
|
1166
|
+
}
|
|
1167
|
+
// The historical-version preview bar sits below the bar as a full-width
|
|
1168
|
+
// strip: mounting the toolbar is all an app does to get correct version-
|
|
1169
|
+
// preview UX, so it never hand-rolls one.
|
|
1170
|
+
const versionPiece = buildVersionPreview(deps);
|
|
1171
|
+
pieces.push(versionPiece);
|
|
1172
|
+
container.replaceChildren(shell.bar, versionPiece.root);
|
|
1173
|
+
return {
|
|
1174
|
+
tools: shell.tools,
|
|
1175
|
+
destroy() {
|
|
1176
|
+
for (const p of pieces)
|
|
1177
|
+
p.destroy();
|
|
1178
|
+
shell.destroy();
|
|
1179
|
+
container.replaceChildren();
|
|
1180
|
+
},
|
|
1181
|
+
};
|
|
1182
|
+
}
|
|
1183
|
+
// The bare header shell for an app that builds its own header but wants the
|
|
1184
|
+
// proven layout. Mount pieces (and your own content) into the returned
|
|
1185
|
+
// `start` / `tools` / `end` slots; the avatar in `end` stays top-right and
|
|
1186
|
+
// never wraps when `tools` does.
|
|
1187
|
+
export function createSessionHeader(container, deps) {
|
|
1188
|
+
const shell = buildBarShell();
|
|
1189
|
+
// A header shell built with session access includes the historical-version
|
|
1190
|
+
// preview bar below the bar, so a composable-chrome app gets the same
|
|
1191
|
+
// correct preview UX the full toolbar does without a separate mount.
|
|
1192
|
+
const versionPiece = deps ? buildVersionPreview(deps) : null;
|
|
1193
|
+
container.replaceChildren(shell.bar, ...(versionPiece ? [versionPiece.root] : []));
|
|
1194
|
+
return {
|
|
1195
|
+
start: shell.start,
|
|
1196
|
+
tools: shell.tools,
|
|
1197
|
+
end: shell.end,
|
|
1198
|
+
destroy() {
|
|
1199
|
+
versionPiece?.destroy();
|
|
1200
|
+
shell.destroy();
|
|
1201
|
+
container.replaceChildren();
|
|
1202
|
+
},
|
|
1203
|
+
};
|
|
1204
|
+
}
|
|
1205
|
+
//# sourceMappingURL=session-toolbar.js.map
|