Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
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
2 changes: 1 addition & 1 deletion src/components/editors/AnimationWindow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -1781,7 +1781,7 @@
min={0.1}
max={8}
value={speed}
onchange={(/** @type {number} */ v) => target && setSpeed(target.uuid, v || 1)}
onchange={(/** @type {number} */ v) => target && setSpeed(target.uuid, v || 1, { record: true })}
/>
</label>
<label
Expand Down
45 changes: 39 additions & 6 deletions src/components/editors/ShaderEditor.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -175,17 +175,49 @@
let edges = $state.raw([]);
let pushing = false;

// 37 R26: xyflow's VIEW state (a node's `selected`, `dragging`, `measured`…) is this
// screen's, never the document's — the nodesHandler.serializeNode rule. Writing it in made
// a click that selects a node a replicated, undoable graph edit: scrubbing a vector on an
// unselected node recorded TWO undo steps (the selection, then the scrub), and Ctrl+Z
// undid the selection. Selection is kept LOCAL and carried across a document rebuild.
const VIEW_KEYS = ['selected', 'dragging', 'measured', 'resizing'];
let viewKey = '';
/** @param {any} item */
function docShape(item) {
const out = { ...item };
for (const k of VIEW_KEYS) delete out[k];
return out;
}

$effect(() => {
const next = doc;
const key = scope;
untrack(() => {
pushing = true;
// another document: nothing in it is selected yet
const sameDoc = viewKey === key;
viewKey = key;
// this screen's view of each node (selection, xyflow's measured size) survives the
// rebuild; the document's copy of those fields (an older save wrote them) does not
/** @type {Map<string, any>} */
const view = new Map();
if (sameDoc)
for (const n of nodes) {
/** @type {any} */
const v = {};
for (const k of VIEW_KEYS) if (n[k] !== undefined) v[k] = n[k];
view.set(n.id, v);
}
const wasEdgeSelected = new Set((sameDoc ? edges : []).filter((/** @type {any} */ e) => e.selected).map((/** @type {any} */ e) => e.id));
nodes = (next?.nodes ?? []).map((/** @type {any} */ n) => ({
...n,
...docShape(n),
...(view.get(n.id) ?? {}),
// the card needs to know which document to write its params into
data: { ...(n.data ?? {}), __graphKey: key }
}));
edges = next?.edges ?? [];
edges = (next?.edges ?? []).map((/** @type {any} */ e) =>
wasEdgeSelected.has(e.id) ? { ...e, selected: true } : docShape(e)
);
pushing = false;
});
});
Expand All @@ -197,13 +229,14 @@
if (pushing || !doc) return;
const stripped = localNodes.map((/** @type {any} */ n) => {
const { __graphKey, ...rest } = n.data ?? {};
return { ...n, data: rest };
return docShape({ ...n, data: rest });
});
const strippedEdges = localEdges.map(docShape);
if (
JSON.stringify(stripped) !== JSON.stringify(doc.nodes) ||
JSON.stringify(localEdges) !== JSON.stringify(doc.edges)
JSON.stringify(stripped) !== JSON.stringify((doc.nodes ?? []).map(docShape)) ||
JSON.stringify(strippedEdges) !== JSON.stringify((doc.edges ?? []).map(docShape))
)
setShaderGraphFor(scope, { nodes: stripped, edges: localEdges });
setShaderGraphFor(scope, { nodes: stripped, edges: strippedEdges });
});
});

Expand Down
65 changes: 50 additions & 15 deletions src/components/menu/Inspector.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@
setObjectsTexture
} from '$lib/materialsHandler';
import { recordEntry, beginHistoryBatch, endHistoryBatch, recordTransformSet } from '$lib/history';
import { currentHistoryGesture, withHistoryGesture } from '$lib/historyGesture';
import { deviceOf, deviceSpec, isDeviceObject, setDeviceFor, previewDeviceParams } from '$lib/audioDevices';
import { MUSIC_TOOLBOX_ID, musicToolboxPick } from '$lib/musicToolbox';
import { openModuleToolbox, moduleToolboxes } from '$lib/moduleToolboxes';
Expand Down Expand Up @@ -1097,17 +1098,34 @@
// same one a multi-gizmo drag records, so replay + replication come free.
// Typed transforms recorded nothing at all before; with a selection they must,
// because setting an absolute value collapses the whole set onto one plane.
// 37 R26: the seal also carries the field's history GESTURE (one scrub, or one typing
// session — $lib/historyGesture). Typing slower than the 500ms seal records twice, and
// the second entry folds into the first; a NEW gesture seals the pending one first, so
// two gestures never share an entry.
/** @type {Map<string, any>|null} */
let xformGestureStart = null;
/** @type {object|null} */
let xformGestureToken = null;
/** @type {any} */
let xformGestureTimer;
function trackTransformGesture() {
const token = currentHistoryGesture();
if (xformGestureStart != null && token !== xformGestureToken) {
clearTimeout(xformGestureTimer);
sealTransformGesture();
}
if (xformGestureStart == null) {
xformGestureStart = new Map();
xformGestureToken = token;
for (const object of insTargets) xformGestureStart.set(object.uuid, poseOf(object));
}
clearTimeout(xformGestureTimer);
xformGestureTimer = setTimeout(() => {
xformGestureTimer = setTimeout(sealTransformGesture, 500);
}
function sealTransformGesture() {
const token = xformGestureToken;
xformGestureToken = null;
withHistoryGesture(token, () => {
const keepOrigin = true; // a hand-placed origin survives the re-seat
const befores = xformGestureStart;
xformGestureStart = null;
Expand Down Expand Up @@ -1135,7 +1153,7 @@
keepOrigin
);
else if (items.length === 1 && originTarget && originSet) reseatPivot(); // 24-E3: the single-object pivot too
}, 500);
});
}

