From 7f8b4a4d69ed927c80af7b8b44c2d949243c0be2 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 19 Aug 2026 09:43:10 +0200 Subject: [PATCH 1/3] feat(viewer): add a toolbar extension API to the embeddable viewer Lets consumers add their own toolbar tools via createViewer({ toolbarTools }) instead of forking the app shell: a ToolDefinition list of Vue components rendered after the built-in tool groups, a narrow useViewerMessaging() composable for talking to a backend, and a new /ui subpath exporting the stable subset of the internal component kit (Button, Kbd, KbdGroup, Tooltip*) to build them with. Documented in docs/extending-the-viewer.md and demonstrated in examples/embedded_custom_tool.html. Also fixes `prepare` so a git-dependency install (e.g. github:compas-dev/compas_threejs_ts#) actually builds dist-lib before use, via a small cross-platform script - the previous inline `cmd1 || true && cmd2` no-ops the build under cmd.exe on Windows, since its &&/|| chaining doesn't group the way POSIX shells do. --- docs/extending-the-viewer.md | 133 +++++++++++++++++++++++++++++ examples/embedded_custom_tool.html | 65 ++++++++++++++ examples/embedded_custom_tool.js | 100 ++++++++++++++++++++++ package.json | 6 +- scripts/copy-library-types.mjs | 1 + scripts/prepare.mjs | 18 ++++ src/components/layout/Toolbar.vue | 13 ++- src/library/index.ts | 10 ++- src/library/public.d.ts | 16 ++++ src/library/types.ts | 13 +++ src/library/ui.d.ts | 9 ++ src/library/ui.ts | 8 ++ src/viewer/viewer_context.ts | 22 +++++ tests/browser/custom_tool.spec.ts | 45 ++++++++++ tests/library_import.test.ts | 21 +++++ tests/viewer_lifecycle.test.ts | 100 +++++++++++++++++++++- tsconfig.core.json | 1 + vite.config.library.ts | 6 +- 18 files changed, 579 insertions(+), 8 deletions(-) create mode 100644 docs/extending-the-viewer.md create mode 100644 examples/embedded_custom_tool.html create mode 100644 examples/embedded_custom_tool.js create mode 100644 scripts/prepare.mjs create mode 100644 src/library/ui.d.ts create mode 100644 src/library/ui.ts create mode 100644 tests/browser/custom_tool.spec.ts diff --git a/docs/extending-the-viewer.md b/docs/extending-the-viewer.md new file mode 100644 index 0000000..aea6cb5 --- /dev/null +++ b/docs/extending-the-viewer.md @@ -0,0 +1,133 @@ +# Extending the embeddable viewer + +`@compas-dev/compas-threejs-ts` ships an embeddable API (`src/library/index.ts`) built around +`createViewer(container, options)`. Consumers add their own UI - custom toolbar buttons, panels, +whatever - by passing options into that call, not by forking this repo's app shell +(`App.vue`/`Toolbar.vue`/`Sidebar.vue`). `timber_model_viewer/frontend-src` is a real, working +example of this pattern - read alongside this guide. + +## Installing the package + +```sh +npm install @compas-dev/compas-threejs-ts three vue +``` + +`three` and `vue` are peer/regular dependencies you provide yourself. Once installed: + +```ts +import "@compas-dev/compas-threejs-ts/style.css"; +import { createViewer } from "@compas-dev/compas-threejs-ts"; + +const container = document.getElementById("app")!; +createViewer(container, { mode: "websocket" }); +``` + +While the extension API below is still evolving, point your `package.json` at a branch instead +of a published version: + +```json +"@compas-dev/compas-threejs-ts": "github:compas-dev/compas_threejs_ts#" +``` + +`npm install` builds the package automatically on install (via its `prepare` script) - no manual +build step in the dependency's checkout required. To pick up new commits on that branch: + +```sh +npm update @compas-dev/compas-threejs-ts +``` + +Once your customization has stabilized, prefer switching to a published semver range +(`^1.x`) - see [`releasing.md`](./releasing.md). Registry installs are reproducible from the +lockfile alone; branch installs re-resolve to whatever the branch currently points at. + +## Adding a custom toolbar tool + +Pass `toolbarTools` - an array of `{ id, component, order? }` - to `createViewer`. Each +`component` is mounted directly inside the toolbar, after the built-in tool groups: + +```ts +// tools/MyTool.vue +``` + +```vue + + + +``` + +```ts +// main.ts +import { createViewer } from "@compas-dev/compas-threejs-ts"; +import MyTool from "./tools/MyTool.vue"; + +createViewer(container, { + mode: "websocket", + toolbarTools: [{ id: "my-tool", component: MyTool, order: 10 }], +}); +``` + +- `order` controls left-to-right placement among _your_ tools (lower first, default `0`); the + built-in groups (transform, add-object, view, display) always render first, ahead of any + `toolbarTools`. +- Group several related buttons under one entry by wrapping them in a single component (see + `timber_model_viewer/frontend-src/src/tools/CompasTimberGroup.vue`, which bundles five buttons + behind one `ToolDefinition`). + +## Talking to your backend + +`useViewerMessaging()` is the public, minimal messaging surface - deliberately not the full +internal viewer runtime, so tools depend on a small stable contract instead of internals that +are free to change: + +```ts +interface ViewerMessaging { + send(message: unknown): boolean; // sent as-is if already a string/binary, else JSON.stringify'd + sendData(message: Record): boolean; // always JSON +} +``` + +Use `sendData` for structured messages, and `send` when you've already built the raw payload +yourself (e.g. splicing a large uploaded JSON file straight into a message envelope without an +extra parse/stringify round trip - see `LoadTimberModel.vue`). + +## UI kit + +`@compas-dev/compas-threejs-ts/ui` re-exports the subset of the internal component kit that's +stable for building tools with: `Button`, `Kbd`, `KbdGroup`, `Tooltip`, `TooltipContent`, +`TooltipProvider`, `TooltipTrigger`. Use these instead of writing your own so custom tools look +consistent with the built-in toolbar. + +## What's public vs. internal + +Only what's exported from `@compas-dev/compas-threejs-ts` and `@compas-dev/compas-threejs-ts/ui` +is a stable contract (`src/library/public.d.ts` / `src/library/ui.d.ts` are the hand-maintained +source of truth for what ships - keep them in sync with `src/library/types.ts` / `src/library/ +ui.ts` when changing the public surface). Everything else under `src/` - `components/`, +`viewer/`, `composables/`, `communications/`, `conversions/`, `store/` - is free to change +between versions; don't import from `@/...` paths across the package boundary. + +## Adding a new extension point + +If `toolbarTools` isn't enough for what you're building (e.g. a docked side panel rather than a +toolbar button), extend the pattern rather than forking the app shell: + +1. Add the option to `CompasViewerOptions` in `src/library/types.ts`, and mirror it in + `src/library/public.d.ts`. +2. Add an injection key + `useX()` composable in `src/viewer/viewer_context.ts`. +3. `app.provide()` it in `src/library/index.ts`'s `createViewer`. +4. Consume it generically in the relevant layout component (e.g. `Sidebar.vue`) - default to + today's built-in behavior when the option is omitted, so existing consumers (including this + repo's own standalone app, `src/main.ts`) are unaffected. +5. Run `npm run check` before opening a PR - it covers lint, typecheck (including the strict + `tsconfig.core.json` pass over the public-facing files), tests, and both build targets. diff --git a/examples/embedded_custom_tool.html b/examples/embedded_custom_tool.html new file mode 100644 index 0000000..d2f1afd --- /dev/null +++ b/examples/embedded_custom_tool.html @@ -0,0 +1,65 @@ + + + + + + Embedded COMPAS ThreeJS custom tool + + + + + + + +
+ + + diff --git a/examples/embedded_custom_tool.js b/examples/embedded_custom_tool.js new file mode 100644 index 0000000..2aeaaf4 --- /dev/null +++ b/examples/embedded_custom_tool.js @@ -0,0 +1,100 @@ +import { defineComponent, h, ref } from "vue"; +import { Box, pbDumpBytes } from "@gramaziokohler/compas-pb-ts"; + +import { createViewer, useViewerMessaging } from "../dist-lib/index.js"; +import { + Button, + Kbd, + KbdGroup, + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../dist-lib/ui.js"; + +// A toolbar tool authored exactly the way a consumer would: a Vue component +// built from the public ui kit (`@compas-dev/compas-threejs-ts/ui`) and +// `useViewerMessaging()`, with no access to viewer internals. It's passed +// into `toolbarTools` below rather than forking Toolbar.vue. +const PingTool = defineComponent({ + name: "PingTool", + setup() { + const pingCount = ref(0); + const { sendData } = useViewerMessaging(); + + function handleClick() { + pingCount.value += 1; + sendData({ + dispatch: "other_action", + action: "ping", + count: pingCount.value, + }); + } + + return () => + h(TooltipProvider, { delayDuration: 600 }, () => + h(Tooltip, null, () => [ + h(TooltipTrigger, null, () => + h( + Button, + { + variant: "secondary", + size: "icon", + "data-testid": "ping-tool-button", + onClick: handleClick, + }, + () => "Hi", + ), + ), + h(TooltipContent, { side: "bottom" }, () => [ + h("p", null, `Sent ${pingCount.value} ping(s)`), + h(KbdGroup, null, () => [h(Kbd, null, () => "click")]), + ]), + ]), + ); + }, +}); + +const container = document.querySelector("#viewer"); +if (!(container instanceof HTMLElement)) { + throw new Error("Viewer container was not found"); +} + +// Messages sent by tools via `useViewerMessaging()` are routed through this +// `send` option instead of a real backend connection - the same hook a +// consumer would use to wire up their own transport. +const outgoingMessages = []; + +const viewer = createViewer(container, { + mode: "embedded", + defaultLighting: true, + showToolbar: true, + toolbarTools: [{ id: "ping-tool", component: PingTool, order: 10 }], + send(message) { + outgoingMessages.push(message); + return true; + }, + onError(error) { + console.error(error.code, error.message, error.details); + }, +}); + +const box = new Box({ + data: { + guid: crypto.randomUUID(), + name: "Box", + frame: { + point: { x: 0, y: 0, z: 0 }, + xaxis: { x: 1, y: 0, z: 0 }, + yaxis: { x: 0, y: 1, z: 0 }, + }, + xsize: 3, + ysize: 3, + zsize: 1, + }, +}); +viewer.dispatch(pbDumpBytes(box)); + +document.body.dataset.exampleReady = "true"; +window.__compasCustomTool = { viewer, outgoingMessages }; +window.addEventListener("pagehide", () => viewer.dispose(), { once: true }); diff --git a/package.json b/package.json index 9af6a35..dd8a81e 100644 --- a/package.json +++ b/package.json @@ -29,6 +29,10 @@ "types": "./dist-lib/index.d.ts", "import": "./dist-lib/index.js" }, + "./ui": { + "types": "./dist-lib/ui.d.ts", + "import": "./dist-lib/ui.js" + }, "./style.css": "./dist-lib/style.css" }, "files": [ @@ -55,7 +59,7 @@ "test:package": "node scripts/test-package.mjs", "check": "npm run format:check && npm run lint && npm run typecheck && npm test && npm run build:app && npm run build:library", "audit:prod": "npm audit --omit=dev --audit-level=high", - "prepare": "git config core.hooksPath .githooks || true", + "prepare": "node scripts/prepare.mjs", "prepublishOnly": "npm run check && npm run audit:prod", "lint": "eslint . --max-warnings=0", "lint:fix": "eslint . --fix", diff --git a/scripts/copy-library-types.mjs b/scripts/copy-library-types.mjs index fc26dab..a16b444 100644 --- a/scripts/copy-library-types.mjs +++ b/scripts/copy-library-types.mjs @@ -1,3 +1,4 @@ import { copyFileSync } from "node:fs"; copyFileSync("src/library/public.d.ts", "dist-lib/index.d.ts"); +copyFileSync("src/library/ui.d.ts", "dist-lib/ui.d.ts"); diff --git a/scripts/prepare.mjs b/scripts/prepare.mjs new file mode 100644 index 0000000..8e64e02 --- /dev/null +++ b/scripts/prepare.mjs @@ -0,0 +1,18 @@ +import { spawnSync } from "node:child_process"; + +// Best-effort: sets up the commit-msg hook for local contributor checkouts. +// Failing here (e.g. no .git directory, such as inside a package tarball) +// must never block the build below - npm's own git-dependency install flow +// depends on this script's exit code reflecting only the build. +spawnSync("git", ["config", "core.hooksPath", ".githooks"], { + stdio: "inherit", + shell: true, +}); + +const npmCommand = process.platform === "win32" ? "npm.cmd" : "npm"; +const build = spawnSync(npmCommand, ["run", "build:library"], { + stdio: "inherit", + shell: true, +}); +if (build.error) throw build.error; +process.exit(build.status ?? 1); diff --git a/src/components/layout/Toolbar.vue b/src/components/layout/Toolbar.vue index 8329ff0..a917c4d 100644 --- a/src/components/layout/Toolbar.vue +++ b/src/components/layout/Toolbar.vue @@ -7,6 +7,11 @@ + @@ -15,13 +20,17 @@ import TransformGroup from "@/components/tools/transforms/TransformGroup.vue"; import AddObjectGroup from "@/components/tools/objects/AddObjectGroup.vue"; import ViewGroup from "@/components/tools/views/ViewGroup.vue"; import DisplayGroup from "@/components/tools/display/DisplayGroup.vue"; -import { useViewerRuntime } from "@/viewer/viewer_context"; +import { useToolbarTools, useViewerRuntime } from "@/viewer/viewer_context"; import { useHover } from "@/composables/useHover"; -import { ref, watchEffect } from "vue"; +import { computed, ref, watchEffect } from "vue"; const toolbarElement = ref(null); const { isHovered } = useHover(toolbarElement); const { theme, blockPicker } = useViewerRuntime().store; +const toolbarTools = useToolbarTools(); +const sortedToolbarTools = computed(() => + [...toolbarTools].sort((a, b) => (a.order ?? 0) - (b.order ?? 0)), +); watchEffect(() => { blockPicker.value = isHovered.value; diff --git a/src/library/index.ts b/src/library/index.ts index c8f7a87..c4255bd 100644 --- a/src/library/index.ts +++ b/src/library/index.ts @@ -2,7 +2,11 @@ import { createApp, markRaw } from "vue"; import "../style.css"; import App from "../App.vue"; -import { viewerRuntimeKey } from "../viewer/viewer_context"; +import { + toolbarToolsKey, + useViewerMessaging, + viewerRuntimeKey, +} from "../viewer/viewer_context"; import { ViewerRuntime } from "../viewer/viewer_runtime"; import { CompasViewerError } from "./errors"; import type { CompasViewer, CompasViewerOptions } from "./types"; @@ -10,6 +14,8 @@ import type { CompasViewer, CompasViewerOptions } from "./types"; export type { CompasViewer, CompasViewerOptions, + ToolDefinition, + ViewerMessaging, ViewerMode, ViewerWebSocketOptions, } from "./types"; @@ -18,6 +24,7 @@ export { type CompasViewerErrorOptions, } from "./errors"; export { CompasViewerError }; +export { useViewerMessaging }; export function createViewer( container: HTMLElement, @@ -39,6 +46,7 @@ export function createViewer( showToolbar: options.showToolbar ?? true, }); app.provide(viewerRuntimeKey, runtime); + app.provide(toolbarToolsKey, options.toolbarTools ?? []); app.mount(container); let disposed = false; diff --git a/src/library/public.d.ts b/src/library/public.d.ts index e8f5c89..c650f6b 100644 --- a/src/library/public.d.ts +++ b/src/library/public.d.ts @@ -1,3 +1,5 @@ +import type { Component } from "vue"; + export type ViewerMode = "embedded" | "websocket"; export type CompasViewerErrorCode = @@ -30,11 +32,23 @@ export interface ViewerWebSocketOptions { secure?: boolean; } +export interface ToolDefinition { + id: string; + component: Component; + order?: number; +} + +export interface ViewerMessaging { + send(message: unknown): boolean; + sendData(message: Record): boolean; +} + export interface CompasViewerOptions { mode?: ViewerMode; websocket?: ViewerWebSocketOptions; defaultLighting?: boolean; showToolbar?: boolean; + toolbarTools?: ToolDefinition[]; send?: (message: unknown) => boolean | void; onError?: (error: CompasViewerError) => void; } @@ -50,3 +64,5 @@ export declare function createViewer( container: HTMLElement, options?: CompasViewerOptions, ): CompasViewer; + +export declare function useViewerMessaging(): ViewerMessaging; diff --git a/src/library/types.ts b/src/library/types.ts index b3e61a6..c2c2b7b 100644 --- a/src/library/types.ts +++ b/src/library/types.ts @@ -1,3 +1,4 @@ +import type { Component } from "vue"; import type { CompasViewerError } from "./errors"; export type ViewerMode = "embedded" | "websocket"; @@ -9,11 +10,23 @@ export interface ViewerWebSocketOptions { secure?: boolean; } +export interface ToolDefinition { + id: string; + component: Component; + order?: number; +} + +export interface ViewerMessaging { + send(message: unknown): boolean; + sendData(message: Record): boolean; +} + export interface CompasViewerOptions { mode?: ViewerMode; websocket?: ViewerWebSocketOptions; defaultLighting?: boolean; showToolbar?: boolean; + toolbarTools?: ToolDefinition[]; send?: (message: unknown) => boolean | void; onError?: (error: CompasViewerError) => void; } diff --git a/src/library/ui.d.ts b/src/library/ui.d.ts new file mode 100644 index 0000000..923f5e0 --- /dev/null +++ b/src/library/ui.d.ts @@ -0,0 +1,9 @@ +import type { Component } from "vue"; + +export declare const Button: Component; +export declare const Kbd: Component; +export declare const KbdGroup: Component; +export declare const Tooltip: Component; +export declare const TooltipContent: Component; +export declare const TooltipProvider: Component; +export declare const TooltipTrigger: Component; diff --git a/src/library/ui.ts b/src/library/ui.ts new file mode 100644 index 0000000..b1b0250 --- /dev/null +++ b/src/library/ui.ts @@ -0,0 +1,8 @@ +export { Button } from "../components/ui/button"; +export { Kbd, KbdGroup } from "../components/ui/kbd"; +export { + Tooltip, + TooltipContent, + TooltipProvider, + TooltipTrigger, +} from "../components/ui/tooltip"; diff --git a/src/viewer/viewer_context.ts b/src/viewer/viewer_context.ts index 20d073d..bb55d1a 100644 --- a/src/viewer/viewer_context.ts +++ b/src/viewer/viewer_context.ts @@ -1,12 +1,17 @@ import type { InjectionKey } from "vue"; import { inject } from "vue"; +import type { ToolDefinition, ViewerMessaging } from "../library/types"; import type { ViewerRuntime } from "./viewer_runtime"; export const viewerRuntimeKey: InjectionKey = Symbol( "compas-viewer-runtime", ); +export const toolbarToolsKey: InjectionKey = Symbol( + "compas-viewer-toolbar-tools", +); + export function useViewerRuntime(): ViewerRuntime { const runtime = inject(viewerRuntimeKey); if (!runtime) { @@ -14,3 +19,20 @@ export function useViewerRuntime(): ViewerRuntime { } return runtime; } + +export function useToolbarTools(): ToolDefinition[] { + return inject(toolbarToolsKey, []); +} + +/** + * Public, minimal messaging surface for custom tools - deliberately narrower than + * ViewerRuntime so consumers depend on a small stable contract instead of internals + * that are free to change. + */ +export function useViewerMessaging(): ViewerMessaging { + const runtime = useViewerRuntime(); + return { + send: (message) => runtime.send(message), + sendData: (message) => runtime.sendData(message), + }; +} diff --git a/tests/browser/custom_tool.spec.ts b/tests/browser/custom_tool.spec.ts new file mode 100644 index 0000000..15646f7 --- /dev/null +++ b/tests/browser/custom_tool.spec.ts @@ -0,0 +1,45 @@ +import { expect, test } from "@playwright/test"; + +test("renders a custom toolbar tool and routes its messages through `send`", async ({ + page, +}) => { + const errors: string[] = []; + const externalRequests: string[] = []; + + page.on("console", (message) => { + if (message.type() === "error") errors.push(message.text()); + }); + page.on("pageerror", (error) => errors.push(error.message)); + page.on("request", (request) => { + const url = new URL(request.url()); + if (url.hostname !== "127.0.0.1") externalRequests.push(request.url()); + }); + + await page.goto("/examples/embedded_custom_tool.html"); + await expect(page.locator("body")).toHaveAttribute( + "data-example-ready", + "true", + ); + await expect(page.locator("canvas")).toHaveCount(1); + + const pingButton = page.getByTestId("ping-tool-button"); + await expect(pingButton).toBeVisible(); + await pingButton.click(); + await pingButton.click(); + + const outgoing = await page.evaluate(() => { + const customTool = ( + window as typeof window & { + __compasCustomTool?: { outgoingMessages: unknown[] }; + } + ).__compasCustomTool; + return customTool?.outgoingMessages ?? []; + }); + + expect(outgoing).toEqual([ + { dispatch: "other_action", action: "ping", count: 1 }, + { dispatch: "other_action", action: "ping", count: 2 }, + ]); + expect(externalRequests).toEqual([]); + expect(errors).toEqual([]); +}); diff --git a/tests/library_import.test.ts b/tests/library_import.test.ts index 573b291..16808d4 100644 --- a/tests/library_import.test.ts +++ b/tests/library_import.test.ts @@ -18,4 +18,25 @@ describe("public library entry", () => { }), ); }); + + it("exposes useViewerMessaging as a function", async () => { + const library = await import("../src/library"); + expect(library.useViewerMessaging).toBeTypeOf("function"); + }); + + it("exposes the documented ui kit re-export surface", async () => { + const ui = await import("../src/library/ui"); + expect(Object.keys(ui).sort()).toEqual([ + "Button", + "Kbd", + "KbdGroup", + "Tooltip", + "TooltipContent", + "TooltipProvider", + "TooltipTrigger", + ]); + for (const component of Object.values(ui)) { + expect(component).toBeTruthy(); + } + }); }); diff --git a/tests/viewer_lifecycle.test.ts b/tests/viewer_lifecycle.test.ts index e6874c5..37d322b 100644 --- a/tests/viewer_lifecycle.test.ts +++ b/tests/viewer_lifecycle.test.ts @@ -6,7 +6,7 @@ import { pbDumpBytes, Quaternion, } from "@gramaziokohler/compas-pb-ts"; -import { nextTick } from "vue"; +import { defineComponent, h, nextTick } from "vue"; import { afterEach, describe, expect, it, vi } from "vitest"; vi.mock("three", async () => { @@ -31,12 +31,35 @@ vi.mock("three", async () => { return { ...actual, WebGLRenderer }; }); -import { CompasViewerError, createViewer } from "../src/library"; +import { + CompasViewerError, + createViewer, + useViewerMessaging, +} from "../src/library"; +import type { ToolDefinition } from "../src/library/types"; import { ViewerRuntime } from "../src/viewer/viewer_runtime"; import * as THREE from "three"; const viewers: Array<{ dispose(): void }> = []; +function definePingTool(toolId: string) { + return defineComponent({ + name: `PingTool-${toolId}`, + setup() { + const { sendData } = useViewerMessaging(); + function handleClick() { + sendData({ dispatch: "other_action", action: "ping", tool: toolId }); + } + return () => + h( + "button", + { class: "ping-tool", "data-tool-id": toolId, onClick: handleClick }, + toolId, + ); + }, + }); +} + function boxBytes(guid: string): Uint8Array { return pbDumpBytes( new Box({ @@ -312,3 +335,76 @@ describe("createViewer", () => { runtime.dispose(); }); }); + +describe("toolbar extension API", () => { + it("mounts toolbarTools after the built-in groups, sorted by order", () => { + const container = document.createElement("div"); + document.body.append(container); + const tools: ToolDefinition[] = [ + { id: "later", component: definePingTool("later"), order: 20 }, + { id: "earlier", component: definePingTool("earlier"), order: 5 }, + { id: "unordered", component: definePingTool("unordered") }, + ]; + + const viewer = createViewer(container, { + mode: "embedded", + toolbarTools: tools, + }); + viewers.push(viewer); + + const toolbar = container.querySelector(".toolbar"); + expect(toolbar).not.toBeNull(); + + const builtInGroups = toolbar!.querySelectorAll(".toolbar-group"); + expect(builtInGroups).toHaveLength(4); + + const toolIds = Array.from(toolbar!.querySelectorAll(".ping-tool")).map( + (button) => button.getAttribute("data-tool-id"), + ); + expect(toolIds).toEqual(["unordered", "earlier", "later"]); + + const lastBuiltInGroup = builtInGroups[builtInGroups.length - 1]!; + const firstToolButton = toolbar!.querySelector(".ping-tool")!; + expect( + lastBuiltInGroup.compareDocumentPosition(firstToolButton) & + Node.DOCUMENT_POSITION_FOLLOWING, + ).toBeTruthy(); + }); + + it("routes useViewerMessaging().sendData through the `send` option", () => { + const container = document.createElement("div"); + document.body.append(container); + const outgoing: unknown[] = []; + + const viewer = createViewer(container, { + mode: "embedded", + toolbarTools: [{ id: "ping", component: definePingTool("ping") }], + send: (message) => { + outgoing.push(message); + return true; + }, + }); + viewers.push(viewer); + + const button = container.querySelector(".ping-tool"); + expect(button).not.toBeNull(); + button!.click(); + button!.click(); + + expect(outgoing).toEqual([ + { dispatch: "other_action", action: "ping", tool: "ping" }, + { dispatch: "other_action", action: "ping", tool: "ping" }, + ]); + }); + + it("renders only the built-in groups when toolbarTools is omitted", () => { + const container = document.createElement("div"); + document.body.append(container); + + const viewer = createViewer(container, { mode: "embedded" }); + viewers.push(viewer); + + expect(container.querySelectorAll(".toolbar-group")).toHaveLength(4); + expect(container.querySelectorAll(".ping-tool")).toHaveLength(0); + }); +}); diff --git a/tsconfig.core.json b/tsconfig.core.json index 152ef8f..d1b973c 100644 --- a/tsconfig.core.json +++ b/tsconfig.core.json @@ -15,6 +15,7 @@ "src/library/errors.ts", "src/library/types.ts", "src/library/public.d.ts", + "src/library/ui.d.ts", "src/viewer/**/*.ts" ] } diff --git a/vite.config.library.ts b/vite.config.library.ts index 1587980..1514259 100644 --- a/vite.config.library.ts +++ b/vite.config.library.ts @@ -15,9 +15,11 @@ export default defineConfig({ emptyOutDir: true, sourcemap: true, lib: { - entry: path.resolve(import.meta.dirname, "src/library/index.ts"), + entry: { + index: path.resolve(import.meta.dirname, "src/library/index.ts"), + ui: path.resolve(import.meta.dirname, "src/library/ui.ts"), + }, formats: ["es"], - fileName: "index", cssFileName: "style", }, rollupOptions: { From 69b6392b27208762c69d22e4bb27d988d6be7387 Mon Sep 17 00:00:00 2001 From: Eric Date: Wed, 19 Aug 2026 14:58:45 +0200 Subject: [PATCH 2/3] feat(viewer): add configurable panel docking and theming variables Extends the toolbar extension API from the previous commit with per-panel placement and styling, so a consumer can fully restyle/relayout without forking the app shell: - toolbarPlacement / openbarPlacement / objectActionsPlacement options ("corner" | "docked-top" / "docked-left"), each defaulting to today's floating-corner behavior. Docked-top toolbar and object-actions bars share a height via --docked-bar-height, derived from the new --docked-bar-padding so consumers only need to tune one value. - --toolbar-background/--toolbar-border-color and their object-actions equivalents, so those two panels' look is independently overridable instead of only through the shared .theme class (which still covers Openbar and box-shadow/backdrop-filter for all three). - --section-title-background/-shadow/-blur, replacing the FUNCTIONS/METADATA headers' hardcoded glass bezel. - Toolbar's root selector picked up a `div.` prefix, matching its sibling components: without it, its specificity exactly ties a consumer's `:root .theme { border: ... }`-style override and loses on source order. Verified end-to-end against timber_model_viewer's frontend-src, including a real minified-build bug in Vite's CSS minifier (silently drops an unprefixed backdrop-filter declared alongside its -webkit- pair). --- src/App.vue | 65 +++++- src/components/layout/ObjectActions.vue | 57 ++++-- src/components/layout/ObjectInfo.vue | 13 +- src/components/layout/Openbar.vue | 255 +++++++++++++----------- src/components/layout/RightSidebar.vue | 11 +- src/components/layout/Sidebar.vue | 12 +- src/components/layout/Toolbar.vue | 33 ++- src/library/index.ts | 12 ++ src/library/public.d.ts | 28 +++ src/library/types.ts | 28 +++ src/style.css | 32 +++ src/viewer/viewer_context.ts | 31 ++- tests/viewer_lifecycle.test.ts | 117 +++++++++++ 13 files changed, 540 insertions(+), 154 deletions(-) diff --git a/src/App.vue b/src/App.vue index 443fb1e..c6a005d 100644 --- a/src/App.vue +++ b/src/App.vue @@ -1,19 +1,39 @@