Add universal right canvas shell
Alessandro committed
Apr 26, 2026 at 12:56 UTC
c9f1bd21ca2d0b03950f792a624499dd3d8f72cf
8 files changed
+791
-6
tests/test_webui_extension_surfaces.py
+8
@@ -52,6 +52,14 @@ SURFACE_SCENARIOS: list[tuple[str, str]] = [
52
("plugins-list-dropdown-end", "webui/components/plugins/list/plugin-list.html"),
53
("modal-shell-start", "webui/js/modals.js"),
54
("modal-shell-end", "webui/js/modals.js"),
55
+ ("right-canvas-shell-start", "webui/components/canvas/right-canvas.html"),
56
+ ("right-canvas-tabs-start", "webui/components/canvas/right-canvas.html"),
57
+ ("right-canvas-tabs-end", "webui/components/canvas/right-canvas.html"),
58
+ ("right-canvas-toolbar-start", "webui/components/canvas/right-canvas.html"),
59
+ ("right-canvas-toolbar-end", "webui/components/canvas/right-canvas.html"),
60
+ ("right-canvas-panels", "webui/components/canvas/right-canvas.html"),
61
+ ("right-canvas-empty-state", "webui/components/canvas/right-canvas.html"),
62
+ ("right-canvas-shell-end", "webui/components/canvas/right-canvas.html"),
63
]
64
65
webui/components/canvas/right-canvas-store.js
new
+283
@@ -0,0 +1,283 @@
1
+import { createStore } from "/js/AlpineStore.js";
2
+import { callJsExtensions } from "/js/extensions.js";
3
+
4
+const STORAGE_KEY = "a0.rightCanvas";
5
+const DEFAULT_WIDTH = 720;
6
+const MIN_WIDTH = 420;
7
+const MAX_WIDTH = 900;
8
+const DESKTOP_BREAKPOINT = 1200;
9
+const MOBILE_BREAKPOINT = 768;
10
+
11
+function clamp(value, min, max) {
12
+ return Math.min(Math.max(value, min), max);
13
+}
14
+
15
+function viewportWidth() {
16
+ return Math.max(document.documentElement.clientWidth || 0, globalThis.innerWidth || 0);
17
+}
18
+
19
+const model = {
20
+ surfaces: [],
21
+ activeSurfaceId: "",
22
+ isOpen: false,
23
+ width: DEFAULT_WIDTH,
24
+ isOverlayMode: false,
25
+ isMobileMode: false,
26
+ _initialized: false,
27
+ _registering: false,
28
+ _rootElement: null,
29
+ _resizeCleanup: null,
30
+ _lastPayloadBySurface: {},
31
+
32
+ async init(element = null) {
33
+ if (element) this._rootElement = element;
34
+ if (this._initialized) {
35
+ this.applyLayoutState();
36
+ return;
37
+ }
38
+
39
+ this._initialized = true;
40
+ this.restore();
41
+ this.updateLayoutMode();
42
+ this.applyLayoutState();
43
+ globalThis.addEventListener("resize", () => {
44
+ this.updateLayoutMode();
45
+ this.setWidth(this.width, { persist: false });
46
+ this.applyLayoutState();
47
+ });
48
+
49
+ if (!this._registering) {
50
+ this._registering = true;
51
+ await callJsExtensions("right_canvas_register_surfaces", this);
52
+ this._registering = false;
53
+ this.ensureActiveSurface();
54
+ if (this.isOpen && this.activeSurfaceId) {
55
+ globalThis.requestAnimationFrame?.(() => {
56
+ void this.open(this.activeSurfaceId, this._lastPayloadBySurface[this.activeSurfaceId] || {});
57
+ });
58
+ }
59
+ }
60
+ },
61
+
62
+ registerSurface(surface) {
63
+ if (!surface?.id) return;
64
+ const normalized = {
65
+ title: surface.id,
66
+ icon: "web_asset",
67
+ order: 100,
68
+ canOpen: () => true,
69
+ open: () => {},
70
+ close: () => {},
71
+ modalPath: "",
72
+ ...surface,
73
+ };
74
+
75
+ const index = this.surfaces.findIndex((item) => item.id === normalized.id);
76
+ if (index >= 0) {
77
+ this.surfaces.splice(index, 1, normalized);
78
+ } else {
79
+ this.surfaces.push(normalized);
80
+ }
81
+ this.surfaces.sort((a, b) => (a.order ?? 100) - (b.order ?? 100));
82
+ this.ensureActiveSurface();
83
+ },
84
+
85
+ ensureActiveSurface() {
86
+ if (!this.surfaces.length) {
87
+ this.activeSurfaceId = "";
88
+ return;
89
+ }
90
+ if (!this.surfaces.some((surface) => surface.id === this.activeSurfaceId)) {
91
+ this.activeSurfaceId = this.surfaces[0].id;
92
+ }
93
+ },
94
+
95
+ async open(surfaceId = "", payload = {}) {
96
+ const targetId = surfaceId || this.activeSurfaceId || this.surfaces[0]?.id || "";
97
+ const surface = this.getSurface(targetId);
98
+ if (!surface) return false;
99
+ if (typeof surface.canOpen === "function" && surface.canOpen(payload) === false) {
100
+ return false;
101
+ }
102
+
103
+ this.activeSurfaceId = targetId;
104
+ this.isOpen = true;
105
+ this._lastPayloadBySurface[targetId] = payload || {};
106
+ this.persist();
107
+ this.applyLayoutState();
108
+
109
+ try {
110
+ await surface.open?.(payload || {});
111
+ } catch (error) {
112
+ console.error(`Canvas surface ${targetId} failed to open`, error);
113
+ }
114
+ return true;
115
+ },
116
+
117
+ async close() {
118
+ const surface = this.currentSurface();
119
+ this.isOpen = false;
120
+ this.persist();
121
+ this.applyLayoutState();
122
+ try {
123
+ await surface?.close?.(this._lastPayloadBySurface[this.activeSurfaceId] || {});
124
+ } catch (error) {
125
+ console.error(`Canvas surface ${this.activeSurfaceId} failed to close`, error);
126
+ }
127
+ },
128
+
129
+ async dockSurface(surfaceId, payload = {}) {
130
+ const surface = this.getSurface(surfaceId);
131
+ if (!surface) return false;
132
+ const modalPath = payload.modalPath || surface.modalPath || "";
133
+ if (modalPath && globalThis.isModalOpen?.(modalPath)) {
134
+ await globalThis.closeModal?.(modalPath);
135
+ }
136
+ return await this.open(surfaceId, { ...payload, source: "modal" });
137
+ },
138
+
139
+ async undockSurface(surfaceId = "", payload = {}) {
140
+ const targetId = surfaceId || this.activeSurfaceId;
141
+ const surface = this.getSurface(targetId);
142
+ const modalPath = payload.modalPath || surface?.modalPath || "";
143
+ if (!surface || !modalPath) return false;
144
+ if (this.activeSurfaceId === targetId) {
145
+ this.isOpen = false;
146
+ this.persist();
147
+ this.applyLayoutState();
148
+ try {
149
+ await surface.close?.(this._lastPayloadBySurface[targetId] || {});
150
+ } catch (error) {
151
+ console.error(`Canvas surface ${targetId} failed to close while undocking`, error);
152
+ }
153
+ }
154
+ const modalPromise = globalThis.ensureModalOpen?.(modalPath);
155
+ if (modalPromise?.catch) {
156
+ modalPromise.catch((error) => console.error(`Canvas surface ${targetId} failed to undock`, error));
157
+ }
158
+ return true;
159
+ },
160
+
161
+ async undockActiveSurface() {
162
+ return await this.undockSurface(this.activeSurfaceId);
163
+ },
164
+
165
+ currentSurfaceCanUndock() {
166
+ return Boolean(this.currentSurface()?.modalPath);
167
+ },
168
+
169
+ async toggle(surfaceId = "", payload = {}) {
170
+ const targetId = surfaceId || this.activeSurfaceId || this.surfaces[0]?.id || "";
171
+ if (this.isOpen && targetId === this.activeSurfaceId) {
172
+ await this.close();
173
+ return false;
174
+ }
175
+ return await this.open(targetId, payload);
176
+ },
177
+
178
+ setWidth(px, options = {}) {
179
+ const { persist = true } = options;
180
+ const max = this.maxWidth();
181
+ const next = clamp(Number(px) || DEFAULT_WIDTH, MIN_WIDTH, max);
182
+ this.width = next;
183
+ this.applyLayoutState();
184
+ if (persist) this.persist();
185
+ },
186
+
187
+ maxWidth() {
188
+ return Math.max(MIN_WIDTH, Math.min(MAX_WIDTH, Math.floor(viewportWidth() * 0.58)));
189
+ },
190
+
191
+ defaultWidth() {
192
+ return Math.min(DEFAULT_WIDTH, Math.floor(viewportWidth() * 0.45));
193
+ },
194
+
195
+ startResize(event) {
196
+ if (this.isOverlayMode || this.isMobileMode || !this.isOpen) return;
197
+ if (event.button !== 0) return;
198
+ event.preventDefault();
199
+
200
+ const onPointerMove = (moveEvent) => {
201
+ const nextWidth = viewportWidth() - moveEvent.clientX;
202
+ this.setWidth(nextWidth);
203
+ };
204
+ const onPointerUp = () => {
205
+ globalThis.removeEventListener("pointermove", onPointerMove);
206
+ globalThis.removeEventListener("pointerup", onPointerUp);
207
+ document.body.classList.remove("right-canvas-resizing");
208
+ this.persist();
209
+ };
210
+
211
+ document.body.classList.add("right-canvas-resizing");
212
+ globalThis.addEventListener("pointermove", onPointerMove);
213
+ globalThis.addEventListener("pointerup", onPointerUp);
214
+ },
215
+
216
+ persist() {
217
+ try {
218
+ localStorage.setItem(
219
+ STORAGE_KEY,
220
+ JSON.stringify({
221
+ isOpen: this.isOpen,
222
+ activeSurfaceId: this.activeSurfaceId,
223
+ width: this.width,
224
+ }),
225
+ );
226
+ } catch (error) {
227
+ console.warn("Could not persist right canvas state", error);
228
+ }
229
+ },
230
+
231
+ restore() {
232
+ this.width = this.defaultWidth();
233
+ try {
234
+ const saved = JSON.parse(localStorage.getItem(STORAGE_KEY) || "{}");
235
+ this.isOpen = Boolean(saved.isOpen);
236
+ this.activeSurfaceId = String(saved.activeSurfaceId || "");
237
+ if (saved.width) this.width = Number(saved.width);
238
+ } catch (error) {
239
+ console.warn("Could not restore right canvas state", error);
240
+ }
241
+ this.setWidth(this.width, { persist: false });
242
+ },
243
+
244
+ updateLayoutMode() {
245
+ const width = viewportWidth();
246
+ this.isOverlayMode = width < DESKTOP_BREAKPOINT;
247
+ this.isMobileMode = width <= MOBILE_BREAKPOINT;
248
+ },
249
+
250
+ applyLayoutState() {
251
+ this.updateLayoutMode();
252
+ document.documentElement.style.setProperty("--right-canvas-width", `${this.width}px`);
253
+ document.body.classList.toggle("right-canvas-open", this.isOpen);
254
+ document.body.classList.toggle("right-canvas-overlay-mode", this.isOverlayMode);
255
+ document.body.classList.toggle("right-canvas-mobile-mode", this.isMobileMode);
256
+ },
257
+
258
+ widthStyle() {
259
+ if (this.isMobileMode) return "";
260
+ if (this.isOverlayMode) {
261
+ return `width: min(${this.width}px, calc(100vw - 44px));`;
262
+ }
263
+ return `width: ${this.isOpen ? this.width : 52}px;`;
264
+ },
265
+
266
+ getSurface(id) {
267
+ return this.surfaces.find((surface) => surface.id === id) || null;
268
+ },
269
+
270
+ currentSurface() {
271
+ return this.getSurface(this.activeSurfaceId);
272
+ },
273
+
274
+ isSurfaceActive(id) {
275
+ return this.activeSurfaceId === id;
276
+ },
277
+
278
+ activeTitle() {
279
+ return this.currentSurface()?.title || "Canvas";
280
+ },
281
+};
282
+
283
+export const store = createStore("rightCanvas", model);
webui/components/canvas/right-canvas.css
new
+306
@@ -0,0 +1,306 @@
1
+.container > *,
2
+#right-panel {
3
+ min-width: 0;
4
+}
5
+
6
+#right-panel {
7
+ position: relative;
8
+}
9
+
10
+.container > x-component[path="canvas/right-canvas.html"],
11
+.container > x-component[path="canvas/right-canvas.html"] > div[x-data] {
12
+ display: flex;
13
+ flex: 0 0 auto;
14
+ height: 100%;
15
+ min-width: 0;
16
+ min-height: 0;
17
+}
18
+
19
+body.right-canvas-resizing {
20
+ cursor: col-resize;
21
+ user-select: none;
22
+}
23
+
24
+.right-canvas {
25
+ --right-canvas-chrome: color-mix(in srgb, var(--color-background) 94%, #000 6%);
26
+ --right-canvas-surface: color-mix(in srgb, var(--color-panel) 88%, var(--color-background) 12%);
27
+ --right-canvas-border: color-mix(in srgb, var(--color-border) 70%, transparent);
28
+ position: relative;
29
+ z-index: 1200;
30
+ display: flex;
31
+ flex: 0 0 auto;
32
+ height: 100%;
33
+ min-width: 52px;
34
+ max-width: min(900px, 58vw);
35
+ overflow: hidden;
36
+ border-left: 1px solid var(--right-canvas-border);
37
+ background: var(--right-canvas-chrome);
38
+ transition: width 0.18s ease, transform 0.18s ease, box-shadow 0.18s ease;
39
+}
40
+
41
+.right-canvas.is-closed .right-canvas-shell {
42
+ display: none;
43
+}
44
+
45
+.right-canvas-rail {
46
+ display: flex;
47
+ flex: 0 0 52px;
48
+ flex-direction: column;
49
+ align-items: center;
50
+ gap: 7px;
51
+ padding: 10px 7px;
52
+ border-right: 1px solid var(--right-canvas-border);
53
+ background: color-mix(in srgb, var(--color-background) 92%, #000 8%);
54
+}
55
+
56
+.right-canvas-rail-button,
57
+.right-canvas-icon-button,
58
+.right-canvas-tab {
59
+ appearance: none;
60
+ border: 1px solid transparent;
61
+ color: var(--color-text);
62
+ font: inherit;
63
+ cursor: pointer;
64
+}
65
+
66
+.right-canvas-rail-button,
67
+.right-canvas-icon-button {
68
+ display: inline-flex;
69
+ align-items: center;
70
+ justify-content: center;
71
+ width: 34px;
72
+ height: 34px;
73
+ min-width: 34px;
74
+ min-height: 34px;
75
+ padding: 0;
76
+ border-radius: 7px;
77
+ background: transparent;
78
+ opacity: 0.76;
79
+ transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
80
+}
81
+
82
+.right-canvas-rail-button:hover,
83
+.right-canvas-icon-button:hover,
84
+.right-canvas-rail-button.is-active {
85
+ opacity: 1;
86
+ border-color: color-mix(in srgb, var(--color-primary) 28%, var(--right-canvas-border));
87
+ background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
88
+}
89
+
90
+.right-canvas-rail-button .material-symbols-outlined,
91
+.right-canvas-icon-button .material-symbols-outlined {
92
+ font-size: 19px;
93
+}
94
+
95
+.right-canvas-shell {
96
+ display: flex;
97
+ flex: 1 1 auto;
98
+ min-width: 0;
99
+ min-height: 0;
100
+ flex-direction: column;
101
+}
102
+
103
+.right-canvas-header {
104
+ display: grid;
105
+ grid-template-columns: minmax(0, 1fr) auto;
106
+ align-items: center;
107
+ gap: 8px;
108
+ min-height: 45px;
109
+ padding: 7px 8px 0 10px;
110
+ border-bottom: 1px solid var(--right-canvas-border);
111
+ background: color-mix(in srgb, var(--color-background) 91%, #000 9%);
112
+}
113
+
114
+.right-canvas-tabs {
115
+ display: flex;
116
+ align-items: end;
117
+ gap: 4px;
118
+ min-width: 0;
119
+ overflow-x: auto;
120
+ overflow-y: hidden;
121
+ scrollbar-width: thin;
122
+}
123
+
124
+.right-canvas-tabs::-webkit-scrollbar {
125
+ height: 4px;
126
+}
127
+
128
+.right-canvas-tabs::-webkit-scrollbar-track {
129
+ background: transparent;
130
+}
131
+
132
+.right-canvas-tabs::-webkit-scrollbar-thumb {
133
+ background: color-mix(in srgb, var(--color-border) 76%, transparent);
134
+ border-radius: 999px;
135
+}
136
+
137
+.right-canvas-tab {
138
+ display: inline-flex;
139
+ flex: 0 1 auto;
140
+ align-items: center;
141
+ gap: 7px;
142
+ min-width: 0;
143
+ height: 34px;
144
+ padding: 0 10px;
145
+ border-radius: 7px 7px 0 0;
146
+ background: transparent;
147
+ opacity: 0.7;
148
+ white-space: nowrap;
149
+ transition: border-color 0.16s ease, background-color 0.16s ease, opacity 0.16s ease;
150
+}
151
+
152
+.right-canvas-tab:hover,
153
+.right-canvas-tab.is-active {
154
+ opacity: 1;
155
+ border-color: var(--right-canvas-border);
156
+ background: color-mix(in srgb, var(--color-panel) 72%, transparent);
157
+}
158
+
159
+.right-canvas-tab .material-symbols-outlined {
160
+ flex: 0 0 auto;
161
+ font-size: 18px;
162
+}
163
+
164
+.right-canvas-tab-label {
165
+ min-width: 0;
166
+ overflow: hidden;
167
+ text-overflow: ellipsis;
168
+ font-size: 0.84rem;
169
+ font-weight: 650;
170
+}
171
+
172
+.right-canvas-toolbar {
173
+ display: flex;
174
+ align-items: center;
175
+ gap: 5px;
176
+ padding-bottom: 6px;
177
+}
178
+
179
+.right-canvas-panels {
180
+ position: relative;
181
+ display: flex;
182
+ flex: 1 1 auto;
183
+ min-width: 0;
184
+ min-height: 0;
185
+ overflow: hidden;
186
+ background: var(--right-canvas-surface);
187
+}
188
+
189
+.right-canvas-panels > x-extension {
190
+ display: contents;
191
+}
192
+
193
+.right-canvas-panels > x-extension > x-component {
194
+ display: contents;
195
+}
196
+
197
+.right-canvas-surface-panel {
198
+ display: flex;
199
+ flex: 1 1 auto;
200
+ width: 100%;
201
+ min-width: 0;
202
+ min-height: 0;
203
+ overflow: hidden;
204
+}
205
+
206
+.right-canvas-surface-panel > x-component,
207
+.right-canvas-surface-panel > x-component > div[x-data] {
208
+ display: flex;
209
+ flex: 1 1 auto;
210
+ width: 100%;
211
+ height: 100%;
212
+ min-width: 0;
213
+ min-height: 0;
214
+}
215
+
216
+.right-canvas-empty-state {
217
+ display: grid;
218
+ flex: 1 1 auto;
219
+ place-items: center;
220
+ align-content: center;
221
+ gap: 8px;
222
+ color: color-mix(in srgb, var(--color-text) 60%, transparent);
223
+}
224
+
225
+.right-canvas-empty-state .material-symbols-outlined {
226
+ font-size: 28px;
227
+}
228
+
229
+.right-canvas-resize-handle {
230
+ position: absolute;
231
+ top: 0;
232
+ bottom: 0;
233
+ left: -3px;
234
+ z-index: 3;
235
+ width: 7px;
236
+ cursor: col-resize;
237
+}
238
+
239
+body.right-canvas-overlay-mode .right-canvas {
240
+ position: fixed;
241
+ top: 0;
242
+ right: 0;
243
+ bottom: 0;
244
+ height: 100%;
245
+ max-width: calc(100vw - 44px);
246
+ box-shadow: -16px 0 38px rgba(0, 0, 0, 0.34);
247
+ transform: translateX(100%);
248
+}
249
+
250
+body.right-canvas-overlay-mode .right-canvas.is-open {
251
+ transform: translateX(0);
252
+}
253
+
254
+body.right-canvas-overlay-mode .right-canvas.is-closed {
255
+ overflow: visible;
256
+}
257
+
258
+body.right-canvas-overlay-mode .right-canvas.is-closed .right-canvas-rail {
259
+ position: absolute;
260
+ right: 100%;
261
+ top: 50%;
262
+ height: auto;
263
+ max-height: 70vh;
264
+ transform: translateY(-50%);
265
+ border: 1px solid var(--right-canvas-border);
266
+ border-right: 0;
267
+ border-radius: 8px 0 0 8px;
268
+ box-shadow: -10px 0 30px rgba(0, 0, 0, 0.24);
269
+}
270
+
271
+body.right-canvas-overlay-mode .right-canvas-resize-handle {
272
+ display: none;
273
+}
274
+
275
+body.right-canvas-mobile-mode .right-canvas {
276
+ left: 0;
277
+ width: 100vw !important;
278
+ max-width: 100vw;
279
+ min-width: 100vw;
280
+ border-left: 0;
281
+}
282
+
283
+body.right-canvas-mobile-mode .right-canvas.is-closed {
284
+ transform: translateX(100%);
285
+}
286
+
287
+body.right-canvas-mobile-mode .right-canvas-rail {
288
+ display: none;
289
+}
290
+
291
+body.right-canvas-mobile-mode .right-canvas-header {
292
+ min-height: 48px;
293
+ padding: 7px 8px 0;
294
+}
295
+
296
+body.right-canvas-mobile-mode .right-canvas-tab {
297
+ min-width: 42px;
298
+ justify-content: center;
299
+ padding: 0 9px;
300
+}
301
+
302
+@media (max-width: 480px) {
303
+ .right-canvas-tab-label {
304
+ display: none;
305
+ }
306
+}
webui/components/canvas/right-canvas.html
new
+106
@@ -0,0 +1,106 @@
1
+<html>
2
+<head>
3
+ <script type="module">
4
+ import { store } from "/components/canvas/right-canvas-store.js";
5
+ </script>
6
+</head>
7
+<body>
8
+ <div x-data>
9
+ <template x-if="$store.rightCanvas">
10
+ <aside
11
+ id="right-canvas"
12
+ class="right-canvas"
13
+ :class="{
14
+ 'is-open': $store.rightCanvas.isOpen,
15
+ 'is-closed': !$store.rightCanvas.isOpen,
16
+ 'is-overlay': $store.rightCanvas.isOverlayMode,
17
+ 'is-mobile': $store.rightCanvas.isMobileMode
18
+ }"
19
+ :style="$store.rightCanvas.widthStyle()"
20
+ x-init="$store.rightCanvas.init($el)"
21
+ x-effect="$store.rightCanvas.applyLayoutState()"
22
+ aria-label="Universal Canvas"
23
+ >
24
+ <div class="right-canvas-resize-handle" title="Resize canvas" @pointerdown="$store.rightCanvas.startResize($event)"></div>
25
+
26
+ <nav class="right-canvas-rail" aria-label="Canvas surfaces">
27
+ <template x-for="surface in $store.rightCanvas.surfaces" :key="surface.id">
28
+ <button
29
+ type="button"
30
+ class="right-canvas-rail-button"
31
+ :class="{ 'is-active': $store.rightCanvas.isSurfaceActive(surface.id) && $store.rightCanvas.isOpen }"
32
+ :title="surface.title"
33
+ :aria-label="surface.title"
34
+ @click="$store.rightCanvas.toggle(surface.id)"
35
+ >
36
+ <span class="material-symbols-outlined" x-text="surface.icon"></span>
37
+ </button>
38
+ </template>
39
+ </nav>
40
+
41
+ <section class="right-canvas-shell">
42
+ <x-extension id="right-canvas-shell-start"></x-extension>
43
+
44
+ <header class="right-canvas-header">
45
+ <div class="right-canvas-tabs" role="tablist" aria-label="Canvas tabs">
46
+ <x-extension id="right-canvas-tabs-start"></x-extension>
47
+ <template x-for="surface in $store.rightCanvas.surfaces" :key="surface.id">
48
+ <button
49
+ type="button"
50
+ class="right-canvas-tab"
51
+ role="tab"
52
+ :aria-selected="$store.rightCanvas.isSurfaceActive(surface.id).toString()"
53
+ :title="surface.title"
54
+ :class="{ 'is-active': $store.rightCanvas.isSurfaceActive(surface.id) }"
55
+ @click="$store.rightCanvas.open(surface.id)"
56
+ >
57
+ <span class="material-symbols-outlined" aria-hidden="true" x-text="surface.icon"></span>
58
+ <span class="right-canvas-tab-label" x-text="surface.title"></span>
59
+ </button>
60
+ </template>
61
+ <x-extension id="right-canvas-tabs-end"></x-extension>
62
+ </div>
63
+
64
+ <div class="right-canvas-toolbar" aria-label="Canvas toolbar">
65
+ <x-extension id="right-canvas-toolbar-start"></x-extension>
66
+ <button
67
+ type="button"
68
+ class="right-canvas-icon-button"
69
+ title="Open as window"
70
+ aria-label="Open as window"
71
+ x-show="$store.rightCanvas.currentSurfaceCanUndock()"
72
+ @click="$store.rightCanvas.undockActiveSurface()"
73
+ >
74
+ <span class="material-symbols-outlined">open_in_new</span>
75
+ </button>
76
+ <button
77
+ type="button"
78
+ class="right-canvas-icon-button"
79
+ title="Close canvas"
80
+ aria-label="Close canvas"
81
+ @click="$store.rightCanvas.close()"
82
+ >
83
+ <span class="material-symbols-outlined">close</span>
84
+ </button>
85
+ <x-extension id="right-canvas-toolbar-end"></x-extension>
86
+ </div>
87
+ </header>
88
+
89
+ <main class="right-canvas-panels">
90
+ <x-extension id="right-canvas-panels"></x-extension>
91
+ <template x-if="$store.rightCanvas.surfaces.length === 0">
92
+ <div class="right-canvas-empty-state">
93
+ <x-extension id="right-canvas-empty-state"></x-extension>
94
+ <span class="material-symbols-outlined">space_dashboard</span>
95
+ <span>Canvas</span>
96
+ </div>
97
+ </template>
98
+ </main>
99
+
100
+ <x-extension id="right-canvas-shell-end"></x-extension>
101
+ </section>
102
+ </aside>
103
+ </template>
104
+ </div>
105
+</body>
106
+</html>
webui/css/modals.css
+34
-1
@@ -78,9 +78,10 @@ some classes like modal-header are shared between the old and the new system */
78
/* Modal Header */
79
.modal-header {
80
display: grid;
81
- grid-template-columns: 40fr 0.5fr;
81
+ grid-template-columns: minmax(0, 1fr) auto auto;
82
align-items: center;
83
justify-content: space-between;
84
+ gap: 0.5rem;
85
padding: 0.5rem 1.5rem 0.5rem 2rem;
86
background-color: var(--color-background);
87
color: var(--color-primary);
@@ -88,6 +89,10 @@ some classes like modal-header are shared between the old and the new system */
89
}
90
.modal-header h2 {
91
margin: 0;
92
+ min-width: 0;
93
+ overflow: hidden;
94
+ text-overflow: ellipsis;
95
+ white-space: nowrap;
96
}
97
98
/* Modal Subheader */
@@ -114,6 +119,34 @@ some classes like modal-header are shared between the old and the new system */
119
opacity: 1;
120
}
121
122
+.modal-dock-button {
123
+ display: inline-flex;
124
+ align-items: center;
125
+ justify-content: center;
126
+ width: 34px;
127
+ height: 34px;
128
+ min-width: 34px;
129
+ min-height: 34px;
130
+ padding: 0;
131
+ border: 1px solid transparent;
132
+ border-radius: 7px;
133
+ background: transparent;
134
+ color: var(--color-text);
135
+ cursor: pointer;
136
+ opacity: 0.72;
137
+ transition: background-color 0.16s ease, border-color 0.16s ease, opacity 0.16s ease;
138
+}
139
+
140
+.modal-dock-button:hover {
141
+ opacity: 1;
142
+ border-color: color-mix(in srgb, var(--color-primary) 28%, var(--color-border));
143
+ background: color-mix(in srgb, var(--color-background-hover) 72%, transparent);
144
+}
145
+
146
+.modal-dock-button .material-symbols-outlined {
147
+ font-size: 19px;
148
+}
149
+
150
/* Modal Description */
151
.modal-description {
152
padding: 0.8rem 1rem 0 1rem;
webui/index.css
+9
-4
@@ -137,6 +137,7 @@ html {
137
body {
138
overscroll-behavior: none;
139
-webkit-overscroll-behavior: none;
140
+ overflow-x: hidden;
141
}
142
143
x-extension:not(:empty) {
@@ -393,6 +394,8 @@ img {
394
display: -webkit-flex;
395
display: flex;
396
height: 100%;
397
+ min-width: 0;
398
+ overflow: hidden;
399
}
400
401
.panel {
@@ -408,9 +411,10 @@ img {
411
#right-panel {
412
display: -webkit-flex;
413
display: flex;
411
- width: 100%;
414
+ width: auto;
415
+ min-width: 0;
416
flex-direction: column;
413
- flex-grow: 1;
417
+ flex: 1 1 0;
418
-webkit-transition: margin-left var(--transition-speed) ease-in-out;
419
transition: margin-left var(--transition-speed) ease-in-out;
420
}
@@ -477,7 +481,8 @@ div#right-panel::-webkit-scrollbar-thumb:hover {
481
482
#time-date-container {
483
z-index: 1000;
480
- position: fixed;
484
+ position: absolute;
485
+ top: 0;
486
right: var(--spacing-md);
487
display: flex;
488
align-items: center;
@@ -1684,4 +1689,4 @@ body *::-webkit-scrollbar-thumb:active {
1689
1690
.light-mode *::-webkit-scrollbar-thumb:active {
1691
background-color: #8a8a8a;
1687
-}
\ No newline at end of file
1692
+}
webui/index.html
+3
-1
@@ -19,6 +19,7 @@
19
<link rel="stylesheet" href="css/notification.css">
20
<link rel="stylesheet" href="css/buttons.css">
21
<link rel="stylesheet" href="css/tables.css">
22
+ <link rel="stylesheet" href="components/canvas/right-canvas.css">
23
24
<!-- Flatpickr for datetime picker -->
25
<link rel="stylesheet" href="vendor/flatpickr/flatpickr.min.css">
@@ -139,7 +140,8 @@
140
<x-component path="chat/input/chat-bar.html"></x-component>
141
</div>
142
</div>
142
- </div>
143
+ <x-component path="canvas/right-canvas.html"></x-component>
144
+ </div>
145
146
<!-- Drag and Drop Overlay Component -->
147
<x-component path="chat/attachments/dragDropOverlay.html"></x-component>
webui/js/modals.js
+42
@@ -56,6 +56,8 @@ function modalSuppressesBackdrop(modal) {
56
const path = String(modal?.path || "");
57
return path === "/plugins/_browser/webui/main.html"
58
|| path === "plugins/_browser/webui/main.html"
59
+ || path === "/plugins/_office/webui/main.html"
60
+ || path === "plugins/_office/webui/main.html"
61
|| modal?.element?.classList?.contains("modal-floating")
62
|| modal?.element?.classList?.contains("modal-no-backdrop")
63
|| modal?.inner?.classList?.contains("modal-no-backdrop");
@@ -151,6 +153,7 @@ function createModalElement(path) {
153
path: path,
154
element: newModal,
155
title: newModal.querySelector(".modal-title"),
156
+ header: newModal.querySelector(".modal-header"),
157
body: newModal.querySelector(".modal-bd"),
158
close: close_button,
159
footerSlot: newModal.querySelector(".modal-footer-slot"),
@@ -162,6 +165,43 @@ function createModalElement(path) {
165
};
166
}
167
168
+function getDockMetadata(doc, modalPath) {
169
+ const htmlDataset = doc?.documentElement?.dataset || {};
170
+ const bodyDataset = doc?.body?.dataset || {};
171
+ const surfaceId = htmlDataset.canvasSurface || bodyDataset.canvasSurface || "";
172
+ if (!surfaceId) return null;
173
+ return {
174
+ surfaceId,
175
+ modalPath: htmlDataset.canvasModalPath || bodyDataset.canvasModalPath || modalPath,
176
+ title: htmlDataset.canvasDockTitle || bodyDataset.canvasDockTitle || "Open in canvas",
177
+ icon: htmlDataset.canvasDockIcon || bodyDataset.canvasDockIcon || "dock_to_right",
178
+ };
179
+}
180
+
181
+function configureModalDockButton(modal, doc) {
182
+ const metadata = getDockMetadata(doc, modal.path);
183
+ if (!metadata || !modal.header || modal.header.querySelector(".modal-dock-button")) {
184
+ return;
185
+ }
186
+
187
+ const button = document.createElement("button");
188
+ button.type = "button";
189
+ button.className = "modal-dock-button";
190
+ button.title = metadata.title;
191
+ button.setAttribute("aria-label", metadata.title);
192
+ button.innerHTML = `<span class="material-symbols-outlined" aria-hidden="true">${metadata.icon}</span>`;
193
+ button.addEventListener("click", async () => {
194
+ const canvas = globalThis.Alpine?.store?.("rightCanvas")
195
+ || (await import("/components/canvas/right-canvas-store.js")).store;
196
+ await canvas?.dockSurface?.(metadata.surfaceId, {
197
+ modalPath: metadata.modalPath,
198
+ source: "modal",
199
+ });
200
+ });
201
+
202
+ modal.close?.insertAdjacentElement("beforebegin", button);
203
+}
204
+
205
// Function to open modal with content from URL
206
export async function openModal(modalPath, beforeClose = null) {
207
const openCtx = { modalPath, modal: null, cancel: false };
@@ -208,6 +248,8 @@ export async function openModal(modalPath, beforeClose = null) {
248
if (doc.body && doc.body.classList) {
249
modal.body.classList.add(...doc.body.classList);
250
}
251
+ configureModalDockButton(modal, doc);
252
+ updateModalZIndexes();
253
254
// Some modals have a footer. Check if it exists and move it to footer slot
255
// Use requestAnimationFrame to let Alpine mount the component first