/**
Expand Down Expand Up @@ -1195,6 +1213,21 @@
$peers.send({ type: 'object', element: $selectedObject.toJSON(), override: true });
}

/**
* 37 R26: a light's own number (intensity, distance, angle…) — the fields recorded no
* undo at all. One step per scrub/typing session; the props kind's `light` key replays it.
* @param {string} key @param {number} value
*/
function setLightValue(key, value) {
const light = $selectedObject;
const before = light[key];
light[key] = value;
selectedObject.update((s) => s);
sendLightUpdate();
if (before !== value)
recordEntry({ kind: 'props', uuid: light.uuid, before: { light: { [key]: before } }, after: { light: { [key]: value } } });
}

/** Object flag → the whole selection. The checkbox/row has already written the
* PRIMARY (bind:checked), so the rest of the set is set to that same value and
* every member replicates its own message. No history kind covers these flags,
Expand Down Expand Up @@ -1310,11 +1343,21 @@
$peers.send({ type: 'name', name: $selectedObject.name, uuid: $selectedObject.uuid });
}

/** Object-level property (renderOrder/frustumCulled): local apply + replicate (147) @param {string} parameter @param {any} value */
function setObjectParam(parameter, value) {
/** Object-level property (renderOrder/frustumCulled): local apply + replicate (147) @param {string} parameter @param {any} value @param {boolean} [record] */
function setObjectParam(parameter, value, record = false) {
// 37 R26: `record` = an undo step per changed object (the props kind replays
// `renderOrder`); one batch for a set, and one step per scrub/typing session
/** @type {[any, any][]} */
const befores = record ? insTargets.map((/** @type {any} */ o) => [o, o[parameter]]) : [];
$selectedObject[parameter] = value;
selectedObject.update((v) => v);
sendParam(parameter); // fans the value + messages over the selection
const changed = befores.filter(([o, before]) => o[parameter] !== before);
if (!changed.length) return;
if (changed.length > 1) beginHistoryBatch();
for (const [o, before] of changed)
recordEntry({ kind: 'props', uuid: o.uuid, before: { [parameter]: before }, after: { [parameter]: o[parameter] } });
if (changed.length > 1) endHistoryBatch(parameter);
}

// ---- move to group (shared by mesh and light targets) -------------------
Expand Down Expand Up @@ -3194,7 +3237,7 @@
step={0.2}
snap={5}
ariaLabel="Render order"
onchange={(v) => setObjectParam('renderOrder', Math.round(v) || 0)}
onchange={(v) => setObjectParam('renderOrder', Math.round(v) || 0, true)}
/>
</div>
</div>
Expand Down Expand Up @@ -3437,11 +3480,7 @@
<div id="inspector-intensity">
<DragRow label="I" accent="text-yellow-300" step={0.02} min={0} snap={0.5}
value={$selectedObject.intensity}
onchange={(v) => {
$selectedObject.intensity = v;
selectedObject.update((s) => s);
sendLightUpdate();
}} />
onchange={(v) => setLightValue('intensity', v)} />
</div>
</div>
{#each LIGHT_PARAMS[$selectedObject.type] ?? [] as spec (spec.key)}
Expand All @@ -3451,11 +3490,7 @@
max={spec.max ?? 10}
step={spec.step ?? 0.05}
value={Number($selectedObject[spec.key] ?? 0)}
onchange={(v) => {
$selectedObject[spec.key] = v;
selectedObject.update((s) => s);
sendLightUpdate();
}} />
onchange={(v) => setLightValue(spec.key, v)} />
{/each}

{#if aimsByRotation}
Expand Down
42 changes: 37 additions & 5 deletions src/components/ui/DragRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@
displayDecimals,
parseValue
} from '$lib/units';
import { withHistoryGesture } from '$lib/historyGesture';

// THE numeric field (phase 64.2, rebuilt in 16-Q3). One control everywhere:
// transform rows, the boxes beside sliders, and the loose number inputs that
Expand All @@ -18,6 +19,10 @@
// Shift x100; integer fields (decimals 0) step by 1/10/100
// Esc back to the value you started with, then blur
//
// 37 R26: each scrub, and each typing session (focus -> Enter / Esc / blur, arrows
// included), is ONE history gesture ($lib/historyGesture): whatever the consumer records
// while it runs folds into one undo step, and an Esc that lands back where it started
// leaves none.
// It stays a real <input> the whole time (no button/typing mode swap): the caret
// is always available, ids keep working for tests and labels, and touch gets the
// numeric keypad via inputmode. type="text" on purpose — the native number
Expand Down Expand Up @@ -110,6 +115,11 @@
let entryValue = 0;
let startValue = 0;
let startX = 0;
/** the history gesture the next commit belongs to (37 R26); null = none open
* @type {object|null} */
let gesture = null;
/** true while Esc blurs the field, so the blur's `change` cannot re-commit the typed text */
let reverting = false;

// while typing show exactly what was typed; otherwise render the live value
// (or an em-dash when the selection holds several different values)
Expand All @@ -118,7 +128,14 @@
/** @param {number} next */
function commit(next) {
if (!Number.isFinite(next)) return;
onchange(clamp(next));
withHistoryGesture(gesture, () => onchange(clamp(next)));
}

/** `change` fires on blur with the box's text; while Esc reverts that text is stale
* @param {any} event */
function onChange(event) {
if (reverting) return;
onInput(event);
}

/** @param {any} event */
Expand Down Expand Up @@ -181,7 +198,17 @@
if (event.key === 'Escape') {
commit(entryValue);
typed = fmt(entryValue);
inputEl?.blur();
// 37 R26 (Q1): the blur below fires the browser's `change` with the TYPED text still
// in the box (the reset of the text lands a render later), and that re-committed
// the typed value — so Esc reverted arrow steps and not typing. Put the text back
// now and ignore that one `change`.
if (inputEl) inputEl.value = typed;
reverting = true;
try {
inputEl?.blur();
} finally {
reverting = false;
}
event.preventDefault();
event.stopPropagation();
} else if (event.key === 'Enter') {
Expand All @@ -192,6 +219,7 @@
/** @param {any} event */
function onFocus(event) {
focused = true;
gesture = {}; // a typing session
entryValue = Number(value) || 0;
typed = fmt(value);
// select all so typing REPLACES, the usual expectation for a value field
Expand All @@ -202,6 +230,7 @@
function onBlur() {
focused = false;
scrubbing = false;
gesture = null;
}

/** @param {any} event */
Expand All @@ -226,7 +255,8 @@
if (Math.abs(dx) < 3) return; // dead zone keeps a click a click
scrubbing = true;
event.currentTarget.setPointerCapture?.(event.pointerId);
onscrubstart();
gesture = {}; // a scrub is its own step, even on a field you were typing in
withHistoryGesture(gesture, onscrubstart);
}
// dragging must not smear a text selection across the field
document.getSelection?.()?.removeAllRanges?.();
Expand All @@ -252,7 +282,9 @@
scrubbing = false;
if (wasScrubbing) {
event.currentTarget.releasePointerCapture?.(event.pointerId);
onscrubend();
withHistoryGesture(gesture, onscrubend);
// typing after a scrub on a focused field is the next step
gesture = focused ? {} : null;
return;
}
// a click that did not scrub = "let me type it" (anywhere on the field)
Expand Down Expand Up @@ -281,7 +313,7 @@
{disabled}
use:keys
oninput={onInput}
onchange={onInput}
onchange={onChange}
onfocus={onFocus}
onblur={onBlur}
/>
Expand Down
11 changes: 10 additions & 1 deletion src/components/ui/SliderRow.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,7 @@
// ×100). It used to be a plain <input type="number"> that only committed on
// Enter/blur, which made its arrows look broken.
import DragRow from './DragRow.svelte';
import { withHistoryGesture } from '$lib/historyGesture';

/** @type {{label?: string, value?: number, min?: number, max?: number, step?: number, decimals?: number, id?: string, mixed?: boolean, onchange?: (next: number) => void}} */
let {
Expand All @@ -24,10 +25,16 @@
onchange = () => {}
} = $props();

// 37 R26: a drag of the range is ONE history gesture, like a scrub of the box — whatever
// the consumer records per tick folds into one undo step ($lib/historyGesture). It ends
// with the range's `change` (the release); a keyboard step has none open, so each is its own.
/** @type {object|null} */
let rangeGesture = null;

/** @param {any} raw */
function commit(raw) {
const next = parseFloat(raw);
if (!Number.isNaN(next)) onchange(next);
if (!Number.isNaN(next)) withHistoryGesture(rangeGesture, () => onchange(next));
}
</script>

Expand All @@ -43,7 +50,9 @@
{max}
{step}
{value}
onpointerdown={() => (rangeGesture = {})}
oninput={(e) => commit(e.currentTarget.value)}
onchange={() => (rangeGesture = null)}
/>
<div class="w-16 shrink-0">
<DragRow
Expand Down
Loading
Loading