Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
18 commits
Select commit Hold shift + click to select a range
c392d9b
[feat] animation window v1: local transform animator with easing graph
AlexZ005 Jul 18, 2026
b1f1325
[fix] settings drawer: sticky header + narrow-width inset above controls
AlexZ005 Jul 19, 2026
e939439
[fix] node palette: touch drag-to-place a node
AlexZ005 Jul 19, 2026
fc09b95
[fix] settings drawer sits above the bottom-right mic button (z-axis)
AlexZ005 Jul 19, 2026
5651aaa
[feat] tabbed bottom dock: Flow Code + Animation dock as Flow tabs
AlexZ005 Jul 19, 2026
aaea849
[fix] floating windows: resize + tab-group move no longer jump to top…
AlexZ005 Jul 19, 2026
d204130
[fix] resizing a grouped (tabbed) window resizes all of its tabs
AlexZ005 Jul 19, 2026
15fb297
[fix] floating Node editor no longer closes a docked Explorer
AlexZ005 Jul 19, 2026
faf4434
[fix] Controls icons stay lit for floating panels; buttons are a clea…
AlexZ005 Jul 19, 2026
b3e0844
[fix] Flow Code Apply/Reload buttons stay visible when tab-grouped
AlexZ005 Jul 19, 2026
245d9b1
[fix] Object List: group-aware geometry + raise-to-front on toolbar c…
AlexZ005 Jul 19, 2026
634444d
[fix] Flow Code Apply/Reload: header when floating/docked, content ro…
AlexZ005 Jul 19, 2026
0f005e5
[fix] docked Animation dropdowns use the panel light text colour
AlexZ005 Jul 19, 2026
b07c4e0
[fix] Node editor button toggles the whole docked flow group; leaves …
AlexZ005 Jul 19, 2026
a59376e
[fix] Node editor button hides only DOCKED flow tabs, not floating Fl…
AlexZ005 Jul 19, 2026
b082072
[fix] undocked Node editor button toggles only its floating window
AlexZ005 Jul 19, 2026
1dddc14
[fix] remove Object List floating-window size cap (match other windows)
AlexZ005 Jul 19, 2026
ac2b4a9
[fix] tab-group strip tracks its z-order so a raised window covers it
AlexZ005 Jul 19, 2026
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
9 changes: 6 additions & 3 deletions src/App.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@
import Menu from './components/Menu.svelte'
import Flow from './components/Flow.svelte'
import FlowCode from './components/editors/FlowCode.svelte'
import AnimationWindow from './components/editors/AnimationWindow.svelte'
import Explorer from './components/editors/Explorer.svelte'
import TextEditorWindow from './components/editors/TextEditorWindow.svelte'
import ImagePreviewWindow from './components/editors/ImagePreviewWindow.svelte'
Expand Down Expand Up @@ -106,9 +107,10 @@
import('./lib/packs'),
import('./lib/customNodes'),
import('./lib/nodesHandler'),
import('./lib/objectMenu')
]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, objectMenuLib]) => {
window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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, objectMenu: objectMenuLib }
import('./lib/objectMenu'),
import('./lib/animationPreview')
]).then(([sceneStore, appStore, flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawModeLib, pathCapture, lockControl, prefabsLib, physics, userModulesLib, environmentLib, animatedImports, fileHandler, fileWindowsLib, sceneBounds, cameraClip, ping, sessionsLib, geometryEdit, lightParams, themesLib, vrRadialMenu, vrPaletteLib, vrWindowPosesLib, vrKeyboardLib, faceEditLib, avatarModelLib, explorerLib, bottomDock, explorerDrop, assetShare, soundRuntime, dungeonPlay, sceneAssetsLib, THREE, GLTFExporterModule, snappingLib, flowSocketsLib, networkQualityLib, packsLib, customNodesLib, nodesHandlerLib, objectMenuLib, animationPreviewLib]) => {
window.__stores = { ...sceneStore, ...appStore, ...flowStore, meshEdit, vrControls, autosave, voiceChat, annotationsHandler, flowRuntime, history, materialsHandler, objectActions, commandsHandler, moduleSDK, drawMode: drawModeLib, pathCapture, lockControl, prefabs: prefabsLib, physics, userModules: userModulesLib, environment: environmentLib, animatedImports, fileHandler, fileWindows: fileWindowsLib, sceneBounds, cameraClip, ping, sessions: sessionsLib, geometryEdit, lightParams, themes: themesLib, vrRadialMenu, vrPalette: vrPaletteLib, vrWindowPoses: vrWindowPosesLib, vrKeyboard: vrKeyboardLib, faceEdit: faceEditLib, 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, objectMenu: objectMenuLib, animationPreview: animationPreviewLib }
})
}
})
Expand Down Expand Up @@ -143,6 +145,7 @@
{#if !$isLocked}
<Flow />
<FlowCode />
<AnimationWindow />
<Explorer />
<TextEditorWindow />
<ImagePreviewWindow />
Expand Down
40 changes: 40 additions & 0 deletions src/components/DockTabs.svelte
Original file line number Diff line number Diff line change
@@ -0,0 +1,40 @@
<script>
// Notebook tab strip for the bottom dock's Flow-family (Node editor / Flow Code /
// Animation). Rendered at the top edge of each Flow-family docked panel; since only
// the visible panel renders, only one strip shows. Clicking a tab activates it; the
// "+" opens Flow Code / Animation (docked). The Explorer is NOT here (it is a
// separate, exclusive panel with no tabs).
import { flowTabs, bottomDockActive, activateDock } from '$lib/bottomDock';
import { flowCodeClose, animationClose } from '../stores/appStore.js';
import ContextMenu from './ContextMenu.svelte';

let addMenu = $state(/** @type {{x:number,y:number}|null} */ (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'); } }
];
function openAdd(/** @type {MouseEvent} */ e) {
const r = /** @type {HTMLElement} */ (e.currentTarget).getBoundingClientRect();
addMenu = { x: r.left, y: r.bottom + 4 };
}
</script>

<div class="absolute -top-6 left-3 z-20 flex gap-0.5">
{#each $flowTabs as tab (tab.key)}
<button
class="tab-note px-4 pb-0.5 pt-1 text-xs font-semibold {$bottomDockActive === tab.key
? 'bg-gray-700 text-white'
: 'bg-gray-900/70 text-gray-400 hover:text-gray-200'}"
onclick={() => activateDock(tab.key)}>{tab.title}</button
>
{/each}
<button
class="tab-note bg-gray-900/70 px-3 pb-0.5 pt-1 text-xs font-semibold text-gray-300 hover:text-white"
title="Add a view (Flow Code, Animation)"
onclick={openAdd}>+</button
>
</div>

{#if addMenu}
<ContextMenu x={addMenu.x} y={addMenu.y} items={addItems} on:close={() => (addMenu = null)} />
{/if}
87 changes: 41 additions & 46 deletions src/components/Flow.svelte
Original file line number Diff line number Diff line change
@@ -1,38 +1,34 @@
<script lang="ts">
// Flow host (phase 68): a bottom-docked panel with top-edge drag-resize, or
// an undocked floating window (same-context portal — stores, peers and VR
// keep working because nothing leaves the page). Both states persist.
import { flowGraphClose, flowCodeClose } from '../stores/appStore.js';
// 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 } from '../stores/appStore.js';
import { onMount } from 'svelte';
import { SvelteFlowProvider } from '@xyflow/svelte';
import ContextMenu from './ContextMenu.svelte';
import Nodes from './editors/Nodes.svelte';
import ScriptPanel from './editors/ScriptPanel.svelte';
import NodeDesigner from './editors/NodeDesigner.svelte';
import DockTabs from './DockTabs.svelte';
import { dragWindow } from '$lib/dragWindow';
import { focusStack } from '$lib/windowFocus';
import { tabbable } from '$lib/windowTabs';
import { tabbable, resizeGroup, tabGroups } from '$lib/windowTabs';
import { dockable } from '$lib/docking';
import { bottomDockActive, dockShared, setDockOccupant } from '$lib/bottomDock';
import { setDockOccupant, dockHeight, visibleDockKey, activateDock } from '$lib/bottomDock';
import { fly } from 'svelte/transition';

const clampH = (h: number) =>
Math.min(Math.max(h || 320, 200), Math.round(window.innerHeight * 0.8));

let height = $state(320);
const clampH = (h: number) => Math.min(Math.max(h || 320, 200), Math.round(window.innerHeight * 0.8));
let docked = $state(true);
let winW = $state(760);
let winH = $state(480);
// keep the floating window within the viewport — a persisted 760px width used to
// push the header's Dock/X buttons off-screen on a narrow (mobile) viewport, and
// there was no re-clamp on load or window resize
// keep the floating window within the viewport (a persisted wide rect used to push
// the header buttons off a narrow screen)
function clampWin() {
if (typeof window === 'undefined') return;
winW = Math.min(winW, window.innerWidth - 8);
winH = Math.min(winH, Math.round(window.innerHeight * 0.9));
}
if (typeof localStorage !== 'undefined') {
height = clampH(parseInt(localStorage.getItem('flowHeight') ?? '320'));
docked = localStorage.getItem('flowDocked') !== 'false';
winW = parseInt(localStorage.getItem('flowWinW') ?? '760') || 760;
winH = parseInt(localStorage.getItem('flowWinH') ?? '480') || 480;
Expand All @@ -48,30 +44,36 @@
function setDocked(v: boolean) {
docked = v;
localStorage.setItem('flowDocked', String(v));
if (v) activateDock('flow'); // re-docking makes it the visible tab
}

// Flow tab "+": add another view. Flow Code opens now; Animation lands next batch.
// Added views open as their own floating windows (tabbable — drag to group with
// Flow, tear a tab off to detach again).
// 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);
const effW = $derived(myGroup ? myGroup.rect.width : winW);
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.
let addMenu: { x: number; y: number } | null = $state(null);
const addItems = [
{ label: '+ Flow Code', tooltip: 'Edit the graph as JSON', action: () => flowCodeClose.set(false) },
{ label: 'Animation (soon)', disabled: true }
{ 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'); } }
];
function openAddMenu(e: MouseEvent) {
const r = (e.currentTarget as HTMLElement).getBoundingClientRect();
addMenu = { x: r.left, y: r.bottom + 4 };
}

// tabbed dock coexistence with the Explorer (95); height feeds the
// --bottom-inset so side UI ends above the dock (105)
// report docked+open (+ the shared dock height) so the dock knows this tab exists;
// only the visible Flow-family tab actually renders
$effect(() => {
setDockOccupant('flow', !$flowGraphClose && docked, height);
setDockOccupant('flow', !$flowGraphClose && docked, $dockHeight);
return () => setDockOccupant('flow', false);
});
const dockVisible = $derived(!$dockShared || $bottomDockActive === 'flow');
const dockVisible = $derived($visibleDockKey === 'flow');

// --- docked: top-edge resize (min 200px, max 80vh, persisted) ---
// --- docked: top-edge resize (shared dock height, persisted by the store) ---
let resizing = $state(false);
function startResize(e: any) {
resizing = true;
Expand All @@ -80,13 +82,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('flowHeight', String(height));
}

// --- undocked: corner resize ---
Expand All @@ -99,8 +100,11 @@
}
function doWinResize(e: any) {
if (!winResizing) return;
winW = Math.min(Math.max(280, winW + e.movementX), window.innerWidth - 8);
winH = Math.min(Math.max(240, winH + e.movementY), window.innerHeight);
const baseW = myGroup ? myGroup.rect.width : winW;
const baseH = myGroup ? myGroup.rect.height : winH;
winW = Math.min(Math.max(280, baseW + e.movementX), window.innerWidth - 8);
winH = Math.min(Math.max(240, baseH + e.movementY), window.innerHeight);
resizeGroup('flow', winW, winH); // if grouped, resize the whole group
}
function endWinResize(e: any) {
if (!winResizing) return;
Expand All @@ -117,9 +121,9 @@
id="flow-list"
transition:fly={{ y: 320, 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)"
>
<!-- top-edge resize hot zone: cursor instant, cue after a hover delay (82) -->
<!-- top-edge resize hot zone -->
<div
class="resize-cue absolute -top-1 left-0 right-0 z-10 h-2 cursor-ns-resize"
style="touch-action: none"
Expand All @@ -128,23 +132,14 @@
onpointermove={doResize}
onpointerup={endResize}
></div>
<!-- Flow notebook tab + "add view" (Explorer is toolbar-controlled now, not
a tab here); the tabs sit ON the dock's top edge -->
<div class="absolute -top-6 left-3 z-20 flex gap-0.5">
<span class="tab-note bg-gray-700 px-4 pb-0.5 pt-1 text-xs font-semibold text-white">Node editor</span>
<button
class="tab-note bg-gray-900/70 px-3 pb-0.5 pt-1 text-xs font-semibold text-gray-300 hover:text-white"
title="Add a view (Flow Code, …)"
onclick={openAddMenu}>+</button
>
</div>
<DockTabs />
<button
id="flow-undock"
class="ui-button-quiet absolute right-2 top-2 z-10"
title="Undock into a floating window"
onclick={() => setDocked(false)}>⧉</button
>
<div style="height: {height - 16}px">
<div style="height: {$dockHeight - 16}px">
<SvelteFlowProvider>
<Nodes />
</SvelteFlowProvider>
Expand All @@ -158,15 +153,15 @@
use:focusStack
use:tabbable={{ key: 'flow', title: 'Node editor', openStore: flowGraphClose, isOpen: (v) => !v, close: () => flowGraphClose.set(true) }}
use:dockable={{ key: 'flow' }}
style="z-index: var(--z-window); width: {winW}px; height: {winH}px"
style="z-index: var(--z-window)"
style:width="{effW}px"
style:height="{effH}px"
>
<div class="ui-panel-header move-handle shrink-0 cursor-move select-none py-1.5">
<span>Node editor</span>
<span class="flex-1"></span>
<button id="flow-add-view" class="ui-button-quiet" title="Add a view (Flow Code, …)" onclick={openAddMenu}>+</button>
<button id="flow-dock" class="ui-button-quiet" title="Dock to the bottom" onclick={() => setDocked(true)}>
⇩ Dock
</button>
<button id="flow-add-view" class="ui-button-quiet" title="Add a view (Flow Code, Animation)" onclick={openAddMenu}>+</button>
<button id="flow-dock" class="ui-button-quiet" title="Dock to the bottom" onclick={() => setDocked(true)}>⇩ Dock</button>
<button class="ui-button-quiet" title="Close (N)" onclick={() => flowGraphClose.set(true)}>✕</button>
</div>
<div class="min-h-0 flex-1">
Expand Down
2 changes: 2 additions & 0 deletions src/components/Scene.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -14,6 +14,7 @@
import { moduleClickHandlers, moduleInteractiveGroups } from '$lib/moduleSDK';
import { updateSpatialAudio } from '$lib/voiceChat';
import { tickAnimatedMixers } from '$lib/animatedImports';
import { tickAnimationPreview } from '$lib/animationPreview';
import { drawMode, strokePointFromRay, endStroke, setDrawScene } from '$lib/drawMode';
import { capturePathClick } from '$lib/pathCapture';
import { surfaceSnap, dropToSurface } from '$lib/snapping';
Expand Down Expand Up @@ -245,6 +246,7 @@
updateVRControls(); // also manages ray/hover visibility outside sessions
updateSpatialAudio(camera.current, scene); // voices follow avatars (throttled)
tickAnimatedMixers(); // imported clips run on the synced clock
tickAnimationPreview(); // Animation window: local transform preview (not synced)
tickMeshEdit(); // vertex handles follow the object if it moves (119)
updateLightHelpers();
if (!renderer.xr.isPresenting) updateEditorNavigation(delta, camera.current, $orbitControls);
Expand Down
Loading