From 85aa2498be74452a069c95dedc91c3108bddad78 Mon Sep 17 00:00:00 2001 From: "Anthony Fu (via agent)" Date: Fri, 30 Oct 2026 00:07:25 +0000 Subject: [PATCH] feat(hub): add toggle dock actions and entry descriptions A `toggle: true` action is an on/off tool, such as an in-page element picker. It never takes the selection: it hides an open panel while on, restores it when off, and its dock button renders as active. A click, `docks.setActionActive(id, false)`, or Escape in hub-ui turns it off. Dock entries also get an optional `description`, shown under the title in the dock button tooltip. Co-Authored-By: Claude Opus 5.5 --- docs/content/1.guide/17.client-context.md | 25 +++++- .../1.guide/22.build-your-own-hub-ui.md | 3 +- docs/content/8.references/6.hub-api.md | 10 +-- .../src/client/components/dock/Dock.vue | 3 + .../src/client/components/dock/DockEdge.vue | 3 + .../client/components/dock/DockEntries.vue | 9 +- .../src/client/components/dock/DockEntry.vue | 4 +- .../components/dock/DockGroupButton.vue | 7 +- .../components/dock/DockGroupPopover.vue | 19 +++- .../components/dock/DockGroupSidebar.vue | 10 +-- .../hub-ui/src/client/i18n/locales/de.json | 1 + .../hub-ui/src/client/i18n/locales/en.json | 1 + .../hub-ui/src/client/i18n/locales/es.json | 1 + .../hub-ui/src/client/i18n/locales/fr.json | 1 + .../hub-ui/src/client/i18n/locales/ja.json | 1 + .../hub-ui/src/client/i18n/locales/ko.json | 1 + .../hub-ui/src/client/i18n/locales/pt-BR.json | 1 + .../hub-ui/src/client/i18n/locales/ru.json | 1 + .../hub-ui/src/client/i18n/locales/zh-CN.json | 1 + .../hub-ui/src/client/i18n/locales/zh-TW.json | 1 + .../state/client-script.integration.test.ts | 86 +++++++++++++++++++ packages/hub-ui/src/client/state/context.ts | 76 +++++++++++++++- packages/hub-ui/src/client/state/docks.ts | 7 +- .../src/client/state/floating-tooltip.ts | 12 ++- .../hub/src/client/__tests__/host.test.ts | 53 ++++++++++++ packages/hub/src/client/docks.ts | 9 ++ packages/hub/src/client/host.ts | 72 +++++++++++++++- packages/hub/src/types/docks.ts | 27 ++++++ .../@devframes/hub/client.snapshot.d.ts | 1 + .../tsnapi/@devframes/hub/index.snapshot.d.ts | 2 + 30 files changed, 421 insertions(+), 27 deletions(-) diff --git a/docs/content/1.guide/17.client-context.md b/docs/content/1.guide/17.client-context.md index 846cb62bb..6a85e6c3e 100644 --- a/docs/content/1.guide/17.client-context.md +++ b/docs/content/1.guide/17.client-context.md @@ -76,7 +76,30 @@ The exported function (`DockClientScriptContext`) receives the client context an - **`current`** holds this entry's state: `entryMeta`, `isActive`, `domElements`, `events` (`entry:activated`, `entry:deactivated`, `entry:updated`, `dom:panel:mounted`, `dom:iframe:mounted`). - **`messages`**: an entry-scoped messages client (`category` defaults to the entry id; `info`/`warn`/`error`/`success`/`debug` shortcuts for `add()`). -Failed setup retries on the next activation, or on a dock update for eager scripts. Setup is cached per RPC connection, dock and import descriptor. Action clicks always execute again. +Failed setup retries on the next activation, or on a dock update for eager scripts. Setup is cached per RPC connection, dock and import descriptor. Action clicks always execute again, except for a [toggle action](#toggle-actions). + +### Toggle actions + +Set `toggle: true` on an `action` entry to make it an on/off tool, such as an in-page element picker. A click turns it on and a second click turns it off. While it is on: + +- the dock button shows as active (`current.isActive` is `true`); +- the selected dock stays selected, and an open panel is hidden until the tool turns off; +- `Escape` turns it off in the reference hub UI. + +The script sets up once and drives the tool from `entry:activated` / `entry:deactivated`. Call `docks.setActionActive(id, false)` when the tool is done, which brings the hidden panel back. Selecting another dock, or `switchEntry(null)`, also turns the tool off, and then the panel stays as the new selection leaves it. + +```ts +// Dock entry: { type: 'action', toggle: true, action: { importFrom } } +import type { DockClientScriptContext } from '@devframes/hub/client' + +export default function setup(ctx: DockClientScriptContext) { + const id = ctx.current.entryMeta.id + ctx.current.events.on('entry:activated', () => { + startPicker({ onPick: () => ctx.docks.setActionActive(id, false) }) + }) + ctx.current.events.on('entry:deactivated', () => stopPicker()) +} +``` ### Shipping a client script diff --git a/docs/content/1.guide/22.build-your-own-hub-ui.md b/docs/content/1.guide/22.build-your-own-hub-ui.md index f65233056..6d06716bc 100644 --- a/docs/content/1.guide/22.build-your-own-hub-ui.md +++ b/docs/content/1.guide/22.build-your-own-hub-ui.md @@ -39,7 +39,8 @@ and loads dock client scripts. Honor: Render the built-in variants of the open dock union (`DevframeDockEntryRegistry`, `@devframes/hub/types`): `iframe` (the entry's `url` in a kept-alive iframe, honoring `subTabs` soft nav), `action` (a -dock-rail button running its client script), `custom-render` (a container its +dock-rail button running its client script, shown as active while a +`toggle: true` action is on), `custom-render` (a container its client script mounts into), `launcher` (a launch call-to-action reflecting `launcher.status`), `group` (one dock-rail button collapsing its members), and `~builtin` (your native views for reserved ids). What to render per type is in diff --git a/docs/content/8.references/6.hub-api.md b/docs/content/8.references/6.hub-api.md index 348b2a0c7..a79606e3e 100644 --- a/docs/content/8.references/6.hub-api.md +++ b/docs/content/8.references/6.hub-api.md @@ -148,7 +148,7 @@ The properties of `DevframeClientContext`: [The client context](/guide/client-co |----------|-------------| | `rpc` | The [RPC client](/guide/client): server/client functions, shared state. | | `clientType` | `'embedded'` (inside the user app) or `'standalone'` (independent hub page). | -| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `getStateById()`, `register()` / `update()` for [client-only docks](/guide/client-context#client-only-docks). | +| `docks` | `entries`, `selected`, `groupedEntries`, `switchEntry()`, `toggleEntry()`, `setActionActive(id, active)` for [toggle actions](/guide/client-context#toggle-actions), `getStateById()`, `register()` / `update()` for [client-only docks](/guide/client-context#client-only-docks). | | `panel` | Current `state`, local `events`, session, position, size, and drag/resize state for the dock panel. | | `commands` | Command palette: `register()`, `execute()`, `getKeybindings()`, `paletteOpen`, `paletteScopeId`, `openPalette(atCommandId?)`. Passing an id opens the palette drilled into that command's children ([Activating a group](/guide/hub#activating-a-group)). | | `renderers` | Dock-renderer registry: `register()`, `get()`, `has()`, `mount(entry, container)`. Routes a dock `type` to a renderer (local boot or the hub's [manifest](/guide/hub-initiate#renderer-modules); local wins). `mount()` resolves a `status`: `mounted` (with `dispose`), `missing-renderer`, or `load-error` (with `error`). | @@ -161,11 +161,11 @@ Which `ClientScriptEntry` field carries an entry's client script, and when it ru | Entry kind | Field | Runs | |---|---|---| -| `action` | `action` | when the dock button is activated | +| `action` | `action` | on each click; once for a `toggle: true` action | | `custom-render` | `renderer` | to render the entry's panel | | `iframe` | `clientScript` (optional) | inside the host page on first activation | -`ClientScriptEntry.eager` defaults to `false`. Set it to `true` on an `iframe` `clientScript` or an `action` to initialize it after RPC trust, before dock activation. A `custom-render` `renderer` needs its mounted panel, so it always initializes on activation regardless of `eager`. Setup is cached per RPC connection and dock; action clicks execute on every activation. +`ClientScriptEntry.eager` defaults to `false`. Set it to `true` on an `iframe` `clientScript` or an `action` to initialize it after RPC trust, before dock activation. A `custom-render` `renderer` needs its mounted panel, so it always initializes on activation regardless of `eager`. Setup is cached per RPC connection and dock; action clicks execute on every activation, except a `toggle: true` action, which sets up once. ## Frame-nav messages @@ -179,12 +179,12 @@ The origin-locked `postMessage` protocol on `devframe:frame-nav`: [Shared-iframe ## Dock entry types -The built-in variants of the open dock union (`DevframeDockEntryRegistry`, `@devframes/hub/types`) a hub UI provider renders: [Build Your Own Hub UI](/guide/build-your-own-hub-ui). Each entry's `icon` takes any of the [icon values](/references/node-api#icon-values). Each entry's `title` has an optional `titleLocales` map of translations, resolved with `resolveTitle(entry, locale)` from `@devframes/hub/client`; commands and launchers carry the same pair. +The built-in variants of the open dock union (`DevframeDockEntryRegistry`, `@devframes/hub/types`) a hub UI provider renders: [Build Your Own Hub UI](/guide/build-your-own-hub-ui). Each entry's `icon` takes any of the [icon values](/references/node-api#icon-values). Each entry's `title` has an optional `titleLocales` map of translations, resolved with `resolveTitle(entry, locale)` from `@devframes/hub/client`; commands and launchers carry the same pair. An optional one-line `description` explains the entry; the reference hub UI shows it under the title in the dock button tooltip. | Type | The hub UI provider renders | |---|---| | `iframe` | the entry's `url` in a kept-alive iframe (per `frameId` when shared); honor `subTabs` soft nav; show the existing address bar with `addressBar: true`, or configure Back, Reload, and Open externally by passing an `addressBar` object | -| `action` | a dock-rail button; activating runs its client script | +| `action` | a dock-rail button; activating runs its client script. With `toggle: true`, render it as active while `getStateById(id).isActive` is `true` | | `custom-render` | a container its client script mounts into | | `launcher` | a launch call-to-action reflecting `launcher.status` | | `group` | one dock-rail button collapsing its member entries | diff --git a/packages/hub-ui/src/client/components/dock/Dock.vue b/packages/hub-ui/src/client/components/dock/Dock.vue index 0935b1d10..7ac06d31a 100644 --- a/packages/hub-ui/src/client/components/dock/Dock.vue +++ b/packages/hub-ui/src/client/components/dock/Dock.vue @@ -226,6 +226,9 @@ const isMinimized = computed(() => { return false if (docksGroupPanel.value) return false + // Keep an active tool's button in sight while it hides the panel. + if (context.when.context.dockActionActive) + return false if (context.panel.store.inactiveTimeout === 0) return true // @ts-expect-error compatibility diff --git a/packages/hub-ui/src/client/components/dock/DockEdge.vue b/packages/hub-ui/src/client/components/dock/DockEdge.vue index bd8e87a7d..9c0937df3 100644 --- a/packages/hub-ui/src/client/components/dock/DockEdge.vue +++ b/packages/hub-ui/src/client/components/dock/DockEdge.vue @@ -116,6 +116,9 @@ const isCollapsed = computed(() => { return false if (hasPanelContent.value) return false + // Keep an active tool's button in sight while it hides the panel. + if (context.when.context.dockActionActive) + return false if (store.inactiveTimeout === 0) return true // @ts-expect-error compatibility (mirrors Dock.vue's own touch-device check) diff --git a/packages/hub-ui/src/client/components/dock/DockEntries.vue b/packages/hub-ui/src/client/components/dock/DockEntries.vue index 55aa6dc2d..db25673a9 100644 --- a/packages/hub-ui/src/client/components/dock/DockEntries.vue +++ b/packages/hub-ui/src/client/components/dock/DockEntries.vue @@ -48,6 +48,11 @@ function isDockVisible(dock: DevframeDockEntry): boolean { return true } +// Selected, or a toggle action that is on (it never takes the selection). +function isDockActive(dock: DevframeDockEntry): boolean { + return selected.value?.id === dock.id || !!props.context.docks.getStateById(dock.id)?.isActive +} + function toggleDockEntry(dock: DevframeDockEntry) { if (selected.value?.id === dock.id) emit('select', undefined!) @@ -73,8 +78,8 @@ function toggleDockEntry(dock: DevframeDockEntry) { :context="context" :dock :is-action="dock.type === 'action'" - :is-selected="selected?.id === dock.id" - :is-dimmed="dimInactive && selected ? (selected.id !== dock.id) : false" + :is-selected="isDockActive(dock)" + :is-dimmed="dimInactive && selected ? !isDockActive(dock) : false" :is-vertical="isVertical" :badge="dock.badge" :badge-variant="dock.badgeVariant" diff --git a/packages/hub-ui/src/client/components/dock/DockEntry.vue b/packages/hub-ui/src/client/components/dock/DockEntry.vue index 4d7e9934c..9fc8e3d48 100644 --- a/packages/hub-ui/src/client/components/dock/DockEntry.vue +++ b/packages/hub-ui/src/client/components/dock/DockEntry.vue @@ -3,7 +3,7 @@ import type { DevframeDockBadgeVariant, DevframeDockEntryBase } from '@devframes import type { DocksContext } from '@devframes/hub/client' import { useEventListener } from '@vueuse/core' import { computed, useTemplateRef } from 'vue' -import { setFloatingTooltip } from '../../state/floating-tooltip' +import { dockTooltipContent, setFloatingTooltip } from '../../state/floating-tooltip' import { openDockContextMenu } from './DockContextMenu' import DockIcon from './DockIcon.vue' @@ -51,7 +51,7 @@ function updateTooltip() { if (!button.value) return setFloatingTooltip({ - content: props.dock.title, + content: dockTooltipContent(props.dock), el: button.value, }) } diff --git a/packages/hub-ui/src/client/components/dock/DockGroupButton.vue b/packages/hub-ui/src/client/components/dock/DockGroupButton.vue index f481ce357..2b2621209 100644 --- a/packages/hub-ui/src/client/components/dock/DockGroupButton.vue +++ b/packages/hub-ui/src/client/components/dock/DockGroupButton.vue @@ -47,6 +47,9 @@ const isActive = computed(() => { return !!id && members.value.some(m => m.id === id) }) +// A member toggle action that is on lights the button up too, without owning the panel. +const hasActiveTool = computed(() => members.value.some(m => m.type === 'action' && props.context.docks.getStateById(m.id)?.isActive)) + const groupButton = useTemplateRef('groupButton') const isPanelVisible = ref(false) const docksGroupPanel = useDocksGroupPanel() @@ -131,8 +134,8 @@ function onClick() { :context="context" :dock="group" :is-vertical="isVertical" - :is-selected="isActive || isPanelVisible" - :is-dimmed="dimInactive && selected ? !isActive : false" + :is-selected="isActive || isPanelVisible || hasActiveTool" + :is-dimmed="dimInactive && selected ? !isActive && !hasActiveTool : false" :badge="group.badge" :badge-variant="group.badgeVariant" @click="onClick" diff --git a/packages/hub-ui/src/client/components/dock/DockGroupPopover.vue b/packages/hub-ui/src/client/components/dock/DockGroupPopover.vue index 598a5876f..50feefcd3 100644 --- a/packages/hub-ui/src/client/components/dock/DockGroupPopover.vue +++ b/packages/hub-ui/src/client/components/dock/DockGroupPopover.vue @@ -3,6 +3,7 @@ import type { DevframeDockEntriesGrouped, DevframeDockEntry, DevframeViewGroup } import type { DocksContext } from '@devframes/hub/client' import { computed } from 'vue' import { t } from '../../i18n' +import { dockTooltipContent, setFloatingTooltip } from '../../state/floating-tooltip' import { accentVarStyle } from '../../utils/accent-color' import { openDockContextMenu } from './DockContextMenu' import DockIcon from './DockIcon.vue' @@ -19,6 +20,16 @@ const emit = defineEmits<{ (e: 'select', entry: DevframeDockEntry): void }>() +// A row already shows its title, so only a `description` is worth a tooltip. +function showTooltip(event: PointerEvent, member: DevframeDockEntry) { + if (member.description) + setFloatingTooltip({ content: dockTooltipContent(member), el: event.currentTarget as HTMLElement, placement: 'right' }) +} + +function hideTooltip() { + setFloatingTooltip(null) +} + const isEmpty = computed(() => props.members.every(([, items]) => items.length === 0)) function openContextMenu(e: MouseEvent, member: DevframeDockEntry) { @@ -55,9 +66,11 @@ function openContextMenu(e: MouseEvent, member: DevframeDockEntry) { v-for="member of items" :key="member.id" class="flex items-center gap-2 w-full px2 py1.5 rounded text-sm text-left transition" - :class="selectedId === member.id ? 'text-primary bg-active' : 'op80 hover:op100 hover:bg-active'" - @click="emit('select', member)" - @contextmenu="openContextMenu($event, member)" + :class="selectedId === member.id || context.docks.getStateById(member.id)?.isActive ? 'text-primary bg-active' : 'op80 hover:op100 hover:bg-active'" + @pointerenter="showTooltip($event, member)" + @pointerleave="hideTooltip" + @click="hideTooltip(); emit('select', member)" + @contextmenu="hideTooltip(); openContextMenu($event, member)" > {{ member.title }} diff --git a/packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue b/packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue index 006398ea5..0c1655f39 100644 --- a/packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue +++ b/packages/hub-ui/src/client/components/dock/DockGroupSidebar.vue @@ -5,7 +5,7 @@ import { watchDebounced } from '@vueuse/core' import { computed, h, ref, useTemplateRef, watch } from 'vue' import { t } from '../../i18n' import { deriveSidebarCapacity, docksSplitGroupsWithCapacity, getGroupMembersGrouped } from '../../state/dock-settings' -import { setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip' +import { dockTooltipContent, setDocksSidebarOverflowPanel, setFloatingTooltip, useDocksSidebarOverflowPanel } from '../../state/floating-tooltip' import { useSettings } from '../../state/settings-defaults' import { accentVarStyle } from '../../utils/accent-color' import { openDockContextMenu } from './DockContextMenu' @@ -92,8 +92,8 @@ function select(id: string) { props.context.docks.switchEntry(id) } -function showTooltip(event: PointerEvent, title: string) { - setFloatingTooltip({ content: title, el: event.currentTarget as HTMLElement }) +function showTooltip(event: PointerEvent, entry: { title: string, description?: string }) { + setFloatingTooltip({ content: dockTooltipContent(entry), el: event.currentTarget as HTMLElement }) } function hideTooltip() { @@ -175,7 +175,7 @@ const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPa
@@ -192,7 +192,7 @@ const moreButtonActive = computed(() => selectedInOverflow.value || isOverflowPa :aria-label="member.title" class="relative flex items-center justify-center w-8 h-8 rounded-lg transition" :class="selectedId === member.id ? 'text-primary bg-active' : 'op60 hover:op100 hover:bg-active'" - @pointerenter="showTooltip($event, member.title)" + @pointerenter="showTooltip($event, member)" @pointerleave="hideTooltip" @pointerdown="hideTooltip" @click="select(member.id)" diff --git a/packages/hub-ui/src/client/i18n/locales/de.json b/packages/hub-ui/src/client/i18n/locales/de.json index 85d1706a6..0a956007a 100644 --- a/packages/hub-ui/src/client/i18n/locales/de.json +++ b/packages/hub-ui/src/client/i18n/locales/de.json @@ -11,6 +11,7 @@ "command.togglePalette": "Befehlspalette umschalten", "command.closePanel": "Panel schließen", + "command.stopActiveTool": "Aktives Werkzeug beenden", "command.openSettings": "Einstellungen öffnen", "command.hide": "{productName} ausblenden", "command.dockMode": "Dock-Modus", diff --git a/packages/hub-ui/src/client/i18n/locales/en.json b/packages/hub-ui/src/client/i18n/locales/en.json index 2eb653816..ac1f1adec 100644 --- a/packages/hub-ui/src/client/i18n/locales/en.json +++ b/packages/hub-ui/src/client/i18n/locales/en.json @@ -11,6 +11,7 @@ "command.togglePalette": "Toggle Command Palette", "command.closePanel": "Close Panel", + "command.stopActiveTool": "Stop Active Tool", "command.openSettings": "Open Settings", "command.hide": "Hide {productName}", "command.dockMode": "Dock Mode", diff --git a/packages/hub-ui/src/client/i18n/locales/es.json b/packages/hub-ui/src/client/i18n/locales/es.json index 98e451d8d..0ae5159f1 100644 --- a/packages/hub-ui/src/client/i18n/locales/es.json +++ b/packages/hub-ui/src/client/i18n/locales/es.json @@ -11,6 +11,7 @@ "command.togglePalette": "Alternar paleta de comandos", "command.closePanel": "Cerrar panel", + "command.stopActiveTool": "Detener herramienta activa", "command.openSettings": "Abrir ajustes", "command.hide": "Ocultar {productName}", "command.dockMode": "Modo de anclaje", diff --git a/packages/hub-ui/src/client/i18n/locales/fr.json b/packages/hub-ui/src/client/i18n/locales/fr.json index 4c57b86de..58168b380 100644 --- a/packages/hub-ui/src/client/i18n/locales/fr.json +++ b/packages/hub-ui/src/client/i18n/locales/fr.json @@ -11,6 +11,7 @@ "command.togglePalette": "Basculer la palette de commandes", "command.closePanel": "Fermer le panneau", + "command.stopActiveTool": "Arrêter l'outil actif", "command.openSettings": "Ouvrir les paramètres", "command.hide": "Masquer {productName}", "command.dockMode": "Mode d'ancrage", diff --git a/packages/hub-ui/src/client/i18n/locales/ja.json b/packages/hub-ui/src/client/i18n/locales/ja.json index 0cfc91f17..4a47d4d0d 100644 --- a/packages/hub-ui/src/client/i18n/locales/ja.json +++ b/packages/hub-ui/src/client/i18n/locales/ja.json @@ -11,6 +11,7 @@ "command.togglePalette": "コマンドパレットの切り替え", "command.closePanel": "パネルを閉じる", + "command.stopActiveTool": "アクティブなツールを停止", "command.openSettings": "設定を開く", "command.hide": "{productName} を隠す", "command.dockMode": "ドックモード", diff --git a/packages/hub-ui/src/client/i18n/locales/ko.json b/packages/hub-ui/src/client/i18n/locales/ko.json index f33faab97..8f2eef454 100644 --- a/packages/hub-ui/src/client/i18n/locales/ko.json +++ b/packages/hub-ui/src/client/i18n/locales/ko.json @@ -11,6 +11,7 @@ "command.togglePalette": "명령 팔레트 전환", "command.closePanel": "패널 닫기", + "command.stopActiveTool": "활성 도구 중지", "command.openSettings": "설정 열기", "command.hide": "{productName} 숨기기", "command.dockMode": "도킹 모드", diff --git a/packages/hub-ui/src/client/i18n/locales/pt-BR.json b/packages/hub-ui/src/client/i18n/locales/pt-BR.json index e98064f17..ac367dacc 100644 --- a/packages/hub-ui/src/client/i18n/locales/pt-BR.json +++ b/packages/hub-ui/src/client/i18n/locales/pt-BR.json @@ -11,6 +11,7 @@ "command.togglePalette": "Alternar paleta de comandos", "command.closePanel": "Fechar painel", + "command.stopActiveTool": "Parar ferramenta ativa", "command.openSettings": "Abrir configurações", "command.hide": "Ocultar {productName}", "command.dockMode": "Modo de encaixe", diff --git a/packages/hub-ui/src/client/i18n/locales/ru.json b/packages/hub-ui/src/client/i18n/locales/ru.json index ee3e28c6e..093887048 100644 --- a/packages/hub-ui/src/client/i18n/locales/ru.json +++ b/packages/hub-ui/src/client/i18n/locales/ru.json @@ -11,6 +11,7 @@ "command.togglePalette": "Переключить палитру команд", "command.closePanel": "Закрыть панель", + "command.stopActiveTool": "Остановить активный инструмент", "command.openSettings": "Открыть настройки", "command.hide": "Скрыть {productName}", "command.dockMode": "Режим панели", diff --git a/packages/hub-ui/src/client/i18n/locales/zh-CN.json b/packages/hub-ui/src/client/i18n/locales/zh-CN.json index 0987fd845..e9ad918cf 100644 --- a/packages/hub-ui/src/client/i18n/locales/zh-CN.json +++ b/packages/hub-ui/src/client/i18n/locales/zh-CN.json @@ -11,6 +11,7 @@ "command.togglePalette": "切换命令面板", "command.closePanel": "关闭面板", + "command.stopActiveTool": "停止当前工具", "command.openSettings": "打开设置", "command.hide": "隐藏 {productName}", "command.dockMode": "停靠模式", diff --git a/packages/hub-ui/src/client/i18n/locales/zh-TW.json b/packages/hub-ui/src/client/i18n/locales/zh-TW.json index 9aceffe56..3e5352620 100644 --- a/packages/hub-ui/src/client/i18n/locales/zh-TW.json +++ b/packages/hub-ui/src/client/i18n/locales/zh-TW.json @@ -11,6 +11,7 @@ "command.togglePalette": "切換命令面板", "command.closePanel": "關閉面板", + "command.stopActiveTool": "停止使用中的工具", "command.openSettings": "開啟設定", "command.hide": "隱藏 {productName}", "command.dockMode": "停靠模式", diff --git a/packages/hub-ui/src/client/state/client-script.integration.test.ts b/packages/hub-ui/src/client/state/client-script.integration.test.ts index 01585a5f3..729b2e823 100644 --- a/packages/hub-ui/src/client/state/client-script.integration.test.ts +++ b/packages/hub-ui/src/client/state/client-script.integration.test.ts @@ -312,3 +312,89 @@ it('does not activate an iframe when trust is lost while its setup completes', a delete fixture.__DF_SETUP_REVOKE_UI__ } }) + +describe('toggle action docks', () => { + const toolScript = 'data:text/javascript,export default ctx => globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__(ctx)' + + async function setup() { + const events: string[] = [] + let attempts = 0 + globalThis.__DEVFRAME_CLIENT_SCRIPT_ATTEMPT__ = ((ctx: any) => { + attempts++ + ctx.current.events.on('entry:activated', () => events.push('on')) + ctx.current.events.on('entry:deactivated', () => events.push('off')) + }) as () => void + const { rpc, sharedStates } = createStubRpc() + const context = await createDocksContext('embedded', rpc) + const panel = { id: 'panel', type: 'iframe', title: 'Panel', icon: 'ph:browser', url: '/fixture' } satisfies DevframeDockEntry + const tool = { id: 'tool', type: 'action', title: 'Inspect', icon: 'ph:cursor', toggle: true, action: { importFrom: toolScript } } satisfies DevframeDockEntry + sharedStates.get('devframe:docks')!.push([panel, tool]) + await nextTick() + return { context, events, attempts: () => attempts } + } + + it('turns on without taking the selection, hides the panel, and restores it on a second click', async () => { + const { context, events, attempts } = await setup() + await context.docks.switchEntry('panel') + + await expect(context.docks.switchEntry('tool')).resolves.toBe(true) + expect(context.docks.selectedId).toBe('panel') + expect(context.panel.session.open).toBe(false) + expect(context.docks.getStateById('tool')!.isActive).toBe(true) + expect(context.when.context.dockActionActive).toBe(true) + + await expect(context.docks.switchEntry('tool')).resolves.toBe(true) + expect(context.docks.getStateById('tool')!.isActive).toBe(false) + expect(context.panel.session.open).toBe(true) + expect(context.docks.selectedId).toBe('panel') + + await context.docks.switchEntry('tool') + expect(attempts()).toBe(1) + expect(events.filter(e => e === 'on')).toHaveLength(2) + expect(events.at(-1)).toBe('on') + }) + + it('turns off from the script or Escape and restores the panel', async () => { + const { context } = await setup() + await context.docks.switchEntry('panel') + await context.docks.switchEntry('tool') + + await expect(context.docks.setActionActive('tool', false)).resolves.toBe(true) + await expect(context.docks.setActionActive('tool', false)).resolves.toBe(false) + expect(context.panel.session.open).toBe(true) + + await context.docks.switchEntry('tool') + await context.commands.execute('devframes:stop-active-tool') + await nextTick() + expect(context.docks.getStateById('tool')!.isActive).toBe(false) + expect(context.panel.session.open).toBe(true) + }) + + it('leaves the panel closed when it was closed before the tool turned on', async () => { + const { context } = await setup() + await context.docks.switchEntry('tool') + expect(context.docks.selectedId).toBeNull() + await context.docks.switchEntry('tool') + expect(context.panel.session.open).toBe(false) + }) + + it('turns off without restoring when another dock is selected or the selection is cleared', async () => { + const { context, events } = await setup() + await context.docks.switchEntry('tool') + await context.docks.switchEntry('panel') + expect(context.docks.getStateById('tool')!.isActive).toBe(false) + expect(context.docks.selectedId).toBe('panel') + expect(context.panel.session.open).toBe(true) + expect(events.at(-1)).toBe('off') + + await context.docks.switchEntry('tool') + await context.docks.switchEntry(null) + expect(context.docks.getStateById('tool')!.isActive).toBe(false) + expect(context.panel.session.open).toBe(false) + }) + + it('rejects setActionActive for a dock that is not a toggle action', async () => { + const { context } = await setup() + await expect(context.docks.setActionActive('panel', true)).resolves.toBe(false) + }) +}) diff --git a/packages/hub-ui/src/client/state/context.ts b/packages/hub-ui/src/client/state/context.ts index 5410f5d92..53a676419 100644 --- a/packages/hub-ui/src/client/state/context.ts +++ b/packages/hub-ui/src/client/state/context.ts @@ -124,6 +124,16 @@ export async function createDocksContext( ?? null }) + /** Toggle actions that are on; they never take the selection. */ + const activeActions: Set = reactive(new Set()) + /** Whether turning the last toggle action off should reopen the panel it hid. */ + let reopenPanel = false + const restorePanel = (): void => { + if (reopenPanel) + sessionStore.value.open = true + reopenPanel = false + } + const dockEntryStateMap: Map = reactive(new Map()) watchEffect(() => { for (const entry of entries.value) { @@ -133,7 +143,7 @@ export async function createDocksContext( } dockEntryStateMap.set( entry.id, - createDockEntryState(entry, selected), + createDockEntryState(entry, selected, activeActions), ) } }) @@ -148,7 +158,7 @@ export async function createDocksContext( // `getStateById`) synchronously right after registering, so the state has // to exist immediately, mirroring hub's synchronous client-host reconcile. if (!dockEntryStateMap.has(entry.id)) - dockEntryStateMap.set(entry.id, createDockEntryState(entry, selected)) + dockEntryStateMap.set(entry.id, createDockEntryState(entry, selected, activeActions)) return { update: (patch: Partial) => { if (patch.id != null && patch.id !== entry.id) @@ -161,6 +171,8 @@ export async function createDocksContext( if (!clientDocks.delete(entry.id)) return dockEntryStateMap.delete(entry.id) + if (activeActions.delete(entry.id) && activeActions.size === 0) + restorePanel() // Clearing the selection when the active member vanishes matches the // hub reconcile behavior for a removed selected entry (shared-iframe // soft-nav §7.5). @@ -207,6 +219,7 @@ export async function createDocksContext( paletteOpen: commandsContext?.paletteOpen ?? false, dockSelectedId: selectedDockId.value ?? '', popupOpen: isDockPopupOpen.value, + dockActionActive: activeActions.size > 0, }) // The shared frame's live member tab, keyed by `frameId`. Lets `switchEntry` @@ -315,8 +328,44 @@ export async function createDocksContext( }) } + /** A new selection wins over the panel a toggle action hid, so it is not restored. */ + const turnOffActions = (): void => { + reopenPanel = false + for (const id of [...activeActions]) { + activeActions.delete(id) + dockEntryStateMap.get(id)?.events.emit('entry:deactivated') + } + } + + const setActionActive = async (id: string, active: boolean): Promise => { + const entry = entries.value.find(e => e.id === id) + if (entry?.type !== 'action' || !entry.toggle || activeActions.has(id) === active) + return false + if (!active) { + activeActions.delete(id) + dockEntryStateMap.get(id)?.events.emit('entry:deactivated') + if (activeActions.size === 0) + restorePanel() + return true + } + if (!rpc.isTrusted) + return false + // Run once: the script listens to the entry events instead of re-running per click. + await executeSetupScript(entry, scriptContext(entry), true) + if (!rpc.isTrusted || activeActions.has(id)) + return false + if (activeActions.size === 0) { + reopenPanel = sessionStore.value.open + sessionStore.value.open = false + } + activeActions.add(id) + dockEntryStateMap.get(id)?.events.emit('entry:activated') + return true + } + const switchEntry = async (id: string | null = null): Promise => { if (id == null) { + turnOffActions() initialRestorePending.value = false selectedDockId.value = null sessionStore.value.open = false @@ -324,6 +373,7 @@ export async function createDocksContext( return true } if (id === '~client-auth-notice') { + turnOffActions() initialRestorePending.value = false selectedDockId.value = id sessionStore.value.open = true @@ -339,6 +389,14 @@ export async function createDocksContext( if (redirect !== null) return switchEntry(redirect) + // A toggle action never takes the selection, so it skips the group's default + // member load as well. A popup forwards the click to the main frame. + if (entry.type === 'action' && entry.toggle) { + if (await triggerMainFrameDockAction(clientType, entry.id) != null) + return false + return setActionActive(entry.id, !activeActions.has(entry.id)) + } + const loading = loadGroupDefaultChild(entry, switchEntry) if (loading) await loading @@ -357,6 +415,7 @@ export async function createDocksContext( return false initialRestorePending.value = false + turnOffActions() selectedDockId.value = entry.id sessionStore.value.open = true @@ -492,6 +551,18 @@ export async function createDocksContext( selectedDockId.value = null }, }, + { + id: 'devframes:stop-active-tool', + source: 'client', + title: t('command.stopActiveTool'), + icon: 'ph:stop-circle-duotone', + when: 'dockActionActive && !paletteOpen', + keybindings: [{ key: 'Escape' }], + action: () => { + for (const id of [...activeActions]) + void setActionActive(id, false) + }, + }, { id: 'devframes:open-settings', source: 'client', @@ -692,6 +763,7 @@ export async function createDocksContext( getStateById: (id: string) => dockEntryStateMap.get(id), switchEntry, toggleEntry, + setActionActive, register: registerClientDock, update: updateClientDock, }, diff --git a/packages/hub-ui/src/client/state/docks.ts b/packages/hub-ui/src/client/state/docks.ts index 7c43b18b9..ded7c4dac 100644 --- a/packages/hub-ui/src/client/state/docks.ts +++ b/packages/hub-ui/src/client/state/docks.ts @@ -37,12 +37,14 @@ export function DEFAULT_DOCK_SESSION_STORE(): DockSessionStorage { export function createDockEntryState( entry: DevframeDockEntry, selected: Ref, + /** Toggle actions that are on. Their own activation emits the entry events. */ + activeActions: ReadonlySet = new Set(), ): DockEntryState { const events = createEventEmitter() const state: DockEntryState = reactive({ entryMeta: entry, get isActive() { - return selected.value?.id === entry.id + return selected.value?.id === entry.id || activeActions.has(entry.id) }, domElements: {}, events: markRaw(events), @@ -54,7 +56,8 @@ export function createDockEntryState( if (newSelectedId === entry.id) { events.emit('entry:activated') } - else { + // A toggle action that is on stays on across selection changes it didn't cause. + else if (!activeActions.has(entry.id)) { events.emit('entry:deactivated') } }, diff --git a/packages/hub-ui/src/client/state/floating-tooltip.ts b/packages/hub-ui/src/client/state/floating-tooltip.ts index 13eee8668..965e062ef 100644 --- a/packages/hub-ui/src/client/state/floating-tooltip.ts +++ b/packages/hub-ui/src/client/state/floating-tooltip.ts @@ -1,5 +1,5 @@ import type { VNode } from 'vue' -import { shallowRef } from 'vue' +import { h, shallowRef } from 'vue' export interface FloatingPopoverProps { el: HTMLElement @@ -13,6 +13,16 @@ const docksOverflowPanel = shallowRef(null) const docksSidebarOverflowPanel = shallowRef(null) const dockContextMenu = shallowRef(null) +/** A dock entry's tooltip: its title, with its `description` below when set. */ +export function dockTooltipContent(entry: { title: string, description?: string }): FloatingPopoverProps['content'] { + if (!entry.description) + return entry.title + return () => h('div', { class: 'flex flex-col gap-0.5 max-w-64' }, [ + h('span', entry.title), + h('span', { class: 'text-xs color-muted' }, entry.description), + ]) +} + export function setFloatingTooltip(info: FloatingPopoverProps | null) { tooltip.value = info } diff --git a/packages/hub/src/client/__tests__/host.test.ts b/packages/hub/src/client/__tests__/host.test.ts index 563ccb6dc..ea1d62d5e 100644 --- a/packages/hub/src/client/__tests__/host.test.ts +++ b/packages/hub/src/client/__tests__/host.test.ts @@ -590,3 +590,56 @@ it('does not activate an iframe when trust is lost while its setup completes', a runtime.dispose() } }) + +it('runs a toggle action as an on/off tool that hides and restores the panel', async () => { + expect.assertions(12) + const { rpc, states } = createStubRpc() + const runtime = await createDevframeClientRuntime({ rpc }) + const events: string[] = [] + const setup = vi.fn((ctx: any) => { + ctx.current.events.on('entry:activated', () => events.push('on')) + ctx.current.events.on('entry:deactivated', () => events.push('off')) + }) + const fixture = globalThis as typeof globalThis & { __DF_TOGGLE_TEST__?: (ctx: any) => void } + fixture.__DF_TOGGLE_TEST__ = setup + const tool = { + id: 'tool', + title: 'Inspect', + icon: 'ph:cursor', + type: 'action', + toggle: true, + action: { importFrom: 'data:text/javascript,export default ctx => globalThis.__DF_TOGGLE_TEST__(ctx)' }, + } satisfies DevframeDockEntry + const { docks, panel } = runtime.context + try { + states.get('devframe:docks')!.push([iframeEntry('panel'), tool]) + await docks.switchEntry('panel') + panel.session.open = true + + await expect(docks.switchEntry('tool')).resolves.toBe(true) + expect(docks.selectedId).toBe('panel') + expect(docks.getStateById('tool')!.isActive).toBe(true) + expect(panel.session.open).toBe(false) + + // A second click turns it off and brings the panel back. + await expect(docks.toggleEntry('tool')).resolves.toBe(true) + expect(panel.session.open).toBe(true) + + await docks.switchEntry('tool') + await expect(docks.setActionActive('tool', false)).resolves.toBe(true) + await expect(docks.setActionActive('tool', false)).resolves.toBe(false) + + // A new selection wins: the tool goes off and the old panel is not restored. + await docks.switchEntry('tool') + await docks.switchEntry(null) + expect(docks.getStateById('tool')!.isActive).toBe(false) + expect(panel.session.open).toBe(false) + + expect(setup).toHaveBeenCalledOnce() + expect(events).toEqual(['on', 'off', 'on', 'off', 'on', 'off']) + } + finally { + runtime.dispose() + delete fixture.__DF_TOGGLE_TEST__ + } +}) diff --git a/packages/hub/src/client/docks.ts b/packages/hub/src/client/docks.ts index c61965cf9..aec2a0992 100644 --- a/packages/hub/src/client/docks.ts +++ b/packages/hub/src/client/docks.ts @@ -193,6 +193,14 @@ export interface DocksEntriesContext { * @returns Whether the selection was changed successfully */ toggleEntry: (id: string) => Promise + /** + * Turn a {@link import('../types/docks').DevframeViewAction.toggle toggle action} + * on or off without touching the selection. Turning it on hides an open panel; + * turning it off restores that panel. + * + * @returns Whether the action's state changed + */ + setActionActive: (id: string, active: boolean) => Promise /** * Register a **client-only** dock entry, live in this page and merged with * the server-provided docks (`devframe:docks` shared state) into @@ -226,6 +234,7 @@ export interface DockRegistration() + /** Whether turning the last toggle action off should reopen the panel it hid. */ + let reopenPanel = false /** Bumped by every `switchEntry` call, so a stale one can tell it was superseded. */ let switchRequest = 0 const entryToStateMap = new Map() @@ -269,7 +273,7 @@ export async function createDevframeClientRuntime( return { entryMeta, get isActive() { - return selectedId === entryMeta.id + return selectedId === entryMeta.id || activeActions.has(entryMeta.id) }, domElements: {}, events: createEventEmitter(), @@ -347,6 +351,8 @@ export async function createDevframeClientRuntime( continue const removed = entryToStateMap.get(id) entryToStateMap.delete(id) + if (activeActions.delete(id) && activeActions.size === 0) + restorePanel() // Tear down a shared-frame adapter when its anchor iframe goes away. const removedMeta = removed?.entryMeta as DevframeViewIframe | undefined if (removedMeta?.type === 'iframe' && removedMeta.subTabs) { @@ -396,6 +402,7 @@ export async function createDevframeClientRuntime( getStateById: id => entryToStateMap.get(id), switchEntry, toggleEntry: id => (selectedId === id ? switchEntry(null) : switchEntry(id)), + setActionActive, register(entry, force) { if (clientDocks.has(entry.id) && !force) throw new Error(`[@devframes/hub] a client dock "${entry.id}" is already registered; pass force to overwrite`) @@ -442,8 +449,70 @@ export async function createDevframeClientRuntime( await setupClientScript(entry.id, entry.renderer) } + function isToggleAction(entry: DevframeDockEntry | undefined): boolean { + return entry?.type === 'action' && !!entry.toggle + } + + function restorePanel(): void { + if (reopenPanel) + panel.session.open = true + reopenPanel = false + } + + /** A new selection wins over the panel a toggle action hid, so it is not restored. */ + function turnOffActions(): void { + reopenPanel = false + for (const id of [...activeActions]) { + activeActions.delete(id) + entryToStateMap.get(id)?.events.emit('entry:deactivated') + } + } + + async function setActionActive(id: string, active: boolean): Promise { + const state = entryToStateMap.get(id) + const entry = state?.entryMeta + if (!state || entry?.type !== 'action' || !entry.toggle || activeActions.has(id) === active) + return false + if (!active) { + activeActions.delete(id) + state.events.emit('entry:deactivated') + if (activeActions.size === 0) + restorePanel() + return true + } + if (loadScriptsEnabled) { + if (!rpc.isTrusted) + return false + // Run once: the script listens to the entry events instead of re-running per click. + await setupClientScript(id, entry.action) + if (disposed || activeActions.has(id) || entryToStateMap.get(id) !== state) + return false + } + if (activeActions.size === 0) { + reopenPanel = panel.session.open + panel.session.open = false + } + activeActions.add(id) + state.events.emit('entry:activated') + return true + } + + /** A click flips a toggle action; clearing an empty selection still turns tools off. */ + function switchToggleAction(next: string | null): Promise | undefined { + if (next !== null && isToggleAction(entryToStateMap.get(next)?.entryMeta)) + return setActionActive(next, !activeActions.has(next)) + if (next === null && selectedId === null && activeActions.size > 0) { + turnOffActions() + return Promise.resolve(true) + } + return undefined + } + async function switchEntry(id?: string | null): Promise { const next = id ?? null + const toggled = switchToggleAction(next) + if (toggled) + return toggled if (next === selectedId && entryToStateMap.get(next ?? '')?.entryMeta.type !== 'action') return false if (next !== null && !entryToStateMap.has(next)) @@ -462,6 +531,7 @@ export async function createDevframeClientRuntime( const previous = selectedId selectedId = next + turnOffActions() // Mirror onto the session context so a persisting host and the when-clause // context see the current selection. panel.session.selectedDockId = next diff --git a/packages/hub/src/types/docks.ts b/packages/hub/src/types/docks.ts index e023fc893..64293c10c 100644 --- a/packages/hub/src/types/docks.ts +++ b/packages/hub/src/types/docks.ts @@ -86,6 +86,12 @@ export interface DevframeDockEntryBase { title: string /** Translations of `title` by BCP 47 tag; see `resolveTitle()` from `@devframes/hub`. */ titleLocales?: DevframeTitleLocales + /** + * A longer, one-line hint of what the entry does. A viewer shows it under + * the {@link title} in the dock button tooltip, so a button whose icon is + * ambiguous still explains itself on hover. + */ + description?: string icon: DevframeDockEntryIcon /** * The default order of the entry in the dock. @@ -396,6 +402,27 @@ export interface DevframeViewLauncher extends DevframeDockEntryBase { export interface DevframeViewAction extends DevframeDockEntryBase { type: 'action' action: ClientScriptEntry + /** + * Make the action an on/off tool, such as an in-page element picker. + * + * - The {@link action} script runs once (cached like other client scripts) + * and drives the tool from the entry's `entry:activated` / + * `entry:deactivated` events. + * - Activating it does not select it: the panel's selected dock stays as is. + * An open panel is hidden while the tool is on and comes back when it goes + * off. + * - While on, the dock button renders as active (`DockEntryState.isActive`). + * - `docks.switchEntry(id)` (a click) flips it on and off. + * - It also goes off on `Escape` in a viewer that supports it, on + * `docks.setActionActive(id, false)`, or when another dock is selected or + * the selection is cleared (then the panel is not restored, the new + * selection wins). + * + * When unset, the action is one-shot: each click runs the script again. + * + * @default false + */ + toggle?: boolean } export interface DevframeViewCustomRender extends DevframeDockEntryBase { diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts index d0473e2c0..b6339daaa 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/client.snapshot.d.ts @@ -105,6 +105,7 @@ export interface DocksEntriesContext { getStateById: (_: string) => DockEntryState | undefined; switchEntry: (_?: string | null) => Promise; toggleEntry: (_: string) => Promise; + setActionActive: (_: string, _: boolean) => Promise; register: (_: T, _?: boolean) => DockRegistration; update: (_: DevframeDockUserEntry) => void; } diff --git a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts index 272c2b786..2e0043b25 100644 --- a/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts +++ b/tests/__snapshots__/tsnapi/@devframes/hub/index.snapshot.d.ts @@ -88,6 +88,7 @@ export interface DevframeDockEntryBase { id: string; title: string; titleLocales?: DevframeTitleLocales; + description?: string; icon: DevframeDockEntryIcon; defaultOrder?: number; category?: DevframeDockEntryCategory; @@ -299,6 +300,7 @@ export interface DevframeTitled { export interface DevframeViewAction extends DevframeDockEntryBase { type: 'action'; action: ClientScriptEntry; + toggle?: boolean; } export interface DevframeViewBuiltin extends DevframeDockEntryBase { type: '~builtin';