From 5f583caa05d18f44fee519cecd9b9a9e15fe3178 Mon Sep 17 00:00:00 2001 From: Codex Date: Thu, 16 Jul 2026 20:56:23 +0300 Subject: [PATCH] feat(ui): add bounded workspace windows --- apps/catalog/src/CatalogApp.tsx | 44 +++ apps/catalog/src/styles.css | 59 ++++ docs/COMPONENTS.md | 8 + docs/WINDOWS_AND_LAYERS.md | 20 +- packages/ui-core/styles.css | 173 ++++++++++- packages/ui-react/src/WorkspaceWindow.tsx | 343 ++++++++++++++++++++++ packages/ui-react/src/index.ts | 1 + registry/components.json | 16 + 8 files changed, 661 insertions(+), 3 deletions(-) create mode 100644 packages/ui-react/src/WorkspaceWindow.tsx diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index 622bfef..25e5635 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -43,9 +43,11 @@ import { useApplicationWorkspace, Window, WindowFooterActions, + WorkspaceWindow, type IconName, type ShareAccessMember, type ToolbarPlacement, + type WorkspaceWindowRect, } from "@nodedc/ui-react"; import { applyFaviconAssets, createIcoBlob, generateFavicons, type FaviconAssetUrls, type GeneratedFavicon } from "./favicon.js"; import { @@ -371,6 +373,10 @@ export function CatalogApp() { const [strokeColor, setStrokeColor] = useState("#2b2b36"); const [strokeOpacity, setStrokeOpacity] = useState(100); const [inspectorOpen, setInspectorOpen] = useState(false); + const workspaceWindowDemoRef = useRef(null); + const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true); + const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false); + const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState({ x: 24, y: 24, width: 340, height: 230 }); const [createModalOpen, setCreateModalOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [shareAccessOpen, setShareAccessOpen] = useState(false); @@ -1296,6 +1302,44 @@ export function CatalogApp() { + +
+ {workspaceWindowDemoOpen ? ( + undefined} + onClose={() => { + setWorkspaceWindowDemoOpen(false); + setWorkspaceWindowDemoMaximized(false); + }} + title="Визуальный канал" + subtitle="WORKSPACE / AUXILIARY VIEW" + status="LIVE" + footer={Стрелки — позиция · Shift + стрелки — 10 px} + active + zIndex={2} + > +
+ + Рабочая поверхность + Перемещается, изменяет размер и разворачивается только внутри сцены. +
+
+ ) : ( + + )} +
+

Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.

+

Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.

diff --git a/apps/catalog/src/styles.css b/apps/catalog/src/styles.css index dfad280..5b07864 100644 --- a/apps/catalog/src/styles.css +++ b/apps/catalog/src/styles.css @@ -897,6 +897,65 @@ textarea { grid-column: 1 / -1; } +.catalog-workspace-window-preview .catalog-preview__body { + align-content: stretch; +} + +.catalog-workspace-window-demo { + position: relative; + min-height: 24rem; + overflow: hidden; + border-radius: var(--nodedc-radius-card); + background: + linear-gradient(color-mix(in srgb, var(--nodedc-text-primary) 5%, transparent) 1px, transparent 1px), + linear-gradient(90deg, color-mix(in srgb, var(--nodedc-text-primary) 5%, transparent) 1px, transparent 1px), + color-mix(in srgb, var(--nodedc-canvas) 88%, var(--nodedc-glass-panel-bg-soft)); + background-size: 2.5rem 2.5rem; + box-shadow: inset 0 0 0 1px color-mix(in srgb, var(--nodedc-text-primary) 8%, transparent); +} + +.catalog-workspace-window-demo .nodedc-workspace-window__body { + display: grid; + padding: 0.25rem 0.75rem 0.5rem; +} + +.catalog-workspace-window-demo__media { + display: grid; + min-height: 0; + place-content: center; + justify-items: center; + gap: 0.45rem; + border-radius: calc(var(--nodedc-radius-card) * 0.72); + background: + radial-gradient(circle at 62% 30%, color-mix(in srgb, rgb(var(--nodedc-accent-rgb)) 18%, transparent), transparent 38%), + color-mix(in srgb, var(--nodedc-canvas) 72%, transparent); + color: var(--nodedc-text-secondary); + text-align: center; +} + +.catalog-workspace-window-demo__media strong { + color: var(--nodedc-text-primary); + font-size: 0.82rem; +} + +.catalog-workspace-window-demo__media span, +.catalog-workspace-window-demo__hint { + color: var(--nodedc-text-muted); + font-size: 0.66rem; + line-height: 1.4; +} + +.catalog-workspace-window-demo__media span { + max-width: 16rem; +} + +.catalog-workspace-window-demo__reopen { + position: absolute; + top: 50%; + left: 50%; + transform: translate(-50%, -50%); +} + .catalog-inspector-launcher .nodedc-button { width: 100%; } diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 9541c2a..5c74994 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -86,6 +86,14 @@ Window — единая механика открытия modal и правой Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически. +## WorkspaceWindow + +`WorkspaceWindow` — modeless-окно внутри рабочей сцены приложения. Оно рендерится непосредственно в переданном workspace, не использует portal и не может перекрыть шапку, навигацию или соседние панели за пределами этого workspace. + +Приложение контролирует `rect`, `maximized`, видимость, active-state и `zIndex`. Компонент владеет pointer/keyboard-механикой перемещения и изменения размера, кнопками maximize/restore и close, а также повторно ограничивает геометрию при изменении размеров workspace через `ResizeObserver`. Родительский bounds-контейнер должен быть позиционированным и обрезать содержимое (`position: relative; overflow: hidden`). + +Окно предназначено для вспомогательных камер, инструментов и сопоставляемых представлений внутри сцены. Modal workflow, подтверждение и viewport-level Inspector по-прежнему используют `Window`. + ## Toolbar `Toolbar` — канонический dock из нового Engine с требованиями BIM Viewer. Он поддерживает позиции `left`, `right`, `bottom`, lens magnification и autohide. Контролируются фон, border, outline, минимальный/максимальный размер и количество иконок линзы. diff --git a/docs/WINDOWS_AND_LAYERS.md b/docs/WINDOWS_AND_LAYERS.md index 1651ab3..12fc690 100644 --- a/docs/WINDOWS_AND_LAYERS.md +++ b/docs/WINDOWS_AND_LAYERS.md @@ -69,8 +69,26 @@ Side window использует ту же механику, но placement `end По умолчанию это modeless-слой: он не затемняет страницу, не перехватывает клики по основной области, не закрывается по backdrop, не блокирует body scroll и не удерживает Tab внутри панели. Escape и кнопка закрытия остаются доступны. Если конкретный workflow должен быть modal, это задаётся явно, а не получается случайно из placement. +## Workspace window + +`WorkspaceWindow` отличается от modal и side inspector границей слоя: это inline modeless-окно, ограниченное конкретной рабочей сценой приложения. + +Обязательное поведение: + +1. окно рендерится внутри bounds-контейнера без portal; +2. приложение контролирует rectangle, maximized-state, visibility, active-state и z-order; +3. drag доступен за header мышью, touch/pen pointer и стрелками клавиатуры; +4. resize выполняется нижней правой ручкой pointer-ом или стрелками клавиатуры; +5. move и resize всегда ограничены текущими размерами workspace; +6. `ResizeObserver` повторно ограничивает rectangle после изменения layout; +7. maximize заполняет только workspace, а restore возвращает сохранённый приложением rectangle; +8. close сообщает intent приложению и не создаёт внутренний store; +9. родительский bounds-контейнер использует `position: relative` и `overflow: hidden`. + +Workspace window используется для вспомогательных камер, инструментов и сопоставляемых представлений внутри stage. Оно не заменяет modal `Window`, viewport-level Inspector или `ApplicationPanel`. + ## Управление состоянием Библиотека не создаёт глобальный store окон. Приложение владеет тем, какое окно открыто и какие данные в нём загружены. Библиотека владеет одинаковым поведением самого слоя. -Если приложению позже потребуется stack нескольких modeless-окон, это будет отдельный пакетный контракт, а не расширение локального z-index. +Stack workspace-окон остаётся контролируемым приложением: оно передаёт active-state и `zIndex`, а компонент отвечает только за одинаковую геометрию и взаимодействие. Глобальный stack viewport-level modeless-окон остаётся отдельным будущим контрактом. diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index 3781d0e..c5b1553 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -15,13 +15,13 @@ box-sizing: border-box; } -:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close) { +:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize) { border: 0; outline: 0; font: inherit; } -:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close):focus-visible { +:where(.nodedc-button, .nodedc-icon-button, .nodedc-checker, .nodedc-select-trigger, .nodedc-select__toggle, .nodedc-dropdown-option, .nodedc-segmented__item, .nodedc-header__nav-item, .nodedc-inspector__section-trigger, .nodedc-window__close, .nodedc-workspace-window__action, .nodedc-workspace-window__resize):focus-visible { background-color: var(--nodedc-focus-surface); box-shadow: var(--nodedc-glass-control-shadow), inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22); } @@ -2163,6 +2163,174 @@ textarea.nodedc-field__control { gap: var(--nodedc-space-2); } +.nodedc-workspace-window { + position: absolute; + isolation: isolate; + display: grid; + min-width: 0; + min-height: 0; + grid-template-rows: auto minmax(0, 1fr) auto; + overflow: hidden; + border: 0; + border-radius: var(--nodedc-radius-modal); + animation: nodedc-window-in var(--nodedc-duration-normal) var(--nodedc-ease-standard); + touch-action: none; +} + +.nodedc-workspace-window[data-active="true"] { + box-shadow: var(--nodedc-modal-shadow), 0 0 0 1px color-mix(in srgb, var(--nodedc-text-primary) 16%, transparent); +} + +.nodedc-workspace-window[data-maximized="true"] { + border-radius: inherit; +} + +.nodedc-workspace-window__head { + display: grid; + min-height: 3.45rem; + grid-template-columns: minmax(0, 1fr) auto; + align-items: center; + gap: var(--nodedc-space-3); + padding: 0.62rem 0.65rem 0.5rem 1rem; + cursor: grab; + outline: 0; + touch-action: none; + user-select: none; +} + +.nodedc-workspace-window__head:focus-visible { + background: var(--nodedc-focus-surface); + box-shadow: inset 0 0 0 1px rgb(var(--nodedc-accent-rgb) / 0.22); +} + +.nodedc-workspace-window[data-maximized="true"] .nodedc-workspace-window__head { + cursor: default; +} + +.nodedc-workspace-window[data-interaction="move"] .nodedc-workspace-window__head { + cursor: grabbing; +} + +.nodedc-workspace-window__titles { + display: grid; + min-width: 0; + gap: 0.2rem; +} + +.nodedc-workspace-window__title-row { + display: flex; + min-width: 0; + align-items: center; + gap: var(--nodedc-space-2); +} + +.nodedc-workspace-window__title, +.nodedc-workspace-window__subtitle { + margin: 0; + overflow: hidden; + text-overflow: ellipsis; + white-space: nowrap; +} + +.nodedc-workspace-window__title { + color: var(--nodedc-text-primary); + font-size: var(--nodedc-font-size-md); + font-weight: var(--nodedc-font-weight-strong); + line-height: 1.2; +} + +.nodedc-workspace-window__subtitle, +.nodedc-workspace-window__status { + color: var(--nodedc-text-muted); + font-size: var(--nodedc-font-size-xs); + font-weight: var(--nodedc-font-weight-medium); + line-height: 1.25; +} + +.nodedc-workspace-window__status { + flex: 0 0 auto; +} + +.nodedc-workspace-window__actions { + display: flex; + align-items: center; + gap: 0.35rem; +} + +.nodedc-workspace-window__action { + display: inline-grid; + width: 2rem; + height: 2rem; + place-items: center; + border-radius: var(--nodedc-radius-circle); + background: var(--nodedc-glass-control-bg); + color: var(--nodedc-text-secondary); + box-shadow: var(--nodedc-glass-control-shadow); + cursor: pointer; +} + +.nodedc-workspace-window__action:hover { + background: var(--nodedc-glass-control-hover); + color: var(--nodedc-text-primary); +} + +.nodedc-workspace-window__body { + min-width: 0; + min-height: 0; + overflow: auto; + overscroll-behavior: contain; + padding: 0.25rem 1rem 0.8rem; + scrollbar-width: thin; + scrollbar-color: rgba(255, 255, 255, 0.14) transparent; + touch-action: auto; +} + +.nodedc-workspace-window__footer { + display: flex; + align-items: center; + justify-content: space-between; + gap: var(--nodedc-space-2); + padding: 0.55rem 1rem 0.8rem; +} + +.nodedc-workspace-window__resize { + position: absolute; + z-index: 3; + right: 0.2rem; + bottom: 0.2rem; + width: 1.55rem; + height: 1.55rem; + border-radius: 0.4rem; + background: transparent; + color: var(--nodedc-text-muted); + cursor: nwse-resize; + touch-action: none; +} + +.nodedc-workspace-window__resize::before, +.nodedc-workspace-window__resize::after { + position: absolute; + right: 0.32rem; + bottom: 0.32rem; + border-right: 1px solid currentColor; + border-bottom: 1px solid currentColor; + content: ""; +} + +.nodedc-workspace-window__resize::before { + width: 0.72rem; + height: 0.72rem; +} + +.nodedc-workspace-window__resize::after { + width: 0.36rem; + height: 0.36rem; +} + +.nodedc-workspace-window[data-interaction="resize"] .nodedc-workspace-window__resize { + color: var(--nodedc-text-primary); +} + .nodedc-toolbar-wrap { position: fixed; z-index: calc(var(--nodedc-layer-header) + 20); @@ -2823,6 +2991,7 @@ textarea.nodedc-field__control { .nodedc-dropdown-surface, .nodedc-overlay, .nodedc-window, + .nodedc-workspace-window, .nodedc-application-panel, .nodedc-inspector__section-content { animation: none; diff --git a/packages/ui-react/src/WorkspaceWindow.tsx b/packages/ui-react/src/WorkspaceWindow.tsx new file mode 100644 index 0000000..79b1f8f --- /dev/null +++ b/packages/ui-react/src/WorkspaceWindow.tsx @@ -0,0 +1,343 @@ +import { + useEffect, + useId, + useRef, + useState, + type HTMLAttributes, + type KeyboardEvent, + type PointerEvent, + type ReactNode, + type RefObject, +} from "react"; +import { cn } from "./cn.js"; +import { Icon } from "./Icon.js"; + +export interface WorkspaceWindowRect { + x: number; + y: number; + width: number; + height: number; +} + +export interface WorkspaceWindowProps extends Omit, "title" | "onResize"> { + boundsRef: RefObject; + rect: WorkspaceWindowRect; + onRectChange: (rect: WorkspaceWindowRect) => void; + maximized: boolean; + onMaximizedChange: (maximized: boolean) => void; + onActivate?: () => void; + onClose: () => void; + title: ReactNode; + subtitle?: ReactNode; + status?: ReactNode; + footer?: ReactNode; + children: ReactNode; + minWidth?: number; + minHeight?: number; + resizable?: boolean; + active?: boolean; + zIndex?: number; + closeLabel?: string; + maximizeLabel?: string; + restoreLabel?: string; + moveLabel?: string; + resizeLabel?: string; +} + +type WorkspaceBounds = { width: number; height: number }; + +type WorkspaceInteraction = { + kind: "move" | "resize"; + pointerId: number; + startClientX: number; + startClientY: number; + startRect: WorkspaceWindowRect; +}; + +const rectEquals = (left: WorkspaceWindowRect, right: WorkspaceWindowRect) => ( + left.x === right.x + && left.y === right.y + && left.width === right.width + && left.height === right.height +); + +const clamp = (value: number, minimum: number, maximum: number) => ( + Math.min(Math.max(value, minimum), maximum) +); + +const clampDimension = (value: number, minimum: number, available: number) => { + const limit = Math.max(0, available); + const effectiveMinimum = Math.min(Math.max(0, minimum), limit); + return clamp(value, effectiveMinimum, limit); +}; + +const normalizeRect = ( + rect: WorkspaceWindowRect, + bounds: WorkspaceBounds, + minWidth: number, + minHeight: number, +): WorkspaceWindowRect => { + const width = clampDimension(rect.width, minWidth, bounds.width); + const height = clampDimension(rect.height, minHeight, bounds.height); + return { + x: clamp(rect.x, 0, Math.max(0, bounds.width - width)), + y: clamp(rect.y, 0, Math.max(0, bounds.height - height)), + width, + height, + }; +}; + +const resizeRect = ( + rect: WorkspaceWindowRect, + deltaX: number, + deltaY: number, + bounds: WorkspaceBounds, + minWidth: number, + minHeight: number, +): WorkspaceWindowRect => { + const normalized = normalizeRect(rect, bounds, minWidth, minHeight); + return { + ...normalized, + width: clampDimension(normalized.width + deltaX, minWidth, bounds.width - normalized.x), + height: clampDimension(normalized.height + deltaY, minHeight, bounds.height - normalized.y), + }; +}; + +const keyboardStep = (event: KeyboardEvent) => event.shiftKey ? 10 : 1; + +export function WorkspaceWindow({ + boundsRef, + rect, + onRectChange, + maximized, + onMaximizedChange, + onActivate, + onClose, + title, + subtitle, + status, + footer, + children, + minWidth = 260, + minHeight = 180, + resizable = true, + active = false, + zIndex, + closeLabel = "Закрыть окно", + maximizeLabel = "Развернуть окно", + restoreLabel = "Восстановить окно", + moveLabel = "Переместить окно", + resizeLabel = "Изменить размер окна", + className, + style, + onPointerDownCapture, + onFocusCapture, + ...props +}: WorkspaceWindowProps) { + const titleId = useId(); + const descriptionId = useId(); + const interactionRef = useRef(null); + const rectRef = useRef(rect); + const onRectChangeRef = useRef(onRectChange); + const [interactionKind, setInteractionKind] = useState(null); + + rectRef.current = rect; + onRectChangeRef.current = onRectChange; + + const readBounds = (): WorkspaceBounds | null => { + const bounds = boundsRef.current; + if (!bounds) return null; + return { width: bounds.clientWidth, height: bounds.clientHeight }; + }; + + const emitRect = (nextRect: WorkspaceWindowRect) => { + if (rectEquals(rectRef.current, nextRect)) return; + rectRef.current = nextRect; + onRectChangeRef.current(nextRect); + }; + + useEffect(() => { + const bounds = boundsRef.current; + if (!bounds || maximized) return; + + const clampToBounds = () => { + const nextBounds = readBounds(); + if (nextBounds) emitRect(normalizeRect(rectRef.current, nextBounds, minWidth, minHeight)); + }; + + clampToBounds(); + const observer = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(clampToBounds); + observer?.observe(bounds); + window.addEventListener("resize", clampToBounds); + return () => { + observer?.disconnect(); + window.removeEventListener("resize", clampToBounds); + }; + }, [boundsRef, maximized, minHeight, minWidth, rect.height, rect.width, rect.x, rect.y]); + + useEffect(() => { + const handlePointerMove = (event: globalThis.PointerEvent) => { + const interaction = interactionRef.current; + if (!interaction || event.pointerId !== interaction.pointerId) return; + const bounds = readBounds(); + if (!bounds) return; + + const deltaX = event.clientX - interaction.startClientX; + const deltaY = event.clientY - interaction.startClientY; + const nextRect = interaction.kind === "move" + ? normalizeRect({ + ...interaction.startRect, + x: interaction.startRect.x + deltaX, + y: interaction.startRect.y + deltaY, + }, bounds, minWidth, minHeight) + : resizeRect(interaction.startRect, deltaX, deltaY, bounds, minWidth, minHeight); + emitRect(nextRect); + event.preventDefault(); + }; + + const endInteraction = (event: globalThis.PointerEvent) => { + if (interactionRef.current?.pointerId !== event.pointerId) return; + interactionRef.current = null; + setInteractionKind(null); + }; + + window.addEventListener("pointermove", handlePointerMove, { passive: false }); + window.addEventListener("pointerup", endInteraction); + window.addEventListener("pointercancel", endInteraction); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", endInteraction); + window.removeEventListener("pointercancel", endInteraction); + }; + }, [boundsRef, minHeight, minWidth]); + + useEffect(() => { + if ((!resizable && interactionRef.current?.kind === "resize") || maximized) { + interactionRef.current = null; + setInteractionKind(null); + } + }, [maximized, resizable]); + + const beginInteraction = (kind: WorkspaceInteraction["kind"], event: PointerEvent) => { + if (maximized || event.button !== 0 || (kind === "resize" && !resizable)) return; + if (kind === "move" && (event.target as HTMLElement).closest("button, input, select, textarea, a")) return; + const bounds = readBounds(); + if (!bounds) return; + const startRect = normalizeRect(rectRef.current, bounds, minWidth, minHeight); + emitRect(startRect); + interactionRef.current = { + kind, + pointerId: event.pointerId, + startClientX: event.clientX, + startClientY: event.clientY, + startRect, + }; + setInteractionKind(kind); + event.currentTarget.focus(); + event.preventDefault(); + }; + + const handleMoveKeyDown = (event: KeyboardEvent) => { + if (maximized || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; + const bounds = readBounds(); + if (!bounds) return; + const step = keyboardStep(event); + const next = { ...rectRef.current }; + if (event.key === "ArrowLeft") next.x -= step; + if (event.key === "ArrowRight") next.x += step; + if (event.key === "ArrowUp") next.y -= step; + if (event.key === "ArrowDown") next.y += step; + emitRect(normalizeRect(next, bounds, minWidth, minHeight)); + event.preventDefault(); + }; + + const handleResizeKeyDown = (event: KeyboardEvent) => { + if (maximized || !resizable || !["ArrowLeft", "ArrowRight", "ArrowUp", "ArrowDown"].includes(event.key)) return; + const bounds = readBounds(); + if (!bounds) return; + const step = keyboardStep(event); + const deltaX = event.key === "ArrowLeft" ? -step : event.key === "ArrowRight" ? step : 0; + const deltaY = event.key === "ArrowUp" ? -step : event.key === "ArrowDown" ? step : 0; + emitRect(resizeRect(rectRef.current, deltaX, deltaY, bounds, minWidth, minHeight)); + event.preventDefault(); + }; + + const geometryStyle = maximized + ? { left: 0, top: 0, width: "100%", height: "100%" } + : { left: rect.x, top: rect.y, width: rect.width, height: rect.height }; + + return ( +
{ + onActivate?.(); + onPointerDownCapture?.(event); + }} + onFocusCapture={(event) => { + onActivate?.(); + onFocusCapture?.(event); + }} + > +
beginInteraction("move", event)} + onKeyDown={handleMoveKeyDown} + > +
+
+

