diff --git a/CLAUDE.md b/CLAUDE.md index 06e7d08b..0d261cd6 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -707,7 +707,15 @@ loadable play content. Everything a user does must be visible to connected peers Multi-slot objects are REFUSED, the switchMaterialType precedent. UI: `components/editors/ShaderEditor.svelte` (a FLOW_FAMILY dock tab, its own xyflow instance so flowGraphs/nodesync stay byte-untouched; scope follows the SELECTION - like the node editor's flow graphs, so there is no scope control) + + like the node editor's flow graphs, so there is no scope control. It was the ONE + dock view with NO FLOATING MODE at all — no `docked` flag, no dragWindow, no window + chrome — and TWO other modules carried an exception for that fact (`panelToggles`' + `dockOnly` shape, and `dockMenu.dockTabItems` withholding "Undock" rather than + shipping a row that could only do nothing). It has UvEditor's docked/floating split + now — `shaderDocked` + `#shader-window` with dragWindow / a KEYED focusStack / + tabbable / bottomDockable / a corner grip, plus the `dockModeArm` consumer that is + what makes Undock and drag-a-tab-out reach it — so both exceptions are gone and the + seven dock views behave identically) + `ShaderSidebar.svelte` + `nodes/ShaderNode.svelte` (ONE generic node for the whole catalog) + `nodes/ShaderTexturePicker.svelte` (a file input that imports into the Explorer, an Explorer drag-drop target, thumbnail, clear, and a "waiting for peer" diff --git a/src/App.svelte b/src/App.svelte index adcf82fd..e200f5dc 100644 --- a/src/App.svelte +++ b/src/App.svelte @@ -20,6 +20,7 @@ import ModelPreviewWindow from './components/editors/ModelPreviewWindow.svelte' import DungeonMinimap from './components/play/DungeonMinimap.svelte' import PlayReticle from './components/play/PlayReticle.svelte' + import TouchPlayControls from './components/play/TouchPlayControls.svelte' import DrawToolbar from './components/menu/DrawToolbar.svelte' import SculptToolbar from './components/menu/SculptToolbar.svelte' import SplineToolbar from './components/menu/SplineToolbar.svelte' @@ -74,6 +75,9 @@ import { initModules, disabledModules } from '$lib/moduleSDK' import { coreModules } from './modules/index.js' import ModulesManager from './components/menu/ModulesManager.svelte' + // W9: the pref that decides whether the bottom dock RESIZES the viewport (default) + // or overlays it. A local view preference — see the .viewport style below. + import { viewPrefs } from '$lib/viewPrefs' // before children mount: node components/effects must exist when the // flow editor and runtime first look them up @@ -327,9 +331,11 @@ import('./lib/colocationPresence'), import('./lib/xrAnchors'), import('./lib/colocationAnchors'), - import('./lib/colocationNudge') - ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib]) => { - window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib } + import('./lib/colocationNudge'), + import('./lib/touchControls'), + import('./lib/playMode') + ]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, jointsLib, possessLib, handModelsLib, terrainSculptLib, userModulesLib, environmentLib, sceneMusicLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, shadowDefaultsLib, paletteLib, viewModeLib, inputRuntimeLib, shortcutsLib, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, meshToolParamsLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, nodeCatalogLib, objectMenuLib, animationPreviewLib, aiProvidersLib, aiToolsLib, aiAssistantLib, meshProvidersLib, meshJobsLib, flowGraphsLib, objectFlowLib, peerServerLib, cloudHooksLib, cloudPluginLib, connectionStateLib, peerApprovalLib, particleRuntimeLib, particleActionsLib, particlePresetsLib, versionLib, whatsNewLib, confirmDialogLib, scenePhysicsLib, playInteractLib, moveSmoothingLib, playSettingsLib, colliderSpecLib, colliderHelpersLib, colliderEditLib, editSessionLib, trackpadNavLib, vrSleeveLib, gridSettingsLib, viewPrefsLib, cameraBookmarksLib, cameraObjectsLib, cameraHelpersLib, onionSkinLib, cameraPreviewLib, addObjectsLib, cameraPipLib, inputDeviceLib, sceneTemplatesLib, bvhPickingLib, multiTransformLib, objectOriginLib, moduleGalleryLib, uvEditorLib, uvUnwrapLib, meshTopologyLib, meshBudgetLib, proportionalLib, proportionalRingLib, scenePickLib, snapEngineLib, meshPivotLib, selectionPrefsLib, editOverlaysLib, objectPermissionsLib, scenePostLib, postEffectsLib, viewportOverridesLib, postprocessingModule, shaderBackendsLib, shaderGraphLib, shaderSyncLib, shaderTexturesLib, shaderCatalogLib, unitsLib, postBackendsLib, workspaceLib, editResumeLib, moduleRequirementsLib, hudDocsLib, hudSyncLib, idbLib, hudKindsLib, hudImagesLib, gameStateLib, gameSyncLib, hudActionsLib, moduleNodeIOLib, moduleToolboxesLib, splineTubeLib, splineToolLib, splineEditLib, terrainCarveLib, flattenActionsLib, hudViewportDragLib, gamepadPrefsLib, charControllerLib, hudRichTextLib, moduleHudKindsLib, hudMinimapLib, hudArrangeLib, gamePresenceLib, levelsLib, peerVarsLib, projectManifestLib, projectFileLib, transientObjectsLib, spawnerLib, triggerSyncLib, saveNameLib, sceneIdentityLib, importDuplicatesLib, peerScenesLib, sharedLibraryLib, transferLedgerLib, explorerViewLib, colocationLib, colocationCalibrateLib, colocationPresenceLib, xrAnchorsLib, colocationAnchorsLib, colocationNudgeLib, touchControlsLib, playModeLib]) => { + window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, joints: jointsLib, possess: possessLib, handModels: handModelsLib, terrainSculpt: terrainSculptLib, userModules: userModulesLib, environment: environmentLib, sceneMusic: sceneMusicLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, shadowDefaults: shadowDefaultsLib, palette: paletteLib, viewModeCtl: viewModeLib, inputRuntime: inputRuntimeLib, shortcutsRegistry: shortcutsLib, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, meshToolParams: meshToolParamsLib, avatarModel: avatarModelLib, explorer: explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssets: sceneAssetsLib, THREE, GLTFExporterModule, snapping: snappingLib, flowSockets: flowSocketsLib, networkQuality: networkQualityLib, packs: packsLib, customNodes: customNodesLib, nodesHandler: nodesHandlerLib, nodeCatalog: nodeCatalogLib, objectMenu: objectMenuLib, animationPreview: animationPreviewLib, aiProviders: aiProvidersLib, aiTools: aiToolsLib, aiAssistant: aiAssistantLib, meshProviders: meshProvidersLib, meshJobs: meshJobsLib, flowGraphsCtl: flowGraphsLib, objectFlow: objectFlowLib, peerServer: peerServerLib, cloudHooks: cloudHooksLib, cloudPlugin: cloudPluginLib, connectionState: connectionStateLib, peerApproval: peerApprovalLib, particleRuntime: particleRuntimeLib, particleActions: particleActionsLib, particlePresets: particlePresetsLib, version: versionLib, whatsNew: whatsNewLib, confirmDialog: confirmDialogLib, scenePhysics: scenePhysicsLib, playInteract: playInteractLib, moveSmoothing: moveSmoothingLib, playSettings: playSettingsLib, colliderSpec: colliderSpecLib, colliderHelpers: colliderHelpersLib, colliderEdit: colliderEditLib, editSession: editSessionLib, trackpadNav: trackpadNavLib, vrSleeve: vrSleeveLib, gridSettings: gridSettingsLib, viewPrefs: viewPrefsLib, cameraBookmarks: cameraBookmarksLib, cameraObjects: cameraObjectsLib, cameraHelpers: cameraHelpersLib, onionSkin: onionSkinLib, cameraPreview: cameraPreviewLib, addObjects: addObjectsLib, cameraPip: cameraPipLib, inputDevice: inputDeviceLib, sceneTemplates: sceneTemplatesLib, bvhPicking: bvhPickingLib, multiTransform: multiTransformLib, objectOrigin: objectOriginLib, moduleGallery: moduleGalleryLib, uvEditor: uvEditorLib, uvUnwrap: uvUnwrapLib, meshTopology: meshTopologyLib, meshBudget: meshBudgetLib, proportional: proportionalLib, proportionalRing: proportionalRingLib, scenePick: scenePickLib, snapEngine: snapEngineLib, meshPivot: meshPivotLib, selectionPrefs: selectionPrefsLib, editOverlays: editOverlaysLib, objectPermissions: objectPermissionsLib, scenePost: scenePostLib, postEffects: postEffectsLib, viewportOverrides: viewportOverridesLib, postprocessing: postprocessingModule, shaderBackends: shaderBackendsLib, shaderGraph: shaderGraphLib, shaderSync: shaderSyncLib, shaderTextures: shaderTexturesLib, shaderCatalog: shaderCatalogLib, units: unitsLib, postBackends: postBackendsLib, workspace: workspaceLib, editResume: editResumeLib, moduleRequirements: moduleRequirementsLib, hudDocs: hudDocsLib, hudSync: hudSyncLib, idb: idbLib, hudKinds: hudKindsLib, hudImages: hudImagesLib, gameState: gameStateLib, gameSync: gameSyncLib, hudActions: hudActionsLib, moduleNodeIO: moduleNodeIOLib, moduleToolboxes: moduleToolboxesLib, splineTube: splineTubeLib, splineTool: splineToolLib, splineEdit: splineEditLib, terrainCarve: terrainCarveLib, flattenActions: flattenActionsLib, hudViewportDrag: hudViewportDragLib, gamepadPrefs: gamepadPrefsLib, charController: charControllerLib, hudRichText: hudRichTextLib, moduleHudKinds: moduleHudKindsLib, hudMinimap: hudMinimapLib, hudArrange: hudArrangeLib, gamePresence: gamePresenceLib, levels: levelsLib, peerVars: peerVarsLib, projectManifest: projectManifestLib, projectFile: projectFileLib, transientObjects: transientObjectsLib, spawner: spawnerLib, triggerSync: triggerSyncLib, saveName: saveNameLib, sceneIdentity: sceneIdentityLib, importDuplicates: importDuplicatesLib, peerScenes: peerScenesLib, sharedLibrary: sharedLibraryLib, transferLedger: transferLedgerLib, explorerView: explorerViewLib, colocation: colocationLib, colocationCalibrate: colocationCalibrateLib, colocationPresence: colocationPresenceLib, xrAnchors: xrAnchorsLib, colocationAnchors: colocationAnchorsLib, colocationNudge: colocationNudgeLib, touchControls: touchControlsLib, playMode: playModeLib } }) } }) @@ -389,6 +395,10 @@ + + - - - + +
+ + + +
+ + diff --git a/src/components/ContextMenu.svelte b/src/components/ContextMenu.svelte index 5a352697..a7f0a54b 100644 --- a/src/components/ContextMenu.svelte +++ b/src/components/ContextMenu.svelte @@ -6,7 +6,17 @@ import { autofocusOk, typeToFocus } from '$lib/inputDevice'; // Generic context menu. items: [{ label, action?, disabled?, tooltip?, danger?, - // icon?, hint?, checked?, children?: items[] } | { section } | { header }] + // icon?, hint?, checked?, keepOpen?, rowActions?, children?: items[] } | + // { section } | { header }] + // + // W1: `keepOpen: true` runs the action and LEAVES THE MENU UP — opt-in per item, + // so every other menu in the app closes on a click exactly as before. It exists for + // a menu that is a CHECKLIST rather than a command list (the toolbar's Customize + // pane): toggling one button's visibility used to dismiss the list you were working + // through. Outside click and Escape still close. The item array is a prop, so a + // consumer that wants the rows to show the state it just wrote must pass a REACTIVE + // array (Controls derives its Customize items from the layout record) — re-rendering + // in place keeps the menu's position and scroll, because the node is never replaced. // Submenus (any depth) open on hover, marked with ▸. Flips up/left near screen edges. // // 15-Q: dense menus grew a TYPE-TO-FILTER row (flattened command-palette matches). @@ -53,6 +63,7 @@ return; } item.action?.(); + if (item.keepOpen) return; dispatch('close'); } diff --git a/src/components/ContextMenuItems.svelte b/src/components/ContextMenuItems.svelte index d9496ea9..c41b0b64 100644 --- a/src/components/ContextMenuItems.svelte +++ b/src/components/ContextMenuItems.svelte @@ -15,9 +15,36 @@ // 16-P3 adds `checked: true` — the ACTIVE choice of a group (bold + accent), // which replaced the old '● ' label prefix. // + // W1 adds two more: + // keepOpen: true the row's action runs and the menu STAYS UP (owned by + // ContextMenu's `run`, documented there) + // rowActions: [{ icon, label, tooltip?, disabled?, run }] + // small trailing controls INSIDE the row — the toolbar's + // Customize list needs a reorder pair beside each button, and + // a row that both toggles and reorders cannot say that with a + // label. They are inline CONTROLS, not menu commands: the + // click never reaches the row (stopPropagation) and never + // closes the menu, so `keepOpen` does not apply to them. + // `label` is both the tooltip and the accessible name. + // + // W8b adds one: + // key: 'move' a STABLE identity for a row in a list that REORDERS itself. + // The each block below is keyed on it, so when a `keepOpen` + // action rewrites the array into a new order svelte MOVES each + // row's DOM node instead of rewriting the labels in place. That + // is what makes a per-row reorder control usable: the button + // you just pressed travels with its row, stays focused, and a + // second press walks the same item another slot. Without it the + // node stays put, the row under your finger becomes a DIFFERENT + // item, and pressing again undoes what you just did. + // Optional and namespaced against the index fallback, so every + // menu that passes no key is keyed by position — byte-identical + // to the unkeyed block this replaces. + // // 16-P1: which submenu is open (`openPath`) and where the keyboard cursor sits // (`navPath` + `highlight`) are owned by ContextMenu — ONE truth shared by mouse // and keyboard. Hover-intent lives here: 120ms to open, 150ms to close. + import { tick } from 'svelte'; import Icon from './ui/Icon.svelte'; export let items: any[] = []; export let onrun: (item: any) => void; @@ -37,6 +64,26 @@ let openTimer: any = null; let closeTimer: any = null; + /** W8b: run a row's inline control and KEEP THE KEYBOARD ON IT. + * + * A reorder control rewrites the list it lives in, and the keyed each block then + * RELOCATES this row's node to its new slot — which Chromium treats as a blur, so + * the control you just pressed silently stops being focused and a repeat press has + * nowhere to go. The node survives (that is what the key buys); only the focus does + * not. Re-focusing it by its own accessible NAME is what makes "press again to move + * it again" true for the keyboard as well as the mouse — the label names the BUTTON, + * so it follows the item wherever the move put it, and a control that has become + * disabled at an end of the list is simply not re-focused. */ + async function runRowAction(act: any, host: HTMLElement) { + act.run?.(); + await tick(); + const root = host.closest('[role="menu"]') ?? document; + const again = root.querySelector( + `button.ctx-act[aria-label="${String(act.label).replace(/"/g, '\\"')}"]` + ); + if (again && !again.disabled) again.focus({ preventScroll: true }); + } + /** the child submenu open at THIS level (null = none) */ $: openChild = openPath.length > path.length ? openPath[path.length] : null; /** is the keyboard cursor on this level? */ @@ -113,7 +160,7 @@ const disabledClass = 'cursor-default px-3 py-1.5 text-gray-400 dark:text-gray-500 whitespace-nowrap'; -{#each items as item} +{#each items as item, rowAt (item?.key != null ? 'k:' + item.key : 'i:' + rowAt)} {#if item.header} @@ -187,6 +234,7 @@ data-ctx-active={atNav && indexOf.get(item) === highlight} role="menuitem" title={item.tooltip ?? ''} + aria-label={item.rowActions ? item.label : undefined} on:mouseenter={() => hoverRow(item, indexOf.get(item) ?? -1)} on:mouseleave={leaveRow} on:click={() => onrun(item)} @@ -199,6 +247,22 @@ {#if item.hint} {item.hint} {/if} + {#if item.rowActions} + + {#each item.rowActions as act} + + {/each} + + {/if} {/if} @@ -269,6 +333,33 @@ display: inline-flex; color: rgb(148 163 184); } + /* W1: inline controls at the end of a row (the Customize list's reorder pair). + Muted until hovered so the row still reads as its label first. */ + .ctx-actions { + flex: 0 0 auto; + display: inline-flex; + align-items: center; + gap: 2px; + margin-left: 10px; + } + .ctx-act { + display: inline-flex; + align-items: center; + justify-content: center; + width: 18px; + height: 18px; + border-radius: 4px; + color: rgb(148 163 184); + background: transparent; + } + .ctx-act:hover:not(:disabled) { + color: inherit; + background: rgb(148 163 184 / 0.25); + } + .ctx-act:disabled { + opacity: 0.35; + cursor: default; + } .ctx-hint { flex: 0 0 auto; margin-left: 12px; diff --git a/src/components/DockTabs.svelte b/src/components/DockTabs.svelte index aa110165..3bdaf4bf 100644 --- a/src/components/DockTabs.svelte +++ b/src/components/DockTabs.svelte @@ -1,43 +1,235 @@ -
- {#each $flowTabs as tab (tab.key)} +
+ {#each $dockTabs as tab (tab.key)} openTabMenu(e, tab.key)} + onclick={() => tabClick(tab.key)}>{tab.title} {/each} + + {#if dragKey && !dropOut} +
+ {/if}
+ + +
+
{#if addMenu} (addMenu = null)} /> {/if} +{#if tabMenu} + (tabMenu = null)} /> +{/if} diff --git a/src/components/Flow.svelte b/src/components/Flow.svelte index 7577ab89..01a0305f 100644 --- a/src/components/Flow.svelte +++ b/src/components/Flow.svelte @@ -2,9 +2,9 @@ // Flow host: the Node editor. DOCKED mode is a Flow-family TAB in the shared bottom // dock (DockTabs strip; shares dockHeight with Flow Code + Animation; only the // visible tab renders). UNDOCKED mode is a floating, resizable window. Both persist. - import { flowGraphClose, flowCodeClose, animationClose, uvEditorClose, mobileUndockAllowed, shaderEditorClose, hudEditorClose } from '../stores/appStore.js'; + import { flowGraphClose, mobileUndockAllowed } from '../stores/appStore.js'; import { get } from 'svelte/store'; - import { onMount } from 'svelte'; + import { onMount, untrack } from 'svelte'; import { SvelteFlowProvider } from '@xyflow/svelte'; import ContextMenu from './ContextMenu.svelte'; import Nodes from './editors/Nodes.svelte'; @@ -16,7 +16,9 @@ import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { dockable } from '$lib/docking'; - import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock'; + import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm } from '$lib/bottomDock'; + import { bottomDockable } from '$lib/bottomDockDrop'; + import { dockAddItems } from '$lib/dockMenu'; import { fly } from 'svelte/transition'; const clampH = (h: number) => Math.min(Math.max(h || 320, 200), Math.round(window.innerHeight * 0.8)); @@ -66,6 +68,20 @@ if (v) activateDock('flow'); // re-docking makes it the visible tab } + // W5: consume the shared dock-mode arm — the tab strip's right-click menu asks + // through it (the Explorer has had this exact effect since 4b). `docked` is read + // from localStorage ONCE at mount, so writing that flag from outside is inert; + // `setDocked` owns the mode and is what has to run. Cleared as it is acted on. + $effect(() => { + const arm = $dockModeArm; + if (!arm || arm.key !== 'flow') return; + dockModeArm.set(null); + untrack(() => { + if (arm.docked !== docked) setDocked(arm.docked); + flowGraphClose.set(false); + }); + }); + // tab-grouped windows share one size: show the group's rect so a resize on any // member updates every tab, not just the active one. const myGroup = $derived($tabGroups.find((g: any) => g.members.includes('flow')) ?? null); @@ -73,15 +89,10 @@ const effH = $derived(myGroup ? myGroup.rect.height : winH); // Flow "+" (floating window only — docked mode uses the DockTabs strip): open - // another Flow-family view. They start docked, so they appear as dock tabs. + // another dock view. They start docked, so they appear as dock tabs. Same list the + // strip's "+" renders ($lib/dockMenu) — they used to be two copies that drifted. let addMenu: { x: number; y: number } | null = $state(null); - const addItems = [ - { label: '+ Flow Code', tooltip: 'Edit the graph as JSON', action: () => { flowCodeClose.set(false); activateDock('flowcode'); } }, - { label: '+ Animation', tooltip: 'Animate the selected object', action: () => { animationClose.set(false); activateDock('animation'); } }, - { label: '+ UV editor', tooltip: 'Edit the selected mesh’s UV map and textures', action: () => { uvEditorClose.set(false); activateDock('uv'); } }, - { label: '+ Shader editor', tooltip: 'Drive this material from a node graph', action: () => { shaderEditorClose.set(false); activateDock('shader'); } }, - { label: '+ HUD editor', tooltip: 'Lay out the on-screen HUD its nodes drive', action: () => { hudEditorClose.set(false); activateDock('hud'); } } - ]; + const addItems = dockAddItems(); function openAddMenu(e: MouseEvent) { const r = (e.currentTarget as HTMLElement).getBoundingClientRect(); addMenu = { x: r.left, y: r.bottom + 4 }; @@ -93,7 +104,9 @@ setDockOccupant('flow', !$flowGraphClose && docked, $dockHeight); return () => setDockOccupant('flow', false); }); - const dockVisible = $derived($visibleDockKey === 'flow'); + // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant + // report above is untouched, so the strip comes back with its tabs intact) + const dockVisible = $derived($visibleDockKey === 'flow' && !$dockMinimized); // --- docked: top-edge resize (shared dock height, persisted by the store) --- let resizing = $state(false); @@ -159,7 +172,7 @@ >
setDocked(false)}>⧉ -
+
@@ -184,8 +197,9 @@ id="flow-window" class="ui-panel fixed flex flex-col overflow-hidden" use:dragWindow={{ key: 'flowWin', defaultRect: { left: 120, top: 90 } }} - use:focusStack + use:focusStack={'flow'} use:tabbable={{ key: 'flow', title: 'Node editor', openStore: flowGraphClose, isOpen: (v) => !v, close: () => flowGraphClose.set(true) }} + use:bottomDockable={{ key: 'flow' }} use:dockable={{ key: 'flow' }} style="z-index: var(--z-window)" style:width="{effW}px" diff --git a/src/components/Scene.svelte b/src/components/Scene.svelte index 3387d1ef..d1e5e6b9 100644 --- a/src/components/Scene.svelte +++ b/src/components/Scene.svelte @@ -22,6 +22,7 @@ } from '$lib/objectActions'; // 85: what a double-click does (a LOCAL pref; store-only leaf, no cycle) import { doubleClickAction } from '$lib/selectionPrefs'; + import { noteXRSessionStarted } from '$lib/playMode'; import { recordTransform } from '$lib/history'; import { suspendAnimation, resumeAnimation, pumpFlowTick } from '$lib/flowRuntime'; import { holdBody, releaseBody } from '$lib/physics'; @@ -533,6 +534,14 @@ const onSourcesChange = () => onInputSourcesChange(); const onSessionStart = () => { renderer.xr.getSession()?.addEventListener('inputsourceschange', onSourcesChange); + // W3: the AUTHORITATIVE half of the VR flag. playMode sets it optimistically + // one line before the click (the VR configuration has to be armed before the + // first XR frame) and arms a watchdog to undo that GUESS if no session ever + // starts. This is the event that proves one did, so it cancels the watchdog + // and asserts the flag. Asserting rather than trusting the guess is what lets + // a session accepted AFTER the watchdog already fired still come up in VR. + noteXRSessionStarted(); + $isVRMode = true; }; renderer.xr.addEventListener('sessionstart', onSessionStart); diff --git a/src/components/editors/AnimationWindow.svelte b/src/components/editors/AnimationWindow.svelte index e119e789..ca65c7e1 100644 --- a/src/components/editors/AnimationWindow.svelte +++ b/src/components/editors/AnimationWindow.svelte @@ -40,12 +40,16 @@ import ContextMenu from '../ContextMenu.svelte'; import DockTabs from '../DockTabs.svelte'; import { createGesture } from '$lib/modalGrab'; + // W5: the BINDING for this pane's grab key lives in the shortcut registry (an + // `external` row), so Settings can move it; the key itself is answered here. + import { comboOf, bindingOf } from '$lib/shortcuts'; import { dragWindow } from '$lib/dragWindow'; import DragRow from '../ui/DragRow.svelte'; import { focusStack } from '$lib/windowFocus'; import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; - import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock'; + import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm } from '$lib/bottomDock'; + import { bottomDockable } from '$lib/bottomDockDrop'; // live-follow the primary selection (keeps a truthy [] before the first select) const target = $derived($selectedObject && $selectedObject.uuid ? $selectedObject : null); @@ -227,6 +231,20 @@ if (v) activateDock('animation'); } + // W5: consume the shared dock-mode arm — the tab strip's right-click menu asks + // through it (the Explorer has had this exact effect since 4b). `docked` is read + // from localStorage ONCE at mount, so writing that flag from outside is inert; + // `setDocked` owns the mode and is what has to run. Cleared as it is acted on. + $effect(() => { + const arm = $dockModeArm; + if (!arm || arm.key !== 'animation') return; + dockModeArm.set(null); + untrack(() => { + if (arm.docked !== docked) setDocked(arm.docked); + animationClose.set(false); + }); + }); + // tab-grouped windows share one size: show the group's rect so a resize on any // member updates every tab, not just the active one. const myGroup = $derived($tabGroups.find((g) => g.members.includes('animation')) ?? null); @@ -236,7 +254,9 @@ setDockOccupant('animation', !$animationClose && docked, $dockHeight); return () => setDockOccupant('animation', false); }); - const dockVisible = $derived($visibleDockKey === 'animation'); + // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant + // report above is untouched, so the strip comes back with its tabs intact) + const dockVisible = $derived($visibleDockKey === 'animation' && !$dockMinimized); // Switching objects LEAVES the previous one where it was: its playhead, its // pose and its clip all stay put (they live per uuid in `playback`), so coming @@ -519,7 +539,10 @@ /** @type {any} */ (window).__animationDebug = { selKeys: () => selKeys.map(([id, i]) => id + ':' + i), marqueeMode: () => marqMode, - markerCount: () => clipMarkers.length + markerCount: () => clipMarkers.length, + // W5: the armed transform is component state too, and the G row is the one + // thing about it a check has no store for + xform: () => xform }; return () => delete /** @type {any} */ (window).__animationDebug; }); @@ -1113,6 +1136,8 @@ * Ctrl+Space add the key at the playhead to the selection * Esc drop the selection (or cancel a grab) * 1 / 2 arm Move / Scale + * G arm Move (Blender's grab; the combo is the `animation.grab` row + * in the shortcut registry, so Settings can move it) * Shift+arrows transform the selection: ←/→ in time, ↑/↓ in value * Del remove the selection * @param {HTMLElement} node @@ -1131,6 +1156,17 @@ const mult = e.ctrlKey || e.metaKey ? 10 : e.shiftKey ? 100 : 1; const arrow = { ArrowLeft: [-1, 0], ArrowRight: [1, 0], ArrowUp: [0, 1], ArrowDown: [0, -1] }[e.key]; + // W5: Blender's G arms Move. The same key is Move on the gizmo and Arm Move + // in the UV editor; all three coexist because this handler claims (and stops) + // the press while the plot holds focus, which is what `scope: 'animation'` + // records in the registry. The COMBO comes from the registry so the Settings + // row really moves the key; tested before the other commands so the binding + // is authoritative wherever the user puts it. + if (comboOf(e) === bindingOf('animation.grab')) { + claim(e); + xform = 'move'; + return; + } if (e.key === 'Delete' || e.key === 'Backspace') { if (!selKeys.length) return; claim(e); @@ -2432,7 +2468,7 @@ style="z-index: var(--z-bottom); height: {$dockHeight}px; border-top: 1px solid rgb(55 65 81 / 0.6)" >
!v, close: () => animationClose.set(true) }} + use:bottomDockable={{ key: 'animation' }} style="z-index: var(--z-window); max-width: 96vw; max-height: 88vh" style:width="{effW}px" style:height="{effH}px" diff --git a/src/components/editors/Explorer.svelte b/src/components/editors/Explorer.svelte index 0d0a7e01..74d31cdd 100644 --- a/src/components/editors/Explorer.svelte +++ b/src/components/editors/Explorer.svelte @@ -4,8 +4,9 @@ // Explorer (95, tree v2 in 106): dockable asset browser — real file-manager // tree on the left (inline create/rename, expand/collapse, drag re-parent, // cascade delete, resizable), thumbnail grid on the right (subfolder cards - // + items), drag files in to import. Shares the bottom dock with the Flow - // editor as notebook tabs (bottomDock.js); undocks into a floating window. + // + items), drag files in to import. It is an ordinary bottom-dock TAB beside + // the Flow-family views (bottomDock.js), sharing their strip and their height; + // undocks into a floating window. import { get } from 'svelte/store'; import { tick, untrack } from 'svelte'; import { explorerClose, mobileUndockAllowed, explorerSceneSaveArm, peers } from '../../stores/appStore.js'; @@ -156,12 +157,14 @@ import { sceneAssets } from '$lib/sceneAssets'; import { setNodeData } from '$lib/nodesHandler'; import { findNodeAnyGraph } from '../../stores/flowStore'; - import { bottomDockActive, visibleDockKey, setDockOccupant } from '$lib/bottomDock'; + import { bottomDockActive, visibleDockKey, dockMinimized, setDockOccupant, dockHeight, dockModeArm } from '$lib/bottomDock'; + import { bottomDockable } from '$lib/bottomDockDrop'; import { dragWindow } from '$lib/dragWindow'; import { focusStack } from '$lib/windowFocus'; import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; import { dockable } from '$lib/docking'; import ContextMenu from '../ContextMenu.svelte'; + import DockTabs from '../DockTabs.svelte'; import WindowShell from '../shared/WindowShell.svelte'; import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import { fly } from 'svelte/transition'; @@ -173,7 +176,6 @@ const WIN_MIN = { minW: 420, minH: 280 }; const WIN_DEFAULT = { w: 720, h: 440 }; - let height = $state(300); let inlineStats: any = $state(null); // N4: poly stats for the Properties inline preview let docked = $state(true); let winW = $state(720); @@ -187,7 +189,16 @@ let shell = $state(null); let selected = $state(null); if (typeof localStorage !== 'undefined') { - height = clampH(parseInt(localStorage.getItem('explorerHeight') ?? '300')); + // one-shot migration: the Explorer used to keep a docked height of its own + // ('explorerHeight'). It is a dock TAB now, so the dock's shared height owns + // it — adopt the old value once, then drop the key. + try { + const legacyH = localStorage.getItem('explorerHeight'); + if (legacyH) { + dockHeight.set(clampH(parseInt(legacyH) || 300)); + localStorage.removeItem('explorerHeight'); + } + } catch {} docked = localStorage.getItem('explorerDocked') !== 'false'; // 18-B: a size saved on a bigger screen must not come back oversized — // that is the state whose resize grip sits off-screen. Fitted BEFORE the @@ -219,15 +230,35 @@ if (v) bottomDockActive.set('explorer'); // re-docking makes it the visible panel } - // The Explorer is the dock's separate (exclusive) panel — it reports docked+open - // (+height for the --bottom-inset) and is visible only when it owns the dock. It is - // mutually exclusive with the Flow-family tabs (activating a Flow tab closes it), so - // it shows NO tab strip of its own. + // 4b: CONSUME the dock arm. The Controls toolbar's Explorer menu offers "Open as + // dock tab" / "Open as floating window", and `docked` above is read from + // localStorage exactly ONCE, at mount — so the toolbar writing that flag would be + // inert at a live panel and the row would read as a dead button. It asks through + // the store instead and `setDocked` (which owns the flag, this branch and the dock + // occupancy together) is what acts. Same write-once shape as `explorerSceneSaveArm`. + // W5: the seam is GENERAL now (`dockModeArm`, keyed by dock key) so the tab strip's + // own context menu can undock any tab; the Explorer-only `explorerDockArm` it used + // to own is gone, and the Controls menu asks through this one. + $effect(() => { + const arm = $dockModeArm; + if (!arm || arm.key !== 'explorer') return; + dockModeArm.set(null); + untrack(() => { + if (arm.docked !== docked) setDocked(arm.docked); + explorerClose.set(false); // the rows say "Open as …", so open it + }); + }); + + // A dock tab like any other: report docked+open (+ the SHARED dock height, which + // feeds --bottom-inset) so the strip lists it, and render only while it is the + // visible tab. Being covered by another tab closes nothing — this stays open. $effect(() => { - setDockOccupant('explorer', !$explorerClose && docked, height); + setDockOccupant('explorer', !$explorerClose && docked, $dockHeight); return () => setDockOccupant('explorer', false); }); - const dockVisible = $derived($visibleDockKey === 'explorer'); + // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant + // report above is untouched, so the strip comes back with its tabs intact) + const dockVisible = $derived($visibleDockKey === 'explorer' && !$dockMinimized); // tab-grouped windows share one size: show the group's rect so a resize on any // member updates every tab, not just the active one. @@ -235,7 +266,7 @@ const effW = $derived(myGroup ? myGroup.rect.width : winW); const effH = $derived(myGroup ? myGroup.rect.height : winH); - // --- docked: top-edge resize (Flow pattern) --- + // --- docked: top-edge resize (shared dock height, persisted by the store) --- let resizing = $state(false); function startResize(e: any) { resizing = true; @@ -244,13 +275,12 @@ } function doResize(e: any) { if (!resizing) return; - height = clampH(height - e.movementY); + dockHeight.update((h) => clampH(h - e.movementY)); } function endResize(e: any) { if (!resizing) return; resizing = false; e.currentTarget.releasePointerCapture?.(e.pointerId); - localStorage.setItem('explorerHeight', String(height)); } // --- undocked: corner resize --- @@ -4543,7 +4573,7 @@ id="explorer-list" transition:fly={{ y: 300, duration: 200 }} class="fixed inset-x-0 bottom-0 bg-white p-2 dark:bg-gray-800 {dockVisible ? '' : 'hidden'}" - style="z-index: var(--z-bottom); height: {height}px; border-top: 1px solid rgb(55 65 81 / 0.6)" + style="z-index: var(--z-bottom); height: {$dockHeight}px; border-top: 1px solid rgb(55 65 81 / 0.6)" ondragover={(e) => { if (canAccept(e)) return; e.preventDefault(); @@ -4554,16 +4584,21 @@ role="region" >
+
- + setDocked(false)}>⧉
-
+
{@render content()}
@@ -4590,8 +4625,9 @@ id="explorer-window" class="ui-panel fixed flex flex-col overflow-hidden" use:dragWindow={{ key: 'explorerWin', defaultRect: { left: 160, top: 120 } }} - use:focusStack + use:focusStack={'explorer'} use:tabbable={{ key: 'explorer', title: 'Explorer', openStore: explorerClose, isOpen: (v) => !v, close: () => explorerClose.set(true) }} + use:bottomDockable={{ key: 'explorer' }} use:dockable={{ key: 'explorer' }} style="z-index: var(--z-window)" style:width="{effW}px" @@ -4607,20 +4643,36 @@ >
- - {@render filterChip()} - {@render viewChip()} - {@render storageChip()} - {@render identityChip()} + +
+ + {@render filterChip()} + {@render viewChip()} + {@render storageChip()} + {@render identityChip()} +
- + +
{@render content()} diff --git a/src/components/editors/FlowCode.svelte b/src/components/editors/FlowCode.svelte index 7b7efed2..8d34366f 100644 --- a/src/components/editors/FlowCode.svelte +++ b/src/components/editors/FlowCode.svelte @@ -3,6 +3,7 @@ // tab in the bottom dock (Apply + Reload buttons in its toolbar); UNDOCKED mode is a // floating, resizable window. Apply parses the text and REPLACES the graph locally + // broadcasts so peers converge. + import { untrack } from 'svelte'; import { get } from 'svelte/store'; import CodeEditor from './CodeEditor.svelte'; import { flowNodes, flowEdges } from '../../stores/flowStore'; @@ -12,7 +13,8 @@ import { dragWindow } from '$lib/dragWindow'; import { focusStack } from '$lib/windowFocus'; import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; - import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock'; + import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm } from '$lib/bottomDock'; + import { bottomDockable } from '$lib/bottomDockDrop'; let text = $state(''); let error = $state(''); @@ -30,9 +32,23 @@ if (v) activateDock('flowcode'); } + // W5: consume the shared dock-mode arm — the tab strip's right-click menu asks + // through it (the Explorer has had this exact effect since 4b). `docked` is read + // from localStorage ONCE at mount, so writing that flag from outside is inert; + // `setDocked` owns the mode and is what has to run. Cleared as it is acted on. + $effect(() => { + const arm = $dockModeArm; + if (!arm || arm.key !== 'flowcode') return; + dockModeArm.set(null); + untrack(() => { + if (arm.docked !== docked) setDocked(arm.docked); + flowCodeClose.set(false); + }); + }); + // when tab-grouped, ALL members share one size — display the group's rect so a // resize on any member shows on every tab (not just the active one). - const myGroup = $derived($tabGroups.find((g) => g.members.includes('flowCode')) ?? null); + const myGroup = $derived($tabGroups.find((g) => g.members.includes('flowcode')) ?? null); const effW = $derived(myGroup ? myGroup.rect.width : winW); const effH = $derived(myGroup ? myGroup.rect.height : winH); @@ -55,7 +71,9 @@ setDockOccupant('flowcode', !$flowCodeClose && docked, $dockHeight); return () => setDockOccupant('flowcode', false); }); - const dockVisible = $derived($visibleDockKey === 'flowcode'); + // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant + // report above is untouched, so the strip comes back with its tabs intact) + const dockVisible = $derived($visibleDockKey === 'flowcode' && !$dockMinimized); function apply() { let parsed; @@ -104,7 +122,7 @@ const baseH = myGroup ? myGroup.rect.height : winH; winW = Math.min(Math.max(320, baseW + e.movementX), window.innerWidth - 8); winH = Math.min(Math.max(240, baseH + e.movementY), window.innerHeight); - resizeGroup('flowCode', winW, winH); // if grouped, resize the whole group (no-op otherwise) + resizeGroup('flowcode', winW, winH); // if grouped, resize the whole group (no-op otherwise) } function endWinResize(/** @type {any} */ e) { if (!winResizing) return; @@ -145,7 +163,7 @@ style="z-index: var(--z-bottom); height: {$dockHeight}px; border-top: 1px solid rgb(55 65 81 / 0.6)" >
{:else} +
!v, close: () => flowCodeClose.set(true) }} + use:focusStack={'flowcode'} + use:tabbable={{ key: 'flowcode', title: 'Flow Code', openStore: flowCodeClose, isOpen: (v) => !v, close: () => flowCodeClose.set(true) }} + use:bottomDockable={{ key: 'flowcode' }} style="z-index: var(--z-window); max-width: 96vw; max-height: 85vh" style:width="{effW}px" style:height="{effH}px" diff --git a/src/components/editors/HudEditor.svelte b/src/components/editors/HudEditor.svelte index 94d01bd5..86de22fd 100644 --- a/src/components/editors/HudEditor.svelte +++ b/src/components/editors/HudEditor.svelte @@ -66,7 +66,8 @@ import { focusStack } from '$lib/windowFocus'; import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; - import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock'; + import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm } from '$lib/bottomDock'; + import { bottomDockable } from '$lib/bottomDockDrop'; // 21-D5: WHICH document is being authored. `hudDocs` was already keyed // `'scene' | objectUuid`, so "attach this HUD to a camera" is simply authoring the @@ -129,6 +130,20 @@ localStorage.setItem('hudDocked', String(v)); if (v) activateDock('hud'); } + + // W5: consume the shared dock-mode arm — the tab strip's right-click menu asks + // through it (the Explorer has had this exact effect since 4b). `docked` is read + // from localStorage ONCE at mount, so writing that flag from outside is inert; + // `setDocked` owns the mode and is what has to run. Cleared as it is acted on. + $effect(() => { + const arm = $dockModeArm; + if (!arm || arm.key !== 'hud') return; + dockModeArm.set(null); + untrack(() => { + if (arm.docked !== docked) setDocked(arm.docked); + hudEditorClose.set(false); + }); + }); const myGroup = $derived($tabGroups.find((g) => g.members.includes('hud')) ?? null); const effW = $derived(myGroup ? myGroup.rect.width : winW); const effH = $derived(myGroup ? myGroup.rect.height : winH); @@ -136,7 +151,9 @@ setDockOccupant('hud', !$hudEditorClose && docked, $dockHeight); return () => setDockOccupant('hud', false); }); - const dockVisible = $derived($visibleDockKey === 'hud'); + // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant + // report above is untouched, so the strip comes back with its tabs intact) + const dockVisible = $derived($visibleDockKey === 'hud' && !$dockMinimized); // While the editor is open the artboard shows the screen being EDITED, so the runtime // layer is pointed at it too — otherwise you would lay out one screen and watch @@ -1410,7 +1427,7 @@ >
!v, close: () => hudEditorClose.set(true) }} + use:bottomDockable={{ key: 'hud' }} style="z-index: var(--z-window); max-width: 96vw; max-height: 88vh" style:width="{effW}px" style:height="{effH}px" diff --git a/src/components/editors/ShaderEditor.svelte b/src/components/editors/ShaderEditor.svelte index 753d1670..6b398bf6 100644 --- a/src/components/editors/ShaderEditor.svelte +++ b/src/components/editors/ShaderEditor.svelte @@ -13,6 +13,7 @@ // `shaderGraphs` is the Nodes.svelte shape — SvelteFlow 1.x binds PLAIN $state.raw // arrays, not stores, so the two are mirrored both ways behind a re-entrancy guard. import { untrack } from 'svelte'; + import { get } from 'svelte/store'; import { Info, Settings, Trash2 } from '@lucide/svelte'; import { SvelteFlow, @@ -26,7 +27,7 @@ import '@xyflow/svelte/dist/style.css'; import '../../styles/flow.css'; import { selectedObjects, objectsGroup } from '../../stores/sceneStore'; - import { shaderEditorClose, showToast } from '../../stores/appStore.js'; + import { shaderEditorClose, showToast, mobileUndockAllowed } from '../../stores/appStore.js'; import { shaderGraphs, shaderErrors, @@ -38,7 +39,19 @@ } from '$lib/shaderGraph'; import { beginShaderGesture, endShaderGesture } from '$lib/shaderSync'; import { shaderNodeDefs, shaderNodeDef, SURFACE_NODE } from '$lib/shaderCatalog'; - import { setDockOccupant, dockHeight, visibleDockKey } from '$lib/bottomDock'; + import { + setDockOccupant, + dockHeight, + visibleDockKey, + dockMinimized, + activateDock, + dockModeArm + } from '$lib/bottomDock'; + import { bottomDockable } from '$lib/bottomDockDrop'; + import { dragWindow } from '$lib/dragWindow'; + import { focusStack } from '$lib/windowFocus'; + import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; + import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; import DockTabs from '../DockTabs.svelte'; import ContextMenu from '../ContextMenu.svelte'; import ShaderNode from './nodes/ShaderNode.svelte'; @@ -348,41 +361,171 @@ }); } - // dock presence + // ---- docked vs floating ----------------------------------------------------- + // UvEditor's split verbatim. This editor was the ONE dock view with no floating + // mode at all — no `docked` flag, no window chrome, and an occupancy report that + // never asked the question — which two other modules then had to code around + // (`panelToggles`' `dockOnly` and `dockMenu` withholding Undock). Both of those + // exceptions are gone with this block; the seventh view now behaves like its six + // siblings and nothing has to know it is special. + let docked = $state(true); + const WIN_MIN = { minW: 380, minH: 280 }; + const WIN_DEFAULT = { w: 720, h: 480 }; + let winW = $state(720); + let winH = $state(480); + if (typeof localStorage !== 'undefined') { + docked = localStorage.getItem('shaderDocked') !== 'false'; + // 18-B: a size saved on a bigger screen must not come back oversized. Fitted + // BEFORE the assignment so nothing reads $state during init. + const savedWin = clampWinSize( + parseInt(localStorage.getItem('shaderWinW') ?? '720') || 720, + parseInt(localStorage.getItem('shaderWinH') ?? '480') || 480, + WIN_MIN + ); + winW = savedWin.w; + winH = savedWin.h; + } + // touch / limited-width: keep the editor docked (no room to float; undock hidden), + // unless the user opted into undocking on touch (Settings > Allow undocking) + if ( + typeof window !== 'undefined' && + window.matchMedia?.('(pointer: coarse)').matches && + !get(mobileUndockAllowed) + ) + docked = true; + + function setDocked(/** @type {boolean} */ v) { + docked = v; + localStorage.setItem('shaderDocked', String(v)); + if (v) activateDock('shader'); // re-docking makes it the visible tab + } + + // W5: consume the shared dock-mode arm — the tab strip's right-click menu and its + // drag-a-tab-out both ask through it, and `docked` above is read from localStorage + // exactly ONCE at mount, so writing that flag from outside is inert; `setDocked` + // owns the mode and is what has to run. Cleared as it is acted on. THIS is the seam + // that makes "Undock" reach this view at all — the row was withheld until now + // precisely because there was nothing here to consume the ask. + $effect(() => { + const arm = $dockModeArm; + if (!arm || arm.key !== 'shader') return; + dockModeArm.set(null); + untrack(() => { + if (arm.docked !== docked) setDocked(arm.docked); + shaderEditorClose.set(false); + }); + }); + + const myGroup = $derived($tabGroups.find((g) => g.members.includes('shader')) ?? null); + const effW = $derived(myGroup ? myGroup.rect.width : winW); + const effH = $derived(myGroup ? myGroup.rect.height : winH); + + // dock presence — `docked` is part of the question now: a floating Shader editor is + // open and is NOT a dock tab, and reporting it as one leaves a phantom in the strip. $effect(() => { - setDockOccupant('shader', !$shaderEditorClose, $dockHeight); + setDockOccupant('shader', !$shaderEditorClose && docked, $dockHeight); return () => setDockOccupant('shader', false); }); - const dockVisible = $derived($visibleDockKey === 'shader'); + // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant + // report above is untouched, so the strip comes back with its tabs intact) + const dockVisible = $derived($visibleDockKey === 'shader' && !$dockMinimized); + + // W6: the top-edge dock resize, which this panel ALONE never had — its six siblings + // have carried it since the dock existed, so the shared height silently froze + // whenever the Shader editor was the tab on screen. Same handlers, same shared + // `dockHeight`, same clamp (FlowCode is the reference), so the seven behave + // identically. It now pairs with a corner grip in the floating mode, exactly as the + // siblings do — the top edge sizes the shared dock, the corner sizes this window. + const clampH = (/** @type {number} */ h) => + Math.min(Math.max(h || 320, 200), Math.round(window.innerHeight * 0.8)); + let resizing = $state(false); + let winResizing = $state(false); + function startResize(/** @type {any} */ e) { + resizing = true; + e.currentTarget.setPointerCapture(e.pointerId); + e.preventDefault(); + } + function doResize(/** @type {any} */ e) { + if (resizing) dockHeight.update((h) => clampH(h - e.movementY)); + } + function endResize(/** @type {any} */ e) { + if (resizing) { + resizing = false; + e.currentTarget.releasePointerCapture?.(e.pointerId); + } + } + function startWinResize(/** @type {any} */ e) { + winResizing = true; + e.currentTarget.setPointerCapture(e.pointerId); + e.preventDefault(); + e.stopPropagation(); + } + function doWinResize(/** @type {any} */ e) { + if (!winResizing) return; + const baseW = myGroup ? myGroup.rect.width : winW; + const baseH = myGroup ? myGroup.rect.height : winH; + const at = anchorOf(e.currentTarget.parentElement); + const fit = clampResize(baseW + e.movementX, baseH + e.movementY, at.left, at.top, WIN_MIN); + winW = fit.w; + winH = fit.h; + resizeGroup('shader', winW, winH); + } + function endWinResize(/** @type {any} */ e) { + if (!winResizing) return; + winResizing = false; + e.currentTarget.releasePointerCapture?.(e.pointerId); + saveWinSize(); + } + function saveWinSize() { + localStorage.setItem('shaderWinW', String(winW)); + localStorage.setItem('shaderWinH', String(winH)); + } + function resetWinSize() { + const fit = clampWinSize(WIN_DEFAULT.w, WIN_DEFAULT.h, WIN_MIN); + winW = fit.w; + winH = fit.h; + resizeGroup('shader', winW, winH); + saveWinSize(); + } + // 18-B: a window bigger than the screen can never be shrunk again, so re-fit on + // every viewport change rather than only at load + function fitToViewport() { + const fit = clampWinSize(winW, winH, WIN_MIN); + if (fit.w === winW && fit.h === winH) return; + winW = fit.w; + winH = fit.h; + resizeGroup('shader', winW, winH); + } -{#if !$shaderEditorClose && dockVisible} -
-
- - {scopeLabel} -
- {#if doc} - - {/if} - -
-
+ + + +{#snippet actions()} + {#if doc} + + {/if} + +{/snippet} - {#if errors.length} +{#snippet body()} + {#if errors.length}
{#each errors as message, i (i)}
{message}
{/each}
@@ -615,6 +758,83 @@
{/if}
+{/snippet} + + +{#if !$shaderEditorClose && docked && dockVisible} +
+ + +
+
+ + {scopeLabel} +
+ + {@render actions()} +
+
+ {@render body()} +
+{:else if !$shaderEditorClose && !docked} +
!v, + close: () => shaderEditorClose.set(true) + }} + use:bottomDockable={{ key: 'shader' }} + style="z-index: var(--z-window); max-width: 96vw; max-height: 88vh" + style:width="{effW}px" + style:height="{effH}px" + > +
+ Shader editor + {scopeLabel} + + + {@render actions()} +
+ {@render body()} + +
{/if} @@ -664,9 +884,6 @@ flex: 1; min-height: 0; display: flex; - /* docked panels shrink by the Controls HUD footprint on folded screens, so the - canvas is never hidden behind it (the --dock-inset contract) */ - padding-bottom: var(--dock-inset, 0px); } .shader-side { flex: 0 0 148px; diff --git a/src/components/editors/UvEditor.svelte b/src/components/editors/UvEditor.svelte index cf953ea9..ef9a986f 100644 --- a/src/components/editors/UvEditor.svelte +++ b/src/components/editors/UvEditor.svelte @@ -31,6 +31,9 @@ } from '$lib/uvEditor'; // the timeline's gesture engine: snapshot, re-apply the total, commit or revert once import { createGesture } from '$lib/modalGrab'; + // W5: the BINDING for this editor's grab key lives in the shortcut registry (an + // `external` row), so Settings can move it; the key itself is answered here. + import { comboOf, bindingOf } from '$lib/shortcuts'; import ContextMenu from '../ContextMenu.svelte'; // read-only: the Edit Mesh pick is what scopes the UV view (UV5) import { faceEditSelectedTris, faceEditObject, triangleCount } from '$lib/faceEdit'; @@ -41,7 +44,8 @@ import { focusStack } from '$lib/windowFocus'; import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs'; import { clampWinSize, clampResize, anchorOf } from '$lib/windowSize'; - import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock'; + import { setDockOccupant, dockHeight, visibleDockKey, dockMinimized, activateDock, dockModeArm } from '$lib/bottomDock'; + import { bottomDockable } from '$lib/bottomDockDrop'; /** the armed transform modes, in 1/2/3 order */ const MODES = /** @type {['move'|'rotate'|'scale', string, string][]} */ ([ @@ -140,6 +144,20 @@ if (v) activateDock('uv'); } + // W5: consume the shared dock-mode arm — the tab strip's right-click menu asks + // through it (the Explorer has had this exact effect since 4b). `docked` is read + // from localStorage ONCE at mount, so writing that flag from outside is inert; + // `setDocked` owns the mode and is what has to run. Cleared as it is acted on. + $effect(() => { + const arm = $dockModeArm; + if (!arm || arm.key !== 'uv') return; + dockModeArm.set(null); + untrack(() => { + if (arm.docked !== docked) setDocked(arm.docked); + uvEditorClose.set(false); + }); + }); + const myGroup = $derived($tabGroups.find((g) => g.members.includes('uv')) ?? null); const effW = $derived(myGroup ? myGroup.rect.width : winW); const effH = $derived(myGroup ? myGroup.rect.height : winH); @@ -147,7 +165,9 @@ setDockOccupant('uv', !$uvEditorClose && docked, $dockHeight); return () => setDockOccupant('uv', false); }); - const dockVisible = $derived($visibleDockKey === 'uv'); + // W2: a MINIMIZED dock renders nothing while every tab stays open (the occupant + // report above is untouched, so the strip comes back with its tabs intact) + const dockVisible = $derived($visibleDockKey === 'uv' && !$dockMinimized); // Arming the brush opens the Tool panel, so its colour + size are reachable // without hunting for the tab (WindowShell's showSecondary is exactly this @@ -1019,6 +1039,18 @@ e.preventDefault(); e.stopPropagation(); }; + // W5: Blender's G arms Move — the same key the gizmo and the timeline take, and + // all three can have it because this handler runs in CAPTURE phase on the wrap + // and stops the event, so the global registry never sees the press (which is + // exactly what `scope: 'uv'` records over there). The COMBO is asked of the + // registry rather than written as a letter here, so rebinding the row in + // Settings really moves the key. Tested first, so the binding is authoritative + // whatever the user moves it onto. + if (comboOf(e) === bindingOf('uv.grab')) { + claim(); + armXform('move'); + return; + } if (!ctrl && !e.altKey && (e.key === '1' || e.key === '2' || e.key === '3')) { claim(); armXform(e.key === '1' ? 'move' : e.key === '2' ? 'rotate' : 'scale'); @@ -1896,7 +1928,7 @@ >
!v, close: () => uvEditorClose.set(true) }} + use:bottomDockable={{ key: 'uv' }} style="z-index: var(--z-window); max-width: 96vw; max-height: 88vh" style:width="{effW}px" style:height="{effH}px" diff --git a/src/components/menu/CameraPipWindow.svelte b/src/components/menu/CameraPipWindow.svelte index 23d983f4..f9b44f9b 100644 --- a/src/components/menu/CameraPipWindow.svelte +++ b/src/components/menu/CameraPipWindow.svelte @@ -19,6 +19,7 @@ autoPosition, clampPosition } from '$lib/cameraPip'; + import { viewportInset } from '$lib/bottomDock'; const object = $derived($pipTarget ? ($objectsGroup?.getObjectByProperty('uuid', $pipTarget) ?? null) : null); const size = $derived(object ? pipSize(object) : { w: 0, h: 0 }); @@ -36,10 +37,23 @@ panelWidth = !$inspectorClose && rect && rect.width < vw * 0.6 ? rect.width : 0; }); + /** + * W9: every position is measured against the CANVAS, not the window. The picture + * inside this frame is drawn by the renderer into `pipRect` read as canvas pixels + * (`glRect` counts y from the canvas BOTTOM), so a frame dragged into the dock band + * would produce a negative gl y and simply stop drawing — the clamp is what makes + * that unreachable, and the auto-park then clears the dock with no separate + * clearance argument. + * + * The canvas is anchored at the window's top-left (App.svelte insets only its + * bottom), so canvas coordinates and the `position: fixed` frame's own left/top are + * the same numbers; only the HEIGHT differs, which is exactly what the clamp needs. + */ + const canvasH = $derived(Math.max(0, vh - $viewportInset)); const position = $derived( $pipPosition - ? clampPosition($pipPosition, size, { width: vw, height: vh }) - : autoPosition(size, { width: vw, height: vh }, panelWidth) + ? clampPosition($pipPosition, size, { width: vw, height: canvasH }) + : autoPosition(size, { width: vw, height: canvasH }, panelWidth) ); // publish the rect the renderer draws into (null while hidden) @@ -83,7 +97,7 @@ clampPosition( { x: origin.x + (event.clientX - startX), y: origin.y + (event.clientY - startY) }, size, - { width: vw, height: vh } + { width: vw, height: canvasH } ) ); event.preventDefault(); diff --git a/src/components/menu/ColocationBadge.svelte b/src/components/menu/ColocationBadge.svelte index f3e93b95..d23e8a0b 100644 --- a/src/components/menu/ColocationBadge.svelte +++ b/src/components/menu/ColocationBadge.svelte @@ -21,7 +21,8 @@ .colocation-badge { position: fixed; left: 12px; - bottom: 12px; + /* rides above the bottom dock with the rest of the bottom chrome */ + bottom: calc(var(--bottom-inset, 0px) + 12px); z-index: var(--z-hud, 45); display: flex; align-items: center; diff --git a/src/components/menu/Controls.svelte b/src/components/menu/Controls.svelte index 81b6de4a..639e012c 100644 --- a/src/components/menu/Controls.svelte +++ b/src/components/menu/Controls.svelte @@ -1,8 +1,8 @@ - over the `visibleCells` roster, no longer flowbite's + `BottomNav` (whose inner grid column count must be a JIT literal, which a + customizable cell count cannot be). The class list is flowbite's own RESOLVED + output for `position="absolute" navType="application"` plus this component's + overrides, read off the rendered DOM — same border, surface, radius and 40px + height. `bottom-4` stays in it purely as the class the inline style overrides, + exactly as before. The cells are `w-10` literals now: the grid's content-sized + `fr` columns used to take their width from the spacer, and a flex row has to say + it out loud. --> + @@ -711,76 +1728,69 @@ - -

{ - checkPlay(); - }} -> - - {#if willEnterAR} - - - {:else if willEnterXR} -

- -