diff --git a/.gitignore b/.gitignore index 938dda4..55ed5f4 100644 --- a/.gitignore +++ b/.gitignore @@ -5,3 +5,4 @@ dist/ .vite/ coverage/ *.tsbuildinfo +runtime-data/ diff --git a/apps/catalog/src/CatalogApp.tsx b/apps/catalog/src/CatalogApp.tsx index c9b0bb1..4b02bb9 100644 --- a/apps/catalog/src/CatalogApp.tsx +++ b/apps/catalog/src/CatalogApp.tsx @@ -23,6 +23,7 @@ import { Inspector, MediaSourceField, RangeControl, + SegmentedControl, Select, ShareAccessModal, ShareLinkModal, @@ -31,14 +32,16 @@ import { Switch, TextAreaField, TextField, + Toolbar, useApplicationWorkspace, Window, WindowFooterActions, type IconName, type ShareAccessMember, + type ToolbarPlacement, } from "@nodedc/ui-react"; -type CatalogSection = "foundation" | "controls" | "media" | "windows" | "modals" | "icons"; +type CatalogSection = "controls" | "media" | "modals" | "icons"; const accents: Array<{ label: string; value: RgbTuple; hex: string }> = [ { label: "NODE.DC", value: [255, 47, 146], hex: "#ff2f92" }, @@ -52,20 +55,58 @@ type MaterialDraft = { panelOpacity: number; fieldHex: string; fieldOpacity: number; + nestedHex: string; }; +interface StoredLayout { + theme?: NodedcTheme; + accentHex?: string; + materialByTheme?: Record; + environment?: { + lightColor?: string; + brightness?: number; + glowDistance?: number; + connectionType?: string; + connectionColor?: string; + usePortColors?: boolean; + fillColor?: string; + fillOpacity?: number; + strokeColor?: string; + strokeOpacity?: number; + }; + media?: { + source?: "file" | "url"; + url?: string; + fileName?: string; + fileSrc?: string; + visible?: boolean; + }; + toolbar?: { + placement?: ToolbarPlacement; + background?: string; + border?: string; + outline?: string; + minSize?: number; + maxSize?: number; + lensCount?: number; + autoHide?: boolean; + }; +} + const materialDefaults: Record = { dark: { panelHex: "#151517", panelOpacity: 100, fieldHex: "#2a2a2c", fieldOpacity: 100, + nestedHex: "#0b0b0d", }, light: { panelHex: "#ffffff", panelOpacity: 100, fieldHex: "#ffffff", fieldOpacity: 100, + nestedHex: "#f4f4f4", }, }; @@ -84,38 +125,26 @@ const selectOptions = [ ] as const; const sectionDefinitions: Record = { - foundation: { - eyebrow: "01 / FOUNDATION", - title: "Основа", - description: "Тема, нейтральные поверхности и неизменяемая геометрия приложения.", - icon: "panel", - }, controls: { - eyebrow: "02 / CONTROLS", + eyebrow: "01 / CONTROLS", title: "Контролы", - description: "Кнопки, поля, выбор, Environment Settings и сворачиваемые секции нового Engine.", + description: "Кнопки, поля, выбор, оконные действия и вызываемый Inspector нового Engine.", icon: "sliders", }, media: { - eyebrow: "03 / MEDIA & SETTINGS", + eyebrow: "02 / MEDIA & SETTINGS", title: "Медиа и настройки", description: "Общий CMS/Launcher-контракт файла, URL, превью и нейтральных карточек настроек.", icon: "image", }, - windows: { - eyebrow: "04 / WINDOWS", - title: "Окна", - description: "Обычные окна и modeless-панели с единым portal- и focus-контрактом.", - icon: "apps", - }, modals: { - eyebrow: "05 / MODALS", + eyebrow: "03 / MODALS", title: "Модалки", description: "Полная карта modal-паттернов Launcher, нового Engine и BIM Viewer.", icon: "clipboard", }, icons: { - eyebrow: "06 / ICONS", + eyebrow: "04 / ICONS", title: "Иконки", description: "Канонический общий набор по Launcher, SEO, BIM Viewer и новым участкам Engine.", icon: "grid", @@ -234,11 +263,10 @@ export function CatalogApp() { dark: { ...materialDefaults.dark }, light: { ...materialDefaults.light }, })); - const [environmentLayoutSaved, setEnvironmentLayoutSaved] = useState(false); + const [layoutSaveState, setLayoutSaveState] = useState<"idle" | "loading" | "saving" | "saved" | "error">("loading"); const workspace = useApplicationWorkspace(); const { navigationOpen: guidelineOpen, activeView: activeSection, contentExpanded: panelExpanded } = workspace; const [selectedStatus, setSelectedStatus] = useState<(typeof selectOptions)[number]["value"]>("active"); - const [checker, setChecker] = useState(true); const [brightness, setBrightness] = useState(49); const [glowDistance, setGlowDistance] = useState(105); const [usePortColors, setUsePortColors] = useState(false); @@ -249,8 +277,7 @@ export function CatalogApp() { const [fillOpacity, setFillOpacity] = useState(100); const [strokeColor, setStrokeColor] = useState("#2b2b36"); const [strokeOpacity, setStrokeOpacity] = useState(100); - const [windowOpen, setWindowOpen] = useState(false); - const [sidePanelOpen, setSidePanelOpen] = useState(false); + const [inspectorOpen, setInspectorOpen] = useState(false); const [createModalOpen, setCreateModalOpen] = useState(false); const [confirmOpen, setConfirmOpen] = useState(false); const [shareAccessOpen, setShareAccessOpen] = useState(false); @@ -268,6 +295,15 @@ export function CatalogApp() { const [mediaError, setMediaError] = useState(""); const [mediaVisible, setMediaVisible] = useState(true); const mediaObjectUrlRef = useRef(null); + const pendingMediaFileRef = useRef(null); + const [toolbarPlacement, setToolbarPlacement] = useState("bottom"); + const [toolbarBg, setToolbarBg] = useState("#111115"); + const [toolbarBorder, setToolbarBorder] = useState("#111117"); + const [toolbarOutline, setToolbarOutline] = useState("#1c1c1c"); + const [toolbarMinSize, setToolbarMinSize] = useState(25); + const [toolbarMaxSize, setToolbarMaxSize] = useState(87); + const [toolbarLensCount, setToolbarLensCount] = useState(5); + const [toolbarAutoHide, setToolbarAutoHide] = useState(false); const [shareEmail, setShareEmail] = useState(""); const [shareRole, setShareRole] = useState("editor"); const [shareMessage, setShareMessage] = useState(""); @@ -297,7 +333,63 @@ export function CatalogApp() { fieldOpacity: material.fieldOpacity / 100, }, }); - }, [accent, fieldMaterial, material.fieldOpacity, material.panelOpacity, panelMaterial, theme]); + document.documentElement.style.setProperty("--nodedc-canvas-soft", material.nestedHex); + }, [accent, fieldMaterial, material.fieldOpacity, material.nestedHex, material.panelOpacity, panelMaterial, theme]); + + useEffect(() => { + let active = true; + fetch("/api/layout", { cache: "no-store" }) + .then(async (response) => { + if (!response.ok) throw new Error("layout_load_failed"); + return await response.json() as StoredLayout | null; + }) + .then((stored) => { + if (!active || !stored) return; + if (stored.theme === "dark" || stored.theme === "light") setTheme(stored.theme); + if (stored.accentHex) setAccentHex(stored.accentHex); + if (stored.materialByTheme?.dark && stored.materialByTheme?.light) setMaterialByTheme(stored.materialByTheme); + const environment = stored.environment; + if (environment) { + if (environment.lightColor) setLightColor(environment.lightColor); + if (typeof environment.brightness === "number") setBrightness(environment.brightness); + if (typeof environment.glowDistance === "number") setGlowDistance(environment.glowDistance); + if (environment.connectionType) setConnectionType(environment.connectionType); + if (environment.connectionColor) setConnectionColor(environment.connectionColor); + if (typeof environment.usePortColors === "boolean") setUsePortColors(environment.usePortColors); + if (environment.fillColor) setFillColor(environment.fillColor); + if (typeof environment.fillOpacity === "number") setFillOpacity(environment.fillOpacity); + if (environment.strokeColor) setStrokeColor(environment.strokeColor); + if (typeof environment.strokeOpacity === "number") setStrokeOpacity(environment.strokeOpacity); + } + const media = stored.media; + if (media) { + if (media.source === "file" || media.source === "url") setMediaSource(media.source); + if (typeof media.url === "string") setMediaUrl(media.url); + if (media.fileName) setMediaFileName(media.fileName); + if (media.fileSrc) setFileMediaSrc(media.fileSrc); + if (typeof media.visible === "boolean") setMediaVisible(media.visible); + } + const toolbar = stored.toolbar; + if (toolbar) { + if (toolbar.placement === "left" || toolbar.placement === "right" || toolbar.placement === "bottom") setToolbarPlacement(toolbar.placement); + if (toolbar.background) setToolbarBg(toolbar.background); + if (toolbar.border) setToolbarBorder(toolbar.border); + if (toolbar.outline) setToolbarOutline(toolbar.outline); + if (typeof toolbar.minSize === "number") setToolbarMinSize(toolbar.minSize); + if (typeof toolbar.maxSize === "number") setToolbarMaxSize(toolbar.maxSize); + if (typeof toolbar.lensCount === "number") setToolbarLensCount(toolbar.lensCount); + if (typeof toolbar.autoHide === "boolean") setToolbarAutoHide(toolbar.autoHide); + } + setLayoutSaveState("saved"); + }) + .catch(() => { + if (active) setLayoutSaveState("error"); + }) + .finally(() => { + if (active) setLayoutSaveState((current) => current === "loading" ? "idle" : current); + }); + return () => { active = false; }; + }, []); useEffect(() => () => { if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current); @@ -313,6 +405,7 @@ export function CatalogApp() { if (mediaObjectUrlRef.current) URL.revokeObjectURL(mediaObjectUrlRef.current); const objectUrl = URL.createObjectURL(file); mediaObjectUrlRef.current = objectUrl; + pendingMediaFileRef.current = file; setFileMediaSrc(objectUrl); setMediaFileName(file.name); setMediaSource("file"); @@ -344,11 +437,28 @@ export function CatalogApp() { })); }; - const saveEnvironmentLayout = () => { - const draft = { + const saveEnvironmentLayout = async () => { + setLayoutSaveState("saving"); + try { + let persistedFileSrc = fileMediaSrc; + const pendingMedia = pendingMediaFileRef.current; + if (pendingMedia) { + const uploadResponse = await fetch("/api/layout/media", { + method: "PUT", + headers: { "content-type": "video/mp4", "x-file-name": encodeURIComponent(pendingMedia.name) }, + body: pendingMedia, + }); + if (!uploadResponse.ok) throw new Error("media_upload_failed"); + const uploaded = await uploadResponse.json() as { url: string; fileName: string }; + persistedFileSrc = uploaded.url; + setFileMediaSrc(uploaded.url); + setMediaFileName(uploaded.fileName); + pendingMediaFileRef.current = null; + } + const draft: StoredLayout = { theme, accentHex, - material, + materialByTheme, environment: { lightColor, brightness, @@ -361,13 +471,34 @@ export function CatalogApp() { strokeColor, strokeOpacity, }, + media: { + source: mediaSource, + url: mediaUrl, + fileName: mediaFileName, + fileSrc: persistedFileSrc, + visible: mediaVisible, + }, + toolbar: { + placement: toolbarPlacement, + background: toolbarBg, + border: toolbarBorder, + outline: toolbarOutline, + minSize: toolbarMinSize, + maxSize: toolbarMaxSize, + lensCount: toolbarLensCount, + autoHide: toolbarAutoHide, + }, }; - try { - window.localStorage.setItem("nodedc-design-guideline-draft", JSON.stringify(draft)); + const response = await fetch("/api/layout", { + method: "PUT", + headers: { "content-type": "application/json" }, + body: JSON.stringify(draft), + }); + if (!response.ok) throw new Error("layout_save_failed"); + setLayoutSaveState("saved"); } catch { - // The preview remains useful in restricted browser contexts even without local persistence. + setLayoutSaveState("error"); } - setEnvironmentLayoutSaved(true); }; const environmentSections = [ @@ -389,6 +520,42 @@ export function CatalogApp() { updateMaterial({ fieldHex })} /> `${value}%`} onChange={(fieldOpacity) => updateMaterial({ fieldOpacity })} /> + + updateMaterial({ nestedHex })} /> + + + ), + }, + { + id: "toolbar-settings", + label: "Рабочее поле — Toolbar", + tone: "accent" as const, + content: ( + <> + + - - - + {guidelineOpen ? ( + + placement={toolbarPlacement} + background={toolbarBg} + border={toolbarBorder} + outline={toolbarOutline} + accent={accentHex} + minSize={toolbarMinSize} + maxSize={toolbarMaxSize} + lensCount={toolbarLensCount} + autoHide={toolbarAutoHide} + items={(Object.entries(sectionDefinitions) as Array<[CatalogSection, (typeof sectionDefinitions)[CatalogSection]]>).map(([id, item]) => ({ + id, + label: item.title, + icon: item.icon, + active: activeSection === id, + onSelect: openSection, + }))} + /> + ) : null} setSidePanelOpen(false)} + draggable + onClose={() => setInspectorOpen(false)} > -
- -

Боковая панель демонстрирует только layer-механику: она modeless, не затемняет stage и не забирает фокус. Environment controls живут в разделе «Контролы».

-
+
div { - display: grid; - gap: 0.3rem; -} - -.catalog-environment-title span { - color: var(--nodedc-text-muted); - font-size: 0.7rem; -} - -.catalog-environment-draft { - margin: -0.25rem 0.875rem 0.75rem; - color: var(--nodedc-text-muted); - font-size: 0.68rem; - line-height: 1.4; -} - .catalog-icon-catalog { display: grid; gap: 1rem; @@ -489,8 +371,7 @@ textarea { } @media (max-width: 1280px) { - .catalog-grid, - .catalog-grid--foundation { + .catalog-grid { grid-template-columns: 1fr; } @@ -557,7 +438,6 @@ textarea { gap: 0.25rem; } - .catalog-swatches, .catalog-icon-grid { grid-template-columns: repeat(2, minmax(0, 1fr)); } @@ -566,32 +446,12 @@ textarea { grid-template-columns: 1fr; } - .catalog-shell-diagram { - grid-template-columns: 1fr 1.5fr; - grid-template-rows: 3rem 7rem 7rem; - } - - .catalog-shell-diagram__header { - grid-column: 1 / -1; - } - - .catalog-shell-diagram__stage { - grid-column: 1 / -1; - } - - .catalog-environment-demo { - min-height: auto; - grid-template-columns: 1fr; - padding: 0.75rem; - } - .catalog-window-actions-demo .nodedc-button { width: 100%; } } @media (max-width: 430px) { - .catalog-swatches, .catalog-icon-grid { grid-template-columns: 1fr; } diff --git a/docs/APPLICATION_TEMPLATE.md b/docs/APPLICATION_TEMPLATE.md index df90d3a..86f2c4f 100644 --- a/docs/APPLICATION_TEMPLATE.md +++ b/docs/APPLICATION_TEMPLATE.md @@ -14,6 +14,8 @@ Действие, относящееся ко всему рабочему окну, передаётся через `ApplicationPanel.utilityActions`. Например, сохранение настроенного layout всегда находится в шапке перед expand/close и остаётся доступным при переходе между разделами; отдельные карточки не рисуют собственные копии этой кнопки. +Постоянный theme switch передаётся через `ApplicationPanel.headerTools` и располагается в той же action-зоне. Если приложение использует Engine/BIM toolbar, он подключается отдельно к общему workspace-state и может располагаться слева, справа или снизу. + ## Состояния desktop | Состояние | Навигация | Content window | Stage | diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 04be8d3..d6550b0 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -4,7 +4,7 @@ NODE.DC состоит из самостоятельных репозиториев и разных frontend-стеков. Поэтому общий UI не может существовать как папка, которую вручную копируют из последнего приложения. Дизайн-система поставляется как версионируемые пакеты, а приложения сохраняют собственную доменную логику. -Репозиторий физически отделён от `NODEDC_PLATFORM`, но логически относится к платформенному слою. Он не является backend-сервисом и не владеет данными приложений. +Репозиторий физически отделён от `NODEDC_PLATFORM`, но логически относится к платформенному слою. Пакеты дизайн-системы не владеют данными приложений. Living catalog имеет собственный минимальный runtime-store только для сохранения единого демонстрационного layout и загруженного stage-media между браузерными сессиями. ## Слои @@ -27,7 +27,7 @@ Core не зависит от React. `@nodedc/ui-react` предоставляет компоненты для Launcher/Hub, Engine, Ops и React-модулей. Компоненты контролируемые: бизнес-состояние остаётся у приложения, а визуальное и базовое интерактивное поведение принадлежит библиотеке. -`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `useApplicationWorkspace` задаёт единую механику открытия navigation/content/expanded состояний. `MediaSourceField` отделяет стабильный UI выбора файла/URL от upload/storage API приложения. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо. +`ApplicationShell` и `ApplicationPanel` образуют готовый launcher-style шаблон нового React-приложения. `useApplicationWorkspace` задаёт единую механику открытия navigation/content/expanded состояний. `MediaSourceField` отделяет стабильный UI выбора файла/URL от upload/storage API приложения. `Toolbar` фиксирует Engine/BIM dock, magnification, placement и autohide. `Icon` предоставляет ограниченный аудированный словарь glyph, чтобы приложения не импортировали vendor-наборы независимо. ### DOM @@ -37,6 +37,8 @@ Core не зависит от React. Каталог является исполняемой документацией. Компонент не считается зафиксированным, если его состояния нельзя увидеть и проверить в каталоге. +Кнопка Save в `ApplicationPanel` записывает один server-side catalog layout: тему, обе material-схемы, Environment controls, Toolbar и media source. Browser localStorage не является источником истины. + ## Граница shared/domain В дизайн-системе живут элементы, значение которых не зависит от предметной области: diff --git a/docs/COMPONENTS.md b/docs/COMPONENTS.md index 74c00a6..ec0593c 100644 --- a/docs/COMPONENTS.md +++ b/docs/COMPONENTS.md @@ -77,6 +77,14 @@ Window — единая механика открытия modal и правой Содержание, сохранение и запросы принадлежат приложению. +Для modeless Inspector доступен `draggable`: окно двигается за header, не блокирует приложение и ограничивается viewport. Modal-окна не становятся draggable автоматически. + +## Toolbar + +`Toolbar` — канонический dock из нового Engine с требованиями BIM Viewer. Он поддерживает позиции `left`, `right`, `bottom`, lens magnification и autohide. Контролируются фон, border, outline, минимальный/максимальный размер и количество иконок линзы. + +Дефолты перенесены из Engine без переизобретения: `#111115`, `#111117`, `#1c1c1c`, `25 px`, `87 px`, `5`. Toolbar получает те же section actions, что и основная navigation, но не владеет route-state. + ## ConfirmationModal ConfirmationModal оборачивает Window и защищает async-confirm от повторного запуска. До завершения операции закрытие можно заблокировать. diff --git a/package.json b/package.json index babf7b0..cade927 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,7 @@ "build:packages": "npm run build --workspace @nodedc/ui-core && npm run build --workspace @nodedc/ui-dom && npm run build --workspace @nodedc/ui-react", "check": "npm run build:packages && npm run typecheck --workspaces --if-present && npm run validate:registry", "dev": "npm run build:packages && npm run dev --workspace @nodedc/ui-catalog", + "serve": "node server/catalog-server.mjs", "validate:registry": "node scripts/validate-registry.mjs" }, "engines": { diff --git a/packages/ui-core/styles.css b/packages/ui-core/styles.css index c1327ba..b5143e0 100644 --- a/packages/ui-core/styles.css +++ b/packages/ui-core/styles.css @@ -1304,6 +1304,22 @@ textarea.nodedc-field__control { gap: 0.45rem; } +.nodedc-application-panel__tools { + display: flex; + align-items: center; + margin-right: 0.1rem; +} + +.nodedc-application-panel__tools .nodedc-segmented { + min-height: 2.92rem; + padding: 0.22rem; +} + +.nodedc-application-panel__tools .nodedc-segmented__item { + min-height: 2.48rem; + padding-inline: 1rem; +} + .nodedc-application-panel__action { display: grid; width: 2.92rem; @@ -1926,6 +1942,16 @@ textarea.nodedc-field__control { padding: 1.125rem 0.75rem 0.875rem 1.625rem; } +.nodedc-window[data-placement="end"][data-draggable="true"] .nodedc-window__head { + cursor: grab; + touch-action: none; + user-select: none; +} + +.nodedc-window[data-placement="end"][data-draggable="true"] .nodedc-window__head:active { + cursor: grabbing; +} + .nodedc-window[data-placement="end"] .nodedc-window__body { padding: 0 0.75rem 1.125rem; } @@ -2050,6 +2076,108 @@ textarea.nodedc-field__control { gap: var(--nodedc-space-2); } +.nodedc-toolbar-wrap { + position: fixed; + z-index: calc(var(--nodedc-layer-header) + 20); + pointer-events: auto; +} + +.nodedc-toolbar-wrap[data-placement="left"] { + top: 50%; + left: 0.75rem; + transform: translateY(-50%); +} + +.nodedc-toolbar-wrap[data-placement="right"] { + top: 50%; + right: 0.75rem; + transform: translateY(-50%); +} + +.nodedc-toolbar-wrap[data-placement="bottom"] { + bottom: 0.75rem; + left: 50%; + transform: translateX(-50%); +} + +.nodedc-toolbar { + display: flex; + align-items: center; + gap: 0.35rem; + padding: 0.42rem; + border: 1px solid var(--nodedc-toolbar-outline); + border-radius: calc(var(--nodedc-toolbar-radius) + 0.42rem); + background: color-mix(in srgb, var(--nodedc-toolbar-bg) 88%, transparent); + box-shadow: 0 10px 28px color-mix(in srgb, var(--nodedc-toolbar-outline) 36%, transparent); + backdrop-filter: blur(24px) saturate(1.12); + -webkit-backdrop-filter: blur(24px) saturate(1.12); + transition: opacity 180ms ease, transform 220ms ease; +} + +.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar, +.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar { + flex-direction: column; +} + +.nodedc-toolbar-wrap[data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar { + opacity: 0; + pointer-events: none; +} + +.nodedc-toolbar-wrap[data-placement="left"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar { + transform: translateX(calc(-100% - 1rem)); +} + +.nodedc-toolbar-wrap[data-placement="right"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar { + transform: translateX(calc(100% + 1rem)); +} + +.nodedc-toolbar-wrap[data-placement="bottom"][data-autohide="true"]:not([data-visible="true"]) .nodedc-toolbar { + transform: translateY(calc(100% + 1rem)); +} + +.nodedc-toolbar__hotzone { + position: fixed; +} + +.nodedc-toolbar-wrap[data-placement="left"] .nodedc-toolbar__hotzone { + inset: 5rem auto 5rem 0; + width: 1.1rem; +} + +.nodedc-toolbar-wrap[data-placement="right"] .nodedc-toolbar__hotzone { + inset: 5rem 0 5rem auto; + width: 1.1rem; +} + +.nodedc-toolbar-wrap[data-placement="bottom"] .nodedc-toolbar__hotzone { + right: 5rem; + bottom: 0; + left: 5rem; + height: 1.1rem; +} + +.nodedc-toolbar__button { + display: grid; + width: var(--nodedc-toolbar-item-size); + height: var(--nodedc-toolbar-item-size); + flex: 0 0 var(--nodedc-toolbar-item-size); + place-items: center; + border: 1px solid var(--nodedc-toolbar-border); + border-radius: var(--nodedc-toolbar-radius); + outline: 0; + background: var(--nodedc-toolbar-bg); + color: var(--nodedc-toolbar-accent); + padding: 0; + transition: width 120ms ease, height 120ms ease, flex-basis 120ms ease, background 120ms ease, color 120ms ease; +} + +.nodedc-toolbar__button[data-active="true"] { + border-color: var(--nodedc-toolbar-accent); + background: var(--nodedc-toolbar-accent); + color: rgb(var(--nodedc-on-accent-rgb)); +} + .nodedc-window.nodedc-share-access-window { width: min(35rem, calc(100vw - 2rem)); } diff --git a/packages/ui-react/src/ApplicationShell.tsx b/packages/ui-react/src/ApplicationShell.tsx index 7b74171..7665bd1 100644 --- a/packages/ui-react/src/ApplicationShell.tsx +++ b/packages/ui-react/src/ApplicationShell.tsx @@ -49,6 +49,8 @@ export interface ApplicationPanelProps extends Omit, expandLabel?: string; closeLabel?: string; onExpandedChange?: (expanded: boolean) => void; + /** Persistent custom controls rendered before utility icon actions. */ + headerTools?: ReactNode; /** Persistent utility actions rendered before expand/close in the panel header. */ utilityActions?: ApplicationPanelUtilityAction[]; onClose: () => void; @@ -70,6 +72,7 @@ export function ApplicationPanel({ expandLabel = "Развернуть окно", closeLabel = "Закрыть окно", onExpandedChange, + headerTools, utilityActions = [], onClose, children, @@ -85,6 +88,7 @@ export function ApplicationPanel({ {description ?

{description}

: null}
+ {headerTools ?
{headerTools}
: null} {utilityActions.map((action) => ( + ); + })} +
+ + ); +} diff --git a/packages/ui-react/src/Window.tsx b/packages/ui-react/src/Window.tsx index 5a11a11..6700a0d 100644 --- a/packages/ui-react/src/Window.tsx +++ b/packages/ui-react/src/Window.tsx @@ -2,12 +2,14 @@ import { useEffect, useId, useRef, + useState, type HTMLAttributes, type PointerEvent, type ReactNode, } from "react"; import { createPortal } from "react-dom"; import { cn } from "./cn.js"; +import { Icon } from "./Icon.js"; const focusableSelector = [ "a[href]", @@ -34,6 +36,7 @@ export interface WindowProps extends Omit, "title closeOnEscape?: boolean; lockBodyScroll?: boolean; trapFocus?: boolean; + draggable?: boolean; onClose: () => void; } @@ -50,13 +53,17 @@ export function Window({ closeOnEscape = true, lockBodyScroll, trapFocus, + draggable = false, onClose, className, + style, ...props }: WindowProps) { const titleId = useId(); const descriptionId = useId(); const dialogRef = useRef(null); + const dragRef = useRef<{ pointerId: number; offsetX: number; offsetY: number } | null>(null); + const [dragPosition, setDragPosition] = useState<{ left: number; top: number } | null>(null); const shouldLockBodyScroll = lockBodyScroll ?? placement === "center"; const shouldTrapFocus = trapFocus ?? placement === "center"; @@ -104,6 +111,34 @@ export function Window({ }; }, [closeOnEscape, onClose, open, shouldLockBodyScroll, shouldTrapFocus]); + useEffect(() => { + if (!open) setDragPosition(null); + }, [open]); + + useEffect(() => { + if (!draggable || !open) return; + const handlePointerMove = (event: globalThis.PointerEvent) => { + const drag = dragRef.current; + const dialog = dialogRef.current; + if (!drag || !dialog || event.pointerId !== drag.pointerId) return; + const bounds = dialog.getBoundingClientRect(); + const left = Math.max(8, Math.min(window.innerWidth - bounds.width - 8, event.clientX - drag.offsetX)); + const top = Math.max(76, Math.min(window.innerHeight - bounds.height - 8, event.clientY - drag.offsetY)); + setDragPosition({ left, top }); + }; + const handlePointerUp = (event: globalThis.PointerEvent) => { + if (dragRef.current?.pointerId === event.pointerId) dragRef.current = null; + }; + window.addEventListener("pointermove", handlePointerMove); + window.addEventListener("pointerup", handlePointerUp); + window.addEventListener("pointercancel", handlePointerUp); + return () => { + window.removeEventListener("pointermove", handlePointerMove); + window.removeEventListener("pointerup", handlePointerUp); + window.removeEventListener("pointercancel", handlePointerUp); + }; + }, [draggable, open]); + if (!open || typeof document === "undefined") return null; const allowBackdropClose = closeOnBackdrop ?? placement === "center"; @@ -119,20 +154,31 @@ export function Window({ className={cn("nodedc-window nodedc-material-rim", className)} data-size={size === "md" ? undefined : size} data-placement={placement} + data-draggable={draggable ? "true" : undefined} role="dialog" aria-modal={placement === "center" ? "true" : undefined} aria-labelledby={titleId} aria-describedby={subtitle ? descriptionId : undefined} tabIndex={-1} + style={dragPosition ? { ...style, position: "fixed", left: dragPosition.left, top: dragPosition.top, right: "auto", bottom: "auto" } : style} {...props} > -
+
{ + if (!draggable || event.button !== 0 || (event.target as HTMLElement).closest("button, input, select, textarea, a")) return; + const bounds = dialogRef.current?.getBoundingClientRect(); + if (!bounds) return; + dragRef.current = { pointerId: event.pointerId, offsetX: event.clientX - bounds.left, offsetY: event.clientY - bounds.top }; + event.preventDefault(); + }} + >

{title}

{subtitle ?

{subtitle}

: null}
{children}
diff --git a/packages/ui-react/src/index.ts b/packages/ui-react/src/index.ts index ec682d5..0501957 100644 --- a/packages/ui-react/src/index.ts +++ b/packages/ui-react/src/index.ts @@ -17,4 +17,5 @@ export * from "./Select.js"; export * from "./StatusBadge.js"; export * from "./Settings.js"; export * from "./SharingModals.js"; +export * from "./Toolbar.js"; export * from "./Window.js"; diff --git a/registry/components.json b/registry/components.json index 47baaba..4365803 100644 --- a/registry/components.json +++ b/registry/components.json @@ -120,11 +120,12 @@ "domExports": ["createModalController"], "domContract": ["nodedc-overlay", "nodedc-window"], "summary": "Canonical controlled window for modal and right-side panel presentations.", - "behavior": ["portal to body", "focus entry", "modal-only focus trap", "focus restore", "Escape close", "backdrop close policy", "modal-only body scroll lock"], + "behavior": ["portal to body", "focus entry", "modal-only focus trap", "focus restore", "Escape close", "backdrop close policy", "modal-only body scroll lock", "optional bounded dragging for modeless inspectors"], "rules": [ "Open state belongs to the application; focus/layer behavior belongs to the component.", "The same window contract is used by Launcher, CMS and SEO; theme variables supply color differences.", - "A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock." + "A right-side inspector is modeless by default: no dimming, no backdrop blur, no backdrop close, no focus trap and no body scroll lock.", + "A draggable inspector moves by its header and remains clamped to the viewport." ] }, { @@ -206,6 +207,21 @@ "A layout-level save action belongs to ApplicationPanel.utilityActions and is never duplicated inside individual content cards." ] }, + { + "id": "toolbar", + "status": "baseline", + "package": "@nodedc/ui-react", + "exports": ["Toolbar"], + "domContract": ["nodedc-toolbar-wrap", "nodedc-toolbar", "nodedc-toolbar__button"], + "summary": "Engine/BIM-derived magnifying application toolbar with left, right and bottom placements.", + "variants": ["left", "right", "bottom"], + "behavior": ["controlled active item", "lens magnification", "optional autohide hotzone", "theme-independent geometry"], + "rules": [ + "Engine defaults are background #111115, border #111117, outline #1c1c1c, min 25 px, max 87 px and five lens items.", + "Toolbar actions call application navigation; they do not duplicate route state.", + "Placement and material are controlled values and belong to the persisted application layout." + ] + }, { "id": "icon", "status": "baseline", @@ -216,7 +232,7 @@ "variants": ["14 px", "16 px", "18 px"], "rules": [ "Use semantic names from registry/icons.json.", - "The containing component owns surface, hit target and state; the icon owns only the glyph and is optically scaled to 78% inside controls.", + "The containing component owns surface, hit target and state; the icon owns only the glyph and uses the canonical 16 px default without an extra CSS scale.", "Do not copy complete Lucide or Font Awesome catalogs into an application." ] }, diff --git a/server/catalog-server.mjs b/server/catalog-server.mjs new file mode 100644 index 0000000..eb3d0c3 --- /dev/null +++ b/server/catalog-server.mjs @@ -0,0 +1,140 @@ +import { createReadStream, createWriteStream } from "node:fs"; +import { mkdir, readFile, rename, stat, writeFile } from "node:fs/promises"; +import { createServer } from "node:http"; +import { basename, extname, join, normalize } from "node:path"; +import { pipeline } from "node:stream/promises"; +import { fileURLToPath } from "node:url"; + +const root = fileURLToPath(new URL("..", import.meta.url)); +const distDir = join(root, "apps", "catalog", "dist"); +const runtimeDir = join(root, "runtime-data"); +const uploadDir = join(runtimeDir, "uploads"); +const layoutPath = join(runtimeDir, "layout.json"); +const port = Number(process.env.PORT || 3333); + +await mkdir(uploadDir, { recursive: true }); + +const mimeTypes = { + ".css": "text/css; charset=utf-8", + ".gif": "image/gif", + ".html": "text/html; charset=utf-8", + ".jpg": "image/jpeg", + ".jpeg": "image/jpeg", + ".js": "text/javascript; charset=utf-8", + ".json": "application/json; charset=utf-8", + ".map": "application/json; charset=utf-8", + ".mp4": "video/mp4", + ".png": "image/png", + ".svg": "image/svg+xml", + ".webm": "video/webm", +}; + +function json(response, statusCode, value) { + response.writeHead(statusCode, { "content-type": "application/json; charset=utf-8", "cache-control": "no-store" }); + response.end(JSON.stringify(value)); +} + +async function readJsonBody(request, maxBytes = 2 * 1024 * 1024) { + const chunks = []; + let size = 0; + for await (const chunk of request) { + size += chunk.length; + if (size > maxBytes) throw new Error("payload_too_large"); + chunks.push(chunk); + } + return JSON.parse(Buffer.concat(chunks).toString("utf8")); +} + +function safeUploadName(name) { + const extension = extname(name).toLowerCase() === ".mp4" ? ".mp4" : ".mp4"; + const stem = name.replace(/\.[^.]+$/, "").replace(/[^a-z0-9_-]+/gi, "-").replace(/^-+|-+$/g, "").slice(0, 64) || "stage-video"; + return `${Date.now()}-${stem}${extension}`; +} + +async function serveFile(request, response, filePath) { + const info = await stat(filePath); + const contentType = mimeTypes[extname(filePath).toLowerCase()] || "application/octet-stream"; + const range = request.headers.range?.match(/^bytes=(\d*)-(\d*)$/); + if (range) { + const start = range[1] ? Number(range[1]) : 0; + const end = range[2] ? Math.min(Number(range[2]), info.size - 1) : info.size - 1; + if (!Number.isFinite(start) || !Number.isFinite(end) || start > end || start >= info.size) { + response.writeHead(416, { "content-range": `bytes */${info.size}` }); + response.end(); + return; + } + response.writeHead(206, { + "content-type": contentType, + "content-length": end - start + 1, + "content-range": `bytes ${start}-${end}/${info.size}`, + "accept-ranges": "bytes", + }); + if (request.method === "HEAD") return response.end(); + createReadStream(filePath, { start, end }).pipe(response); + return; + } + response.writeHead(200, { + "content-type": contentType, + "content-length": info.size, + "accept-ranges": "bytes", + }); + if (request.method === "HEAD") return response.end(); + createReadStream(filePath).pipe(response); +} + +const server = createServer(async (request, response) => { + try { + const url = new URL(request.url || "/", `http://${request.headers.host || "127.0.0.1"}`); + + if (url.pathname === "/api/layout" && request.method === "GET") { + try { + json(response, 200, JSON.parse(await readFile(layoutPath, "utf8"))); + } catch (error) { + if (error?.code === "ENOENT") return json(response, 200, null); + throw error; + } + return; + } + + if (url.pathname === "/api/layout" && request.method === "PUT") { + const layout = await readJsonBody(request); + const next = { ...layout, savedAt: new Date().toISOString(), schemaVersion: 1 }; + const tempPath = `${layoutPath}.tmp`; + await writeFile(tempPath, `${JSON.stringify(next, null, 2)}\n`, "utf8"); + await rename(tempPath, layoutPath); + json(response, 200, next); + return; + } + + if (url.pathname === "/api/layout/media" && request.method === "PUT") { + const originalName = decodeURIComponent(String(request.headers["x-file-name"] || "stage-video.mp4")); + const fileName = safeUploadName(originalName); + const targetPath = join(uploadDir, fileName); + await pipeline(request, createWriteStream(targetPath, { flags: "wx" })); + json(response, 201, { fileName: originalName, url: `/uploads/${fileName}` }); + return; + } + + if (url.pathname.startsWith("/uploads/")) { + const fileName = basename(normalize(url.pathname.slice("/uploads/".length))); + await serveFile(request, response, join(uploadDir, fileName)); + return; + } + + const relativePath = url.pathname === "/" ? "index.html" : url.pathname.replace(/^\//, ""); + const candidate = join(distDir, normalize(relativePath)); + try { + await serveFile(request, response, candidate); + } catch (error) { + if (error?.code !== "ENOENT") throw error; + await serveFile(request, response, join(distDir, "index.html")); + } + } catch (error) { + json(response, error?.message === "payload_too_large" ? 413 : 500, { error: error?.message || "server_error" }); + } +}); + +server.listen(port, "127.0.0.1", () => { + console.log(`NODE.DC Design Guideline: http://127.0.0.1:${port}`); + console.log(`Layout store: ${layoutPath}`); +});