{title}

+ {status ? {status} : null} +
+ {subtitle ?

{subtitle}

: null} +
+
+ + +
+
+
{children}
+ {footer ?
{footer}
: null} + {resizable && !maximized ? ( +
+ ); +} diff --git a/packages/ui-react/src/index.ts b/packages/ui-react/src/index.ts index 6c4d0ea..9970db1 100644 --- a/packages/ui-react/src/index.ts +++ b/packages/ui-react/src/index.ts @@ -20,3 +20,4 @@ export * from "./Settings.js"; export * from "./SharingModals.js"; export * from "./Toolbar.js"; export * from "./Window.js"; +export * from "./WorkspaceWindow.js"; diff --git a/registry/components.json b/registry/components.json index 1403871..9adba34 100644 --- a/registry/components.json +++ b/registry/components.json @@ -131,6 +131,22 @@ "A draggable inspector moves by its header and remains clamped to the viewport." ] }, + { + "id": "workspace-window", + "status": "baseline", + "package": "@nodedc/ui-react", + "exports": ["WorkspaceWindow", "WorkspaceWindowRect", "WorkspaceWindowProps"], + "domContract": ["nodedc-workspace-window"], + "summary": "Controlled modeless window for media, tools and synchronized views constrained to an application-owned workspace.", + "anatomy": ["controlled local rectangle", "title and optional status", "maximize and close actions", "scrollable body", "optional footer", "keyboard-accessible resize handle"], + "behavior": ["inline render without portal", "parent-bound dragging", "bottom-right resizing", "maximize within parent", "ResizeObserver reclamp", "pointer and keyboard geometry control", "application-owned activation and stacking"], + "rules": [ + "The bounds element is an application-owned positioned and clipped workspace; WorkspaceWindow never escapes it or renders into document.body.", + "Rectangle, maximized state, visibility, activation and z-order are controlled by the application.", + "Use WorkspaceWindow for auxiliary views inside a stage; modal workflows and viewport inspectors continue to use Window.", + "Dragging and resizing remain clamped when the workspace changes size, including when it becomes smaller than the requested minimum." + ] + }, { "id": "confirmation-modal", "status": "baseline",