@brass-build/client 0.4.0 → 0.5.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 +142 -41
- package/CHANGELOG.md +93 -4
- package/README.md +666 -179
- package/dist/acting-organization.d.ts.map +1 -1
- package/dist/acting-organization.js +6 -11
- package/dist/acting-organization.js.map +1 -1
- package/dist/api-fetch.d.ts +8 -0
- package/dist/api-fetch.d.ts.map +1 -1
- package/dist/api-fetch.js +35 -8
- package/dist/api-fetch.js.map +1 -1
- package/dist/batched-get.d.ts +11 -0
- package/dist/batched-get.d.ts.map +1 -0
- package/dist/batched-get.js +92 -0
- package/dist/batched-get.js.map +1 -0
- package/dist/call-service.d.ts +4 -0
- package/dist/call-service.d.ts.map +1 -1
- package/dist/call-service.js +66 -3
- package/dist/call-service.js.map +1 -1
- package/dist/client.d.ts +51 -2
- package/dist/client.d.ts.map +1 -1
- package/dist/client.js +982 -124
- package/dist/client.js.map +1 -1
- package/dist/document-checkpoints.d.ts +25 -2
- package/dist/document-checkpoints.d.ts.map +1 -1
- package/dist/document-checkpoints.js +258 -61
- package/dist/document-checkpoints.js.map +1 -1
- package/dist/document-commands.d.ts.map +1 -1
- package/dist/document-commands.js +5 -3
- package/dist/document-commands.js.map +1 -1
- package/dist/document-contents.d.ts +10 -0
- package/dist/document-contents.d.ts.map +1 -1
- package/dist/document-contents.js +23 -1
- package/dist/document-contents.js.map +1 -1
- package/dist/document-set-subscription.d.ts +56 -0
- package/dist/document-set-subscription.d.ts.map +1 -0
- package/dist/document-set-subscription.js +240 -0
- package/dist/document-set-subscription.js.map +1 -0
- package/dist/document-signals.d.ts +6 -0
- package/dist/document-signals.d.ts.map +1 -1
- package/dist/document-signals.js +31 -1
- package/dist/document-signals.js.map +1 -1
- package/dist/document-streams.d.ts +36 -2
- package/dist/document-streams.d.ts.map +1 -1
- package/dist/document-streams.js +69 -18
- package/dist/document-streams.js.map +1 -1
- package/dist/document-thumbnail-capture.d.ts +12 -2
- package/dist/document-thumbnail-capture.d.ts.map +1 -1
- package/dist/document-thumbnail-capture.js +103 -27
- package/dist/document-thumbnail-capture.js.map +1 -1
- package/dist/documents-namespace.d.ts +92 -10
- package/dist/documents-namespace.d.ts.map +1 -1
- package/dist/documents-namespace.js +285 -39
- package/dist/documents-namespace.js.map +1 -1
- package/dist/documents.d.ts +173 -8
- package/dist/documents.d.ts.map +1 -1
- package/dist/documents.js +1778 -310
- package/dist/documents.js.map +1 -1
- package/dist/errors.d.ts +3 -3
- package/dist/errors.d.ts.map +1 -1
- package/dist/errors.js.map +1 -1
- package/dist/fetch-retry.d.ts +1 -0
- package/dist/fetch-retry.d.ts.map +1 -1
- package/dist/fetch-retry.js +2 -0
- package/dist/fetch-retry.js.map +1 -1
- package/dist/file-choice.d.ts +2 -0
- package/dist/file-choice.d.ts.map +1 -0
- package/dist/file-choice.js +27 -0
- package/dist/file-choice.js.map +1 -0
- package/dist/guest-link.d.ts +18 -1
- package/dist/guest-link.d.ts.map +1 -1
- package/dist/guest-link.js +329 -1
- package/dist/guest-link.js.map +1 -1
- package/dist/handle-types.d.ts +67 -5
- package/dist/handle-types.d.ts.map +1 -1
- package/dist/handles.d.ts +15 -19
- package/dist/handles.d.ts.map +1 -1
- package/dist/handles.js +231 -76
- package/dist/handles.js.map +1 -1
- package/dist/hosted-ui.d.ts +61 -0
- package/dist/hosted-ui.d.ts.map +1 -1
- package/dist/hosted-ui.js +94 -42
- package/dist/hosted-ui.js.map +1 -1
- package/dist/import-contract.d.ts +74 -0
- package/dist/import-contract.d.ts.map +1 -0
- package/dist/import-contract.js +163 -0
- package/dist/import-contract.js.map +1 -0
- package/dist/import-guest.d.ts +9 -0
- package/dist/import-guest.d.ts.map +1 -0
- package/dist/import-guest.js +155 -0
- package/dist/import-guest.js.map +1 -0
- package/dist/import-report.d.ts +17 -0
- package/dist/import-report.d.ts.map +1 -0
- package/dist/import-report.js +7 -0
- package/dist/import-report.js.map +1 -0
- package/dist/importer-wait.d.ts +2 -0
- package/dist/importer-wait.d.ts.map +1 -0
- package/dist/importer-wait.js +5 -0
- package/dist/importer-wait.js.map +1 -0
- package/dist/importer.d.ts +18 -4
- package/dist/importer.d.ts.map +1 -1
- package/dist/importer.js +322 -105
- package/dist/importer.js.map +1 -1
- package/dist/in-development.d.ts +2 -0
- package/dist/in-development.d.ts.map +1 -0
- package/dist/in-development.js +10 -0
- package/dist/in-development.js.map +1 -0
- package/dist/index.d.ts +16 -8
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +24 -8
- package/dist/index.js.map +1 -1
- package/dist/init-keys.d.ts +1 -1
- package/dist/init-keys.d.ts.map +1 -1
- package/dist/init-keys.js +6 -1
- package/dist/init-keys.js.map +1 -1
- package/dist/input-documents.d.ts +67 -0
- package/dist/input-documents.d.ts.map +1 -0
- package/dist/input-documents.js +693 -0
- package/dist/input-documents.js.map +1 -0
- package/dist/inputs-status.d.ts +80 -0
- package/dist/inputs-status.d.ts.map +1 -0
- package/dist/inputs-status.js +460 -0
- package/dist/inputs-status.js.map +1 -0
- package/dist/inputs.d.ts +2 -0
- package/dist/inputs.d.ts.map +1 -0
- package/dist/inputs.js +793 -0
- package/dist/inputs.js.map +1 -0
- package/dist/internal.d.ts +6 -1
- package/dist/internal.d.ts.map +1 -1
- package/dist/internal.js +4 -1
- package/dist/internal.js.map +1 -1
- package/dist/join-content.d.ts +33 -0
- package/dist/join-content.d.ts.map +1 -0
- package/dist/join-content.js +114 -0
- package/dist/join-content.js.map +1 -0
- package/dist/join-stamp.d.ts +3 -0
- package/dist/join-stamp.d.ts.map +1 -0
- package/dist/join-stamp.js +12 -0
- package/dist/join-stamp.js.map +1 -0
- package/dist/manifest-drift.d.ts +9 -0
- package/dist/manifest-drift.d.ts.map +1 -0
- package/dist/manifest-drift.js +46 -0
- package/dist/manifest-drift.js.map +1 -0
- package/dist/open-document.d.ts +109 -2
- package/dist/open-document.d.ts.map +1 -1
- package/dist/open-document.js +1822 -811
- package/dist/open-document.js.map +1 -1
- package/dist/page-restore.d.ts +12 -0
- package/dist/page-restore.d.ts.map +1 -0
- package/dist/page-restore.js +23 -0
- package/dist/page-restore.js.map +1 -0
- package/dist/placement.d.ts +13 -0
- package/dist/placement.d.ts.map +1 -0
- package/dist/placement.js +34 -0
- package/dist/placement.js.map +1 -0
- package/dist/presence.d.ts +1 -0
- package/dist/presence.d.ts.map +1 -1
- package/dist/presence.js +16 -4
- package/dist/presence.js.map +1 -1
- package/dist/read-only-document.d.ts +87 -0
- package/dist/read-only-document.d.ts.map +1 -0
- package/dist/read-only-document.js +4 -0
- package/dist/read-only-document.js.map +1 -0
- package/dist/record-paging.d.ts +63 -6
- package/dist/record-paging.d.ts.map +1 -1
- package/dist/record-paging.js +122 -29
- package/dist/record-paging.js.map +1 -1
- package/dist/references.d.ts +40 -0
- package/dist/references.d.ts.map +1 -0
- package/dist/references.js +135 -0
- package/dist/references.js.map +1 -0
- package/dist/request-lane.d.ts +5 -0
- package/dist/request-lane.d.ts.map +1 -0
- package/dist/request-lane.js +48 -0
- package/dist/request-lane.js.map +1 -0
- package/dist/sealed-arrow.d.ts +170 -0
- package/dist/sealed-arrow.d.ts.map +1 -0
- package/dist/sealed-arrow.js +769 -0
- package/dist/sealed-arrow.js.map +1 -0
- package/dist/sealed-cache.d.ts +21 -0
- package/dist/sealed-cache.d.ts.map +1 -0
- package/dist/sealed-cache.js +247 -0
- package/dist/sealed-cache.js.map +1 -0
- package/dist/sealed-encode.d.ts +87 -0
- package/dist/sealed-encode.d.ts.map +1 -0
- package/dist/sealed-encode.js +876 -0
- package/dist/sealed-encode.js.map +1 -0
- package/dist/sealed-handles.d.ts +45 -0
- package/dist/sealed-handles.d.ts.map +1 -0
- package/dist/sealed-handles.js +408 -0
- package/dist/sealed-handles.js.map +1 -0
- package/dist/sealed-store.d.ts +242 -0
- package/dist/sealed-store.d.ts.map +1 -0
- package/dist/sealed-store.js +1155 -0
- package/dist/sealed-store.js.map +1 -0
- package/dist/serializer.d.ts +6 -15
- package/dist/serializer.d.ts.map +1 -1
- package/dist/session-toolbar.d.ts +20 -5
- package/dist/session-toolbar.d.ts.map +1 -1
- package/dist/session-toolbar.js +419 -67
- package/dist/session-toolbar.js.map +1 -1
- package/dist/session.d.ts +38 -1
- package/dist/session.d.ts.map +1 -1
- package/dist/session.js +562 -79
- package/dist/session.js.map +1 -1
- package/dist/sha256.d.ts +3 -0
- package/dist/sha256.d.ts.map +1 -0
- package/dist/sha256.js +74 -0
- package/dist/sha256.js.map +1 -0
- package/dist/stable-handles.d.ts +19 -0
- package/dist/stable-handles.d.ts.map +1 -0
- package/dist/stable-handles.js +193 -0
- package/dist/stable-handles.js.map +1 -0
- package/dist/staged-seal.d.ts +2 -0
- package/dist/staged-seal.d.ts.map +1 -0
- package/dist/staged-seal.js +76 -0
- package/dist/staged-seal.js.map +1 -0
- package/dist/subscriber-delivery.d.ts +8 -0
- package/dist/subscriber-delivery.d.ts.map +1 -0
- package/dist/subscriber-delivery.js +31 -0
- package/dist/subscriber-delivery.js.map +1 -0
- package/dist/tail-op-import.d.ts +10 -0
- package/dist/tail-op-import.d.ts.map +1 -0
- package/dist/tail-op-import.js +33 -0
- package/dist/tail-op-import.js.map +1 -0
- package/dist/topic-lifecycle.d.ts +6 -0
- package/dist/topic-lifecycle.d.ts.map +1 -1
- package/dist/topic-lifecycle.js +32 -2
- package/dist/topic-lifecycle.js.map +1 -1
- package/dist/trial-replace-dialog.d.ts +10 -0
- package/dist/trial-replace-dialog.d.ts.map +1 -0
- package/dist/trial-replace-dialog.js +153 -0
- package/dist/trial-replace-dialog.js.map +1 -0
- package/dist/types.d.ts +8 -1
- package/dist/types.d.ts.map +1 -1
- package/dist/undo.d.ts +60 -5
- package/dist/undo.d.ts.map +1 -1
- package/dist/undo.js +344 -75
- package/dist/undo.js.map +1 -1
- package/dist/version-bar.d.ts +1 -0
- package/dist/version-bar.d.ts.map +1 -1
- package/dist/version-bar.js +2 -1
- package/dist/version-bar.js.map +1 -1
- package/dist/version.d.ts +2 -2
- package/dist/version.js +1 -1
- package/dist/ws-client.d.ts +21 -0
- package/dist/ws-client.d.ts.map +1 -1
- package/dist/ws-client.js +192 -8
- package/dist/ws-client.js.map +1 -1
- package/dist/ws-frame-limit.d.ts +1 -0
- package/dist/ws-frame-limit.d.ts.map +1 -1
- package/dist/ws-frame-limit.js.map +1 -1
- package/docs/app-manifest.md +121 -17
- package/docs/custom-chrome.md +24 -16
- package/docs/hosted-ui-advanced.md +38 -6
- package/docs/imported-documents.md +104 -51
- package/docs/presence.md +16 -4
- package/docs/schemas.md +8 -5
- package/docs/services.md +6 -6
- package/docs/streams.md +139 -150
- package/docs/text-fields.md +26 -19
- package/package.json +5 -2
- package/src/acting-organization.ts +6 -12
- package/src/api-fetch.ts +53 -3
- package/src/auth-flow.ts +1 -1
- package/src/batched-get.ts +117 -0
- package/src/call-service.ts +78 -3
- package/src/client.ts +1092 -121
- package/src/document-checkpoints.ts +307 -65
- package/src/document-commands.ts +5 -5
- package/src/document-contents.ts +27 -1
- package/src/document-set-subscription.ts +274 -0
- package/src/document-signals.ts +47 -5
- package/src/document-streams.ts +85 -16
- package/src/document-thumbnail-capture.ts +118 -32
- package/src/documents-namespace.ts +539 -84
- package/src/documents.ts +2017 -348
- package/src/errors.ts +17 -6
- package/src/fetch-retry.ts +5 -0
- package/src/file-choice.ts +26 -0
- package/src/guest-link.ts +362 -1
- package/src/handle-types.ts +73 -47
- package/src/handles.ts +253 -83
- package/src/hosted-ui.ts +211 -50
- package/src/import-contract.ts +227 -0
- package/src/import-guest.ts +193 -0
- package/src/import-report.ts +24 -0
- package/src/importer-wait.ts +4 -0
- package/src/importer.ts +386 -131
- package/src/in-development.ts +10 -0
- package/src/index.ts +76 -16
- package/src/init-keys.ts +6 -1
- package/src/input-documents.ts +819 -0
- package/src/inputs-status.ts +598 -0
- package/src/inputs.ts +1090 -0
- package/src/internal.ts +7 -0
- package/src/join-content.ts +135 -0
- package/src/join-stamp.ts +12 -0
- package/src/manifest-drift.ts +54 -0
- package/src/open-document.ts +2346 -930
- package/src/page-restore.ts +38 -0
- package/src/placement.ts +54 -0
- package/src/presence.ts +21 -5
- package/src/read-only-document.ts +133 -0
- package/src/record-paging.ts +147 -25
- package/src/references.ts +207 -0
- package/src/request-lane.ts +59 -0
- package/src/sealed-arrow.ts +843 -0
- package/src/sealed-cache.ts +267 -0
- package/src/sealed-encode.ts +1015 -0
- package/src/sealed-handles.ts +486 -0
- package/src/sealed-store.ts +1330 -0
- package/src/serializer.ts +8 -20
- package/src/session-toolbar.ts +463 -72
- package/src/session.ts +740 -99
- package/src/sha256.ts +65 -0
- package/src/stable-handles.ts +235 -0
- package/src/staged-seal.ts +90 -0
- package/src/subscriber-delivery.ts +37 -0
- package/src/tail-op-import.ts +30 -0
- package/src/topic-lifecycle.ts +38 -2
- package/src/trial-replace-dialog.ts +163 -0
- package/src/types.ts +6 -6
- package/src/undo.ts +386 -73
- package/src/version-bar.ts +5 -1
- package/src/version.ts +1 -1
- package/src/ws-client.ts +208 -16
- package/src/ws-frame-limit.ts +1 -0
package/src/session-toolbar.ts
CHANGED
|
@@ -41,6 +41,8 @@ export interface DocumentTitleDeps {
|
|
|
41
41
|
// Resolve the active doc's server-authored location label (rendered
|
|
42
42
|
// verbatim by the chip; null when the doc is loose).
|
|
43
43
|
getDocument: (docId: string) => Promise<{ location_label: string | null }>;
|
|
44
|
+
// Whether the page opened its document through a `?brass_link=` link.
|
|
45
|
+
onLink?: () => boolean;
|
|
44
46
|
}
|
|
45
47
|
|
|
46
48
|
export interface UndoRedoDeps {
|
|
@@ -49,6 +51,8 @@ export interface UndoRedoDeps {
|
|
|
49
51
|
|
|
50
52
|
export interface PresenceRosterDeps {
|
|
51
53
|
session: DocumentSession;
|
|
54
|
+
// The signed-in person, whose other tabs and devices are not collaborators.
|
|
55
|
+
getUser?: () => BrassUser | null;
|
|
52
56
|
}
|
|
53
57
|
|
|
54
58
|
export interface DriveSyncDeps {
|
|
@@ -58,21 +62,47 @@ export interface DriveSyncDeps {
|
|
|
58
62
|
export interface VersionPreviewDeps {
|
|
59
63
|
session: DocumentSession;
|
|
60
64
|
// Resolved dashboard/embed origin (no trailing slash): the version bar
|
|
61
|
-
// iframe loads `${dashboardBaseUrl}/embed/version-bar`.
|
|
62
|
-
|
|
65
|
+
// iframe loads `${dashboardBaseUrl}/embed/version-bar`. Called at use
|
|
66
|
+
// rather than passed as a value, for the reason on `AccountMenuDeps`.
|
|
67
|
+
dashboardBaseUrl: () => string;
|
|
68
|
+
// The app the bar serves, named in the embed's init like every other hosted
|
|
69
|
+
// surface. Read at use for the same reason as `dashboardBaseUrl`.
|
|
70
|
+
appId?: () => string | undefined;
|
|
63
71
|
// Pins the embed's light/dark theme (matches the other hosted surfaces).
|
|
64
|
-
hostedUiTheme?: 'light' | 'dark' | 'auto';
|
|
72
|
+
hostedUiTheme?: () => 'light' | 'dark' | 'auto';
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
// The strip under the bar on a document created without an account, which
|
|
76
|
+
// says the work will be lost and offers the sign-in that keeps it.
|
|
77
|
+
export interface NotSignedInDeps {
|
|
78
|
+
session: DocumentSession;
|
|
79
|
+
signIn: () => void;
|
|
80
|
+
// The signed-in person, when there is one. A person signed in on someone
|
|
81
|
+
// else's temporary work is told when it goes and offered nothing; in the
|
|
82
|
+
// browser that made it, `keep` claims it in place.
|
|
83
|
+
getUser?: () => BrassUser | null;
|
|
84
|
+
onUserChange?: (listener: (user: BrassUser | null) => void) => () => void;
|
|
85
|
+
canKeep?: () => boolean;
|
|
86
|
+
keep?: () => void;
|
|
65
87
|
}
|
|
66
88
|
|
|
67
89
|
export interface AccountMenuDeps {
|
|
68
90
|
getUser: () => BrassUser | null;
|
|
91
|
+
// The guest's "Sign in" names what a sign-in does when the open document
|
|
92
|
+
// was created without an account (it keeps the document), so the menu
|
|
93
|
+
// reads the active document's expiry off the session when one is given.
|
|
94
|
+
session?: DocumentSession;
|
|
69
95
|
onUserChange: (listener: (user: BrassUser | null) => void) => () => void;
|
|
70
96
|
signIn: () => void;
|
|
71
97
|
signOut: () => void;
|
|
72
98
|
// Base URL of the Brass dashboard, for the account menu's "Open Brass
|
|
73
99
|
// dashboard" link. The client's resolved dashboard origin (picks the
|
|
74
100
|
// environment), with no trailing slash.
|
|
75
|
-
|
|
101
|
+
//
|
|
102
|
+
// Called at use rather than passed as a value: `Brass.init()` resolves the
|
|
103
|
+
// origin, and a bar may be mounted before it settles, so a value read at
|
|
104
|
+
// mount is the default rather than the one that call resolved.
|
|
105
|
+
dashboardBaseUrl: () => string;
|
|
76
106
|
// The organization this app is currently acting in, or null while none has
|
|
77
107
|
// been resolved or the one resolved has no name yet.
|
|
78
108
|
getActingOrganization?: () => { id: string; name: string } | null;
|
|
@@ -91,6 +121,7 @@ export type SessionToolbarDeps = FileMenuDeps &
|
|
|
91
121
|
UndoRedoDeps &
|
|
92
122
|
PresenceRosterDeps &
|
|
93
123
|
AccountMenuDeps &
|
|
124
|
+
NotSignedInDeps &
|
|
94
125
|
VersionPreviewDeps;
|
|
95
126
|
|
|
96
127
|
export interface SessionToolbarOptions {
|
|
@@ -144,12 +175,14 @@ const STYLE_ID = 'brass-toolbar-styles';
|
|
|
144
175
|
const CSS = `
|
|
145
176
|
.brass-ui { --brass-surface:#161b22; --brass-surface-2:#1f2730;
|
|
146
177
|
--brass-fg:#e6edf3; --brass-fg-muted:#9da7b3; --brass-border:#2a313b;
|
|
147
|
-
--brass-accent:#3b82f6; --brass-accent-fg:#fff; --brass-
|
|
178
|
+
--brass-accent:#3b82f6; --brass-accent-fg:#fff; --brass-accent-soft:#1c2b45;
|
|
179
|
+
--brass-danger:#e5534b;
|
|
148
180
|
color:var(--brass-fg);
|
|
149
181
|
font:14px system-ui,-apple-system,Segoe UI,Roboto,sans-serif; box-sizing:border-box; }
|
|
150
182
|
@media (prefers-color-scheme: light) { .brass-ui {
|
|
151
183
|
--brass-surface:#fff; --brass-surface-2:#f1f3f5; --brass-fg:#1b1f24;
|
|
152
|
-
--brass-fg-muted:#5c636a; --brass-border:#d8dee4; --brass-
|
|
184
|
+
--brass-fg-muted:#5c636a; --brass-border:#d8dee4; --brass-accent-soft:#e6effd;
|
|
185
|
+
--brass-danger:#cf222e; } }
|
|
153
186
|
.brass-ui *, .brass-bar * { box-sizing:border-box; }
|
|
154
187
|
/* Host apps commonly set a global button min-height for touch targets, which
|
|
155
188
|
overrides the avatar's height regardless of specificity and stretches the
|
|
@@ -182,6 +215,7 @@ const CSS = `
|
|
|
182
215
|
.brass-bar .b-tools { order:2; flex:1; flex-basis:auto;
|
|
183
216
|
justify-content:center; } .brass-bar .b-right { order:3; margin-left:auto; } }
|
|
184
217
|
.b-menu { display:inline-flex; }
|
|
218
|
+
.b-menu[hidden] { display:none; }
|
|
185
219
|
/* A bare hamburger glyph, no button chrome (background / border / radius), so it
|
|
186
220
|
reads as an icon sitting beside the document title. The 36px box is only an
|
|
187
221
|
invisible tap target; the glyph is sized up so it holds its weight next to the
|
|
@@ -209,6 +243,10 @@ const CSS = `
|
|
|
209
243
|
declared here. */
|
|
210
244
|
.b-pop .b-sep { height:0; border:none; border-top:1px solid var(--brass-border);
|
|
211
245
|
margin:4px 2px; }
|
|
246
|
+
/* A line of fact in a menu (when a document minted without an account
|
|
247
|
+
expires): the same inset as an entry, in the muted tone, and no hover. */
|
|
248
|
+
.b-pop .b-note { padding:8px 10px; color:var(--brass-fg-muted); font-size:12px;
|
|
249
|
+
line-height:1.3; }
|
|
212
250
|
/* [hidden] must beat the block rule above (the account menu swaps its body
|
|
213
251
|
between signed-in actions and the guest's "Sign in"). */
|
|
214
252
|
.b-pop button[hidden], .b-pop a[hidden], .b-pop .b-email[hidden],
|
|
@@ -234,7 +272,7 @@ const CSS = `
|
|
|
234
272
|
border:1px solid var(--brass-accent); border-radius:6px; padding:0 6px;
|
|
235
273
|
margin:0; cursor:pointer; font-family:inherit; overflow:hidden;
|
|
236
274
|
text-overflow:ellipsis; white-space:nowrap; }
|
|
237
|
-
.b-branch:hover { background:var(--brass-accent); color:var(--brass-accent-fg); }
|
|
275
|
+
.b-branch:hover:not(:disabled) { background:var(--brass-accent); color:var(--brass-accent-fg); }
|
|
238
276
|
.b-loc { font-size:11px; line-height:1.2; color:var(--brass-fg-muted);
|
|
239
277
|
background:none; border:none; border-radius:0; padding:1px 3px; min-height:0; cursor:pointer; white-space:nowrap;
|
|
240
278
|
font-family:inherit; }
|
|
@@ -242,7 +280,24 @@ const CSS = `
|
|
|
242
280
|
.b-loc.filed::before { content:'📁 '; }
|
|
243
281
|
.b-loc.loose { text-decoration:underline dashed; text-underline-offset:2px; }
|
|
244
282
|
.b-loc.loose::before { content:'+ '; }
|
|
245
|
-
.b-undo-redo { display:inline-flex; align-items:center; gap:
|
|
283
|
+
.b-undo-redo { display:inline-flex; align-items:center; gap:2px; }
|
|
284
|
+
.b-undo-compact { display:none; }
|
|
285
|
+
.b-tool.b-tool-icon { padding:6px; display:inline-flex; align-items:center; justify-content:center;
|
|
286
|
+
background:none; border-color:transparent; }
|
|
287
|
+
.b-tool.b-tool-icon:hover:not(:disabled) { background:var(--brass-surface-2); }
|
|
288
|
+
/* An icon button's name on hover or keyboard focus. A top-layer popover, so
|
|
289
|
+
no ancestor's overflow clips it. */
|
|
290
|
+
.b-tip { position:fixed; inset:auto; margin:0; padding:4px 8px; border:none;
|
|
291
|
+
border-radius:6px; background:var(--brass-fg); color:var(--brass-surface);
|
|
292
|
+
font-size:12px; line-height:1.3; white-space:nowrap; pointer-events:none;
|
|
293
|
+
display:none; z-index:2147483646; }
|
|
294
|
+
.b-tip:popover-open, .b-tip.b-tip-open { display:block; }
|
|
295
|
+
/* Narrow: the pair sits in the top row beside the avatar, so the tools row
|
|
296
|
+
holds only the app's items and collapses when it has none. */
|
|
297
|
+
@media (max-width:759.98px) {
|
|
298
|
+
.brass-bar .b-undo-compact { display:inline-flex; }
|
|
299
|
+
.brass-bar .b-undo-full { display:none; }
|
|
300
|
+
.brass-bar:not(.b-app-tools) .b-tools { display:none !important; } }
|
|
246
301
|
.b-tool { background:var(--brass-surface-2); color:var(--brass-fg);
|
|
247
302
|
border:1px solid var(--brass-border); border-radius:8px; padding:8px 12px; cursor:pointer; font:inherit; }
|
|
248
303
|
.b-tool:disabled { opacity:.5; cursor:default; }
|
|
@@ -264,6 +319,13 @@ const CSS = `
|
|
|
264
319
|
/* The uploaded avatar image fills the circle; object-fit:cover keeps it
|
|
265
320
|
undistorted. It sits over the monogram, which stays in the DOM as the
|
|
266
321
|
fallback shown when no image is set or the image fails to load. */
|
|
322
|
+
/* The accent-filled controls (this avatar, the guest strip's sign-in, the
|
|
323
|
+
branch chip) restate their fill and text colour on hover, at a
|
|
324
|
+
specificity above a host's own button:hover:not(:disabled) whatever order the
|
|
325
|
+
two stylesheets load in. A host hover rule otherwise repaints the fill with
|
|
326
|
+
its own (often near-white) background under this white text. */
|
|
327
|
+
button.b-avatar:hover:not(:disabled) { background:var(--brass-accent);
|
|
328
|
+
color:var(--brass-accent-fg); }
|
|
267
329
|
.b-avatar-img { width:100%; height:100%; object-fit:cover; display:block; }
|
|
268
330
|
.b-avatar-img[hidden], .b-avatar-initial[hidden] { display:none; }
|
|
269
331
|
/* The account menu opens on the identity it acts for: the avatar again at
|
|
@@ -331,6 +393,23 @@ const CSS = `
|
|
|
331
393
|
reports a problem, so it carries the danger tone rather than the muted one
|
|
332
394
|
every other line uses. */
|
|
333
395
|
.b-drive-unreadable { margin:0; padding:0 10px 6px; font-size:12px; color:var(--brass-danger, #b3261e); }
|
|
396
|
+
/* The strip under the bar while the open document is one the platform will
|
|
397
|
+
remove (created without an account): it says so and carries the sign-in
|
|
398
|
+
that keeps the work. Tinted with the accent so it reads as a notice about
|
|
399
|
+
the document rather than as more chrome; the text takes the slack and the
|
|
400
|
+
button holds the trailing edge until a narrow width wraps it under. */
|
|
401
|
+
.b-guest { width:100%; display:none; flex-wrap:wrap; align-items:center;
|
|
402
|
+
justify-content:space-between; gap:6px 12px; padding:8px 14px; font-size:13px;
|
|
403
|
+
line-height:1.4; background:var(--brass-accent-soft); color:var(--brass-fg);
|
|
404
|
+
border-bottom:1px solid var(--brass-border); }
|
|
405
|
+
.b-guest.b-guest-on { display:flex; }
|
|
406
|
+
.b-guest-msg { flex:1 1 auto; min-width:0; }
|
|
407
|
+
.b-guest button { flex:0 0 auto; background:var(--brass-accent);
|
|
408
|
+
color:var(--brass-accent-fg); border:none; border-radius:6px; cursor:pointer;
|
|
409
|
+
font:inherit; font-size:13px; font-weight:600; padding:6px 12px; }
|
|
410
|
+
.b-guest button:hover:not(:disabled) { background:var(--brass-accent);
|
|
411
|
+
color:var(--brass-accent-fg); filter:brightness(1.1); }
|
|
412
|
+
.b-guest button:focus-visible { outline:2px solid var(--brass-fg); outline-offset:2px; }
|
|
334
413
|
/* Historical-version preview bar: a full-width strip the toolbar shows below
|
|
335
414
|
the bar whenever the active doc is previewing an earlier version. The strip
|
|
336
415
|
itself is the platform-served version-bar iframe (the SDK owns only this
|
|
@@ -403,6 +482,13 @@ function el<K extends keyof HTMLElementTagNameMap>(
|
|
|
403
482
|
return node;
|
|
404
483
|
}
|
|
405
484
|
|
|
485
|
+
// A failed publish shows the server's own sentence, which is what the route
|
|
486
|
+
// answers a refusal with.
|
|
487
|
+
function publishFailureText(err: unknown): string {
|
|
488
|
+
const message = err instanceof Error ? err.message.trim() : '';
|
|
489
|
+
return message === '' || message === 'Request failed' ? 'Publishing failed.' : message;
|
|
490
|
+
}
|
|
491
|
+
|
|
406
492
|
function initialOf(user: BrassUser): string {
|
|
407
493
|
const label = user.display_name ?? user.email ?? user.sub;
|
|
408
494
|
return label.trim().charAt(0).toUpperCase() || '?';
|
|
@@ -453,6 +539,28 @@ function renderCommandButton(cmd: Command, asTool: boolean): HTMLButtonElement {
|
|
|
453
539
|
return b;
|
|
454
540
|
}
|
|
455
541
|
|
|
542
|
+
const COMMAND_ICONS: Record<string, string> = {
|
|
543
|
+
undo: '<path d="M9 14 4 9l5-5"/><path d="M4 9h10.5a5.5 5.5 0 0 1 0 11H11"/>',
|
|
544
|
+
redo: '<path d="m15 14 5-5-5-5"/><path d="M20 9H9.5a5.5 5.5 0 0 0 0 11H13"/>',
|
|
545
|
+
};
|
|
546
|
+
|
|
547
|
+
// A toolbar command as an icon button, named by its label for assistive
|
|
548
|
+
// technology and on hover. A command with no icon keeps its text.
|
|
549
|
+
function commandGlyph(icon: string): string {
|
|
550
|
+
return `<svg viewBox="0 0 24 24" width="18" height="18" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${icon}</svg>`;
|
|
551
|
+
}
|
|
552
|
+
|
|
553
|
+
function renderCommandIcon(cmd: Command): HTMLButtonElement {
|
|
554
|
+
const icon = COMMAND_ICONS[cmd.id];
|
|
555
|
+
if (icon === undefined) return renderCommandButton(cmd, true);
|
|
556
|
+
const b = el('button', 'b-tool b-tool-icon');
|
|
557
|
+
b.type = 'button';
|
|
558
|
+
b.setAttribute('aria-label', cmd.label);
|
|
559
|
+
b.innerHTML = commandGlyph(icon);
|
|
560
|
+
b.disabled = !cmd.enabled || cmd.running;
|
|
561
|
+
return b;
|
|
562
|
+
}
|
|
563
|
+
|
|
456
564
|
// Place a top-layer (position:fixed) popover under its anchor, clamped to the
|
|
457
565
|
// viewport so a wide one near an edge never runs off-screen. `align` picks the
|
|
458
566
|
// edge it lines up with the anchor: `start` (left) or `end` (right).
|
|
@@ -630,9 +738,18 @@ function buildFileMenu(deps: FileMenuDeps): PieceInternal {
|
|
|
630
738
|
|
|
631
739
|
const render = (): void => {
|
|
632
740
|
const rows: HTMLElement[] = [];
|
|
741
|
+
const expiresAt = deps.session.document?.expiresAt ?? null;
|
|
742
|
+
if (expiresAt !== null) {
|
|
743
|
+
const note = el('div', 'b-note b-expiry');
|
|
744
|
+
note.textContent = expiresInLabel(expiresAt);
|
|
745
|
+
rows.push(note);
|
|
746
|
+
}
|
|
633
747
|
let group: string | null = null;
|
|
634
748
|
for (const c of deps.session.commands) {
|
|
635
749
|
if (c.surface !== 'menu') continue;
|
|
750
|
+
// A session command that is not enabled is not offered, so it is left
|
|
751
|
+
// out rather than greyed.
|
|
752
|
+
if (c.group === 'session' && !c.enabled) continue;
|
|
636
753
|
// A rule wherever the family changes: the entries that put a
|
|
637
754
|
// different document on screen, then the ones about this document.
|
|
638
755
|
if (group !== null && c.group !== group) {
|
|
@@ -649,6 +766,8 @@ function buildFileMenu(deps: FileMenuDeps): PieceInternal {
|
|
|
649
766
|
rows.push(b);
|
|
650
767
|
}
|
|
651
768
|
menu.pop.replaceChildren(...rows);
|
|
769
|
+
if (rows.length === 0) menu.close();
|
|
770
|
+
menu.root.hidden = rows.length === 0;
|
|
652
771
|
};
|
|
653
772
|
render();
|
|
654
773
|
const unsub = deps.session.subscribe(render);
|
|
@@ -726,11 +845,11 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
726
845
|
if (record.location_label !== null) {
|
|
727
846
|
loc.textContent = record.location_label;
|
|
728
847
|
loc.className = 'b-loc filed';
|
|
729
|
-
loc.title = 'Move or rename
|
|
848
|
+
loc.title = 'Move or rename';
|
|
730
849
|
} else {
|
|
731
850
|
loc.textContent = 'Add to project';
|
|
732
851
|
loc.className = 'b-loc loose';
|
|
733
|
-
loc.title = '
|
|
852
|
+
loc.title = 'Add to a project';
|
|
734
853
|
}
|
|
735
854
|
loc.hidden = false;
|
|
736
855
|
} catch {
|
|
@@ -753,15 +872,24 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
753
872
|
// Three states, not two. A bound doc whose name has not resolved yet
|
|
754
873
|
// (a fresh create the server just numbered, a create/open still settling)
|
|
755
874
|
// and an open in flight (`status` 'loading', `document` still null) both
|
|
756
|
-
// leave the name UNKNOWN: render nothing rather than assert "
|
|
875
|
+
// leave the name UNKNOWN: render nothing rather than assert "Nothing open",
|
|
757
876
|
// the same way the session reports 'unknown' auth as a neutral state
|
|
758
877
|
// instead of flashing 'signed-out'. Only the genuine empty state (no doc,
|
|
759
|
-
// not loading) reads "
|
|
878
|
+
// not loading) reads "Nothing open".
|
|
879
|
+
// A link the app opens only for signed-in people names the refusal,
|
|
880
|
+
// since the account control beside it is where the visitor signs in.
|
|
881
|
+
const refusal =
|
|
882
|
+
!doc &&
|
|
883
|
+
session.status === 'error' &&
|
|
884
|
+
session.error?.code === 'sign_in_required' &&
|
|
885
|
+
deps.onLink?.() === true
|
|
886
|
+
? session.error.message
|
|
887
|
+
: null;
|
|
760
888
|
const name = doc
|
|
761
889
|
? doc.name
|
|
762
890
|
: session.status === 'loading'
|
|
763
891
|
? null
|
|
764
|
-
: '
|
|
892
|
+
: (refusal ?? 'Nothing open');
|
|
765
893
|
if (name === null) {
|
|
766
894
|
title.hidden = true;
|
|
767
895
|
} else {
|
|
@@ -800,15 +928,96 @@ function buildDocumentTitle(deps: DocumentTitleDeps): PieceInternal {
|
|
|
800
928
|
};
|
|
801
929
|
}
|
|
802
930
|
|
|
803
|
-
|
|
931
|
+
// Shows the aria-label of the icon button under the pointer or keyboard
|
|
932
|
+
// focus inside `root`, below the button and clamped to the viewport.
|
|
933
|
+
// One tip per document, outside every piece, so a piece's own children stay
|
|
934
|
+
// what it renders.
|
|
935
|
+
let sharedTip: HTMLElement | null = null;
|
|
936
|
+
function iconTip(): HTMLElement {
|
|
937
|
+
if (sharedTip?.isConnected) return sharedTip;
|
|
938
|
+
sharedTip = el('div', 'brass-ui b-tip');
|
|
939
|
+
sharedTip.setAttribute('aria-hidden', 'true');
|
|
940
|
+
sharedTip.setAttribute('popover', 'manual');
|
|
941
|
+
document.body.append(sharedTip);
|
|
942
|
+
return sharedTip;
|
|
943
|
+
}
|
|
944
|
+
|
|
945
|
+
function attachIconTooltips(root: HTMLElement): { hide(): void } {
|
|
946
|
+
const tip = iconTip();
|
|
947
|
+
const api = tip as HTMLElement & { showPopover?: () => void; hidePopover?: () => void };
|
|
948
|
+
let shownFor: HTMLElement | null = null;
|
|
949
|
+
const show = (btn: HTMLElement): void => {
|
|
950
|
+
const label = btn.getAttribute('aria-label');
|
|
951
|
+
if (!label) return;
|
|
952
|
+
shownFor = btn;
|
|
953
|
+
tip.textContent = label;
|
|
954
|
+
if (typeof api.showPopover === 'function') {
|
|
955
|
+
try {
|
|
956
|
+
if (!tip.matches(':popover-open')) api.showPopover();
|
|
957
|
+
} catch {
|
|
958
|
+
tip.classList.add('b-tip-open');
|
|
959
|
+
}
|
|
960
|
+
} else tip.classList.add('b-tip-open');
|
|
961
|
+
const r = btn.getBoundingClientRect();
|
|
962
|
+
const w = tip.offsetWidth;
|
|
963
|
+
const left = Math.max(4, Math.min(r.left + r.width / 2 - w / 2, window.innerWidth - w - 4));
|
|
964
|
+
tip.style.left = `${left}px`;
|
|
965
|
+
tip.style.top = `${r.bottom + 6}px`;
|
|
966
|
+
};
|
|
967
|
+
const hide = (): void => {
|
|
968
|
+
shownFor = null;
|
|
969
|
+
tip.classList.remove('b-tip-open');
|
|
970
|
+
try {
|
|
971
|
+
if (tip.matches(':popover-open')) api.hidePopover?.();
|
|
972
|
+
} catch {
|
|
973
|
+
// Not showing.
|
|
974
|
+
}
|
|
975
|
+
};
|
|
976
|
+
const iconOf = (t: EventTarget | null): HTMLElement | null =>
|
|
977
|
+
t instanceof Element ? t.closest<HTMLElement>('.b-tool-icon') : null;
|
|
978
|
+
root.addEventListener('pointerover', (e) => {
|
|
979
|
+
const btn = iconOf(e.target);
|
|
980
|
+
if (btn && e.pointerType === 'mouse') show(btn);
|
|
981
|
+
});
|
|
982
|
+
root.addEventListener('pointerout', (e) => {
|
|
983
|
+
if (iconOf(e.target) === shownFor && iconOf(e.relatedTarget) !== shownFor) hide();
|
|
984
|
+
});
|
|
985
|
+
root.addEventListener('pointerdown', hide);
|
|
986
|
+
root.addEventListener('focusin', (e) => {
|
|
987
|
+
const btn = iconOf(e.target);
|
|
988
|
+
if (btn && btn.matches(':focus-visible')) show(btn);
|
|
989
|
+
});
|
|
990
|
+
root.addEventListener('focusout', hide);
|
|
991
|
+
return { hide };
|
|
992
|
+
}
|
|
993
|
+
|
|
994
|
+
// `full` and `compact` are the toolbar's two copies, which the narrow-width
|
|
995
|
+
// rules swap. `standalone` is the one an app mounts itself: no copy stands in
|
|
996
|
+
// for it at a narrow width, so those rules must not hide it.
|
|
997
|
+
function buildUndoRedo(
|
|
998
|
+
deps: UndoRedoDeps,
|
|
999
|
+
variant: 'full' | 'compact' | 'standalone' = 'full',
|
|
1000
|
+
): PieceInternal {
|
|
804
1001
|
injectStyles(document);
|
|
805
|
-
const root = el(
|
|
1002
|
+
const root = el(
|
|
1003
|
+
'div',
|
|
1004
|
+
variant === 'standalone' ? 'brass-ui b-undo-redo' : `brass-ui b-undo-redo b-undo-${variant}`,
|
|
1005
|
+
);
|
|
1006
|
+
const tips = attachIconTooltips(root);
|
|
806
1007
|
const render = (): void => {
|
|
1008
|
+
// The pair shows only while one of them can act: before the first edit, on
|
|
1009
|
+
// a read-only document, and with nothing open they would only ever show
|
|
1010
|
+
// disabled, and rendering none lets the bar give their row back.
|
|
1011
|
+
const commands = deps.session.commands.filter((c) => c.surface === 'toolbar');
|
|
1012
|
+
tips.hide();
|
|
1013
|
+
if (!commands.some((c) => c.enabled)) {
|
|
1014
|
+
root.replaceChildren();
|
|
1015
|
+
return;
|
|
1016
|
+
}
|
|
807
1017
|
root.replaceChildren(
|
|
808
|
-
...
|
|
809
|
-
.filter((c) => c.surface === 'toolbar')
|
|
1018
|
+
...commands
|
|
810
1019
|
.map((c) => {
|
|
811
|
-
const b =
|
|
1020
|
+
const b = renderCommandIcon(c);
|
|
812
1021
|
b.onclick = () => void c.run();
|
|
813
1022
|
return b;
|
|
814
1023
|
}),
|
|
@@ -820,6 +1029,7 @@ function buildUndoRedo(deps: UndoRedoDeps): PieceInternal {
|
|
|
820
1029
|
root,
|
|
821
1030
|
destroy(): void {
|
|
822
1031
|
unsub();
|
|
1032
|
+
tips.hide();
|
|
823
1033
|
},
|
|
824
1034
|
};
|
|
825
1035
|
}
|
|
@@ -836,9 +1046,28 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
|
|
|
836
1046
|
let boundDoc: DocumentSession['document'] = null;
|
|
837
1047
|
let presenceOff: (() => void) | null = null;
|
|
838
1048
|
|
|
1049
|
+
// The roster lists every other connection, so the reader's own other tabs,
|
|
1050
|
+
// and a page they navigated away from until its socket closes, are in it.
|
|
1051
|
+
// A pip stands for a person: one per sub, and never the reader. A visitor
|
|
1052
|
+
// on a shared link has an empty sub, so each such connection is its own
|
|
1053
|
+
// pip.
|
|
1054
|
+
const collaborators = (
|
|
1055
|
+
participants: readonly PresenceParticipant[],
|
|
1056
|
+
): PresenceParticipant[] => {
|
|
1057
|
+
const own = deps.getUser?.()?.sub;
|
|
1058
|
+
const seen = new Set<string>();
|
|
1059
|
+
return participants.filter((p) => {
|
|
1060
|
+
if (own !== undefined && p.sub === own) return false;
|
|
1061
|
+
const person = p.sub !== '' ? p.sub : `connection:${p.connectionId}`;
|
|
1062
|
+
if (seen.has(person)) return false;
|
|
1063
|
+
seen.add(person);
|
|
1064
|
+
return true;
|
|
1065
|
+
});
|
|
1066
|
+
};
|
|
1067
|
+
|
|
839
1068
|
const renderRoster = (participants: readonly PresenceParticipant[]): void => {
|
|
840
1069
|
root.replaceChildren(
|
|
841
|
-
...participants.map((p) => {
|
|
1070
|
+
...collaborators(participants).map((p) => {
|
|
842
1071
|
const pip = el('span', 'b-pip');
|
|
843
1072
|
pip.style.background = p.color;
|
|
844
1073
|
const label = p.displayName ?? p.sub ?? '?';
|
|
@@ -895,6 +1124,33 @@ function buildPresenceRoster(deps: PresenceRosterDeps): PieceInternal {
|
|
|
895
1124
|
};
|
|
896
1125
|
}
|
|
897
1126
|
|
|
1127
|
+
// The guest's account-menu entry, and its wording on a document created
|
|
1128
|
+
// without an account, where signing in is what keeps it.
|
|
1129
|
+
const SIGN_IN_LABEL = 'Sign in';
|
|
1130
|
+
const SIGN_IN_TO_KEEP_LABEL = 'Sign in to keep your work';
|
|
1131
|
+
const KEEP_LABEL = 'Keep';
|
|
1132
|
+
|
|
1133
|
+
const DAY_MS = 24 * 60 * 60 * 1000;
|
|
1134
|
+
|
|
1135
|
+
// The file menu's line for a document the platform will remove: whole days
|
|
1136
|
+
// until then, rounded up, so the last day still reads as one.
|
|
1137
|
+
export function expiresInLabel(expiresAt: Date, now: number = Date.now()): string {
|
|
1138
|
+
const days = Math.max(1, Math.ceil((expiresAt.getTime() - now) / DAY_MS));
|
|
1139
|
+
return `Your work will be lost in ${days} ${days === 1 ? 'day' : 'days'}`;
|
|
1140
|
+
}
|
|
1141
|
+
|
|
1142
|
+
// Someone else's temporary work, to a signed-in reader who cannot keep it.
|
|
1143
|
+
export function removedInLabel(expiresAt: Date, now: number = Date.now()): string {
|
|
1144
|
+
const days = Math.max(1, Math.ceil((expiresAt.getTime() - now) / DAY_MS));
|
|
1145
|
+
return `This work will be removed in ${days} ${days === 1 ? 'day' : 'days'}`;
|
|
1146
|
+
}
|
|
1147
|
+
|
|
1148
|
+
// The strip's sentence: the same count as the file menu's line, led by the
|
|
1149
|
+
// state that explains it.
|
|
1150
|
+
export function notSignedInLabel(expiresAt: Date, now: number = Date.now()): string {
|
|
1151
|
+
return `You are not signed in. ${expiresInLabel(expiresAt, now)}.`;
|
|
1152
|
+
}
|
|
1153
|
+
|
|
898
1154
|
// A monochrome 16px stroke glyph (inherits `color` via `currentColor`). Built
|
|
899
1155
|
// in the SVG namespace because `createElement('svg')` yields an HTML element
|
|
900
1156
|
// that won't render.
|
|
@@ -992,13 +1248,23 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
992
1248
|
// covering the gap between a click here and the first `publishing` frame
|
|
993
1249
|
// arriving; the two combine as `isPublishing` in `render`.
|
|
994
1250
|
let publishing = false;
|
|
995
|
-
|
|
1251
|
+
// The server's refusal of the last publish from this panel, shown where
|
|
1252
|
+
// the resting sentence sits until the next attempt.
|
|
1253
|
+
let publishError: string | null = null;
|
|
996
1254
|
let publishTimer: ReturnType<typeof setTimeout> | null = null;
|
|
997
1255
|
// Set on a click, cleared by the next publish-state frame. Between the two,
|
|
998
1256
|
// the held state describes the PREVIOUS attempt, and rendering it would
|
|
999
1257
|
// report the last failure over the publish just started.
|
|
1000
1258
|
let publishStateStale = false;
|
|
1001
1259
|
let currentStatus: DocumentDriveStatus | null = null;
|
|
1260
|
+
// The panel is asking whether to publish over the drive's newer version.
|
|
1261
|
+
let confirmingOverwrite = false;
|
|
1262
|
+
pop.addEventListener('toggle', (event) => {
|
|
1263
|
+
if ((event as ToggleEvent).newState === 'closed' && confirmingOverwrite) {
|
|
1264
|
+
confirmingOverwrite = false;
|
|
1265
|
+
render(currentStatus);
|
|
1266
|
+
}
|
|
1267
|
+
});
|
|
1002
1268
|
const clearPublishing = (): void => {
|
|
1003
1269
|
publishing = false;
|
|
1004
1270
|
// The stale mark is cleared with the hold it belongs to. It is otherwise
|
|
@@ -1019,6 +1285,16 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1019
1285
|
// one.
|
|
1020
1286
|
const renderPublishOffer = (target: DrivePublishTarget): void => {
|
|
1021
1287
|
root.hidden = false;
|
|
1288
|
+
if (target.unpublishable) {
|
|
1289
|
+
// Nothing to publish, so the icon asks for no attention; the panel
|
|
1290
|
+
// names the drive and says why the file cannot go to it.
|
|
1291
|
+
root.classList.remove('has-changes', 'is-broken');
|
|
1292
|
+
button.title = `Cannot publish to ${target.driveName}`;
|
|
1293
|
+
const p = el('p', 'b-drive-unreadable');
|
|
1294
|
+
p.textContent = target.detail;
|
|
1295
|
+
pop.replaceChildren(p);
|
|
1296
|
+
return;
|
|
1297
|
+
}
|
|
1022
1298
|
// A document the drive has never received is as far out of sync as one
|
|
1023
1299
|
// whose edits have not been published, so the icon carries the same
|
|
1024
1300
|
// attention an `ahead` document's does. What separates them is what the
|
|
@@ -1035,7 +1311,8 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1035
1311
|
: `Publish to ${target.driveName}`;
|
|
1036
1312
|
|
|
1037
1313
|
const failed =
|
|
1038
|
-
!inFlight &&
|
|
1314
|
+
!inFlight &&
|
|
1315
|
+
(publishError !== null || state?.failed === true || target.publishFailed === true);
|
|
1039
1316
|
|
|
1040
1317
|
const rows: HTMLElement[] = [];
|
|
1041
1318
|
// One line, server-authored either way: the worker's phase copy while it
|
|
@@ -1046,7 +1323,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1046
1323
|
const p = el('p', failed ? 'b-drive-unreadable' : 'b-drive-detail');
|
|
1047
1324
|
p.textContent = inFlight
|
|
1048
1325
|
? (state?.publishing === true ? (state.detail ?? 'Publishing...') : 'Publishing...')
|
|
1049
|
-
: (state?.detail ?? target.detail);
|
|
1326
|
+
: (publishError ?? state?.detail ?? target.detail);
|
|
1050
1327
|
rows.push(p);
|
|
1051
1328
|
|
|
1052
1329
|
const pub = el('button');
|
|
@@ -1054,13 +1331,13 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1054
1331
|
pub.disabled = inFlight;
|
|
1055
1332
|
pub.textContent = inFlight
|
|
1056
1333
|
? 'Publishing...'
|
|
1057
|
-
:
|
|
1334
|
+
: failed
|
|
1058
1335
|
? `Publish to ${target.driveName}, retry`
|
|
1059
1336
|
: `Publish to ${target.driveName}`;
|
|
1060
1337
|
pub.onclick = () => {
|
|
1061
1338
|
const doc = deps.session.document;
|
|
1062
1339
|
if (!doc) return;
|
|
1063
|
-
publishError =
|
|
1340
|
+
publishError = null;
|
|
1064
1341
|
publishing = true;
|
|
1065
1342
|
publishStateStale = true;
|
|
1066
1343
|
// The same safety net the mirror panel keeps: a publish that neither
|
|
@@ -1071,9 +1348,9 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1071
1348
|
render(currentStatus);
|
|
1072
1349
|
}, PUBLISH_HOLD_MS);
|
|
1073
1350
|
render(currentStatus);
|
|
1074
|
-
void doc.pushToDrive().catch(() => {
|
|
1351
|
+
void doc.pushToDrive().catch((err: unknown) => {
|
|
1075
1352
|
clearPublishing();
|
|
1076
|
-
publishError =
|
|
1353
|
+
publishError = publishFailureText(err);
|
|
1077
1354
|
render(currentStatus);
|
|
1078
1355
|
});
|
|
1079
1356
|
};
|
|
@@ -1083,6 +1360,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1083
1360
|
|
|
1084
1361
|
const render = (status: DocumentDriveStatus | null): void => {
|
|
1085
1362
|
currentStatus = status;
|
|
1363
|
+
if (status?.state !== 'diverged' || !status.canPush) confirmingOverwrite = false;
|
|
1086
1364
|
// A document that mirrors nothing can still have somewhere to go: its
|
|
1087
1365
|
// project may be linked to a drive that takes it as a new file, which is
|
|
1088
1366
|
// what an upload into a linked project sits waiting for. Offer that
|
|
@@ -1105,7 +1383,7 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1105
1383
|
// local hold (the server frame already has `publishing: false`).
|
|
1106
1384
|
if (status.state === 'synced') {
|
|
1107
1385
|
clearPublishing();
|
|
1108
|
-
publishError =
|
|
1386
|
+
publishError = null;
|
|
1109
1387
|
}
|
|
1110
1388
|
root.hidden = false;
|
|
1111
1389
|
// Either a local click hold or the server's own `publishing` flag means a
|
|
@@ -1172,6 +1450,11 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1172
1450
|
warn.textContent = status.sourceUnreadable;
|
|
1173
1451
|
rows.push(warn);
|
|
1174
1452
|
}
|
|
1453
|
+
if (publishError !== null && !isPublishing) {
|
|
1454
|
+
const warn = el('p', 'b-drive-unreadable');
|
|
1455
|
+
warn.textContent = publishError;
|
|
1456
|
+
rows.push(warn);
|
|
1457
|
+
}
|
|
1175
1458
|
|
|
1176
1459
|
if (isPublishing) {
|
|
1177
1460
|
// Held while the publish is in flight (a local click hold, or the doc's
|
|
@@ -1182,16 +1465,13 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1182
1465
|
pub.disabled = true;
|
|
1183
1466
|
rows.push(pub);
|
|
1184
1467
|
} else if (status.canPush) {
|
|
1185
|
-
// `canPush`
|
|
1186
|
-
//
|
|
1187
|
-
|
|
1188
|
-
const pub = el('button');
|
|
1189
|
-
pub.type = 'button';
|
|
1190
|
-
pub.textContent = publishError ? 'Publish failed, retry' : 'Publish changes';
|
|
1191
|
-
pub.onclick = () => {
|
|
1468
|
+
// `canPush` is the server's publish decision, so this never offers a
|
|
1469
|
+
// publish the server would refuse.
|
|
1470
|
+
const startPublish = (): void => {
|
|
1192
1471
|
const doc = deps.session.document;
|
|
1193
1472
|
if (!doc) return;
|
|
1194
|
-
|
|
1473
|
+
confirmingOverwrite = false;
|
|
1474
|
+
publishError = null;
|
|
1195
1475
|
publishing = true;
|
|
1196
1476
|
// Safety net: if the publish neither errors nor reaches synced (the
|
|
1197
1477
|
// webhook never confirms), release the held state so the popover
|
|
@@ -1201,12 +1481,45 @@ function buildDriveSync(deps: DriveSyncDeps): PieceInternal {
|
|
|
1201
1481
|
render(currentStatus);
|
|
1202
1482
|
}, PUBLISH_HOLD_MS);
|
|
1203
1483
|
render(currentStatus);
|
|
1204
|
-
void doc.pushToDrive().catch(() => {
|
|
1484
|
+
void doc.pushToDrive().catch((err: unknown) => {
|
|
1205
1485
|
clearPublishing();
|
|
1206
|
-
publishError =
|
|
1486
|
+
publishError = publishFailureText(err);
|
|
1207
1487
|
render(currentStatus);
|
|
1208
1488
|
});
|
|
1209
1489
|
};
|
|
1490
|
+
// A diverged document publishes over a drive version it never took, so
|
|
1491
|
+
// the panel asks first, in place.
|
|
1492
|
+
const overwrites = status.state === 'diverged';
|
|
1493
|
+
if (overwrites && confirmingOverwrite) {
|
|
1494
|
+
const ask = el('p', 'b-drive-unreadable');
|
|
1495
|
+
ask.textContent = status.keepsPriorVersions
|
|
1496
|
+
? `Publishing makes this doc the latest version in ${driveName}. ${driveName} keeps the newer version in its history.`
|
|
1497
|
+
: `Publishing replaces the newer version in ${driveName} with this doc.`;
|
|
1498
|
+
const confirm = el('button');
|
|
1499
|
+
confirm.type = 'button';
|
|
1500
|
+
confirm.textContent = `Publish over ${driveName}`;
|
|
1501
|
+
confirm.onclick = startPublish;
|
|
1502
|
+
const cancel = el('button');
|
|
1503
|
+
cancel.type = 'button';
|
|
1504
|
+
cancel.textContent = 'Cancel';
|
|
1505
|
+
cancel.onclick = () => {
|
|
1506
|
+
confirmingOverwrite = false;
|
|
1507
|
+
render(currentStatus);
|
|
1508
|
+
};
|
|
1509
|
+
rows.push(ask, confirm, cancel);
|
|
1510
|
+
pop.replaceChildren(...rows);
|
|
1511
|
+
return;
|
|
1512
|
+
}
|
|
1513
|
+
const pub = el('button');
|
|
1514
|
+
pub.type = 'button';
|
|
1515
|
+
const verb = publishError !== null ? 'Publish failed, retry' : 'Publish changes';
|
|
1516
|
+
pub.textContent = overwrites ? `${verb}...` : verb;
|
|
1517
|
+
pub.onclick = overwrites
|
|
1518
|
+
? () => {
|
|
1519
|
+
confirmingOverwrite = true;
|
|
1520
|
+
render(currentStatus);
|
|
1521
|
+
}
|
|
1522
|
+
: startPublish;
|
|
1210
1523
|
rows.push(pub);
|
|
1211
1524
|
}
|
|
1212
1525
|
// Resolve a divergence via the hosted take-source / keep-both surface. Not
|
|
@@ -1315,7 +1628,6 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1315
1628
|
),
|
|
1316
1629
|
'Open Brass dashboard',
|
|
1317
1630
|
);
|
|
1318
|
-
dashboardLink.href = `${deps.dashboardBaseUrl}/home`;
|
|
1319
1631
|
dashboardLink.target = '_blank';
|
|
1320
1632
|
dashboardLink.rel = 'noopener noreferrer';
|
|
1321
1633
|
dashboardLink.onclick = () => menu.close();
|
|
@@ -1350,9 +1662,10 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1350
1662
|
// meaningless, so the menu offers a single "Sign in" that takes the top
|
|
1351
1663
|
// window through the app's normal sign-in and returns to this URL.
|
|
1352
1664
|
const signInBtn = el('button');
|
|
1665
|
+
const signInLabel = document.createTextNode(SIGN_IN_LABEL);
|
|
1353
1666
|
signInBtn.append(
|
|
1354
1667
|
strokeIcon('M15 3h4a2 2 0 0 1 2 2v14a2 2 0 0 1-2 2h-4', 'm10 17 5-5-5-5', 'M15 12H3'),
|
|
1355
|
-
|
|
1668
|
+
signInLabel,
|
|
1356
1669
|
);
|
|
1357
1670
|
signInBtn.onclick = () => {
|
|
1358
1671
|
menu.close();
|
|
@@ -1387,6 +1700,11 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1387
1700
|
dashboardLink.hidden = !signedIn;
|
|
1388
1701
|
signOutBtn.hidden = !signedIn;
|
|
1389
1702
|
signInBtn.hidden = signedIn;
|
|
1703
|
+
// On a document that expires, signing in is what keeps it.
|
|
1704
|
+
signInLabel.data =
|
|
1705
|
+
deps.session?.document?.expiresAt != null
|
|
1706
|
+
? SIGN_IN_TO_KEEP_LABEL
|
|
1707
|
+
: SIGN_IN_LABEL;
|
|
1390
1708
|
// The organization line names what the app is acting in, so it is drawn
|
|
1391
1709
|
// only when there is something to name: an app that has not needed an
|
|
1392
1710
|
// organization yet has not resolved one, and resolving one to fill this
|
|
@@ -1395,10 +1713,11 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1395
1713
|
orgEl.textContent = organizationName;
|
|
1396
1714
|
orgEl.title = organizationName;
|
|
1397
1715
|
orgEl.hidden = organizationName === '';
|
|
1716
|
+
const dashboardBaseUrl = deps.dashboardBaseUrl();
|
|
1398
1717
|
dashboardLink.href =
|
|
1399
1718
|
signedIn && organization
|
|
1400
|
-
? `${
|
|
1401
|
-
: `${
|
|
1719
|
+
? `${dashboardBaseUrl}/organizations/${encodeURIComponent(organization.id)}`
|
|
1720
|
+
: `${dashboardBaseUrl}/home`;
|
|
1402
1721
|
// Offered to every signed-in reader, including one the line has nothing
|
|
1403
1722
|
// to name and one who belongs to a single organization. The surface
|
|
1404
1723
|
// behind it both moves the app and creates an organization, so gating it
|
|
@@ -1416,19 +1735,71 @@ function buildAccountMenu(deps: AccountMenuDeps): PieceInternal {
|
|
|
1416
1735
|
organization = next;
|
|
1417
1736
|
render();
|
|
1418
1737
|
});
|
|
1738
|
+
const unsubSession = deps.session?.subscribe(render);
|
|
1419
1739
|
return {
|
|
1420
1740
|
root: menu.root,
|
|
1421
1741
|
destroy(): void {
|
|
1422
1742
|
unsub();
|
|
1423
1743
|
unsubOrganization?.();
|
|
1744
|
+
unsubSession?.();
|
|
1424
1745
|
menu.destroy();
|
|
1425
1746
|
},
|
|
1426
1747
|
};
|
|
1427
1748
|
}
|
|
1428
1749
|
|
|
1750
|
+
// The strip under the bar on a document created without an account. It is
|
|
1751
|
+
// drawn whenever the active doc carries `expiresAt`, re-read on every session
|
|
1752
|
+
// notification: a sign-in claims the document and clears the expiry, and the
|
|
1753
|
+
// active document can change under the chrome. Empty otherwise, so the row
|
|
1754
|
+
// costs no height on an ordinary document.
|
|
1755
|
+
function buildNotSignedIn(deps: NotSignedInDeps): PieceInternal {
|
|
1756
|
+
injectStyles(document);
|
|
1757
|
+
const root = el('div', 'brass-ui b-guest');
|
|
1758
|
+
root.setAttribute('role', 'status');
|
|
1759
|
+
const message = el('span', 'b-guest-msg');
|
|
1760
|
+
const signInBtn = el('button');
|
|
1761
|
+
signInBtn.type = 'button';
|
|
1762
|
+
signInBtn.textContent = SIGN_IN_LABEL;
|
|
1763
|
+
signInBtn.onclick = () => deps.signIn();
|
|
1764
|
+
const keepBtn = el('button');
|
|
1765
|
+
keepBtn.type = 'button';
|
|
1766
|
+
keepBtn.textContent = KEEP_LABEL;
|
|
1767
|
+
keepBtn.onclick = () => deps.keep?.();
|
|
1768
|
+
|
|
1769
|
+
const render = (): void => {
|
|
1770
|
+
const expiresAt = deps.session.document?.expiresAt ?? null;
|
|
1771
|
+
if (expiresAt === null) {
|
|
1772
|
+
root.classList.remove('b-guest-on');
|
|
1773
|
+
root.replaceChildren();
|
|
1774
|
+
return;
|
|
1775
|
+
}
|
|
1776
|
+
if ((deps.getUser?.() ?? null) === null) {
|
|
1777
|
+
message.textContent = notSignedInLabel(expiresAt);
|
|
1778
|
+
root.replaceChildren(message, signInBtn);
|
|
1779
|
+
} else if (deps.canKeep?.() === true && deps.keep !== undefined) {
|
|
1780
|
+
message.textContent = `${expiresInLabel(expiresAt)}.`;
|
|
1781
|
+
root.replaceChildren(message, keepBtn);
|
|
1782
|
+
} else {
|
|
1783
|
+
message.textContent = `${removedInLabel(expiresAt)}.`;
|
|
1784
|
+
root.replaceChildren(message);
|
|
1785
|
+
}
|
|
1786
|
+
root.classList.add('b-guest-on');
|
|
1787
|
+
};
|
|
1788
|
+
render();
|
|
1789
|
+
const unsub = deps.session.subscribe(render);
|
|
1790
|
+
const unsubUser = deps.onUserChange?.(() => render());
|
|
1791
|
+
return {
|
|
1792
|
+
root,
|
|
1793
|
+
destroy(): void {
|
|
1794
|
+
unsub();
|
|
1795
|
+
unsubUser?.();
|
|
1796
|
+
},
|
|
1797
|
+
};
|
|
1798
|
+
}
|
|
1799
|
+
|
|
1429
1800
|
// The historical-version preview bar. When the active doc is previewing an
|
|
1430
1801
|
// earlier version (a hosted `history`-initiated checkout: `doc.readOnly` is
|
|
1431
|
-
// true and `doc.
|
|
1802
|
+
// true and `doc.previewVersion` is set), the SDK mounts the platform-served
|
|
1432
1803
|
// `/embed/version-bar` inline as a strip and routes its intents (Prev / Next
|
|
1433
1804
|
// checkout, return-to-latest, browse, branch-from) to the doc's internal
|
|
1434
1805
|
// methods. Delivered as a server embed so the platform owns and grows the
|
|
@@ -1439,7 +1810,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1439
1810
|
injectStyles(document);
|
|
1440
1811
|
const root = el('div', 'brass-ui b-version');
|
|
1441
1812
|
root.setAttribute('role', 'group');
|
|
1442
|
-
root.setAttribute('aria-label', '
|
|
1813
|
+
root.setAttribute('aria-label', 'Version');
|
|
1443
1814
|
|
|
1444
1815
|
let frame: VersionBarFrame | null = null;
|
|
1445
1816
|
// Once a preview starts (a checkout), the bar owns a browsing session that
|
|
@@ -1452,7 +1823,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1452
1823
|
// The version the reader dismissed the bar at, when the document was left
|
|
1453
1824
|
// holding one. A document that cannot return to its head (one pinned to a
|
|
1454
1825
|
// version) stays on it, so without this the next render reads a non-null
|
|
1455
|
-
// `doc.
|
|
1826
|
+
// `doc.previewVersion` and latches the bar the reader just dismissed straight back
|
|
1456
1827
|
// on. A dismissal at the live head records nothing: there is no version
|
|
1457
1828
|
// left to latch on, and the head is where the next browse-in-the-app
|
|
1458
1829
|
// request arrives, so a record there would swallow it.
|
|
@@ -1481,12 +1852,15 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1481
1852
|
throw err;
|
|
1482
1853
|
}
|
|
1483
1854
|
};
|
|
1484
|
-
|
|
1485
|
-
|
|
1486
|
-
|
|
1855
|
+
// Assembled when the bar opens rather than at mount, so the frame's origin
|
|
1856
|
+
// and theme are the ones `Brass.init()` settled (see `AccountMenuDeps`).
|
|
1857
|
+
const frameDeps = (): VersionBarDeps => ({
|
|
1858
|
+
dashboardBaseUrl: deps.dashboardBaseUrl(),
|
|
1859
|
+
appId: deps.appId?.(),
|
|
1860
|
+
...(deps.hostedUiTheme ? { theme: deps.hostedUiTheme() } : {}),
|
|
1487
1861
|
// Read the live active document at call time: a bridge intent lands
|
|
1488
1862
|
// async, so it must not close over a doc that may have been superseded.
|
|
1489
|
-
currentVersion: () => session.document?.
|
|
1863
|
+
currentVersion: () => session.document?.previewVersion ?? null,
|
|
1490
1864
|
checkoutVersion: async (version) => {
|
|
1491
1865
|
const doc = session.document;
|
|
1492
1866
|
if (!doc) return;
|
|
@@ -1514,7 +1888,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1514
1888
|
// flag is cleared too, or the next render would latch the bar back on.
|
|
1515
1889
|
const doc = session.document;
|
|
1516
1890
|
browsing = false;
|
|
1517
|
-
dismissedAt = doc?.
|
|
1891
|
+
dismissedAt = doc?.previewVersion ?? undefined;
|
|
1518
1892
|
doc?._setVersionBrowsing(false);
|
|
1519
1893
|
// Leaving the bar means leaving the preview: the bar is the only
|
|
1520
1894
|
// surface that returns a document to its head, so a dismissal that
|
|
@@ -1527,7 +1901,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1527
1901
|
// pick sets only `version`, so a stale `dismissedAt` equal to it would
|
|
1528
1902
|
// otherwise leave the reader checked out, read-only, with no bar and
|
|
1529
1903
|
// no exit).
|
|
1530
|
-
if (doc && doc.
|
|
1904
|
+
if (doc && doc.previewVersion !== null) {
|
|
1531
1905
|
void attempt(doc, () => doc.returnToHead())
|
|
1532
1906
|
.then(() => {
|
|
1533
1907
|
if (session.document === doc) dismissedAt = undefined;
|
|
@@ -1536,7 +1910,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1536
1910
|
}
|
|
1537
1911
|
hideBar();
|
|
1538
1912
|
},
|
|
1539
|
-
};
|
|
1913
|
+
});
|
|
1540
1914
|
|
|
1541
1915
|
const render = (): void => {
|
|
1542
1916
|
const doc = session.document;
|
|
@@ -1550,7 +1924,7 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1550
1924
|
browsing = false;
|
|
1551
1925
|
dismissedAt = undefined;
|
|
1552
1926
|
}
|
|
1553
|
-
// A live checkout (`doc.
|
|
1927
|
+
// A live checkout (`doc.previewVersion !== null`) starts / keeps the browsing
|
|
1554
1928
|
// session. Other read-only states (a view-only share link, a still-loading
|
|
1555
1929
|
// doc) are not this bar's concern, and `doc.id` is null only on the offline
|
|
1556
1930
|
// / test path the embed can't serve.
|
|
@@ -1560,8 +1934,8 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1560
1934
|
if (
|
|
1561
1935
|
doc != null &&
|
|
1562
1936
|
docId !== null &&
|
|
1563
|
-
(doc.
|
|
1564
|
-
(doc.versionBrowsing || doc.
|
|
1937
|
+
(doc.previewVersion !== null || doc.versionBrowsing) &&
|
|
1938
|
+
(doc.versionBrowsing || doc.previewVersion !== dismissedAt)
|
|
1565
1939
|
) {
|
|
1566
1940
|
browsing = true;
|
|
1567
1941
|
dismissedAt = undefined;
|
|
@@ -1573,14 +1947,14 @@ function buildVersionPreview(deps: VersionPreviewDeps): PieceInternal {
|
|
|
1573
1947
|
doc._setVersionBrowsing(true);
|
|
1574
1948
|
}
|
|
1575
1949
|
// The bar stays open for the whole session, including at the live head
|
|
1576
|
-
// (`doc.
|
|
1950
|
+
// (`doc.previewVersion === null`, browsing still true), until `close`.
|
|
1577
1951
|
if (doc != null && docId !== null && browsing) {
|
|
1578
1952
|
root.classList.add('b-version-on');
|
|
1579
|
-
if (!frame) frame = createVersionBarFrame(root, frameDeps);
|
|
1953
|
+
if (!frame) frame = createVersionBarFrame(root, frameDeps());
|
|
1580
1954
|
frame.update({
|
|
1581
1955
|
docId,
|
|
1582
1956
|
branch: doc.branchId ?? undefined,
|
|
1583
|
-
version: doc.
|
|
1957
|
+
version: doc.previewVersion,
|
|
1584
1958
|
branchName: doc._currentBranchName() ?? undefined,
|
|
1585
1959
|
pinned: doc._isVersionPinned,
|
|
1586
1960
|
});
|
|
@@ -1638,7 +2012,7 @@ export function createUndoRedo(
|
|
|
1638
2012
|
container: HTMLElement,
|
|
1639
2013
|
deps: UndoRedoDeps,
|
|
1640
2014
|
): MountedPiece {
|
|
1641
|
-
return mountPiece(container, buildUndoRedo(deps));
|
|
2015
|
+
return mountPiece(container, buildUndoRedo(deps, 'standalone'));
|
|
1642
2016
|
}
|
|
1643
2017
|
|
|
1644
2018
|
// The presence roster (coloured initials of who's on the active doc).
|
|
@@ -1708,11 +2082,17 @@ function buildBarShell(): BarShell {
|
|
|
1708
2082
|
// row-gap, leaving asymmetric padding. "Empty" = every child is blank (no
|
|
1709
2083
|
// text, no element children), so an empty save-indicator span or a
|
|
1710
2084
|
// command-less undo/redo piece both count.
|
|
2085
|
+
// `b-app-tools` marks a strip holding something of the app's beside
|
|
2086
|
+
// undo / redo, which the narrow bar shows on a row of its own.
|
|
1711
2087
|
function updateToolsVisibility(): void {
|
|
1712
|
-
const
|
|
1713
|
-
(c
|
|
2088
|
+
const blank = (c: Element): boolean =>
|
|
2089
|
+
(c.textContent ?? '').trim() === '' && c.childElementCount === 0;
|
|
2090
|
+
const children = Array.from(tools.children);
|
|
2091
|
+
tools.style.display = children.every(blank) ? 'none' : '';
|
|
2092
|
+
bar.classList.toggle(
|
|
2093
|
+
'b-app-tools',
|
|
2094
|
+
children.some((c) => !c.classList.contains('b-undo-full') && !blank(c)),
|
|
1714
2095
|
);
|
|
1715
|
-
tools.style.display = allBlank ? 'none' : '';
|
|
1716
2096
|
}
|
|
1717
2097
|
// The app (and the undo/redo subscription) mutates `tools` after mount, so
|
|
1718
2098
|
// re-evaluate visibility on any change.
|
|
@@ -1759,10 +2139,15 @@ export function createSessionToolbar(
|
|
|
1759
2139
|
pieces.push(drivePiece);
|
|
1760
2140
|
}
|
|
1761
2141
|
|
|
1762
|
-
// Middle: SDK toolbar-surface commands (undo/redo) + the app's slot.
|
|
2142
|
+
// Middle: SDK toolbar-surface commands (undo/redo) + the app's slot. On a
|
|
2143
|
+
// narrow screen the middle wraps to a row of its own, so Undo and Redo show
|
|
2144
|
+
// there instead as icons in the right cluster (CSS swaps the two), and the
|
|
2145
|
+
// middle row only appears for the app's own tools.
|
|
1763
2146
|
const undoRedo = buildUndoRedo(deps);
|
|
1764
2147
|
shell.tools.append(undoRedo.root);
|
|
1765
|
-
|
|
2148
|
+
const compactUndo = buildUndoRedo(deps, 'compact');
|
|
2149
|
+
shell.end.append(compactUndo.root);
|
|
2150
|
+
pieces.push(undoRedo, compactUndo);
|
|
1766
2151
|
|
|
1767
2152
|
// Right cluster: presence roster + account menu.
|
|
1768
2153
|
if (showPresence) {
|
|
@@ -1776,12 +2161,14 @@ export function createSessionToolbar(
|
|
|
1776
2161
|
pieces.push(accountPiece);
|
|
1777
2162
|
}
|
|
1778
2163
|
|
|
1779
|
-
//
|
|
1780
|
-
//
|
|
1781
|
-
//
|
|
2164
|
+
// Two full-width strips sit below the bar: the not-signed-in notice on a
|
|
2165
|
+
// document created without an account, and the historical-version preview
|
|
2166
|
+
// bar. Mounting the toolbar is all an app does to get both, so it never
|
|
2167
|
+
// hand-rolls either.
|
|
2168
|
+
const guestPiece = buildNotSignedIn(deps);
|
|
1782
2169
|
const versionPiece = buildVersionPreview(deps);
|
|
1783
|
-
pieces.push(versionPiece);
|
|
1784
|
-
container.replaceChildren(shell.bar, versionPiece.root);
|
|
2170
|
+
pieces.push(guestPiece, versionPiece);
|
|
2171
|
+
container.replaceChildren(shell.bar, guestPiece.root, versionPiece.root);
|
|
1785
2172
|
|
|
1786
2173
|
return {
|
|
1787
2174
|
tools: shell.tools,
|
|
@@ -1799,15 +2186,18 @@ export function createSessionToolbar(
|
|
|
1799
2186
|
// never wraps when `tools` does.
|
|
1800
2187
|
export function createSessionHeader(
|
|
1801
2188
|
container: HTMLElement,
|
|
1802
|
-
deps?: VersionPreviewDeps,
|
|
2189
|
+
deps?: VersionPreviewDeps & NotSignedInDeps,
|
|
1803
2190
|
): SessionHeaderHandle {
|
|
1804
2191
|
const shell = buildBarShell();
|
|
1805
|
-
// A header shell built with session access includes the
|
|
1806
|
-
//
|
|
1807
|
-
//
|
|
2192
|
+
// A header shell built with session access includes the two strips below
|
|
2193
|
+
// the bar (the not-signed-in notice and the historical-version preview
|
|
2194
|
+
// bar), so a composable-chrome app gets what the full toolbar renders
|
|
2195
|
+
// there without a separate mount.
|
|
2196
|
+
const guestPiece = deps ? buildNotSignedIn(deps) : null;
|
|
1808
2197
|
const versionPiece = deps ? buildVersionPreview(deps) : null;
|
|
1809
2198
|
container.replaceChildren(
|
|
1810
2199
|
shell.bar,
|
|
2200
|
+
...(guestPiece ? [guestPiece.root] : []),
|
|
1811
2201
|
...(versionPiece ? [versionPiece.root] : []),
|
|
1812
2202
|
);
|
|
1813
2203
|
return {
|
|
@@ -1815,6 +2205,7 @@ export function createSessionHeader(
|
|
|
1815
2205
|
tools: shell.tools,
|
|
1816
2206
|
end: shell.end,
|
|
1817
2207
|
destroy(): void {
|
|
2208
|
+
guestPiece?.destroy();
|
|
1818
2209
|
versionPiece?.destroy();
|
|
1819
2210
|
shell.destroy();
|
|
1820
2211
|
container.replaceChildren();
|