feat(ui): add bounded workspace windows
This commit is contained in:
parent
a1c4ecaa93
commit
5f583caa05
|
|
@ -43,9 +43,11 @@ import {
|
||||||
useApplicationWorkspace,
|
useApplicationWorkspace,
|
||||||
Window,
|
Window,
|
||||||
WindowFooterActions,
|
WindowFooterActions,
|
||||||
|
WorkspaceWindow,
|
||||||
type IconName,
|
type IconName,
|
||||||
type ShareAccessMember,
|
type ShareAccessMember,
|
||||||
type ToolbarPlacement,
|
type ToolbarPlacement,
|
||||||
|
type WorkspaceWindowRect,
|
||||||
} from "@nodedc/ui-react";
|
} from "@nodedc/ui-react";
|
||||||
import { applyFaviconAssets, createIcoBlob, generateFavicons, type FaviconAssetUrls, type GeneratedFavicon } from "./favicon.js";
|
import { applyFaviconAssets, createIcoBlob, generateFavicons, type FaviconAssetUrls, type GeneratedFavicon } from "./favicon.js";
|
||||||
import {
|
import {
|
||||||
|
|
@ -371,6 +373,10 @@ export function CatalogApp() {
|
||||||
const [strokeColor, setStrokeColor] = useState("#2b2b36");
|
const [strokeColor, setStrokeColor] = useState("#2b2b36");
|
||||||
const [strokeOpacity, setStrokeOpacity] = useState(100);
|
const [strokeOpacity, setStrokeOpacity] = useState(100);
|
||||||
const [inspectorOpen, setInspectorOpen] = useState(false);
|
const [inspectorOpen, setInspectorOpen] = useState(false);
|
||||||
|
const workspaceWindowDemoRef = useRef<HTMLDivElement>(null);
|
||||||
|
const [workspaceWindowDemoOpen, setWorkspaceWindowDemoOpen] = useState(true);
|
||||||
|
const [workspaceWindowDemoMaximized, setWorkspaceWindowDemoMaximized] = useState(false);
|
||||||
|
const [workspaceWindowDemoRect, setWorkspaceWindowDemoRect] = useState<WorkspaceWindowRect>({ x: 24, y: 24, width: 340, height: 230 });
|
||||||
const [createModalOpen, setCreateModalOpen] = useState(false);
|
const [createModalOpen, setCreateModalOpen] = useState(false);
|
||||||
const [confirmOpen, setConfirmOpen] = useState(false);
|
const [confirmOpen, setConfirmOpen] = useState(false);
|
||||||
const [shareAccessOpen, setShareAccessOpen] = useState(false);
|
const [shareAccessOpen, setShareAccessOpen] = useState(false);
|
||||||
|
|
@ -1296,6 +1302,44 @@ export function CatalogApp() {
|
||||||
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
<IconButton label="Закрыть"><Icon name="close" /></IconButton>
|
||||||
</div>
|
</div>
|
||||||
</Preview>
|
</Preview>
|
||||||
|
<Preview title="Workspace window" note="inline / bounded / controlled" className="catalog-preview--wide catalog-workspace-window-preview">
|
||||||
|
<div ref={workspaceWindowDemoRef} className="catalog-workspace-window-demo">
|
||||||
|
{workspaceWindowDemoOpen ? (
|
||||||
|
<WorkspaceWindow
|
||||||
|
boundsRef={workspaceWindowDemoRef}
|
||||||
|
rect={workspaceWindowDemoRect}
|
||||||
|
onRectChange={setWorkspaceWindowDemoRect}
|
||||||
|
maximized={workspaceWindowDemoMaximized}
|
||||||
|
onMaximizedChange={setWorkspaceWindowDemoMaximized}
|
||||||
|
onActivate={() => undefined}
|
||||||
|
onClose={() => {
|
||||||
|
setWorkspaceWindowDemoOpen(false);
|
||||||
|
setWorkspaceWindowDemoMaximized(false);
|
||||||
|
}}
|
||||||
|
title="Визуальный канал"
|
||||||
|
subtitle="WORKSPACE / AUXILIARY VIEW"
|
||||||
|
status="LIVE"
|
||||||
|
footer={<span className="catalog-workspace-window-demo__hint">Стрелки — позиция · Shift + стрелки — 10 px</span>}
|
||||||
|
active
|
||||||
|
zIndex={2}
|
||||||
|
>
|
||||||
|
<div className="catalog-workspace-window-demo__media">
|
||||||
|
<Icon name="video" size={24} />
|
||||||
|
<strong>Рабочая поверхность</strong>
|
||||||
|
<span>Перемещается, изменяет размер и разворачивается только внутри сцены.</span>
|
||||||
|
</div>
|
||||||
|
</WorkspaceWindow>
|
||||||
|
) : (
|
||||||
|
<Button
|
||||||
|
className="catalog-workspace-window-demo__reopen"
|
||||||
|
shape="pill"
|
||||||
|
icon={<Icon name="plus" />}
|
||||||
|
onClick={() => setWorkspaceWindowDemoOpen(true)}
|
||||||
|
>Открыть окно</Button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<p className="catalog-preview__explanation">Rectangle, maximize, close и stacking остаются состоянием приложения; дизайн-система владеет одинаковой bounded-геометрией и доступным управлением.</p>
|
||||||
|
</Preview>
|
||||||
<Preview title="Inspector" note="modeless / draggable" className="catalog-preview--wide catalog-preview--compact catalog-inspector-launcher">
|
<Preview title="Inspector" note="modeless / draggable" className="catalog-preview--wide catalog-preview--compact catalog-inspector-launcher">
|
||||||
<p className="catalog-preview__explanation">Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.</p>
|
<p className="catalog-preview__explanation">Настройки приложения и Toolbar открываются в отдельном перемещаемом Inspector и сохраняются одним серверным layout.</p>
|
||||||
<Button variant="primary" shape="pill" icon={<Icon name="panel" />} onClick={() => setInspectorOpen(true)}>Открыть Inspector</Button>
|
<Button variant="primary" shape="pill" icon={<Icon name="panel" />} onClick={() => setInspectorOpen(true)}>Открыть Inspector</Button>
|
||||||
|
|
|
||||||
|
|
@ -897,6 +897,65 @@ textarea {
|
||||||
grid-column: 1 / -1;
|
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 {
|
.catalog-inspector-launcher .nodedc-button {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -86,6 +86,14 @@ Window — единая механика открытия modal и правой
|
||||||
|
|
||||||
Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически.
|
Для 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
|
||||||
|
|
||||||
`Toolbar` — канонический dock из нового Engine с требованиями BIM Viewer. Он поддерживает позиции `left`, `right`, `bottom`, lens magnification и autohide. Контролируются фон, border, outline, минимальный/максимальный размер и количество иконок линзы.
|
`Toolbar` — канонический dock из нового Engine с требованиями BIM Viewer. Он поддерживает позиции `left`, `right`, `bottom`, lens magnification и autohide. Контролируются фон, border, outline, минимальный/максимальный размер и количество иконок линзы.
|
||||||
|
|
|
||||||
|
|
@ -69,8 +69,26 @@ Side window использует ту же механику, но placement `end
|
||||||
|
|
||||||
По умолчанию это modeless-слой: он не затемняет страницу, не перехватывает клики по основной области, не закрывается по backdrop, не блокирует body scroll и не удерживает Tab внутри панели. Escape и кнопка закрытия остаются доступны. Если конкретный workflow должен быть modal, это задаётся явно, а не получается случайно из placement.
|
По умолчанию это 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 окон. Приложение владеет тем, какое окно открыто и какие данные в нём загружены. Библиотека владеет одинаковым поведением самого слоя.
|
Библиотека не создаёт глобальный store окон. Приложение владеет тем, какое окно открыто и какие данные в нём загружены. Библиотека владеет одинаковым поведением самого слоя.
|
||||||
|
|
||||||
Если приложению позже потребуется stack нескольких modeless-окон, это будет отдельный пакетный контракт, а не расширение локального z-index.
|
Stack workspace-окон остаётся контролируемым приложением: оно передаёт active-state и `zIndex`, а компонент отвечает только за одинаковую геометрию и взаимодействие. Глобальный stack viewport-level modeless-окон остаётся отдельным будущим контрактом.
|
||||||
|
|
|
||||||
|
|
@ -15,13 +15,13 @@
|
||||||
box-sizing: border-box;
|
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;
|
border: 0;
|
||||||
outline: 0;
|
outline: 0;
|
||||||
font: inherit;
|
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);
|
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);
|
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);
|
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 {
|
.nodedc-toolbar-wrap {
|
||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: calc(var(--nodedc-layer-header) + 20);
|
z-index: calc(var(--nodedc-layer-header) + 20);
|
||||||
|
|
@ -2823,6 +2991,7 @@ textarea.nodedc-field__control {
|
||||||
.nodedc-dropdown-surface,
|
.nodedc-dropdown-surface,
|
||||||
.nodedc-overlay,
|
.nodedc-overlay,
|
||||||
.nodedc-window,
|
.nodedc-window,
|
||||||
|
.nodedc-workspace-window,
|
||||||
.nodedc-application-panel,
|
.nodedc-application-panel,
|
||||||
.nodedc-inspector__section-content {
|
.nodedc-inspector__section-content {
|
||||||
animation: none;
|
animation: none;
|
||||||
|
|
|
||||||
|
|
@ -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<HTMLAttributes<HTMLDivElement>, "title" | "onResize"> {
|
||||||
|
boundsRef: RefObject<HTMLElement | null>;
|
||||||
|
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<HTMLElement>) => 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<WorkspaceInteraction | null>(null);
|
||||||
|
const rectRef = useRef(rect);
|
||||||
|
const onRectChangeRef = useRef(onRectChange);
|
||||||
|
const [interactionKind, setInteractionKind] = useState<WorkspaceInteraction["kind"] | null>(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<HTMLElement>) => {
|
||||||
|
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<HTMLElement>) => {
|
||||||
|
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<HTMLButtonElement>) => {
|
||||||
|
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 (
|
||||||
|
<div
|
||||||
|
{...props}
|
||||||
|
className={cn("nodedc-workspace-window nodedc-glass-material nodedc-material-rim nodedc-ui-root", className)}
|
||||||
|
data-active={active ? "true" : undefined}
|
||||||
|
data-maximized={maximized ? "true" : undefined}
|
||||||
|
data-resizable={resizable ? "true" : undefined}
|
||||||
|
data-interaction={interactionKind ?? undefined}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="false"
|
||||||
|
aria-labelledby={titleId}
|
||||||
|
aria-describedby={subtitle ? descriptionId : undefined}
|
||||||
|
style={{ ...style, ...geometryStyle, zIndex: zIndex ?? style?.zIndex }}
|
||||||
|
onPointerDownCapture={(event) => {
|
||||||
|
onActivate?.();
|
||||||
|
onPointerDownCapture?.(event);
|
||||||
|
}}
|
||||||
|
onFocusCapture={(event) => {
|
||||||
|
onActivate?.();
|
||||||
|
onFocusCapture?.(event);
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<header
|
||||||
|
className="nodedc-workspace-window__head"
|
||||||
|
tabIndex={maximized ? -1 : 0}
|
||||||
|
role="group"
|
||||||
|
aria-roledescription="Перемещаемая область окна"
|
||||||
|
aria-label={moveLabel}
|
||||||
|
aria-keyshortcuts="ArrowUp ArrowDown ArrowLeft ArrowRight"
|
||||||
|
onPointerDown={(event) => beginInteraction("move", event)}
|
||||||
|
onKeyDown={handleMoveKeyDown}
|
||||||
|
>
|
||||||
|
<div className="nodedc-workspace-window__titles">
|
||||||
|
<div className="nodedc-workspace-window__title-row">
|
||||||
|
<h2 id={titleId} className="nodedc-workspace-window__title">{title}</h2>
|
||||||
|
{status ? <span className="nodedc-workspace-window__status">{status}</span> : null}
|
||||||
|
</div>
|
||||||
|
{subtitle ? <p id={descriptionId} className="nodedc-workspace-window__subtitle">{subtitle}</p> : null}
|
||||||
|
</div>
|
||||||
|
<div className="nodedc-workspace-window__actions">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nodedc-workspace-window__action"
|
||||||
|
aria-label={maximized ? restoreLabel : maximizeLabel}
|
||||||
|
title={maximized ? restoreLabel : maximizeLabel}
|
||||||
|
onClick={() => onMaximizedChange(!maximized)}
|
||||||
|
>
|
||||||
|
<Icon name={maximized ? "minimize" : "expand"} size={15} strokeWidth={1.6} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nodedc-workspace-window__action"
|
||||||
|
aria-label={closeLabel}
|
||||||
|
title={closeLabel}
|
||||||
|
onClick={onClose}
|
||||||
|
>
|
||||||
|
<Icon name="close" size={15} strokeWidth={1.6} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
<div className="nodedc-workspace-window__body">{children}</div>
|
||||||
|
{footer ? <footer className="nodedc-workspace-window__footer">{footer}</footer> : null}
|
||||||
|
{resizable && !maximized ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="nodedc-workspace-window__resize"
|
||||||
|
aria-label={resizeLabel}
|
||||||
|
title={resizeLabel}
|
||||||
|
onPointerDown={(event) => beginInteraction("resize", event)}
|
||||||
|
onKeyDown={handleResizeKeyDown}
|
||||||
|
/>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -20,3 +20,4 @@ export * from "./Settings.js";
|
||||||
export * from "./SharingModals.js";
|
export * from "./SharingModals.js";
|
||||||
export * from "./Toolbar.js";
|
export * from "./Toolbar.js";
|
||||||
export * from "./Window.js";
|
export * from "./Window.js";
|
||||||
|
export * from "./WorkspaceWindow.js";
|
||||||
|
|
|
||||||
|
|
@ -131,6 +131,22 @@
|
||||||
"A draggable inspector moves by its header and remains clamped to the viewport."
|
"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",
|
"id": "confirmation-modal",
|
||||||
"status": "baseline",
|
"status": "baseline",
|
||||||
|
|
|
||||||
Loading…
Reference in New Issue