From 19f48ec0689625b018af65d23513ac159a7d351f Mon Sep 17 00:00:00 2001 From: devSejung Date: Mon, 2 Nov 2026 23:51:10 +0900 Subject: [PATCH 1/3] fix: fit inline previews and widen the chat column --- src/agents/system-prompt.test.ts | 74 ++++--- src/agents/system-prompt.ts | 2 + ui/src/app/settings.ts | 2 +- .../document-viewer-request.test.ts | 113 +++++++++- ui/src/components/document-viewer-request.ts | 195 +++++++++++++++--- ui/src/components/document-viewer.styles.ts | 30 ++- ui/src/components/document-viewer.test.ts | 15 ++ ui/src/components/document-viewer.ts | 20 +- ...aw-document-change-browser.test-support.ts | 15 +- ...w-document-preview-browser.test-support.ts | 117 +++++++++++ ui/src/i18n/.i18n/raw-copy-baseline.json | 7 - ui/src/pages/config/view.browser.test.ts | 2 +- ui/src/pages/config/view.ts | 4 +- ui/src/styles/chat/layout.css | 10 +- ui/src/styles/components.css | 2 +- ui/src/styles/new-session.css | 4 +- 16 files changed, 527 insertions(+), 85 deletions(-) diff --git a/src/agents/system-prompt.test.ts b/src/agents/system-prompt.test.ts index 89895bfc185..c1ee2969ec8 100644 --- a/src/agents/system-prompt.test.ts +++ b/src/agents/system-prompt.test.ts @@ -509,31 +509,57 @@ describe("buildAgentSystemPrompt", () => { expect(prompt).not.toContain("`[embed ...]`: Control UI/webchat only"); }); - it("includes embed guidance only for webchat sessions", () => { - const prompt = buildAgentSystemPrompt({ - workspaceDir: "/tmp/openclaw", - runtimeInfo: { - channel: "webchat", - }, - }); + it.each(["automatic", "message_tool_only"] as const)( + "includes webchat attachment revision guidance for %s delivery", + (sourceReplyDeliveryMode) => { + const prompt = buildAgentSystemPrompt({ + workspaceDir: "/tmp/openclaw", + sourceReplyDeliveryMode, + runtimeInfo: { + channel: "webchat", + }, + }); - expect(prompt).toContain("## Control UI Embed"); - expect(prompt).toContain("`[embed ...]`: Control UI/webchat only"); - expect(prompt).toContain('[embed ref="cv_123" title="Status" height="320" /]'); - expect(prompt).toContain( - '[embed url="/__openclaw__/canvas/documents/cv_123/index.html" title="Status" height="320" /]', - ); - expect(prompt).toContain("Never local/file:// or arbitrary URL"); - expect(prompt).toContain("URL must start `/__openclaw__/canvas/`; else use `ref`"); - expect(prompt).toContain("Hosted root is profile-, not workspace-scoped"); - expect(prompt).toContain( - "Preview renders its owned attachment snapshot without another model call", - ); - expect(prompt).toContain("No HTML re-output or browser access to server/VM paths is needed"); - expect(prompt).toContain("Relative and external resources are not bundled"); - expect(prompt).toContain("Original download stays available"); - expect(prompt).not.toContain('[embed content_type="html" title="Status"]...[/embed]'); - }); + expect(prompt).toContain("## Control UI Embed"); + expect(prompt).toContain("`[embed ...]`: Control UI/webchat only"); + expect(prompt).toContain('[embed ref="cv_123" title="Status" height="320" /]'); + expect(prompt).toContain( + '[embed url="/__openclaw__/canvas/documents/cv_123/index.html" title="Status" height="320" /]', + ); + expect(prompt).toContain("Never local/file:// or arbitrary URL"); + expect(prompt).toContain("URL must start `/__openclaw__/canvas/`; else use `ref`"); + expect(prompt).toContain("Hosted root is profile-, not workspace-scoped"); + expect(prompt).toContain( + "Preview renders its owned attachment snapshot without another model call", + ); + expect(prompt).toContain("No HTML re-output or browser access to server/VM paths is needed"); + expect(prompt).toContain("Relative and external resources are not bundled"); + expect(prompt).toContain("Original download stays available"); + expect(prompt).not.toContain('[embed content_type="html" title="Status"]...[/embed]'); + const webchatGuidance = prompt.slice(prompt.indexOf("## Control UI Embed")); + expect(webchatGuidance).toContain( + "after creating or revising an image, HTML, PDF, PPT, or PPTX deliverable, attach the current file from the active workspace", + ); + expect(webchatGuidance).toContain("using the current attachment delivery contract"); + expect(webchatGuidance).toContain("even when you overwrite the same path"); + expect(webchatGuidance).toContain( + "Earlier attachments are saved snapshots and do not update when workspace files change", + ); + expect(webchatGuidance).toContain( + "Do not only announce completion or point to an earlier attachment after a revision", + ); + expect(webchatGuidance).not.toContain("show_widget"); + expect(prompt.indexOf("- Completed files:")).toBeGreaterThan( + prompt.indexOf(SYSTEM_PROMPT_CACHE_BOUNDARY), + ); + if (sourceReplyDeliveryMode === "message_tool_only") { + expect(webchatGuidance).toContain("Files: message attachment fields"); + expect(webchatGuidance).not.toContain("`MEDIA:"); + } else { + expect(webchatGuidance).toContain("Attachments: `MEDIA:`"); + } + }, + ); it("teaches direct status answers only on the full Control UI surface", () => { const defaultPrompt = buildAgentSystemPrompt({ diff --git a/src/agents/system-prompt.ts b/src/agents/system-prompt.ts index 823013aa08b..7c47ca73021 100644 --- a/src/agents/system-prompt.ts +++ b/src/agents/system-prompt.ts @@ -527,6 +527,8 @@ function buildWebchatCanvasSection(params: { params.sourceMessageToolOnly ? "- Files: message attachment fields. Web rich render: `[embed ...]`." : "- Attachments: `MEDIA:`. Web rich render: `[embed ...]`.", + "- Completed files: after creating or revising an image, HTML, PDF, PPT, or PPTX deliverable, attach the current file from the active workspace in your visible reply using the current attachment delivery contract, even when you overwrite the same path.", + "- Earlier attachments are saved snapshots and do not update when workspace files change. Do not only announce completion or point to an earlier attachment after a revision.", "- Saved HTML: attach a self-contained file from the active workspace; Preview renders its owned attachment snapshot without another model call. No HTML re-output or browser access to server/VM paths is needed.", "- HTML file previews: inline CSS/JS/SVG and data/blob images. Relative and external resources are not bundled; omitted resources show a notice. Original download stays available.", ...(params.widgetToolName diff --git a/ui/src/app/settings.ts b/ui/src/app/settings.ts index 4b508558a36..f3cc44112a2 100644 --- a/ui/src/app/settings.ts +++ b/ui/src/app/settings.ts @@ -170,7 +170,7 @@ export const UI_APPEARANCE_DEFAULTS = { textScale: 100, terminalTextScale: 100, sidebarLiveActivity: true, - chatMessageMaxWidth: "48rem", + chatMessageMaxWidth: "60rem", chatSendShortcut: "enter", catalogOpenTarget: "viewer", composerHoldToRecord: true, diff --git a/ui/src/components/document-viewer-request.test.ts b/ui/src/components/document-viewer-request.test.ts index aa5f60be601..eac324d96ea 100644 --- a/ui/src/components/document-viewer-request.test.ts +++ b/ui/src/components/document-viewer-request.test.ts @@ -43,6 +43,8 @@ function mountRequest(options: { stageBounds?: DOMRect; canvasBounds?: DOMRect } canvas.width = 1_200; canvas.height = 800; let pageNumber = 1; + let available = true; + let handlerAvailable = true; let targets: readonly DocumentSelectionTarget[] = [ { region: { x: 0.1, y: 0.1, width: 0.7, height: 0.2 }, text: "A larger text run" }, { region: { x: 0.2, y: 0.1, width: 0.2, height: 0.08 }, text: "Owned title" }, @@ -65,17 +67,18 @@ function mountRequest(options: { stageBounds?: DOMRect; canvasBounds?: DOMRect } ); const refresh = () => { render( - html`
+ html`
${canvas}${controller.renderOverlay()}
- ${controller.renderPopover()} + ${controller.renderPopover()}${controller.renderContextMenu()}
${controller.renderStatus()}`, container, @@ -122,8 +125,8 @@ function mountRequest(options: { stageBounds?: DOMRect; canvasBounds?: DOMRect } queueMicrotask(refresh); }, currentPage: () => - container.isConnected ? { number: pageNumber, canvas, targets } : undefined, - handler: () => handler, + container.isConnected && available ? { number: pageNumber, canvas, targets } : undefined, + handler: () => (handlerAvailable ? handler : undefined), }); requestControllers.push(controller); refresh(); @@ -145,6 +148,26 @@ function mountRequest(options: { stageBounds?: DOMRect; canvasBounds?: DOMRect } controller, handler, pointer, + setAvailable: (value: boolean) => { + available = value; + }, + setHandlerAvailable: (value: boolean) => { + handlerAvailable = value; + }, + contextMenu: async (x = 0.25, y = 0.13) => { + const bounds = canvas.getBoundingClientRect(); + const event = new MouseEvent("contextmenu", { + button: 2, + clientX: bounds.left + bounds.width * x, + clientY: bounds.top + bounds.height * y, + bubbles: true, + cancelable: true, + }); + container.querySelector(".page-surface")?.dispatchEvent(event); + await Promise.resolve(); + await Promise.resolve(); + return event; + }, setTargets: (value: readonly DocumentSelectionTarget[]) => { targets = value; }, @@ -207,6 +230,88 @@ function submit(container: HTMLElement): void { } describe("inline document selection requests", () => { + it("requires the right-click menu action before selecting the same title and submitting", async () => { + const request = mountRequest(); + const bubbled = vi.fn(); + request.container.addEventListener("contextmenu", bubbled); + const event = await request.contextMenu(); + expect(event.defaultPrevented).toBe(true); + expect(bubbled).not.toHaveBeenCalled(); + expect(request.controller.active).toBe(false); + expect(request.container.querySelector("form")).toBeNull(); + const action = request.container.querySelector('[role="menuitem"]'); + expect(action?.textContent?.trim()).toBe("Request changes"); + expect(document.activeElement).toBe(action); + action?.click(); + await vi.waitFor(() => expect(request.container.querySelector("form")).not.toBeNull()); + expect(request.container.querySelector('[role="menu"]')).toBeNull(); + expect(request.container.querySelector(".selected-text")?.textContent).toContain("Owned title"); + expect(request.handler).not.toHaveBeenCalled(); + await request.instruction("Make this title shorter."); + submit(request.container); + await vi.waitFor(() => expect(request.handler).toHaveBeenCalledTimes(1)); + expect(request.handler.mock.calls[0]?.[0]).toMatchObject({ + pageNumber: 1, + region: { x: 0.2, y: 0.1, width: 0.2, height: 0.08 }, + selectedText: "Owned title", + instruction: "Make this title shorter.", + cropDataUrl: PNG, + }); + }); + + it("dismisses transient menus without losing the existing selection or unsent draft", async () => { + const request = mountRequest(); + request.controller.begin(); + await request.select(); + await request.instruction("Keep my draft."); + const dismissals = [ + () => + document.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", cancelable: true })), + () => document.body.dispatchEvent(new PointerEvent("pointerdown", { bubbles: true })), + () => window.dispatchEvent(new Event("scroll")), + () => window.dispatchEvent(new Event("resize")), + ]; + for (const dismiss of dismissals) { + await request.contextMenu(0.9, 0.9); + expect(request.container.querySelector('[role="menu"]')).not.toBeNull(); + dismiss(); + await Promise.resolve(); + expect(request.container.querySelector('[role="menu"]')).toBeNull(); + expect(request.container.querySelector("textarea")?.value).toBe("Keep my draft."); + expect(request.container.querySelector(".selected-text")?.textContent).toContain( + "Owned title", + ); + } + expect(request.handler).not.toHaveBeenCalled(); + }); + + it("retires menus with their page/document and leaves unavailable or pending previews alone", async () => { + const request = mountRequest(); + await request.contextMenu(); + await request.changePage(2); + expect(request.container.querySelector('[role="menu"]')).toBeNull(); + await request.contextMenu(); + request.controller.reset(); + await Promise.resolve(); + expect(request.container.querySelector('[role="menu"]')).toBeNull(); + request.setAvailable(false); + expect((await request.contextMenu()).defaultPrevented).toBe(false); + request.setAvailable(true); + request.setHandlerAvailable(false); + expect((await request.contextMenu()).defaultPrevented).toBe(false); + request.setHandlerAvailable(true); + const pending = deferred(); + request.handler.mockReturnValueOnce(pending.promise); + request.controller.begin(); + await request.select(); + await request.instruction("Pending request."); + submit(request.container); + expect((await request.contextMenu()).defaultPrevented).toBe(false); + expect(request.container.querySelector('[role="menu"]')).toBeNull(); + pending.resolve({ admitted: false }); + await vi.waitFor(() => expect(request.controller.busy).toBe(false)); + }); + it("ignores ordinary clicks and only opens a popover after an explicit mode selection", async () => { const request = mountRequest(); await request.select(); diff --git a/ui/src/components/document-viewer-request.ts b/ui/src/components/document-viewer-request.ts index 43b639310e2..0471e6493ab 100644 --- a/ui/src/components/document-viewer-request.ts +++ b/ui/src/components/document-viewer-request.ts @@ -4,6 +4,8 @@ import { keyed } from "lit/directives/keyed.js"; import { ref } from "lit/directives/ref.js"; import { t } from "../i18n/index.ts"; import type { DocumentSelectionTarget } from "./document-viewer-selection.ts"; +import { toolIcons } from "./icons-tools.ts"; +import { promoteToPopoverTopLayer } from "./menu-surface.ts"; export const DOCUMENT_CHANGE_TEXT_MAX = 1_024; export const DOCUMENT_CHANGE_INSTRUCTION_MAX = 4_096; @@ -56,6 +58,16 @@ export class DocumentViewerRequestController { private popover: HTMLFormElement | undefined; private popoverObserver: ResizeObserver | undefined; private selectionVersion = 0; + private context: + | { + page: SelectionPage; + point: { x: number; y: number }; + clientX: number; + clientY: number; + } + | undefined; + private menu: HTMLElement | undefined; + private menuListeners: AbortController | undefined; constructor(private readonly host: RequestHost) {} @@ -71,6 +83,7 @@ export class DocumentViewerRequestController { } private retireSelection(): void { + this.closeContextMenu(); this.selectionVersion++; this.submission?.abort(); this.submission = undefined; @@ -94,6 +107,7 @@ export class DocumentViewerRequestController { } changePage(pageNumber: number): void { + this.closeContextMenu(); if (this.active && this.pageNumber !== pageNumber) { this.retireSelection(); this.pageNumber = pageNumber; @@ -106,7 +120,7 @@ export class DocumentViewerRequestController { return this.active && page?.number === this.pageNumber ? page : undefined; } - private point(event: PointerEvent, page: SelectionPage): { x: number; y: number } | undefined { + private point(event: MouseEvent, page: SelectionPage): { x: number; y: number } | undefined { const bounds = page.canvas.getBoundingClientRect(); if (!page.canvas.isConnected || bounds.width <= 0 || bounds.height <= 0) { return undefined; @@ -117,6 +131,160 @@ export class DocumentViewerRequestController { }; } + contextMenu = (event: MouseEvent): void => { + const page = this.host.currentPage(); + if (!page || this.busy || !this.host.handler()) { + return; + } + const point = this.point(event, page); + if (!point) { + return; + } + event.preventDefault(); + event.stopPropagation(); + this.closeContextMenu(); + this.context = { page, point, clientX: event.clientX, clientY: event.clientY }; + this.host.requestUpdate(); + }; + + closeContextMenu = (): void => { + this.menuListeners?.abort(); + this.menuListeners = undefined; + this.menu = undefined; + if (this.context) { + this.context = undefined; + this.host.requestUpdate(); + } + }; + + private bindContextMenu = (element?: Element): void => { + this.menuListeners?.abort(); + if (!(element instanceof HTMLElement)) { + this.menu = undefined; + return; + } + this.menu = element; + const listeners = new AbortController(); + this.menuListeners = listeners; + const options = { capture: true, signal: listeners.signal }; + const outside = (event: Event) => { + if (!event.composedPath().includes(element)) { + this.closeContextMenu(); + } + }; + document.addEventListener("pointerdown", outside, options); + document.addEventListener("contextmenu", outside, options); + document.addEventListener( + "keydown", + (event) => { + if (event.key === "Escape" || event.key === "Tab") { + if (event.key === "Escape") { + event.preventDefault(); + event.stopPropagation(); + this.context?.page.canvas + .closest(".stage") + ?.focus({ preventScroll: true }); + } + this.closeContextMenu(); + } + }, + options, + ); + window.addEventListener("resize", this.closeContextMenu, options); + window.addEventListener("scroll", this.closeContextMenu, options); + queueMicrotask(() => { + if (this.menu !== element || !element.isConnected) { + return; + } + promoteToPopoverTopLayer(element); + const bounds = element.getBoundingClientRect(); + const context = this.context; + if (context) { + element.style.left = `${Math.max(0, Math.min(context.clientX, window.innerWidth - bounds.width))}px`; + element.style.top = `${Math.max(0, Math.min(context.clientY, window.innerHeight - bounds.height))}px`; + } + element.querySelector("button")?.focus({ preventScroll: true }); + }); + }; + + renderContextMenu() { + const context = this.context; + const page = this.host.currentPage(); + if ( + !context || + !page || + page.canvas !== context.page.canvas || + page.number !== context.page.number + ) { + return nothing; + } + return html``; + } + + private selectPoint(page: SelectionPage, point: { x: number; y: number }): void { + let target: DocumentSelectionTarget | undefined; + for (const candidate of page.targets ?? []) { + const region = candidate.region; + if ( + point.x >= region.x && + point.x <= region.x + region.width && + point.y >= region.y && + point.y <= region.y + region.height && + (!target || region.width * region.height < target.region.width * target.region.height) + ) { + target = candidate; + } + } + this.selectedText = target ? truncateUtf16Safe(target.text, DOCUMENT_CHANGE_TEXT_MAX) : ""; + this.region = target + ? { ...target.region } + : { + x: Math.max(0, Math.min(1 - POINT_REGION_WIDTH, point.x - POINT_REGION_WIDTH / 2)), + y: Math.max(0, Math.min(1 - POINT_REGION_HEIGHT, point.y - POINT_REGION_HEIGHT / 2)), + width: POINT_REGION_WIDTH, + height: POINT_REGION_HEIGHT, + }; + this.error = ""; + } + pointerDown = (event: PointerEvent): void => { const page = this.selectionPage(); if (!page || this.busy || event.button !== 0 || !(event.currentTarget instanceof HTMLElement)) { @@ -180,30 +348,7 @@ export class DocumentViewerRequestController { (point.y - drag.y) * bounds.height, ); if (distance < 3) { - let target: DocumentSelectionTarget | undefined; - for (const candidate of page.targets ?? []) { - const region = candidate.region; - if ( - point.x >= region.x && - point.x <= region.x + region.width && - point.y >= region.y && - point.y <= region.y + region.height && - (!target || region.width * region.height < target.region.width * target.region.height) - ) { - target = candidate; - } - } - if (target) { - this.region = { ...target.region }; - this.selectedText = truncateUtf16Safe(target.text, DOCUMENT_CHANGE_TEXT_MAX); - } else { - this.region = { - x: Math.max(0, Math.min(1 - POINT_REGION_WIDTH, point.x - POINT_REGION_WIDTH / 2)), - y: Math.max(0, Math.min(1 - POINT_REGION_HEIGHT, point.y - POINT_REGION_HEIGHT / 2)), - width: POINT_REGION_WIDTH, - height: POINT_REGION_HEIGHT, - }; - } + this.selectPoint(page, point); } else if (this.region) { // Preserve real drags, including a thin horizontal/vertical gesture, with // a one-CSS-pixel minimum so the crop always has a nonempty source area. diff --git a/ui/src/components/document-viewer.styles.ts b/ui/src/components/document-viewer.styles.ts index a0f0bcb6941..a4cf0b87eb8 100644 --- a/ui/src/components/document-viewer.styles.ts +++ b/ui/src/components/document-viewer.styles.ts @@ -117,19 +117,15 @@ export const documentViewerStyles = css` } .page-layout { display: flex; - height: min(65dvh, 640px); - min-height: 240px; min-width: 0; } .stage { position: relative; box-sizing: border-box; flex: 1; - height: 100%; min-height: 0; min-width: 0; overflow: auto; - scrollbar-gutter: stable; padding: 16px; border-radius: 4px; background: #d5d7da; @@ -146,6 +142,10 @@ export const documentViewerStyles = css` } openclaw-document-contents { flex: 0 0 132px; + height: auto; + min-height: 0; + /* The page determines inline height; thumbnails scroll within that height. */ + contain: size; border-right: 1px solid var(--border, #ccc); } .page-surface { @@ -191,6 +191,26 @@ export const documentViewerStyles = css` box-shadow: 0 6px 24px #0003; font-size: 12px; } + .request-context-menu { + position: fixed; + inset: auto; + z-index: 4; + width: max-content; + max-width: calc(100vw - 16px); + margin: 0; + padding: 4px; + border: 1px solid var(--border, #ccc); + border-radius: 8px; + background: var(--bg, #fff); + color: inherit; + box-shadow: 0 6px 24px #0003; + } + .request-context-menu button { + display: flex; + align-items: center; + gap: 5px; + width: 100%; + } .request-popover-header { display: flex; align-items: center; @@ -319,11 +339,11 @@ export const documentViewerStyles = css` } .expanded .stage { height: auto; + scrollbar-gutter: stable; } @media (max-width: 600px) { .page-layout { flex-direction: column; - height: 60dvh; } openclaw-document-contents { flex: 0 0 120px; diff --git a/ui/src/components/document-viewer.test.ts b/ui/src/components/document-viewer.test.ts index c48a12b3c82..608fc31550a 100644 --- a/ui/src/components/document-viewer.test.ts +++ b/ui/src/components/document-viewer.test.ts @@ -378,6 +378,21 @@ describe("document viewer contents lifecycle", () => { expect(runtime.renderPdfPage).toHaveBeenCalledTimes(2); }); + it("renders an inline page from its available width before it has a content height", async () => { + const loading = createHandle(); + runtime.openPdfDocument.mockReturnValueOnce(loading.handle); + const { viewer, failed } = await mount(); + const stage = viewer.shadowRoot?.querySelector(".stage"); + if (!stage) { + throw new Error("Missing inline page stage"); + } + Object.defineProperty(stage, "clientHeight", { configurable: true, value: 0 }); + loading.loading.resolve(createPdf()); + await ready(viewer); + expect(runtime.renderPdfPage).toHaveBeenCalledTimes(1); + expect(failed).not.toHaveBeenCalled(); + }); + it("keeps slide position accessible while PageDown and PageUp navigate the inline preview", async () => { const { viewer } = await mountReady(); viewer.pageUnit = "slide"; diff --git a/ui/src/components/document-viewer.ts b/ui/src/components/document-viewer.ts index ee8edd21bfd..0a660f6b89d 100644 --- a/ui/src/components/document-viewer.ts +++ b/ui/src/components/document-viewer.ts @@ -77,12 +77,14 @@ class OpenClawDocumentViewer extends OpenClawLitElement { override connectedCallback(): void { super.connectedCallback(); + window.addEventListener("resize", this.handleViewportResize); if (this.hasUpdated) { void this.loadDocument(); } } override disconnectedCallback(): void { + window.removeEventListener("resize", this.handleViewportResize); this.observer?.disconnect(); this.observedStage = undefined; this.expanded = false; @@ -103,10 +105,10 @@ class OpenClawDocumentViewer extends OpenClawLitElement { if ( stage && stage.clientWidth > 0 && - stage.clientHeight > 0 && + (!this.expanded || stage.clientHeight > 0) && (this.waitingForStage || (this.fitMode !== "custom" && stage.clientWidth !== this.stageWidth) || - (this.fitMode === "page" && stage.clientHeight !== this.stageHeight)) + (this.expanded && this.fitMode === "page" && stage.clientHeight !== this.stageHeight)) ) { this.waitingForStage = false; void this.renderPage(); @@ -131,6 +133,12 @@ class OpenClawDocumentViewer extends OpenClawLitElement { this.deadline = undefined; } + private handleViewportResize = (): void => { + if (!this.expanded && this.fitMode === "page") { + void this.renderPage(); + } + }; + private ensureDeadline(): void { this.deadline ??= setTimeout(() => this.fail("unavailable"), DOCUMENT_OPERATION_TIMEOUT_MS); } @@ -221,6 +229,7 @@ class OpenClawDocumentViewer extends OpenClawLitElement { if (!pdf || !engine || !canvas || !stage || !this.isConnected) { return; } + this.changeRequest.closeContextMenu(); const generation = this.generation; const renderGeneration = ++this.renderGeneration; this.waitingForStage = false; @@ -265,10 +274,10 @@ class OpenClawDocumentViewer extends OpenClawLitElement { Number.parseFloat(style.paddingLeft) - Number.parseFloat(style.paddingRight); const availableHeight = - stage.clientHeight - + (this.expanded ? stage.clientHeight : window.innerHeight) - Number.parseFloat(style.paddingTop) - Number.parseFloat(style.paddingBottom); - if (availableWidth <= 0 || availableHeight <= 0) { + if (availableWidth <= 0 || (this.expanded && availableHeight <= 0)) { // A top-layer dialog can mount before its slotted stage is measurable. // Resume once after visibility changes, including at a custom zoom. this.waitingForStage = true; @@ -433,6 +442,7 @@ class OpenClawDocumentViewer extends OpenClawLitElement { @pointermove=${this.changeRequest.pointerMove} @pointerup=${this.changeRequest.pointerUp} @pointercancel=${this.changeRequest.pointerCancel} + @contextmenu=${this.changeRequest.contextMenu} > ${this.changeRequest.renderOverlay()}
- ${this.changeRequest.renderPopover()} + ${this.changeRequest.renderPopover()} ${this.changeRequest.renderContextMenu()}