@xenosystem/blocks 0.4.0 → 0.5.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/auth/index.js +2 -0
- package/dist/chunk-2KG3PWR4.js +17 -0
- package/dist/chunk-DJJGZ6U4.js +215 -0
- package/dist/data/duckdb.d.ts +123 -0
- package/dist/data/duckdb.js +142 -0
- package/dist/data/index.d.ts +3255 -0
- package/dist/data/index.js +7185 -0
- package/dist/index.d.ts +1 -1
- package/dist/index.js +3 -1
- package/dist/ops/index.js +2 -1
- package/dist/time/index.d.ts +2261 -0
- package/dist/time/index.js +4658 -0
- package/dist/transport-B1cdciP8.d.ts +787 -0
- package/dist/trust/index.js +2 -0
- package/dist/{xterm-R3GIKSHA.js → xterm-R3ZKBPPC.js} +1 -0
- package/package.json +100 -80
|
@@ -0,0 +1,4658 @@
|
|
|
1
|
+
import {
|
|
2
|
+
__reExport
|
|
3
|
+
} from "../chunk-2KG3PWR4.js";
|
|
4
|
+
|
|
5
|
+
// src/time/transport/panel.ts
|
|
6
|
+
import { createElement } from "react";
|
|
7
|
+
import { createRoot } from "react-dom/client";
|
|
8
|
+
|
|
9
|
+
// src/time/transport/manifest.ts
|
|
10
|
+
import { WELL_KNOWN_PORT_SCHEMAS } from "@xenosystem/panel-sdk";
|
|
11
|
+
var transportManifest = {
|
|
12
|
+
id: "xeno.core.transport",
|
|
13
|
+
version: "0.1.0",
|
|
14
|
+
title: "Transport",
|
|
15
|
+
icon: "play",
|
|
16
|
+
description: "Playback transport: play/pause/stop/record, seek and scrub, shuttle, loop and in/out points, with a dead-reckoned 60fps playhead readout in frames, timecode, seconds or bars. Emits intents; the host owns the clock.",
|
|
17
|
+
author: "XENO Core",
|
|
18
|
+
defaultSlot: "bottom",
|
|
19
|
+
inputs: [
|
|
20
|
+
{
|
|
21
|
+
id: "clock",
|
|
22
|
+
name: "Playhead Clock",
|
|
23
|
+
type: "object",
|
|
24
|
+
schema: WELL_KNOWN_PORT_SCHEMAS.PLAYHEAD_CLOCK,
|
|
25
|
+
description: "XenoPlayheadClock { playing, originTick, originTime, rateTicksPerSecond, loop? }. Push at 2\u201310 Hz AND immediately whenever playing/rate/loop changes; the panel extrapolates 60fps between pushes."
|
|
26
|
+
},
|
|
27
|
+
{
|
|
28
|
+
id: "timebase",
|
|
29
|
+
name: "Timebase",
|
|
30
|
+
type: "object",
|
|
31
|
+
schema: WELL_KNOWN_PORT_SCHEMAS.TIMEBASE,
|
|
32
|
+
description: "XenoTimebase { ticksPerSecond, display, fps?, dropFrame?, bpm?, timeSignature?, tempoMap?, displayOrigin? }. Only ticksPerSecond affects geometry; the rest is formatting."
|
|
33
|
+
},
|
|
34
|
+
{
|
|
35
|
+
id: "duration",
|
|
36
|
+
name: "Duration",
|
|
37
|
+
type: "number",
|
|
38
|
+
description: "Total length in ticks. Read LIVE on every frame so the playhead clamps to a document that shortens."
|
|
39
|
+
},
|
|
40
|
+
{
|
|
41
|
+
id: "state",
|
|
42
|
+
name: "Transport State",
|
|
43
|
+
type: "object",
|
|
44
|
+
schema: WELL_KNOWN_PORT_SCHEMAS.TRANSPORT_STATE,
|
|
45
|
+
description: "{ recording?, armed?, metronome?, loopEnabled?, punch?, preroll?, bpm? } \u2014 an ABSENT key hides its control (the auto-hide rule)."
|
|
46
|
+
}
|
|
47
|
+
],
|
|
48
|
+
outputs: [
|
|
49
|
+
{
|
|
50
|
+
id: "intent",
|
|
51
|
+
name: "Intent",
|
|
52
|
+
type: "object",
|
|
53
|
+
schema: WELL_KNOWN_PORT_SCHEMAS.TRANSPORT_INTENT,
|
|
54
|
+
description: "XenoTransportIntent \u2014 play/pause/toggle/stop/record/seek/step/shuttle/goToStart/goToEnd/setLoop/setInOut/setMetronome/setTempo. The ONLY output that changes anything."
|
|
55
|
+
},
|
|
56
|
+
{
|
|
57
|
+
id: "seekPreview",
|
|
58
|
+
name: "Seek (live)",
|
|
59
|
+
type: "object",
|
|
60
|
+
schema: WELL_KNOWN_PORT_SCHEMAS.TRANSPORT_INTENT,
|
|
61
|
+
description: "{ tick } while scrubbing (throttled). NOT a commit \u2014 the terminal seek arrives on `intent`."
|
|
62
|
+
}
|
|
63
|
+
],
|
|
64
|
+
commands: [
|
|
65
|
+
{ id: "play", title: "Play", description: "Start playback.", parameters: {}, shortcut: "Space" },
|
|
66
|
+
{ id: "pause", title: "Pause", description: "Pause playback.", parameters: {} },
|
|
67
|
+
{ id: "toggle_play", title: "Play / Pause", description: "Toggle playback.", parameters: {} },
|
|
68
|
+
{ id: "stop", title: "Stop", description: "Stop and return per host policy.", parameters: {} },
|
|
69
|
+
{ id: "record", title: "Record", description: "Start or stop recording.", parameters: {
|
|
70
|
+
recording: { type: "boolean", description: "true starts, false stops" }
|
|
71
|
+
} },
|
|
72
|
+
{ id: "seek", title: "Seek", description: "Move the playhead to a tick.", parameters: {
|
|
73
|
+
tick: { type: "number", description: "Target tick (integer)", required: true }
|
|
74
|
+
} },
|
|
75
|
+
{ id: "step_forward", title: "Step Forward", description: "Step forward.", parameters: {
|
|
76
|
+
amount: { type: "number", description: "How many units (default 1)" },
|
|
77
|
+
unit: { type: "string", description: "frame | second | bar | marker" }
|
|
78
|
+
} },
|
|
79
|
+
{ id: "step_backward", title: "Step Backward", description: "Step backward.", parameters: {
|
|
80
|
+
amount: { type: "number", description: "How many units (default 1)" },
|
|
81
|
+
unit: { type: "string", description: "frame | second | bar | marker" }
|
|
82
|
+
} },
|
|
83
|
+
{ id: "go_to_start", title: "Go To Start", description: "Jump to the beginning.", parameters: {} },
|
|
84
|
+
{ id: "go_to_end", title: "Go To End", description: "Jump to the end.", parameters: {} },
|
|
85
|
+
{ id: "shuttle", title: "Shuttle", description: "Set the shuttle rate (\xD7 normal speed; negative reverses).", parameters: {
|
|
86
|
+
rate: { type: "number", description: "Rate multiplier", required: true }
|
|
87
|
+
} },
|
|
88
|
+
{ id: "set_loop", title: "Set Loop", description: "Set the loop range.", parameters: {
|
|
89
|
+
startTick: { type: "number", description: "Loop start" },
|
|
90
|
+
endTick: { type: "number", description: "Loop end" },
|
|
91
|
+
enabled: { type: "boolean", description: "Enable the loop" }
|
|
92
|
+
} },
|
|
93
|
+
{ id: "toggle_loop", title: "Toggle Loop", description: "Enable/disable looping.", parameters: {} },
|
|
94
|
+
{ id: "set_in_point", title: "Set In Point", description: "Set the in point at the playhead (or a tick).", parameters: {
|
|
95
|
+
tick: { type: "number", description: "Tick (defaults to the playhead)" }
|
|
96
|
+
} },
|
|
97
|
+
{ id: "set_out_point", title: "Set Out Point", description: "Set the out point at the playhead (or a tick).", parameters: {
|
|
98
|
+
tick: { type: "number", description: "Tick (defaults to the playhead)" }
|
|
99
|
+
} },
|
|
100
|
+
{ id: "clear_in_out", title: "Clear In/Out", description: "Clear the in and out points.", parameters: {} },
|
|
101
|
+
{ id: "set_tempo", title: "Set Tempo", description: "Set the tempo in BPM.", parameters: {
|
|
102
|
+
bpm: { type: "number", description: "Quarter-notes per minute", required: true }
|
|
103
|
+
} },
|
|
104
|
+
{ id: "toggle_metronome", title: "Toggle Metronome", description: "Enable/disable the metronome.", parameters: {} },
|
|
105
|
+
{ id: "set_time_display", title: "Set Time Display", description: "Switch the readout unit.", parameters: {
|
|
106
|
+
display: { type: "string", description: "frames | timecode | seconds | bars", required: true }
|
|
107
|
+
} }
|
|
108
|
+
],
|
|
109
|
+
config: [
|
|
110
|
+
{
|
|
111
|
+
key: "display",
|
|
112
|
+
label: "Time display",
|
|
113
|
+
type: "select",
|
|
114
|
+
defaultValue: "",
|
|
115
|
+
options: [
|
|
116
|
+
{ label: "From timebase", value: "" },
|
|
117
|
+
{ label: "Timecode", value: "timecode" },
|
|
118
|
+
{ label: "Frames", value: "frames" },
|
|
119
|
+
{ label: "Seconds", value: "seconds" },
|
|
120
|
+
{ label: "Bars & beats", value: "bars" }
|
|
121
|
+
],
|
|
122
|
+
description: "Override the timebase display unit. Empty = follow the host timebase."
|
|
123
|
+
},
|
|
124
|
+
{ key: "clickReadoutCycles", label: "Click readout to cycle units", type: "boolean", defaultValue: true },
|
|
125
|
+
{ key: "showRecord", label: "Record button", type: "boolean", defaultValue: true, description: "Also requires `state.recording` to be present." },
|
|
126
|
+
{ key: "showLoop", label: "Loop button", type: "boolean", defaultValue: true },
|
|
127
|
+
{ key: "showMetronome", label: "Metronome button", type: "boolean", defaultValue: true },
|
|
128
|
+
{ key: "showPunch", label: "Punch button", type: "boolean", defaultValue: true },
|
|
129
|
+
{ key: "showPreroll", label: "Pre-roll control", type: "boolean", defaultValue: true },
|
|
130
|
+
{ key: "showBpm", label: "BPM control", type: "boolean", defaultValue: true },
|
|
131
|
+
{ key: "showShuttle", label: "Shuttle controls", type: "boolean", defaultValue: true },
|
|
132
|
+
{ key: "showScrubber", label: "Seek scrubber", type: "boolean", defaultValue: true },
|
|
133
|
+
{ key: "showInOut", label: "In / out points", type: "boolean", defaultValue: true },
|
|
134
|
+
{
|
|
135
|
+
key: "shuttleRates",
|
|
136
|
+
label: "Shuttle rates",
|
|
137
|
+
type: "json",
|
|
138
|
+
defaultValue: [1, 2, 4, 8],
|
|
139
|
+
description: "Speed multipliers cycled by the shuttle buttons; negated for reverse."
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
key: "jklShuttle",
|
|
143
|
+
label: "J/K/L shuttle",
|
|
144
|
+
type: "boolean",
|
|
145
|
+
defaultValue: true,
|
|
146
|
+
description: "Motion/Resolve keyboard shuttle: J reverse, K pause, L forward, K+J / K+L nudge."
|
|
147
|
+
},
|
|
148
|
+
{ key: "stepUnit", label: "Step unit", type: "select", defaultValue: "frame", options: [
|
|
149
|
+
{ label: "Frame", value: "frame" },
|
|
150
|
+
{ label: "Second", value: "second" },
|
|
151
|
+
{ label: "Bar", value: "bar" },
|
|
152
|
+
{ label: "Marker", value: "marker" }
|
|
153
|
+
] },
|
|
154
|
+
{ key: "seekThrottleMs", label: "Scrub throttle (ms)", type: "number", defaultValue: 16 }
|
|
155
|
+
],
|
|
156
|
+
capabilities: ["storage.local"],
|
|
157
|
+
sdk: "^1.0.0",
|
|
158
|
+
trust: "trusted"
|
|
159
|
+
};
|
|
160
|
+
|
|
161
|
+
// src/time/transport/types.ts
|
|
162
|
+
var types_exports = {};
|
|
163
|
+
__reExport(types_exports, time_core_star);
|
|
164
|
+
import * as time_core_star from "@xenosystem/time-core";
|
|
165
|
+
|
|
166
|
+
// src/time/transport/clock.ts
|
|
167
|
+
var clock_exports = {};
|
|
168
|
+
__reExport(clock_exports, time_core_star2);
|
|
169
|
+
import * as time_core_star2 from "@xenosystem/time-core";
|
|
170
|
+
|
|
171
|
+
// src/time/transport/format.ts
|
|
172
|
+
var format_exports = {};
|
|
173
|
+
__reExport(format_exports, time_core_star3);
|
|
174
|
+
import * as time_core_star3 from "@xenosystem/time-core";
|
|
175
|
+
|
|
176
|
+
// src/time/transport/throttle.ts
|
|
177
|
+
var throttle_exports = {};
|
|
178
|
+
__reExport(throttle_exports, time_core_star4);
|
|
179
|
+
import * as time_core_star4 from "@xenosystem/time-core";
|
|
180
|
+
|
|
181
|
+
// src/time/transport/controller.ts
|
|
182
|
+
var STORAGE_DISPLAY = "display";
|
|
183
|
+
var asString = (v, fallback) => typeof v === "string" ? v : fallback;
|
|
184
|
+
var asNumber = (v, fallback) => typeof v === "number" && Number.isFinite(v) ? v : fallback;
|
|
185
|
+
var asBool = (v, fallback) => typeof v === "boolean" ? v : fallback;
|
|
186
|
+
var TransportPanelController = class {
|
|
187
|
+
host;
|
|
188
|
+
nowFn;
|
|
189
|
+
seekThrottle;
|
|
190
|
+
// ── Host-owned inputs ─────────────────────────────────────────────────────
|
|
191
|
+
clock = types_exports.IDLE_CLOCK;
|
|
192
|
+
timebase = types_exports.DEFAULT_TIMEBASE;
|
|
193
|
+
durationTicks = 0;
|
|
194
|
+
state = {};
|
|
195
|
+
// ── View state ────────────────────────────────────────────────────────────
|
|
196
|
+
displayOverride = null;
|
|
197
|
+
scrubbing = false;
|
|
198
|
+
scrubTick = 0;
|
|
199
|
+
shuttleIndex = 0;
|
|
200
|
+
lastRenderedTick = -1;
|
|
201
|
+
/** J/K/L: `true` while K is held (K+J / K+L are nudges, not shuttles). */
|
|
202
|
+
kHeld = false;
|
|
203
|
+
// ── Config ────────────────────────────────────────────────────────────────
|
|
204
|
+
configDisplay = null;
|
|
205
|
+
clickReadoutCycles = true;
|
|
206
|
+
showRecord = true;
|
|
207
|
+
showLoop = true;
|
|
208
|
+
showMetronome = true;
|
|
209
|
+
showPunch = true;
|
|
210
|
+
showPreroll = true;
|
|
211
|
+
showBpm = true;
|
|
212
|
+
showShuttle = true;
|
|
213
|
+
showScrubber = true;
|
|
214
|
+
showInOut = true;
|
|
215
|
+
shuttleRates = [1, 2, 4, 8];
|
|
216
|
+
jklShuttle = true;
|
|
217
|
+
stepUnit = "frame";
|
|
218
|
+
revision = 0;
|
|
219
|
+
disposed = false;
|
|
220
|
+
listeners = /* @__PURE__ */ new Set();
|
|
221
|
+
unsubConfig = null;
|
|
222
|
+
constructor(host, options = {}) {
|
|
223
|
+
this.host = host;
|
|
224
|
+
this.nowFn = options.now ?? (() => {
|
|
225
|
+
const perf = globalThis.performance;
|
|
226
|
+
return perf ? perf.now() : Date.now();
|
|
227
|
+
});
|
|
228
|
+
this.readConfig();
|
|
229
|
+
this.seekThrottle = (0, throttle_exports.createThrottle)(
|
|
230
|
+
(tick) => this.host.emit("seekPreview", { tick }),
|
|
231
|
+
Math.max(0, asNumber(host.config.seekThrottleMs, 16)),
|
|
232
|
+
options.throttleClock
|
|
233
|
+
);
|
|
234
|
+
this.unsubConfig = host.onConfigChange(() => {
|
|
235
|
+
this.readConfig();
|
|
236
|
+
this.notify();
|
|
237
|
+
});
|
|
238
|
+
}
|
|
239
|
+
/** Load the persisted display-unit preference. */
|
|
240
|
+
async load() {
|
|
241
|
+
try {
|
|
242
|
+
const stored = await this.host.storage.get(STORAGE_DISPLAY);
|
|
243
|
+
if (stored && format_exports.DISPLAY_CYCLE.includes(stored)) {
|
|
244
|
+
this.displayOverride = stored;
|
|
245
|
+
this.notify();
|
|
246
|
+
}
|
|
247
|
+
} catch (err) {
|
|
248
|
+
this.host.log("warn", "transport: failed to load persisted display unit", err);
|
|
249
|
+
}
|
|
250
|
+
}
|
|
251
|
+
readConfig() {
|
|
252
|
+
const cfg = this.host.config;
|
|
253
|
+
const display = asString(cfg.display, "");
|
|
254
|
+
this.configDisplay = format_exports.DISPLAY_CYCLE.includes(display) ? display : null;
|
|
255
|
+
this.clickReadoutCycles = asBool(cfg.clickReadoutCycles, true);
|
|
256
|
+
this.showRecord = asBool(cfg.showRecord, true);
|
|
257
|
+
this.showLoop = asBool(cfg.showLoop, true);
|
|
258
|
+
this.showMetronome = asBool(cfg.showMetronome, true);
|
|
259
|
+
this.showPunch = asBool(cfg.showPunch, true);
|
|
260
|
+
this.showPreroll = asBool(cfg.showPreroll, true);
|
|
261
|
+
this.showBpm = asBool(cfg.showBpm, true);
|
|
262
|
+
this.showShuttle = asBool(cfg.showShuttle, true);
|
|
263
|
+
this.showScrubber = asBool(cfg.showScrubber, true);
|
|
264
|
+
this.showInOut = asBool(cfg.showInOut, true);
|
|
265
|
+
const rates = Array.isArray(cfg.shuttleRates) ? cfg.shuttleRates.filter((r) => typeof r === "number" && r > 0) : [];
|
|
266
|
+
this.shuttleRates = rates.length > 0 ? rates : [1, 2, 4, 8];
|
|
267
|
+
this.jklShuttle = asBool(cfg.jklShuttle, true);
|
|
268
|
+
const unit = asString(cfg.stepUnit, "frame");
|
|
269
|
+
this.stepUnit = unit === "second" || unit === "bar" || unit === "marker" ? unit : "frame";
|
|
270
|
+
}
|
|
271
|
+
// ── Inputs ────────────────────────────────────────────────────────────────
|
|
272
|
+
/** Accept a clock push. Each push SNAPS the extrapolation to host truth. */
|
|
273
|
+
setClock(input) {
|
|
274
|
+
const next = (0, types_exports.normalizeClock)(input);
|
|
275
|
+
if (!next) {
|
|
276
|
+
this.host.log("warn", "transport: ignored an invalid clock input");
|
|
277
|
+
return;
|
|
278
|
+
}
|
|
279
|
+
this.clock = next;
|
|
280
|
+
this.lastRenderedTick = -1;
|
|
281
|
+
this.notify();
|
|
282
|
+
}
|
|
283
|
+
/** Accept a timebase push. */
|
|
284
|
+
setTimebase(input) {
|
|
285
|
+
const next = (0, types_exports.normalizeTimebase)(input);
|
|
286
|
+
if (!next) {
|
|
287
|
+
this.host.log("warn", "transport: ignored an invalid timebase input");
|
|
288
|
+
return;
|
|
289
|
+
}
|
|
290
|
+
this.timebase = next;
|
|
291
|
+
this.notify();
|
|
292
|
+
}
|
|
293
|
+
/** Accept the live duration (ticks). */
|
|
294
|
+
setDuration(input) {
|
|
295
|
+
this.durationTicks = Math.max(0, (0, types_exports.toTick)(asNumber(input, 0)));
|
|
296
|
+
this.notify();
|
|
297
|
+
}
|
|
298
|
+
/** Accept the transport state. Absent keys hide their controls. */
|
|
299
|
+
setState(input) {
|
|
300
|
+
this.state = input && typeof input === "object" ? input : {};
|
|
301
|
+
this.notify();
|
|
302
|
+
}
|
|
303
|
+
/** The current clock (host truth, not the extrapolation). */
|
|
304
|
+
getClock() {
|
|
305
|
+
return this.clock;
|
|
306
|
+
}
|
|
307
|
+
/** The current timebase. */
|
|
308
|
+
getTimebase() {
|
|
309
|
+
return this.timebase;
|
|
310
|
+
}
|
|
311
|
+
/** The live duration in ticks. */
|
|
312
|
+
getDuration() {
|
|
313
|
+
return this.durationTicks;
|
|
314
|
+
}
|
|
315
|
+
/** The transport state. */
|
|
316
|
+
getState() {
|
|
317
|
+
return this.state;
|
|
318
|
+
}
|
|
319
|
+
/** `playing` is HOST-authoritative — the panel only reports it. */
|
|
320
|
+
isPlaying() {
|
|
321
|
+
return this.clock.playing;
|
|
322
|
+
}
|
|
323
|
+
/** Whether the display should keep animating (a moving playhead only). */
|
|
324
|
+
isAnimating() {
|
|
325
|
+
return (0, clock_exports.isAnimating)(this.clock) && !this.scrubbing;
|
|
326
|
+
}
|
|
327
|
+
// ── The extrapolated playhead ─────────────────────────────────────────────
|
|
328
|
+
/**
|
|
329
|
+
* The tick to display right now: the scrub position while scrubbing, else the dead-reckoned
|
|
330
|
+
* extrapolation, always clamped against the LIVE duration.
|
|
331
|
+
*/
|
|
332
|
+
getCurrentTick() {
|
|
333
|
+
if (this.scrubbing) return (0, types_exports.clampTick)(this.scrubTick, this.durationTicks);
|
|
334
|
+
return (0, clock_exports.extrapolateTick)({
|
|
335
|
+
clock: this.clock,
|
|
336
|
+
now: this.nowFn(),
|
|
337
|
+
durationTicks: this.durationTicks
|
|
338
|
+
});
|
|
339
|
+
}
|
|
340
|
+
/**
|
|
341
|
+
* Advance the display. The view calls this each animation frame; it notifies **only when the
|
|
342
|
+
* integer tick actually changed**, so a paused transport or a sub-tick frame costs nothing.
|
|
343
|
+
*
|
|
344
|
+
* @returns `true` if listeners were notified.
|
|
345
|
+
*/
|
|
346
|
+
pump() {
|
|
347
|
+
const tick = this.getCurrentTick();
|
|
348
|
+
if (tick === this.lastRenderedTick) return false;
|
|
349
|
+
this.lastRenderedTick = tick;
|
|
350
|
+
this.notify();
|
|
351
|
+
return true;
|
|
352
|
+
}
|
|
353
|
+
/** The formatted readout for the current position. */
|
|
354
|
+
getReadout() {
|
|
355
|
+
return (0, format_exports.formatTick)(this.getCurrentTick(), this.timebase, this.getDisplay());
|
|
356
|
+
}
|
|
357
|
+
/** The formatted duration. */
|
|
358
|
+
getDurationReadout() {
|
|
359
|
+
return (0, format_exports.formatTick)(this.durationTicks, this.timebase, this.getDisplay());
|
|
360
|
+
}
|
|
361
|
+
/** Playback progress `0..1` (0 when the duration is unknown). */
|
|
362
|
+
getProgress() {
|
|
363
|
+
if (this.durationTicks <= 0) return 0;
|
|
364
|
+
return Math.min(1, Math.max(0, this.getCurrentTick() / this.durationTicks));
|
|
365
|
+
}
|
|
366
|
+
// ── Display unit ──────────────────────────────────────────────────────────
|
|
367
|
+
/** The effective display unit: panel override → config → the host timebase. */
|
|
368
|
+
getDisplay() {
|
|
369
|
+
return this.displayOverride ?? this.configDisplay ?? this.timebase.display;
|
|
370
|
+
}
|
|
371
|
+
/** Set the display unit (persisted). */
|
|
372
|
+
async setDisplay(display) {
|
|
373
|
+
if (!format_exports.DISPLAY_CYCLE.includes(display)) return;
|
|
374
|
+
this.displayOverride = display;
|
|
375
|
+
try {
|
|
376
|
+
await this.host.storage.set(STORAGE_DISPLAY, display);
|
|
377
|
+
} catch (err) {
|
|
378
|
+
this.host.log("warn", "transport: failed to persist display unit", err);
|
|
379
|
+
}
|
|
380
|
+
this.notify();
|
|
381
|
+
}
|
|
382
|
+
/** Cycle to the next display unit (the readout click). */
|
|
383
|
+
cycleDisplay() {
|
|
384
|
+
if (!this.clickReadoutCycles) return;
|
|
385
|
+
void this.setDisplay((0, format_exports.nextDisplay)(this.getDisplay()));
|
|
386
|
+
}
|
|
387
|
+
// ── Intents (the ONLY mutation channel) ───────────────────────────────────
|
|
388
|
+
emit(intent) {
|
|
389
|
+
this.host.emit("intent", intent);
|
|
390
|
+
}
|
|
391
|
+
/** Start playback. */
|
|
392
|
+
play() {
|
|
393
|
+
this.emit({ kind: "play" });
|
|
394
|
+
}
|
|
395
|
+
/** Pause playback. */
|
|
396
|
+
pause() {
|
|
397
|
+
this.emit({ kind: "pause" });
|
|
398
|
+
}
|
|
399
|
+
/** Toggle playback. The host decides — the panel never flips `playing` itself. */
|
|
400
|
+
togglePlay() {
|
|
401
|
+
this.emit({ kind: "toggle" });
|
|
402
|
+
}
|
|
403
|
+
/** Stop (the host decides whether that returns to start). */
|
|
404
|
+
stop() {
|
|
405
|
+
this.shuttleIndex = 0;
|
|
406
|
+
this.emit({ kind: "stop" });
|
|
407
|
+
}
|
|
408
|
+
/** Start/stop recording. */
|
|
409
|
+
record(recording) {
|
|
410
|
+
this.emit({ kind: "record", recording: recording ?? !this.state.recording });
|
|
411
|
+
}
|
|
412
|
+
/** Seek to a tick (clamped to the live duration). */
|
|
413
|
+
seek(tick) {
|
|
414
|
+
this.emit({ kind: "seek", tick: (0, types_exports.clampTick)(tick, this.durationTicks) });
|
|
415
|
+
}
|
|
416
|
+
/** Step by whole units. */
|
|
417
|
+
step(delta, unit) {
|
|
418
|
+
const amount = Number.isFinite(delta) ? Math.trunc(delta) : 0;
|
|
419
|
+
if (amount === 0) return;
|
|
420
|
+
this.emit({ kind: "step", delta: amount, unit: unit ?? this.stepUnit });
|
|
421
|
+
}
|
|
422
|
+
/** Jump to the start. */
|
|
423
|
+
goToStart() {
|
|
424
|
+
this.emit({ kind: "goToStart" });
|
|
425
|
+
}
|
|
426
|
+
/** Jump to the end. */
|
|
427
|
+
goToEnd() {
|
|
428
|
+
this.emit({ kind: "goToEnd" });
|
|
429
|
+
}
|
|
430
|
+
/** Set the shuttle rate directly (negative reverses). */
|
|
431
|
+
shuttle(rate) {
|
|
432
|
+
if (!Number.isFinite(rate)) return;
|
|
433
|
+
this.emit({ kind: "shuttle", rate });
|
|
434
|
+
}
|
|
435
|
+
/** The configured shuttle rates. */
|
|
436
|
+
getShuttleRates() {
|
|
437
|
+
return this.shuttleRates;
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Step through the shuttle rates in a direction — the classic J/L behaviour: each press moves one
|
|
441
|
+
* notch up the ladder, crossing zero to reverse.
|
|
442
|
+
*
|
|
443
|
+
* @param direction - `+1` faster forward, `-1` faster reverse.
|
|
444
|
+
* @returns The rate emitted.
|
|
445
|
+
*/
|
|
446
|
+
shuttleStep(direction) {
|
|
447
|
+
const dir = direction >= 0 ? 1 : -1;
|
|
448
|
+
const max = this.shuttleRates.length;
|
|
449
|
+
const next = Math.max(-max, Math.min(max, this.shuttleIndex + dir));
|
|
450
|
+
this.shuttleIndex = next;
|
|
451
|
+
const rate = next === 0 ? 0 : Math.sign(next) * (this.shuttleRates[Math.abs(next) - 1] ?? 1);
|
|
452
|
+
this.shuttle(rate);
|
|
453
|
+
return rate;
|
|
454
|
+
}
|
|
455
|
+
/** The current shuttle ladder position (0 = stopped). */
|
|
456
|
+
getShuttleIndex() {
|
|
457
|
+
return this.shuttleIndex;
|
|
458
|
+
}
|
|
459
|
+
/** Set (or clear) the loop range. */
|
|
460
|
+
setLoop(loop) {
|
|
461
|
+
this.emit({ kind: "setLoop", loop });
|
|
462
|
+
}
|
|
463
|
+
/** Toggle looping, preserving the existing range. */
|
|
464
|
+
toggleLoop() {
|
|
465
|
+
const loop = this.clock.loop;
|
|
466
|
+
if (!loop) {
|
|
467
|
+
this.emit({ kind: "setLoop", loop: null });
|
|
468
|
+
return;
|
|
469
|
+
}
|
|
470
|
+
this.emit({ kind: "setLoop", loop: { ...loop, enabled: loop.enabled === false } });
|
|
471
|
+
}
|
|
472
|
+
/** Set the in point (defaults to the playhead). */
|
|
473
|
+
setInPoint(tick) {
|
|
474
|
+
const inTick = (0, types_exports.clampTick)(tick ?? this.getCurrentTick(), this.durationTicks);
|
|
475
|
+
this.emit({ kind: "setInOut", inTick, outTick: this.clock.loop?.endTick ?? null });
|
|
476
|
+
}
|
|
477
|
+
/** Set the out point (defaults to the playhead). */
|
|
478
|
+
setOutPoint(tick) {
|
|
479
|
+
const outTick = (0, types_exports.clampTick)(tick ?? this.getCurrentTick(), this.durationTicks);
|
|
480
|
+
this.emit({ kind: "setInOut", inTick: this.clock.loop?.startTick ?? null, outTick });
|
|
481
|
+
}
|
|
482
|
+
/** Clear both in/out points. */
|
|
483
|
+
clearInOut() {
|
|
484
|
+
this.emit({ kind: "setInOut", inTick: null, outTick: null });
|
|
485
|
+
}
|
|
486
|
+
/** Toggle the metronome. */
|
|
487
|
+
toggleMetronome() {
|
|
488
|
+
this.emit({ kind: "setMetronome", enabled: !this.state.metronome });
|
|
489
|
+
}
|
|
490
|
+
/** Set the tempo. */
|
|
491
|
+
setTempo(bpm) {
|
|
492
|
+
if (!Number.isFinite(bpm) || bpm <= 0) return;
|
|
493
|
+
this.emit({ kind: "setTempo", bpm });
|
|
494
|
+
}
|
|
495
|
+
// ── Scrubbing (preview → commit) ──────────────────────────────────────────
|
|
496
|
+
/** Whether a scrub is in progress. */
|
|
497
|
+
isScrubbing() {
|
|
498
|
+
return this.scrubbing;
|
|
499
|
+
}
|
|
500
|
+
/** Begin a scrub at a tick. */
|
|
501
|
+
beginScrub(tick) {
|
|
502
|
+
this.scrubbing = true;
|
|
503
|
+
this.scrubTick = (0, types_exports.clampTick)(tick, this.durationTicks);
|
|
504
|
+
this.notify();
|
|
505
|
+
}
|
|
506
|
+
/** Update the scrub — emits a throttled `seekPreview`, never a commit. */
|
|
507
|
+
updateScrub(tick) {
|
|
508
|
+
if (!this.scrubbing) return;
|
|
509
|
+
this.scrubTick = (0, types_exports.clampTick)(tick, this.durationTicks);
|
|
510
|
+
this.seekThrottle.call(this.scrubTick);
|
|
511
|
+
this.notify();
|
|
512
|
+
}
|
|
513
|
+
/** Finish the scrub — flushes the preview and commits ONE `seek` intent. */
|
|
514
|
+
endScrub() {
|
|
515
|
+
if (!this.scrubbing) return;
|
|
516
|
+
this.scrubbing = false;
|
|
517
|
+
this.seekThrottle.cancel();
|
|
518
|
+
this.seek(this.scrubTick);
|
|
519
|
+
this.notify();
|
|
520
|
+
}
|
|
521
|
+
/** Abandon the scrub without seeking. */
|
|
522
|
+
cancelScrub() {
|
|
523
|
+
if (!this.scrubbing) return;
|
|
524
|
+
this.scrubbing = false;
|
|
525
|
+
this.seekThrottle.cancel();
|
|
526
|
+
this.notify();
|
|
527
|
+
}
|
|
528
|
+
/** Convert a scrubber fraction `0..1` to a tick. */
|
|
529
|
+
fractionToTick(fraction) {
|
|
530
|
+
const f = Math.min(1, Math.max(0, Number.isFinite(fraction) ? fraction : 0));
|
|
531
|
+
return (0, types_exports.clampTick)(Math.round(f * this.durationTicks), this.durationTicks);
|
|
532
|
+
}
|
|
533
|
+
// ── Control visibility (the auto-hide rule) ───────────────────────────────
|
|
534
|
+
/** Whether a control renders: its config flag AND the host declaring the state key. */
|
|
535
|
+
showsControl(control) {
|
|
536
|
+
switch (control) {
|
|
537
|
+
case "record":
|
|
538
|
+
return this.showRecord && this.state.recording !== void 0;
|
|
539
|
+
case "loop":
|
|
540
|
+
return this.showLoop && this.state.loopEnabled !== void 0;
|
|
541
|
+
case "metronome":
|
|
542
|
+
return this.showMetronome && this.state.metronome !== void 0;
|
|
543
|
+
case "punch":
|
|
544
|
+
return this.showPunch && this.state.punch !== void 0;
|
|
545
|
+
case "preroll":
|
|
546
|
+
return this.showPreroll && this.state.preroll !== void 0;
|
|
547
|
+
case "bpm":
|
|
548
|
+
return this.showBpm && this.state.bpm !== void 0;
|
|
549
|
+
case "shuttle":
|
|
550
|
+
return this.showShuttle;
|
|
551
|
+
case "scrubber":
|
|
552
|
+
return this.showScrubber;
|
|
553
|
+
case "inOut":
|
|
554
|
+
return this.showInOut;
|
|
555
|
+
default:
|
|
556
|
+
return false;
|
|
557
|
+
}
|
|
558
|
+
}
|
|
559
|
+
// ── Keyboard (incl. J/K/L shuttle) ────────────────────────────────────────
|
|
560
|
+
/**
|
|
561
|
+
* Handle a key. Returns `true` when consumed.
|
|
562
|
+
*
|
|
563
|
+
* Space toggles; Home/End jump; ←/→ step; and with `jklShuttle` on, J/K/L drive the Motion /
|
|
564
|
+
* Resolve shuttle — **K held + J or L nudges one frame** instead of shuttling.
|
|
565
|
+
*/
|
|
566
|
+
handleKey(event, phase = "down") {
|
|
567
|
+
const key = event.key.length === 1 ? event.key.toLowerCase() : event.key;
|
|
568
|
+
if (phase === "up") {
|
|
569
|
+
if (this.jklShuttle && key === "k") {
|
|
570
|
+
this.kHeld = false;
|
|
571
|
+
return true;
|
|
572
|
+
}
|
|
573
|
+
return false;
|
|
574
|
+
}
|
|
575
|
+
if (event.ctrl || event.meta) return false;
|
|
576
|
+
switch (key) {
|
|
577
|
+
case " ":
|
|
578
|
+
case "Spacebar":
|
|
579
|
+
this.togglePlay();
|
|
580
|
+
return true;
|
|
581
|
+
case "Home":
|
|
582
|
+
this.goToStart();
|
|
583
|
+
return true;
|
|
584
|
+
case "End":
|
|
585
|
+
this.goToEnd();
|
|
586
|
+
return true;
|
|
587
|
+
case "ArrowLeft":
|
|
588
|
+
this.step(-1, event.shift ? "second" : void 0);
|
|
589
|
+
return true;
|
|
590
|
+
case "ArrowRight":
|
|
591
|
+
this.step(1, event.shift ? "second" : void 0);
|
|
592
|
+
return true;
|
|
593
|
+
default:
|
|
594
|
+
break;
|
|
595
|
+
}
|
|
596
|
+
if (!this.jklShuttle) return false;
|
|
597
|
+
switch (key) {
|
|
598
|
+
case "j":
|
|
599
|
+
if (this.kHeld) this.step(-1, "frame");
|
|
600
|
+
else this.shuttleStep(-1);
|
|
601
|
+
return true;
|
|
602
|
+
case "k":
|
|
603
|
+
this.kHeld = true;
|
|
604
|
+
this.shuttleIndex = 0;
|
|
605
|
+
this.shuttle(0);
|
|
606
|
+
return true;
|
|
607
|
+
case "l":
|
|
608
|
+
if (this.kHeld) this.step(1, "frame");
|
|
609
|
+
else this.shuttleStep(1);
|
|
610
|
+
return true;
|
|
611
|
+
default:
|
|
612
|
+
return false;
|
|
613
|
+
}
|
|
614
|
+
}
|
|
615
|
+
/** Whether K is currently held (the J/K/L nudge modifier). */
|
|
616
|
+
isKHeld() {
|
|
617
|
+
return this.kHeld;
|
|
618
|
+
}
|
|
619
|
+
// ── Host onInput / onCommand routing ──────────────────────────────────────
|
|
620
|
+
/** Route a host `onInput(id, value)` call. */
|
|
621
|
+
onInput(id, value) {
|
|
622
|
+
switch (id) {
|
|
623
|
+
case "clock":
|
|
624
|
+
this.setClock(value);
|
|
625
|
+
break;
|
|
626
|
+
case "timebase":
|
|
627
|
+
this.setTimebase(value);
|
|
628
|
+
break;
|
|
629
|
+
case "duration":
|
|
630
|
+
this.setDuration(value);
|
|
631
|
+
break;
|
|
632
|
+
case "state":
|
|
633
|
+
this.setState(value);
|
|
634
|
+
break;
|
|
635
|
+
default:
|
|
636
|
+
break;
|
|
637
|
+
}
|
|
638
|
+
}
|
|
639
|
+
/** Route a host `onCommand(id, params)` call. */
|
|
640
|
+
async onCommand(id, params) {
|
|
641
|
+
switch (id) {
|
|
642
|
+
case "play":
|
|
643
|
+
this.play();
|
|
644
|
+
return { ok: true };
|
|
645
|
+
case "pause":
|
|
646
|
+
this.pause();
|
|
647
|
+
return { ok: true };
|
|
648
|
+
case "toggle_play":
|
|
649
|
+
this.togglePlay();
|
|
650
|
+
return { ok: true };
|
|
651
|
+
case "stop":
|
|
652
|
+
this.stop();
|
|
653
|
+
return { ok: true };
|
|
654
|
+
case "record":
|
|
655
|
+
this.record(typeof params.recording === "boolean" ? params.recording : void 0);
|
|
656
|
+
return { ok: true };
|
|
657
|
+
case "seek": {
|
|
658
|
+
const tick = asNumber(params.tick, Number.NaN);
|
|
659
|
+
if (!Number.isFinite(tick)) throw new Error("seek: `tick` must be a number");
|
|
660
|
+
this.seek((0, types_exports.toTick)(tick));
|
|
661
|
+
return { ok: true, tick: (0, types_exports.clampTick)(tick, this.durationTicks) };
|
|
662
|
+
}
|
|
663
|
+
case "step_forward":
|
|
664
|
+
case "step_backward": {
|
|
665
|
+
const amount = Math.max(1, Math.trunc(asNumber(params.amount, 1)));
|
|
666
|
+
const unit = asString(params.unit, "");
|
|
667
|
+
this.step(id === "step_forward" ? amount : -amount, unit || void 0);
|
|
668
|
+
return { ok: true };
|
|
669
|
+
}
|
|
670
|
+
case "go_to_start":
|
|
671
|
+
this.goToStart();
|
|
672
|
+
return { ok: true };
|
|
673
|
+
case "go_to_end":
|
|
674
|
+
this.goToEnd();
|
|
675
|
+
return { ok: true };
|
|
676
|
+
case "shuttle": {
|
|
677
|
+
const rate = asNumber(params.rate, Number.NaN);
|
|
678
|
+
if (!Number.isFinite(rate)) throw new Error("shuttle: `rate` must be a number");
|
|
679
|
+
this.shuttle(rate);
|
|
680
|
+
return { ok: true };
|
|
681
|
+
}
|
|
682
|
+
case "set_loop": {
|
|
683
|
+
const startTick = asNumber(params.startTick, Number.NaN);
|
|
684
|
+
const endTick = asNumber(params.endTick, Number.NaN);
|
|
685
|
+
if (!Number.isFinite(startTick) || !Number.isFinite(endTick)) {
|
|
686
|
+
throw new Error("set_loop: `startTick` and `endTick` are required");
|
|
687
|
+
}
|
|
688
|
+
this.setLoop({ startTick: (0, types_exports.toTick)(startTick), endTick: (0, types_exports.toTick)(endTick), enabled: asBool(params.enabled, true) });
|
|
689
|
+
return { ok: true };
|
|
690
|
+
}
|
|
691
|
+
case "toggle_loop":
|
|
692
|
+
this.toggleLoop();
|
|
693
|
+
return { ok: true };
|
|
694
|
+
case "set_in_point":
|
|
695
|
+
this.setInPoint(Number.isFinite(asNumber(params.tick, Number.NaN)) ? (0, types_exports.toTick)(asNumber(params.tick, 0)) : void 0);
|
|
696
|
+
return { ok: true };
|
|
697
|
+
case "set_out_point":
|
|
698
|
+
this.setOutPoint(Number.isFinite(asNumber(params.tick, Number.NaN)) ? (0, types_exports.toTick)(asNumber(params.tick, 0)) : void 0);
|
|
699
|
+
return { ok: true };
|
|
700
|
+
case "clear_in_out":
|
|
701
|
+
this.clearInOut();
|
|
702
|
+
return { ok: true };
|
|
703
|
+
case "set_tempo": {
|
|
704
|
+
const bpm = asNumber(params.bpm, Number.NaN);
|
|
705
|
+
if (!Number.isFinite(bpm) || bpm <= 0) throw new Error("set_tempo: `bpm` must be a positive number");
|
|
706
|
+
this.setTempo(bpm);
|
|
707
|
+
return { ok: true };
|
|
708
|
+
}
|
|
709
|
+
case "toggle_metronome":
|
|
710
|
+
this.toggleMetronome();
|
|
711
|
+
return { ok: true };
|
|
712
|
+
case "set_time_display": {
|
|
713
|
+
const display = asString(params.display, "");
|
|
714
|
+
if (!format_exports.DISPLAY_CYCLE.includes(display)) {
|
|
715
|
+
throw new Error("set_time_display: `display` must be frames | timecode | seconds | bars");
|
|
716
|
+
}
|
|
717
|
+
await this.setDisplay(display);
|
|
718
|
+
return { ok: true, display };
|
|
719
|
+
}
|
|
720
|
+
default:
|
|
721
|
+
throw new Error(`transport: unknown command '${id}'`);
|
|
722
|
+
}
|
|
723
|
+
}
|
|
724
|
+
// ── Serialization ─────────────────────────────────────────────────────────
|
|
725
|
+
serialize() {
|
|
726
|
+
return { displayOverride: this.displayOverride };
|
|
727
|
+
}
|
|
728
|
+
deserialize(state) {
|
|
729
|
+
const s = state;
|
|
730
|
+
if (!s || typeof s !== "object") return;
|
|
731
|
+
if (s.displayOverride && format_exports.DISPLAY_CYCLE.includes(s.displayOverride)) {
|
|
732
|
+
this.displayOverride = s.displayOverride;
|
|
733
|
+
} else if (s.displayOverride === null) {
|
|
734
|
+
this.displayOverride = null;
|
|
735
|
+
}
|
|
736
|
+
this.notify();
|
|
737
|
+
}
|
|
738
|
+
// ── Observer surface ──────────────────────────────────────────────────────
|
|
739
|
+
subscribe(listener) {
|
|
740
|
+
this.listeners.add(listener);
|
|
741
|
+
return () => {
|
|
742
|
+
this.listeners.delete(listener);
|
|
743
|
+
};
|
|
744
|
+
}
|
|
745
|
+
getRevision() {
|
|
746
|
+
return this.revision;
|
|
747
|
+
}
|
|
748
|
+
notify() {
|
|
749
|
+
this.revision += 1;
|
|
750
|
+
for (const listener of [...this.listeners]) listener();
|
|
751
|
+
}
|
|
752
|
+
/** Release timers and subscriptions. Idempotent. */
|
|
753
|
+
dispose() {
|
|
754
|
+
if (this.disposed) return;
|
|
755
|
+
this.disposed = true;
|
|
756
|
+
this.seekThrottle.cancel();
|
|
757
|
+
this.unsubConfig?.();
|
|
758
|
+
this.unsubConfig = null;
|
|
759
|
+
this.listeners.clear();
|
|
760
|
+
}
|
|
761
|
+
};
|
|
762
|
+
function ticksPerStep(unit, timebase) {
|
|
763
|
+
switch (unit) {
|
|
764
|
+
case "second":
|
|
765
|
+
return Math.round(timebase.ticksPerSecond);
|
|
766
|
+
case "bar": {
|
|
767
|
+
const bpm = timebase.bpm ?? 120;
|
|
768
|
+
const meter = timebase.timeSignature ?? [4, 4];
|
|
769
|
+
const beats = meter[0] > 0 ? meter[0] : 4;
|
|
770
|
+
const secondsPerBeat = 60 / bpm * (4 / (meter[1] > 0 ? meter[1] : 4));
|
|
771
|
+
return (0, types_exports.secondsToTicks)(secondsPerBeat * beats, timebase);
|
|
772
|
+
}
|
|
773
|
+
case "frame":
|
|
774
|
+
default: {
|
|
775
|
+
const fps = timebase.fps ?? timebase.ticksPerSecond;
|
|
776
|
+
return Math.max(1, Math.round(timebase.ticksPerSecond / (fps > 0 ? fps : 1)));
|
|
777
|
+
}
|
|
778
|
+
}
|
|
779
|
+
}
|
|
780
|
+
|
|
781
|
+
// src/time/transport/react/TransportPanelView.tsx
|
|
782
|
+
import { useCallback, useEffect, useRef, useSyncExternalStore } from "react";
|
|
783
|
+
|
|
784
|
+
// src/time/transport/react/icons.tsx
|
|
785
|
+
import { jsx, jsxs } from "react/jsx-runtime";
|
|
786
|
+
var base = (size) => ({
|
|
787
|
+
width: size,
|
|
788
|
+
height: size,
|
|
789
|
+
viewBox: "0 0 24 24",
|
|
790
|
+
fill: "none",
|
|
791
|
+
stroke: "currentColor",
|
|
792
|
+
strokeWidth: 1.5,
|
|
793
|
+
strokeLinecap: "round",
|
|
794
|
+
strokeLinejoin: "round",
|
|
795
|
+
"aria-hidden": true
|
|
796
|
+
});
|
|
797
|
+
function PlayIcon({ size = 12 }) {
|
|
798
|
+
return /* @__PURE__ */ jsx("svg", { ...base(size), fill: "currentColor", children: /* @__PURE__ */ jsx("path", { d: "M7 4.5v15l13-7.5z" }) });
|
|
799
|
+
}
|
|
800
|
+
function PauseIcon({ size = 12 }) {
|
|
801
|
+
return /* @__PURE__ */ jsxs("svg", { ...base(size), fill: "currentColor", stroke: "none", children: [
|
|
802
|
+
/* @__PURE__ */ jsx("rect", { x: "6", y: "4", width: "4", height: "16", rx: "1" }),
|
|
803
|
+
/* @__PURE__ */ jsx("rect", { x: "14", y: "4", width: "4", height: "16", rx: "1" })
|
|
804
|
+
] });
|
|
805
|
+
}
|
|
806
|
+
function StopIcon({ size = 11 }) {
|
|
807
|
+
return /* @__PURE__ */ jsx("svg", { ...base(size), fill: "currentColor", stroke: "none", children: /* @__PURE__ */ jsx("rect", { x: "5", y: "5", width: "14", height: "14", rx: "2" }) });
|
|
808
|
+
}
|
|
809
|
+
function RecordIcon({ size = 11 }) {
|
|
810
|
+
return /* @__PURE__ */ jsx("svg", { ...base(size), fill: "currentColor", stroke: "none", children: /* @__PURE__ */ jsx("rect", { x: "6", y: "6", width: "12", height: "12", rx: "3" }) });
|
|
811
|
+
}
|
|
812
|
+
function StartIcon({ size = 11 }) {
|
|
813
|
+
return /* @__PURE__ */ jsxs("svg", { ...base(size), children: [
|
|
814
|
+
/* @__PURE__ */ jsx("path", { d: "M19 20 9 12l10-8z" }),
|
|
815
|
+
/* @__PURE__ */ jsx("path", { d: "M5 19V5" })
|
|
816
|
+
] });
|
|
817
|
+
}
|
|
818
|
+
function EndIcon({ size = 11 }) {
|
|
819
|
+
return /* @__PURE__ */ jsxs("svg", { ...base(size), children: [
|
|
820
|
+
/* @__PURE__ */ jsx("path", { d: "m5 4 10 8-10 8z" }),
|
|
821
|
+
/* @__PURE__ */ jsx("path", { d: "M19 5v14" })
|
|
822
|
+
] });
|
|
823
|
+
}
|
|
824
|
+
function StepBackIcon({ size = 11 }) {
|
|
825
|
+
return /* @__PURE__ */ jsx("svg", { ...base(size), children: /* @__PURE__ */ jsx("path", { d: "M15 18 9 12l6-6" }) });
|
|
826
|
+
}
|
|
827
|
+
function StepForwardIcon({ size = 11 }) {
|
|
828
|
+
return /* @__PURE__ */ jsx("svg", { ...base(size), children: /* @__PURE__ */ jsx("path", { d: "m9 6 6 6-6 6" }) });
|
|
829
|
+
}
|
|
830
|
+
function ShuttleLeftIcon({ size = 11 }) {
|
|
831
|
+
return /* @__PURE__ */ jsxs("svg", { ...base(size), children: [
|
|
832
|
+
/* @__PURE__ */ jsx("path", { d: "M11 19 2 12l9-7z" }),
|
|
833
|
+
/* @__PURE__ */ jsx("path", { d: "M22 19l-9-7 9-7z" })
|
|
834
|
+
] });
|
|
835
|
+
}
|
|
836
|
+
function ShuttleRightIcon({ size = 11 }) {
|
|
837
|
+
return /* @__PURE__ */ jsxs("svg", { ...base(size), children: [
|
|
838
|
+
/* @__PURE__ */ jsx("path", { d: "m13 19 9-7-9-7z" }),
|
|
839
|
+
/* @__PURE__ */ jsx("path", { d: "m2 19 9-7-9-7z" })
|
|
840
|
+
] });
|
|
841
|
+
}
|
|
842
|
+
function LoopIcon({ size = 11 }) {
|
|
843
|
+
return /* @__PURE__ */ jsxs("svg", { ...base(size), children: [
|
|
844
|
+
/* @__PURE__ */ jsx("path", { d: "m17 2 4 4-4 4" }),
|
|
845
|
+
/* @__PURE__ */ jsx("path", { d: "M3 11v-1a4 4 0 0 1 4-4h14" }),
|
|
846
|
+
/* @__PURE__ */ jsx("path", { d: "m7 22-4-4 4-4" }),
|
|
847
|
+
/* @__PURE__ */ jsx("path", { d: "M21 13v1a4 4 0 0 1-4 4H3" })
|
|
848
|
+
] });
|
|
849
|
+
}
|
|
850
|
+
function MetronomeIcon({ size = 11 }) {
|
|
851
|
+
return /* @__PURE__ */ jsxs("svg", { ...base(size), children: [
|
|
852
|
+
/* @__PURE__ */ jsx("path", { d: "M9 3h6l4 18H5z" }),
|
|
853
|
+
/* @__PURE__ */ jsx("path", { d: "m12 18 4-11" })
|
|
854
|
+
] });
|
|
855
|
+
}
|
|
856
|
+
|
|
857
|
+
// src/time/transport/react/TransportPanelView.tsx
|
|
858
|
+
import { jsx as jsx2, jsxs as jsxs2 } from "react/jsx-runtime";
|
|
859
|
+
function TransportPanelView({ controller }) {
|
|
860
|
+
const subscribe = useCallback((cb) => controller.subscribe(cb), [controller]);
|
|
861
|
+
const getSnapshot = useCallback(() => controller.getRevision(), [controller]);
|
|
862
|
+
useSyncExternalStore(subscribe, getSnapshot, getSnapshot);
|
|
863
|
+
const rafRef = useRef(null);
|
|
864
|
+
const scrubRef = useRef(null);
|
|
865
|
+
const animating = controller.isAnimating();
|
|
866
|
+
useEffect(() => {
|
|
867
|
+
if (!animating) return;
|
|
868
|
+
const raf = globalThis.requestAnimationFrame;
|
|
869
|
+
const cancel = globalThis.cancelAnimationFrame;
|
|
870
|
+
if (!raf || !cancel) return;
|
|
871
|
+
const loop = () => {
|
|
872
|
+
controller.pump();
|
|
873
|
+
rafRef.current = raf(loop);
|
|
874
|
+
};
|
|
875
|
+
rafRef.current = raf(loop);
|
|
876
|
+
return () => {
|
|
877
|
+
if (rafRef.current !== null) cancel(rafRef.current);
|
|
878
|
+
rafRef.current = null;
|
|
879
|
+
};
|
|
880
|
+
}, [controller, animating]);
|
|
881
|
+
const playing = controller.isPlaying();
|
|
882
|
+
const state = controller.getState();
|
|
883
|
+
const tickFromPointer = useCallback(
|
|
884
|
+
(clientX) => {
|
|
885
|
+
const el = scrubRef.current;
|
|
886
|
+
if (!el) return 0;
|
|
887
|
+
const rect = el.getBoundingClientRect();
|
|
888
|
+
return controller.fractionToTick(rect.width > 0 ? (clientX - rect.left) / rect.width : 0);
|
|
889
|
+
},
|
|
890
|
+
[controller]
|
|
891
|
+
);
|
|
892
|
+
const onScrubDown = useCallback(
|
|
893
|
+
(e) => {
|
|
894
|
+
e.currentTarget.setPointerCapture(e.pointerId);
|
|
895
|
+
controller.beginScrub(tickFromPointer(e.clientX));
|
|
896
|
+
},
|
|
897
|
+
[controller, tickFromPointer]
|
|
898
|
+
);
|
|
899
|
+
const onScrubMove = useCallback(
|
|
900
|
+
(e) => {
|
|
901
|
+
if (controller.isScrubbing()) controller.updateScrub(tickFromPointer(e.clientX));
|
|
902
|
+
},
|
|
903
|
+
[controller, tickFromPointer]
|
|
904
|
+
);
|
|
905
|
+
const onScrubUp = useCallback(
|
|
906
|
+
(e) => {
|
|
907
|
+
try {
|
|
908
|
+
e.currentTarget.releasePointerCapture(e.pointerId);
|
|
909
|
+
} catch {
|
|
910
|
+
}
|
|
911
|
+
controller.endScrub();
|
|
912
|
+
},
|
|
913
|
+
[controller]
|
|
914
|
+
);
|
|
915
|
+
const onKeyDown = useCallback(
|
|
916
|
+
(e) => {
|
|
917
|
+
if (controller.handleKey({ key: e.key, ctrl: e.ctrlKey, meta: e.metaKey, shift: e.shiftKey }, "down")) {
|
|
918
|
+
e.preventDefault();
|
|
919
|
+
e.stopPropagation();
|
|
920
|
+
}
|
|
921
|
+
},
|
|
922
|
+
[controller]
|
|
923
|
+
);
|
|
924
|
+
const onKeyUp = useCallback(
|
|
925
|
+
(e) => {
|
|
926
|
+
controller.handleKey({ key: e.key }, "up");
|
|
927
|
+
},
|
|
928
|
+
[controller]
|
|
929
|
+
);
|
|
930
|
+
return /* @__PURE__ */ jsxs2(
|
|
931
|
+
"div",
|
|
932
|
+
{
|
|
933
|
+
style: S.root,
|
|
934
|
+
className: "xeno-transport-panel",
|
|
935
|
+
onKeyDown,
|
|
936
|
+
onKeyUp,
|
|
937
|
+
tabIndex: 0,
|
|
938
|
+
role: "group",
|
|
939
|
+
"aria-label": "Transport",
|
|
940
|
+
children: [
|
|
941
|
+
/* @__PURE__ */ jsxs2("div", { style: S.row, children: [
|
|
942
|
+
/* @__PURE__ */ jsx2("button", { type: "button", style: S.iconBtn, onClick: () => controller.goToStart(), title: "Go to start", "aria-label": "Go to start", children: /* @__PURE__ */ jsx2(StartIcon, {}) }),
|
|
943
|
+
/* @__PURE__ */ jsx2("button", { type: "button", style: S.iconBtn, onClick: () => controller.step(-1), title: "Step back", "aria-label": "Step back", children: /* @__PURE__ */ jsx2(StepBackIcon, {}) }),
|
|
944
|
+
controller.showsControl("shuttle") && /* @__PURE__ */ jsx2("button", { type: "button", style: S.iconBtn, onClick: () => controller.shuttleStep(-1), title: "Shuttle reverse (J)", "aria-label": "Shuttle reverse", children: /* @__PURE__ */ jsx2(ShuttleLeftIcon, {}) }),
|
|
945
|
+
/* @__PURE__ */ jsx2(
|
|
946
|
+
"button",
|
|
947
|
+
{
|
|
948
|
+
type: "button",
|
|
949
|
+
style: { ...S.iconBtn, ...S.primaryBtn },
|
|
950
|
+
onClick: () => controller.togglePlay(),
|
|
951
|
+
title: playing ? "Pause (Space)" : "Play (Space)",
|
|
952
|
+
"aria-label": playing ? "Pause" : "Play",
|
|
953
|
+
"aria-pressed": playing,
|
|
954
|
+
children: playing ? /* @__PURE__ */ jsx2(PauseIcon, {}) : /* @__PURE__ */ jsx2(PlayIcon, {})
|
|
955
|
+
}
|
|
956
|
+
),
|
|
957
|
+
/* @__PURE__ */ jsx2("button", { type: "button", style: S.iconBtn, onClick: () => controller.stop(), title: "Stop", "aria-label": "Stop", children: /* @__PURE__ */ jsx2(StopIcon, {}) }),
|
|
958
|
+
controller.showsControl("shuttle") && /* @__PURE__ */ jsx2("button", { type: "button", style: S.iconBtn, onClick: () => controller.shuttleStep(1), title: "Shuttle forward (L)", "aria-label": "Shuttle forward", children: /* @__PURE__ */ jsx2(ShuttleRightIcon, {}) }),
|
|
959
|
+
/* @__PURE__ */ jsx2("button", { type: "button", style: S.iconBtn, onClick: () => controller.step(1), title: "Step forward", "aria-label": "Step forward", children: /* @__PURE__ */ jsx2(StepForwardIcon, {}) }),
|
|
960
|
+
/* @__PURE__ */ jsx2("button", { type: "button", style: S.iconBtn, onClick: () => controller.goToEnd(), title: "Go to end", "aria-label": "Go to end", children: /* @__PURE__ */ jsx2(EndIcon, {}) }),
|
|
961
|
+
controller.showsControl("record") && /* @__PURE__ */ jsx2(
|
|
962
|
+
"button",
|
|
963
|
+
{
|
|
964
|
+
type: "button",
|
|
965
|
+
style: { ...S.iconBtn, color: state.recording ? "var(--xeno-danger)" : void 0, opacity: state.recording ? 1 : 0.7 },
|
|
966
|
+
onClick: () => controller.record(),
|
|
967
|
+
title: "Record",
|
|
968
|
+
"aria-label": "Record",
|
|
969
|
+
"aria-pressed": !!state.recording,
|
|
970
|
+
children: /* @__PURE__ */ jsx2(RecordIcon, {})
|
|
971
|
+
}
|
|
972
|
+
),
|
|
973
|
+
controller.showsControl("loop") && /* @__PURE__ */ jsx2(
|
|
974
|
+
"button",
|
|
975
|
+
{
|
|
976
|
+
type: "button",
|
|
977
|
+
style: { ...S.iconBtn, opacity: state.loopEnabled ? 1 : 0.4 },
|
|
978
|
+
onClick: () => controller.toggleLoop(),
|
|
979
|
+
title: "Loop",
|
|
980
|
+
"aria-label": "Loop",
|
|
981
|
+
"aria-pressed": !!state.loopEnabled,
|
|
982
|
+
children: /* @__PURE__ */ jsx2(LoopIcon, {})
|
|
983
|
+
}
|
|
984
|
+
),
|
|
985
|
+
controller.showsControl("metronome") && /* @__PURE__ */ jsx2(
|
|
986
|
+
"button",
|
|
987
|
+
{
|
|
988
|
+
type: "button",
|
|
989
|
+
style: { ...S.iconBtn, opacity: state.metronome ? 1 : 0.4 },
|
|
990
|
+
onClick: () => controller.toggleMetronome(),
|
|
991
|
+
title: "Metronome",
|
|
992
|
+
"aria-label": "Metronome",
|
|
993
|
+
"aria-pressed": !!state.metronome,
|
|
994
|
+
children: /* @__PURE__ */ jsx2(MetronomeIcon, {})
|
|
995
|
+
}
|
|
996
|
+
),
|
|
997
|
+
controller.showsControl("punch") && /* @__PURE__ */ jsx2("button", { type: "button", style: { ...S.chipBtn, opacity: state.punch ? 1 : 0.4 }, "aria-pressed": !!state.punch, title: "Punch", children: "PUNCH" }),
|
|
998
|
+
/* @__PURE__ */ jsxs2(
|
|
999
|
+
"button",
|
|
1000
|
+
{
|
|
1001
|
+
type: "button",
|
|
1002
|
+
style: S.readout,
|
|
1003
|
+
onClick: () => controller.cycleDisplay(),
|
|
1004
|
+
title: "Click to change time display",
|
|
1005
|
+
"aria-label": `Position ${controller.getReadout()}`,
|
|
1006
|
+
children: [
|
|
1007
|
+
/* @__PURE__ */ jsx2("span", { style: S.readoutValue, children: controller.getReadout() }),
|
|
1008
|
+
/* @__PURE__ */ jsx2("span", { style: S.readoutUnit, children: (0, format_exports.displayLabel)(controller.getDisplay()) })
|
|
1009
|
+
]
|
|
1010
|
+
}
|
|
1011
|
+
),
|
|
1012
|
+
/* @__PURE__ */ jsx2("span", { style: S.duration, children: controller.getDurationReadout() }),
|
|
1013
|
+
controller.showsControl("bpm") && /* @__PURE__ */ jsxs2("label", { style: S.bpm, children: [
|
|
1014
|
+
/* @__PURE__ */ jsx2("span", { style: S.bpmLabel, children: "BPM" }),
|
|
1015
|
+
/* @__PURE__ */ jsx2(
|
|
1016
|
+
"input",
|
|
1017
|
+
{
|
|
1018
|
+
type: "number",
|
|
1019
|
+
value: state.bpm ?? 120,
|
|
1020
|
+
onChange: (e) => controller.setTempo(Number(e.target.value)),
|
|
1021
|
+
style: S.bpmInput,
|
|
1022
|
+
"aria-label": "Tempo"
|
|
1023
|
+
}
|
|
1024
|
+
)
|
|
1025
|
+
] }),
|
|
1026
|
+
controller.showsControl("inOut") && /* @__PURE__ */ jsxs2("span", { style: S.inOut, children: [
|
|
1027
|
+
/* @__PURE__ */ jsx2("button", { type: "button", style: S.chipBtn, onClick: () => controller.setInPoint(), title: "Set in point", children: "IN" }),
|
|
1028
|
+
/* @__PURE__ */ jsx2("button", { type: "button", style: S.chipBtn, onClick: () => controller.setOutPoint(), title: "Set out point", children: "OUT" })
|
|
1029
|
+
] })
|
|
1030
|
+
] }),
|
|
1031
|
+
controller.showsControl("scrubber") && /* @__PURE__ */ jsxs2(
|
|
1032
|
+
"div",
|
|
1033
|
+
{
|
|
1034
|
+
ref: scrubRef,
|
|
1035
|
+
style: S.scrubTrack,
|
|
1036
|
+
onPointerDown: onScrubDown,
|
|
1037
|
+
onPointerMove: onScrubMove,
|
|
1038
|
+
onPointerUp: onScrubUp,
|
|
1039
|
+
onPointerCancel: () => controller.cancelScrub(),
|
|
1040
|
+
role: "slider",
|
|
1041
|
+
"aria-label": "Playhead",
|
|
1042
|
+
"aria-valuemin": 0,
|
|
1043
|
+
"aria-valuemax": controller.getDuration(),
|
|
1044
|
+
"aria-valuenow": controller.getCurrentTick(),
|
|
1045
|
+
"aria-valuetext": controller.getReadout(),
|
|
1046
|
+
tabIndex: 0,
|
|
1047
|
+
children: [
|
|
1048
|
+
/* @__PURE__ */ jsx2("span", { style: { ...S.scrubFill, width: `${controller.getProgress() * 100}%` } }),
|
|
1049
|
+
/* @__PURE__ */ jsx2("span", { style: { ...S.scrubKnob, left: `${controller.getProgress() * 100}%` } })
|
|
1050
|
+
]
|
|
1051
|
+
}
|
|
1052
|
+
)
|
|
1053
|
+
]
|
|
1054
|
+
}
|
|
1055
|
+
);
|
|
1056
|
+
}
|
|
1057
|
+
var S = {
|
|
1058
|
+
root: {
|
|
1059
|
+
display: "flex",
|
|
1060
|
+
flexDirection: "column",
|
|
1061
|
+
gap: 3,
|
|
1062
|
+
padding: "4px 6px",
|
|
1063
|
+
fontSize: 11,
|
|
1064
|
+
color: "var(--xeno-text)",
|
|
1065
|
+
background: "var(--xeno-canvas)",
|
|
1066
|
+
userSelect: "none",
|
|
1067
|
+
outline: "none"
|
|
1068
|
+
},
|
|
1069
|
+
row: { display: "flex", alignItems: "center", gap: 3, flexWrap: "wrap" },
|
|
1070
|
+
iconBtn: {
|
|
1071
|
+
display: "flex",
|
|
1072
|
+
alignItems: "center",
|
|
1073
|
+
justifyContent: "center",
|
|
1074
|
+
width: 20,
|
|
1075
|
+
height: 18,
|
|
1076
|
+
padding: 0,
|
|
1077
|
+
border: "none",
|
|
1078
|
+
borderRadius: 2,
|
|
1079
|
+
background: "var(--xeno-border-subtle)",
|
|
1080
|
+
color: "inherit",
|
|
1081
|
+
cursor: "pointer",
|
|
1082
|
+
flex: "0 0 auto"
|
|
1083
|
+
},
|
|
1084
|
+
primaryBtn: { background: "var(--xeno-control)", width: 26 },
|
|
1085
|
+
chipBtn: {
|
|
1086
|
+
height: 18,
|
|
1087
|
+
padding: "0 5px",
|
|
1088
|
+
border: "none",
|
|
1089
|
+
borderRadius: 2,
|
|
1090
|
+
background: "var(--xeno-border-subtle)",
|
|
1091
|
+
color: "inherit",
|
|
1092
|
+
cursor: "pointer",
|
|
1093
|
+
fontSize: 9,
|
|
1094
|
+
letterSpacing: "0.05em",
|
|
1095
|
+
flex: "0 0 auto"
|
|
1096
|
+
},
|
|
1097
|
+
readout: {
|
|
1098
|
+
display: "flex",
|
|
1099
|
+
alignItems: "baseline",
|
|
1100
|
+
gap: 4,
|
|
1101
|
+
height: 18,
|
|
1102
|
+
padding: "0 6px",
|
|
1103
|
+
marginLeft: 4,
|
|
1104
|
+
border: "1px solid var(--xeno-border)",
|
|
1105
|
+
borderRadius: 2,
|
|
1106
|
+
background: "var(--xeno-canvas)",
|
|
1107
|
+
color: "inherit",
|
|
1108
|
+
cursor: "pointer",
|
|
1109
|
+
flex: "0 0 auto"
|
|
1110
|
+
},
|
|
1111
|
+
readoutValue: { fontVariantNumeric: "tabular-nums", fontSize: 12, letterSpacing: "0.02em" },
|
|
1112
|
+
readoutUnit: { fontSize: 8, color: "var(--xeno-muted)", letterSpacing: "0.06em" },
|
|
1113
|
+
duration: {
|
|
1114
|
+
fontVariantNumeric: "tabular-nums",
|
|
1115
|
+
fontSize: 10,
|
|
1116
|
+
color: "var(--xeno-muted)",
|
|
1117
|
+
flex: "0 0 auto"
|
|
1118
|
+
},
|
|
1119
|
+
bpm: { display: "flex", alignItems: "center", gap: 3, flex: "0 0 auto" },
|
|
1120
|
+
bpmLabel: { fontSize: 8, color: "var(--xeno-muted)", letterSpacing: "0.06em" },
|
|
1121
|
+
bpmInput: {
|
|
1122
|
+
width: 42,
|
|
1123
|
+
height: 18,
|
|
1124
|
+
background: "var(--xeno-canvas)",
|
|
1125
|
+
color: "inherit",
|
|
1126
|
+
border: "1px solid var(--xeno-border)",
|
|
1127
|
+
borderRadius: 2,
|
|
1128
|
+
fontSize: 11,
|
|
1129
|
+
padding: "0 4px",
|
|
1130
|
+
outline: "none",
|
|
1131
|
+
fontVariantNumeric: "tabular-nums"
|
|
1132
|
+
},
|
|
1133
|
+
inOut: { display: "flex", gap: 2, flex: "0 0 auto" },
|
|
1134
|
+
scrubTrack: {
|
|
1135
|
+
position: "relative",
|
|
1136
|
+
height: 8,
|
|
1137
|
+
borderRadius: 2,
|
|
1138
|
+
background: "var(--xeno-border-subtle)",
|
|
1139
|
+
cursor: "pointer",
|
|
1140
|
+
touchAction: "none",
|
|
1141
|
+
outline: "none"
|
|
1142
|
+
},
|
|
1143
|
+
scrubFill: {
|
|
1144
|
+
position: "absolute",
|
|
1145
|
+
left: 0,
|
|
1146
|
+
top: 0,
|
|
1147
|
+
bottom: 0,
|
|
1148
|
+
background: "var(--xeno-control)",
|
|
1149
|
+
borderRadius: 2,
|
|
1150
|
+
pointerEvents: "none"
|
|
1151
|
+
},
|
|
1152
|
+
scrubKnob: {
|
|
1153
|
+
position: "absolute",
|
|
1154
|
+
top: -1,
|
|
1155
|
+
width: 3,
|
|
1156
|
+
height: 10,
|
|
1157
|
+
marginLeft: -1,
|
|
1158
|
+
background: "var(--xeno-text)",
|
|
1159
|
+
borderRadius: 1,
|
|
1160
|
+
pointerEvents: "none"
|
|
1161
|
+
}
|
|
1162
|
+
};
|
|
1163
|
+
|
|
1164
|
+
// src/time/transport/panel.ts
|
|
1165
|
+
function createTransportPanel(options = {}) {
|
|
1166
|
+
return {
|
|
1167
|
+
manifest: transportManifest,
|
|
1168
|
+
activate(host) {
|
|
1169
|
+
const controller = new TransportPanelController(host, options);
|
|
1170
|
+
void controller.load();
|
|
1171
|
+
let root = null;
|
|
1172
|
+
return {
|
|
1173
|
+
render(el) {
|
|
1174
|
+
root = createRoot(el);
|
|
1175
|
+
root.render(createElement(TransportPanelView, { controller }));
|
|
1176
|
+
return () => {
|
|
1177
|
+
root?.unmount();
|
|
1178
|
+
root = null;
|
|
1179
|
+
};
|
|
1180
|
+
},
|
|
1181
|
+
onInput(id, value) {
|
|
1182
|
+
controller.onInput(id, value);
|
|
1183
|
+
},
|
|
1184
|
+
onCommand(id, params) {
|
|
1185
|
+
return controller.onCommand(id, params);
|
|
1186
|
+
},
|
|
1187
|
+
serialize() {
|
|
1188
|
+
return controller.serialize();
|
|
1189
|
+
},
|
|
1190
|
+
deserialize(state) {
|
|
1191
|
+
controller.deserialize(state);
|
|
1192
|
+
},
|
|
1193
|
+
dispose() {
|
|
1194
|
+
root?.unmount();
|
|
1195
|
+
root = null;
|
|
1196
|
+
controller.dispose();
|
|
1197
|
+
}
|
|
1198
|
+
};
|
|
1199
|
+
}
|
|
1200
|
+
};
|
|
1201
|
+
}
|
|
1202
|
+
var transportPanel = createTransportPanel();
|
|
1203
|
+
|
|
1204
|
+
// src/time/timeline/panel.ts
|
|
1205
|
+
import { createElement as createElement2 } from "react";
|
|
1206
|
+
import { createRoot as createRoot2 } from "react-dom/client";
|
|
1207
|
+
import {
|
|
1208
|
+
bindConfig,
|
|
1209
|
+
configBool,
|
|
1210
|
+
configNumber,
|
|
1211
|
+
configString,
|
|
1212
|
+
hasFields,
|
|
1213
|
+
isRecord
|
|
1214
|
+
} from "@xenosystem/panel-sdk";
|
|
1215
|
+
|
|
1216
|
+
// src/time/timeline/controller.ts
|
|
1217
|
+
import {
|
|
1218
|
+
clampTick as clampTick3,
|
|
1219
|
+
extrapolateTick as extrapolateTick3,
|
|
1220
|
+
normalizeClock as normalizeClock3,
|
|
1221
|
+
normalizeTimebase as normalizeTimebase3,
|
|
1222
|
+
createThrottle as createThrottle3,
|
|
1223
|
+
DEFAULT_TIMEBASE as DEFAULT_TIMEBASE3,
|
|
1224
|
+
IDLE_CLOCK as IDLE_CLOCK3
|
|
1225
|
+
} from "@xenosystem/time-core";
|
|
1226
|
+
|
|
1227
|
+
// src/time/timeline/scale.ts
|
|
1228
|
+
var DEFAULT_ZOOM = { min: 5e-4, max: 64 };
|
|
1229
|
+
var XenoTimeScale = class _XenoTimeScale {
|
|
1230
|
+
pxPerTick;
|
|
1231
|
+
scrollTick;
|
|
1232
|
+
scrollTopPx;
|
|
1233
|
+
widthPx;
|
|
1234
|
+
heightPx;
|
|
1235
|
+
gutterPx;
|
|
1236
|
+
constructor(state, geometry) {
|
|
1237
|
+
this.widthPx = Math.max(1, finiteOr(geometry.widthPx, 1));
|
|
1238
|
+
this.heightPx = Math.max(0, finiteOr(geometry.heightPx, 0));
|
|
1239
|
+
this.gutterPx = Math.max(0, Math.min(finiteOr(geometry.gutterPx, 0), this.widthPx - 1));
|
|
1240
|
+
this.pxPerTick = Math.max(Number.MIN_VALUE, finiteOr(state.pxPerTick, 1));
|
|
1241
|
+
this.scrollTick = Math.max(0, finiteOr(state.scrollTick, 0));
|
|
1242
|
+
this.scrollTopPx = Math.max(0, finiteOr(state.scrollTopPx, 0));
|
|
1243
|
+
}
|
|
1244
|
+
/** Width of the lane area — the container minus the gutter. Always ≥ 1. */
|
|
1245
|
+
get lanePx() {
|
|
1246
|
+
return Math.max(1, this.widthPx - this.gutterPx);
|
|
1247
|
+
}
|
|
1248
|
+
/** Ticks visible in the lane area. */
|
|
1249
|
+
get spanTicks() {
|
|
1250
|
+
return this.lanePx / this.pxPerTick;
|
|
1251
|
+
}
|
|
1252
|
+
/** The tick at the right edge of the lane area. */
|
|
1253
|
+
get endTick() {
|
|
1254
|
+
return this.scrollTick + this.spanTicks;
|
|
1255
|
+
}
|
|
1256
|
+
/**
|
|
1257
|
+
* Container-relative x → tick.
|
|
1258
|
+
*
|
|
1259
|
+
* @param x - Pixels from the **container's** left edge (`event.clientX - rect.left`).
|
|
1260
|
+
* @returns The tick under that pixel. May be negative left of the origin.
|
|
1261
|
+
*
|
|
1262
|
+
* @example
|
|
1263
|
+
* ```ts
|
|
1264
|
+
* const rect = el.getBoundingClientRect()
|
|
1265
|
+
* const tick = scale.xToTick(event.clientX - rect.left)
|
|
1266
|
+
* ```
|
|
1267
|
+
*/
|
|
1268
|
+
xToTick(x) {
|
|
1269
|
+
return this.scrollTick + (finiteOr(x, 0) - this.gutterPx) / this.pxPerTick;
|
|
1270
|
+
}
|
|
1271
|
+
/**
|
|
1272
|
+
* Tick → container-relative x.
|
|
1273
|
+
*
|
|
1274
|
+
* @param tick - A tick.
|
|
1275
|
+
* @returns Pixels from the container's left edge.
|
|
1276
|
+
*/
|
|
1277
|
+
tickToX(tick) {
|
|
1278
|
+
return this.gutterPx + (finiteOr(tick, 0) - this.scrollTick) * this.pxPerTick;
|
|
1279
|
+
}
|
|
1280
|
+
/**
|
|
1281
|
+
* A tick DURATION → a pixel width.
|
|
1282
|
+
*
|
|
1283
|
+
* Deliberately separate from {@link tickToX}, because rounding is **not associative**:
|
|
1284
|
+
* `durationToPx(b - a)` and `tickToX(b) - tickToX(a)` differ by up to a pixel, and mixing them is
|
|
1285
|
+
* how a clip's right edge ends up one pixel from its neighbour's left edge at some zoom levels and
|
|
1286
|
+
* overlapping at others. Pick one per axis and never mix.
|
|
1287
|
+
*
|
|
1288
|
+
* @param ticks - A duration in ticks.
|
|
1289
|
+
* @returns Width in CSS px.
|
|
1290
|
+
*/
|
|
1291
|
+
durationToPx(ticks) {
|
|
1292
|
+
return finiteOr(ticks, 0) * this.pxPerTick;
|
|
1293
|
+
}
|
|
1294
|
+
/** A pixel distance → a tick distance. Used for screen-space thresholds. */
|
|
1295
|
+
pxToTicks(px) {
|
|
1296
|
+
return finiteOr(px, 0) / this.pxPerTick;
|
|
1297
|
+
}
|
|
1298
|
+
/** Is this x inside the lane area (rather than the gutter)? */
|
|
1299
|
+
isInLane(x) {
|
|
1300
|
+
return x >= this.gutterPx && x <= this.widthPx;
|
|
1301
|
+
}
|
|
1302
|
+
/** A copy with new state. */
|
|
1303
|
+
with(state) {
|
|
1304
|
+
return new _XenoTimeScale(
|
|
1305
|
+
{
|
|
1306
|
+
pxPerTick: state.pxPerTick ?? this.pxPerTick,
|
|
1307
|
+
scrollTick: state.scrollTick ?? this.scrollTick,
|
|
1308
|
+
scrollTopPx: state.scrollTopPx ?? this.scrollTopPx
|
|
1309
|
+
},
|
|
1310
|
+
{ widthPx: this.widthPx, heightPx: this.heightPx, gutterPx: this.gutterPx }
|
|
1311
|
+
);
|
|
1312
|
+
}
|
|
1313
|
+
/** A copy with new geometry. */
|
|
1314
|
+
resized(geometry) {
|
|
1315
|
+
return new _XenoTimeScale(
|
|
1316
|
+
{ pxPerTick: this.pxPerTick, scrollTick: this.scrollTick, scrollTopPx: this.scrollTopPx },
|
|
1317
|
+
{
|
|
1318
|
+
widthPx: geometry.widthPx ?? this.widthPx,
|
|
1319
|
+
heightPx: geometry.heightPx ?? this.heightPx,
|
|
1320
|
+
gutterPx: geometry.gutterPx ?? this.gutterPx
|
|
1321
|
+
}
|
|
1322
|
+
);
|
|
1323
|
+
}
|
|
1324
|
+
};
|
|
1325
|
+
function finiteOr(value, fallback) {
|
|
1326
|
+
return Number.isFinite(value) ? value : fallback;
|
|
1327
|
+
}
|
|
1328
|
+
function zoomAboutCursor(scale, x, factor, bounds = DEFAULT_ZOOM, maxTick = Number.POSITIVE_INFINITY) {
|
|
1329
|
+
const anchorTick = scale.xToTick(x);
|
|
1330
|
+
const requested = scale.pxPerTick * (Number.isFinite(factor) && factor > 0 ? factor : 1);
|
|
1331
|
+
const pxPerTick = Math.min(bounds.max, Math.max(bounds.min, requested));
|
|
1332
|
+
const laneX = x - scale.gutterPx;
|
|
1333
|
+
const scrollTick = anchorTick - laneX / pxPerTick;
|
|
1334
|
+
return {
|
|
1335
|
+
pxPerTick,
|
|
1336
|
+
scrollTick: clampScroll(scrollTick, pxPerTick, scale.lanePx, maxTick),
|
|
1337
|
+
scrollTopPx: scale.scrollTopPx
|
|
1338
|
+
};
|
|
1339
|
+
}
|
|
1340
|
+
function wheelZoomFactor(deltaY, sensitivity = 0.01) {
|
|
1341
|
+
const raw = Number.isFinite(deltaY) ? deltaY : 0;
|
|
1342
|
+
const clamped = Math.max(-10, Math.min(10, raw));
|
|
1343
|
+
return Math.pow(2, -clamped * sensitivity * 10);
|
|
1344
|
+
}
|
|
1345
|
+
function isZoomGesture(event, zoomModifier = "alt") {
|
|
1346
|
+
if (event.ctrlKey) return true;
|
|
1347
|
+
if (zoomModifier === "alt") return event.altKey === true;
|
|
1348
|
+
if (zoomModifier === "meta") return event.metaKey === true;
|
|
1349
|
+
return false;
|
|
1350
|
+
}
|
|
1351
|
+
function clampScroll(scrollTick, pxPerTick, lanePx, maxTick) {
|
|
1352
|
+
if (!Number.isFinite(scrollTick)) return 0;
|
|
1353
|
+
if (!Number.isFinite(maxTick)) return Math.max(0, scrollTick);
|
|
1354
|
+
const limit = Math.max(0, maxTick - lanePx / pxPerTick / 2);
|
|
1355
|
+
return Math.max(0, Math.min(scrollTick, limit));
|
|
1356
|
+
}
|
|
1357
|
+
function zoomToFit(fromTick, toTick3, lanePx, bounds = DEFAULT_ZOOM) {
|
|
1358
|
+
const span = Math.max(1, toTick3 - fromTick);
|
|
1359
|
+
const pxPerTick = Math.min(bounds.max, Math.max(bounds.min, Math.max(1, lanePx) / span));
|
|
1360
|
+
return { pxPerTick, scrollTick: Math.max(0, fromTick) };
|
|
1361
|
+
}
|
|
1362
|
+
function scrollToReveal(scale, tick, marginPx = 24, maxTick = Number.POSITIVE_INFINITY) {
|
|
1363
|
+
const margin = scale.pxToTicks(marginPx);
|
|
1364
|
+
const target = Math.max(0, tick);
|
|
1365
|
+
if (target < scale.scrollTick + margin) {
|
|
1366
|
+
return clampScroll(target - margin, scale.pxPerTick, scale.lanePx, maxTick);
|
|
1367
|
+
}
|
|
1368
|
+
if (target > scale.endTick - margin) {
|
|
1369
|
+
return clampScroll(target - scale.spanTicks + margin, scale.pxPerTick, scale.lanePx, maxTick);
|
|
1370
|
+
}
|
|
1371
|
+
return scale.scrollTick;
|
|
1372
|
+
}
|
|
1373
|
+
|
|
1374
|
+
// src/time/timeline/layout.ts
|
|
1375
|
+
function buildRowLayout(tracks, collapsedPx = 20) {
|
|
1376
|
+
const offsets = new Float64Array(tracks.length + 1);
|
|
1377
|
+
let total = 0;
|
|
1378
|
+
for (let i = 0; i < tracks.length; i += 1) {
|
|
1379
|
+
offsets[i] = total;
|
|
1380
|
+
const track = tracks[i];
|
|
1381
|
+
const height = track.collapsed ? collapsedPx : track.heightPx;
|
|
1382
|
+
total += Number.isFinite(height) && height > 0 ? height : collapsedPx;
|
|
1383
|
+
}
|
|
1384
|
+
offsets[tracks.length] = total;
|
|
1385
|
+
return { offsets, totalPx: total };
|
|
1386
|
+
}
|
|
1387
|
+
function visibleRows(layout, scrollTopPx, viewportPx, overscan = 1) {
|
|
1388
|
+
const count = layout.offsets.length - 1;
|
|
1389
|
+
if (count <= 0) return { first: 0, last: 0 };
|
|
1390
|
+
const top = Math.max(0, scrollTopPx);
|
|
1391
|
+
const bottom = top + Math.max(0, viewportPx);
|
|
1392
|
+
let first = binarySearchOffset(layout.offsets, top, count);
|
|
1393
|
+
while (first > 0 && layout.offsets[first] > top) first -= 1;
|
|
1394
|
+
let last = first;
|
|
1395
|
+
while (last < count && layout.offsets[last] < bottom) last += 1;
|
|
1396
|
+
return {
|
|
1397
|
+
first: Math.max(0, first - overscan),
|
|
1398
|
+
last: Math.min(count, last + overscan)
|
|
1399
|
+
};
|
|
1400
|
+
}
|
|
1401
|
+
function rowAtY(layout, y) {
|
|
1402
|
+
const count = layout.offsets.length - 1;
|
|
1403
|
+
if (count <= 0 || y < 0 || y >= layout.totalPx) return -1;
|
|
1404
|
+
return Math.min(count - 1, binarySearchOffset(layout.offsets, y, count));
|
|
1405
|
+
}
|
|
1406
|
+
function binarySearchOffset(offsets, value, count) {
|
|
1407
|
+
let low = 0;
|
|
1408
|
+
let high = count - 1;
|
|
1409
|
+
let result = 0;
|
|
1410
|
+
while (low <= high) {
|
|
1411
|
+
const mid = low + high >> 1;
|
|
1412
|
+
if (offsets[mid] <= value) {
|
|
1413
|
+
result = mid;
|
|
1414
|
+
low = mid + 1;
|
|
1415
|
+
} else {
|
|
1416
|
+
high = mid - 1;
|
|
1417
|
+
}
|
|
1418
|
+
}
|
|
1419
|
+
return result;
|
|
1420
|
+
}
|
|
1421
|
+
function visibleClips(track, clips, startTick, endTick) {
|
|
1422
|
+
const ids = track.clipIds;
|
|
1423
|
+
if (ids.length === 0) return [];
|
|
1424
|
+
let low = 0;
|
|
1425
|
+
let high = ids.length - 1;
|
|
1426
|
+
let rightmost = -1;
|
|
1427
|
+
while (low <= high) {
|
|
1428
|
+
const mid = low + high >> 1;
|
|
1429
|
+
const clip = clips[ids[mid]];
|
|
1430
|
+
if (clip && clip.startTick < endTick) {
|
|
1431
|
+
rightmost = mid;
|
|
1432
|
+
low = mid + 1;
|
|
1433
|
+
} else {
|
|
1434
|
+
high = mid - 1;
|
|
1435
|
+
}
|
|
1436
|
+
}
|
|
1437
|
+
if (rightmost < 0) return [];
|
|
1438
|
+
const out = [];
|
|
1439
|
+
for (let i = rightmost; i >= 0; i -= 1) {
|
|
1440
|
+
const clip = clips[ids[i]];
|
|
1441
|
+
if (!clip) continue;
|
|
1442
|
+
if (clip.endTick <= startTick) break;
|
|
1443
|
+
out.push(clip);
|
|
1444
|
+
}
|
|
1445
|
+
out.reverse();
|
|
1446
|
+
return out;
|
|
1447
|
+
}
|
|
1448
|
+
function renderTier(widthPx, breakpoints = [40, 120]) {
|
|
1449
|
+
if (widthPx < breakpoints[0]) return "sliver";
|
|
1450
|
+
if (widthPx < breakpoints[1]) return "compact";
|
|
1451
|
+
return "full";
|
|
1452
|
+
}
|
|
1453
|
+
function dedupeByColumn(clips, scale, minGapPx = 1) {
|
|
1454
|
+
const out = [];
|
|
1455
|
+
let lastX = Number.NEGATIVE_INFINITY;
|
|
1456
|
+
for (const clip of clips) {
|
|
1457
|
+
const x = scale.tickToX(clip.startTick);
|
|
1458
|
+
const width = scale.durationToPx(clip.endTick - clip.startTick);
|
|
1459
|
+
if (width >= minGapPx || x - lastX >= minGapPx) {
|
|
1460
|
+
out.push(clip);
|
|
1461
|
+
lastX = x;
|
|
1462
|
+
}
|
|
1463
|
+
}
|
|
1464
|
+
return out;
|
|
1465
|
+
}
|
|
1466
|
+
function computeVisible(arrangement, scale, options = {}) {
|
|
1467
|
+
const rows = buildRowLayout(arrangement.tracks, options.collapsedPx);
|
|
1468
|
+
const laneHeight = Math.max(0, scale.heightPx - (options.rulerPx ?? 0));
|
|
1469
|
+
const { first, last } = visibleRows(rows, scale.scrollTopPx, laneHeight, options.overscan ?? 1);
|
|
1470
|
+
const startTick = scale.scrollTick;
|
|
1471
|
+
const endTick = scale.endTick;
|
|
1472
|
+
const tracks = [];
|
|
1473
|
+
for (let i = first; i < last; i += 1) {
|
|
1474
|
+
const track = arrangement.tracks[i];
|
|
1475
|
+
if (!track) continue;
|
|
1476
|
+
const topPx = rows.offsets[i];
|
|
1477
|
+
const heightPx = rows.offsets[i + 1] - topPx;
|
|
1478
|
+
const clips = track.collapsed ? [] : visibleClips(track, arrangement.clips, startTick, endTick);
|
|
1479
|
+
tracks.push({
|
|
1480
|
+
track,
|
|
1481
|
+
index: i,
|
|
1482
|
+
topPx,
|
|
1483
|
+
heightPx,
|
|
1484
|
+
clips: options.dedupe === false ? clips : dedupeByColumn(clips, scale)
|
|
1485
|
+
});
|
|
1486
|
+
}
|
|
1487
|
+
return { rows, tracks };
|
|
1488
|
+
}
|
|
1489
|
+
|
|
1490
|
+
// src/time/timeline/ruler.ts
|
|
1491
|
+
import { effectiveFps as effectiveFps2, formatTick as formatTick3 } from "@xenosystem/time-core";
|
|
1492
|
+
var MIN_MAJOR_PX = 120;
|
|
1493
|
+
var MIN_MINOR_PX = 8;
|
|
1494
|
+
var SECOND_LADDER = [
|
|
1495
|
+
{ seconds: 1e-3, subdivisions: 10, medium: 5 },
|
|
1496
|
+
{ seconds: 0.01, subdivisions: 10, medium: 5 },
|
|
1497
|
+
{ seconds: 0.1, subdivisions: 10, medium: 5 },
|
|
1498
|
+
{ seconds: 0.5, subdivisions: 5, medium: 0 },
|
|
1499
|
+
{ seconds: 1, subdivisions: 10, medium: 5 },
|
|
1500
|
+
{ seconds: 2, subdivisions: 4, medium: 2 },
|
|
1501
|
+
{ seconds: 5, subdivisions: 5, medium: 0 },
|
|
1502
|
+
{ seconds: 10, subdivisions: 10, medium: 5 },
|
|
1503
|
+
{ seconds: 15, subdivisions: 3, medium: 0 },
|
|
1504
|
+
{ seconds: 30, subdivisions: 6, medium: 3 },
|
|
1505
|
+
{ seconds: 60, subdivisions: 4, medium: 2 },
|
|
1506
|
+
{ seconds: 120, subdivisions: 4, medium: 2 },
|
|
1507
|
+
{ seconds: 300, subdivisions: 5, medium: 0 },
|
|
1508
|
+
{ seconds: 600, subdivisions: 10, medium: 5 },
|
|
1509
|
+
{ seconds: 900, subdivisions: 3, medium: 0 },
|
|
1510
|
+
{ seconds: 1800, subdivisions: 6, medium: 3 },
|
|
1511
|
+
{ seconds: 3600, subdivisions: 4, medium: 2 },
|
|
1512
|
+
{ seconds: 7200, subdivisions: 4, medium: 2 },
|
|
1513
|
+
{ seconds: 21600, subdivisions: 6, medium: 3 },
|
|
1514
|
+
{ seconds: 43200, subdivisions: 4, medium: 2 },
|
|
1515
|
+
{ seconds: 86400, subdivisions: 4, medium: 2 }
|
|
1516
|
+
];
|
|
1517
|
+
var FRAME_STEPS = [1, 2, 5, 10, 15, 30];
|
|
1518
|
+
function chooseRung(scale, timebase) {
|
|
1519
|
+
const tps = Math.max(1, timebase.ticksPerSecond);
|
|
1520
|
+
if (timebase.display === "frames" || timebase.display === "timecode") {
|
|
1521
|
+
const fps = effectiveFps2(timebase) || 30;
|
|
1522
|
+
const ticksPerFrame = tps / fps;
|
|
1523
|
+
for (const frames of FRAME_STEPS) {
|
|
1524
|
+
const majorTicks2 = ticksPerFrame * frames;
|
|
1525
|
+
if (scale.durationToPx(majorTicks2) >= MIN_MAJOR_PX) {
|
|
1526
|
+
const subdivisions = frames === 1 ? 1 : frames === 15 ? 3 : frames === 30 ? 6 : frames;
|
|
1527
|
+
return { majorTicks: majorTicks2, subdivisions, medium: subdivisions % 2 === 0 ? subdivisions / 2 : 0 };
|
|
1528
|
+
}
|
|
1529
|
+
}
|
|
1530
|
+
}
|
|
1531
|
+
if (timebase.display === "bars") return chooseBarRung(scale, timebase);
|
|
1532
|
+
for (const rung of SECOND_LADDER) {
|
|
1533
|
+
const majorTicks2 = rung.seconds * tps;
|
|
1534
|
+
if (scale.durationToPx(majorTicks2) >= MIN_MAJOR_PX) {
|
|
1535
|
+
return { majorTicks: majorTicks2, subdivisions: rung.subdivisions, medium: rung.medium };
|
|
1536
|
+
}
|
|
1537
|
+
}
|
|
1538
|
+
const last = SECOND_LADDER[SECOND_LADDER.length - 1];
|
|
1539
|
+
let majorTicks = last.seconds * tps;
|
|
1540
|
+
while (scale.durationToPx(majorTicks) < MIN_MAJOR_PX && majorTicks < Number.MAX_SAFE_INTEGER / 4) {
|
|
1541
|
+
majorTicks *= 2;
|
|
1542
|
+
}
|
|
1543
|
+
return { majorTicks, subdivisions: 4, medium: 2 };
|
|
1544
|
+
}
|
|
1545
|
+
function chooseBarRung(scale, timebase) {
|
|
1546
|
+
const tps = Math.max(1, timebase.ticksPerSecond);
|
|
1547
|
+
const bpm = timebase.bpm && timebase.bpm > 0 ? timebase.bpm : 120;
|
|
1548
|
+
const numerator = timebase.timeSignature?.[0] ?? 4;
|
|
1549
|
+
const denominator = timebase.timeSignature?.[1] ?? 4;
|
|
1550
|
+
const ticksPerQuarter = 60 / bpm * tps;
|
|
1551
|
+
const ticksPerBeat = ticksPerQuarter * (4 / denominator);
|
|
1552
|
+
const ticksPerBar = ticksPerBeat * numerator;
|
|
1553
|
+
if (scale.durationToPx(ticksPerBar) < MIN_MAJOR_PX) {
|
|
1554
|
+
let majorTicks2 = ticksPerBar;
|
|
1555
|
+
while (scale.durationToPx(majorTicks2) < MIN_MAJOR_PX && majorTicks2 < Number.MAX_SAFE_INTEGER / 4) {
|
|
1556
|
+
majorTicks2 *= 2;
|
|
1557
|
+
}
|
|
1558
|
+
return { majorTicks: majorTicks2, subdivisions: numerator, medium: 0 };
|
|
1559
|
+
}
|
|
1560
|
+
let majorTicks = ticksPerBar;
|
|
1561
|
+
let subdivisions = numerator;
|
|
1562
|
+
if (scale.durationToPx(ticksPerBeat) >= MIN_MAJOR_PX) {
|
|
1563
|
+
majorTicks = ticksPerBeat;
|
|
1564
|
+
subdivisions = 2;
|
|
1565
|
+
while (scale.durationToPx(majorTicks / 2) >= MIN_MAJOR_PX && majorTicks > 1) {
|
|
1566
|
+
majorTicks /= 2;
|
|
1567
|
+
}
|
|
1568
|
+
}
|
|
1569
|
+
return { majorTicks, subdivisions, medium: subdivisions % 2 === 0 ? subdivisions / 2 : 0 };
|
|
1570
|
+
}
|
|
1571
|
+
function rulerTicks(scale, timebase, options = {}) {
|
|
1572
|
+
const { majorTicks, subdivisions, medium } = chooseRung(scale, timebase);
|
|
1573
|
+
if (!Number.isFinite(majorTicks) || majorTicks <= 0) return [];
|
|
1574
|
+
const minorTicks = majorTicks / Math.max(1, subdivisions);
|
|
1575
|
+
const drawMinors = scale.durationToPx(minorTicks) >= MIN_MINOR_PX;
|
|
1576
|
+
const step = drawMinors ? minorTicks : majorTicks;
|
|
1577
|
+
const perMajor = drawMinors ? Math.max(1, subdivisions) : 1;
|
|
1578
|
+
const firstMajorIndex = Math.floor(scale.scrollTick / majorTicks);
|
|
1579
|
+
const startTick = firstMajorIndex * majorTicks;
|
|
1580
|
+
const out = [];
|
|
1581
|
+
const label = options.label ?? ((tick) => formatTick3(tick, timebase));
|
|
1582
|
+
const maxTicks = Math.ceil(scale.lanePx / Math.max(1, scale.durationToPx(step))) + perMajor + 2;
|
|
1583
|
+
for (let i = 0; i < maxTicks; i += 1) {
|
|
1584
|
+
const tick = startTick + i * step;
|
|
1585
|
+
const x = scale.tickToX(tick);
|
|
1586
|
+
if (x > scale.widthPx) break;
|
|
1587
|
+
if (x < scale.gutterPx - 1) continue;
|
|
1588
|
+
const indexInMajor = i % perMajor;
|
|
1589
|
+
const weight = indexInMajor === 0 ? "major" : medium > 0 && indexInMajor === medium ? "medium" : "minor";
|
|
1590
|
+
out.push({
|
|
1591
|
+
tick: Math.round(tick),
|
|
1592
|
+
x,
|
|
1593
|
+
weight,
|
|
1594
|
+
label: weight === "major" ? label(Math.round(tick)) : void 0
|
|
1595
|
+
});
|
|
1596
|
+
}
|
|
1597
|
+
return out;
|
|
1598
|
+
}
|
|
1599
|
+
function gridTicksFor(scale, timebase) {
|
|
1600
|
+
const { majorTicks, subdivisions } = chooseRung(scale, timebase);
|
|
1601
|
+
const minor = majorTicks / Math.max(1, subdivisions);
|
|
1602
|
+
return scale.durationToPx(minor) >= MIN_MINOR_PX ? minor : majorTicks;
|
|
1603
|
+
}
|
|
1604
|
+
|
|
1605
|
+
// src/time/timeline/snap.ts
|
|
1606
|
+
var DEFAULT_SNAP = { mode: "both", thresholdPx: 10, gridTicks: 0 };
|
|
1607
|
+
var TRIM_SNAP_PX = 5;
|
|
1608
|
+
function collectSnapTargets(arrangement, excludeClipIds, playheadTick, window) {
|
|
1609
|
+
const excluded = new Set(excludeClipIds);
|
|
1610
|
+
const out = [{ tick: 0, kind: "origin" }];
|
|
1611
|
+
if (playheadTick !== null) out.push({ tick: playheadTick, kind: "playhead" });
|
|
1612
|
+
for (const marker of arrangement.markers ?? []) {
|
|
1613
|
+
if (marker.tick >= window.startTick && marker.tick <= window.endTick) {
|
|
1614
|
+
out.push({ tick: marker.tick, kind: "marker", id: marker.id });
|
|
1615
|
+
}
|
|
1616
|
+
if (marker.endTick !== void 0 && marker.endTick >= window.startTick && marker.endTick <= window.endTick) {
|
|
1617
|
+
out.push({ tick: marker.endTick, kind: "marker", id: marker.id });
|
|
1618
|
+
}
|
|
1619
|
+
}
|
|
1620
|
+
for (const track of arrangement.tracks) {
|
|
1621
|
+
for (const clipId of track.clipIds) {
|
|
1622
|
+
if (excluded.has(clipId)) continue;
|
|
1623
|
+
const clip = arrangement.clips[clipId];
|
|
1624
|
+
if (!clip) continue;
|
|
1625
|
+
if (clip.endTick < window.startTick || clip.startTick > window.endTick) continue;
|
|
1626
|
+
out.push({ tick: clip.startTick, kind: "clipStart", id: clip.id });
|
|
1627
|
+
out.push({ tick: clip.endTick, kind: "clipEnd", id: clip.id });
|
|
1628
|
+
}
|
|
1629
|
+
}
|
|
1630
|
+
return out;
|
|
1631
|
+
}
|
|
1632
|
+
function snapDelta(sourceTicks, rawDelta, candidates, scale, config = DEFAULT_SNAP) {
|
|
1633
|
+
const rounded = Math.round(rawDelta);
|
|
1634
|
+
if (config.mode === "off" || sourceTicks.length === 0) {
|
|
1635
|
+
return { deltaTicks: rounded, target: null };
|
|
1636
|
+
}
|
|
1637
|
+
const thresholdTicks = scale.pxToTicks(Math.max(0, config.thresholdPx));
|
|
1638
|
+
if (!Number.isFinite(thresholdTicks) || thresholdTicks <= 0) {
|
|
1639
|
+
return { deltaTicks: rounded, target: null };
|
|
1640
|
+
}
|
|
1641
|
+
let bestDistance = thresholdTicks;
|
|
1642
|
+
let bestDelta = rounded;
|
|
1643
|
+
let bestTarget = null;
|
|
1644
|
+
let bestSource;
|
|
1645
|
+
const useTargets = config.mode === "targets" || config.mode === "both";
|
|
1646
|
+
const useGrid = (config.mode === "grid" || config.mode === "both") && config.gridTicks > 0;
|
|
1647
|
+
for (const source of sourceTicks) {
|
|
1648
|
+
const moved = source + rawDelta;
|
|
1649
|
+
if (useTargets) {
|
|
1650
|
+
for (const candidate of candidates) {
|
|
1651
|
+
const distance = Math.abs(candidate.tick - moved);
|
|
1652
|
+
if (distance < bestDistance) {
|
|
1653
|
+
bestDistance = distance;
|
|
1654
|
+
bestDelta = candidate.tick - source;
|
|
1655
|
+
bestTarget = candidate;
|
|
1656
|
+
bestSource = source;
|
|
1657
|
+
}
|
|
1658
|
+
}
|
|
1659
|
+
}
|
|
1660
|
+
if (useGrid) {
|
|
1661
|
+
const gridTick = Math.round(moved / config.gridTicks) * config.gridTicks;
|
|
1662
|
+
const distance = Math.abs(gridTick - moved);
|
|
1663
|
+
if (distance < bestDistance) {
|
|
1664
|
+
bestDistance = distance;
|
|
1665
|
+
bestDelta = gridTick - source;
|
|
1666
|
+
bestTarget = { tick: gridTick, kind: "grid" };
|
|
1667
|
+
bestSource = source;
|
|
1668
|
+
}
|
|
1669
|
+
}
|
|
1670
|
+
}
|
|
1671
|
+
return { deltaTicks: Math.round(bestDelta), target: bestTarget, sourceTick: bestSource };
|
|
1672
|
+
}
|
|
1673
|
+
function snapTick(tick, candidates, scale, config = DEFAULT_SNAP) {
|
|
1674
|
+
const result = snapDelta([0], tick, candidates, scale, config);
|
|
1675
|
+
return { tick: result.deltaTicks, target: result.target };
|
|
1676
|
+
}
|
|
1677
|
+
function snapWouldFight(scale, ticksPerFrame = 1) {
|
|
1678
|
+
return scale.durationToPx(ticksPerFrame) >= 24;
|
|
1679
|
+
}
|
|
1680
|
+
function withSnapModifier(config, inverted) {
|
|
1681
|
+
if (!inverted) return config;
|
|
1682
|
+
return { ...config, mode: config.mode === "off" ? "both" : "off" };
|
|
1683
|
+
}
|
|
1684
|
+
|
|
1685
|
+
// src/time/timeline/drag.ts
|
|
1686
|
+
var DRAG_THRESHOLD_PX = 4;
|
|
1687
|
+
var DRAG_THRESHOLD_SQ = DRAG_THRESHOLD_PX * DRAG_THRESHOLD_PX;
|
|
1688
|
+
var COPY_THRESHOLD_SQ = 12 * 12;
|
|
1689
|
+
var EDGE_ZONE_PX = 10;
|
|
1690
|
+
var EDGE_ZONE_MIN_CLIP_PX = 3 * EDGE_ZONE_PX;
|
|
1691
|
+
function edgeZone(offsetPx, widthPx) {
|
|
1692
|
+
if (widthPx < EDGE_ZONE_MIN_CLIP_PX) return null;
|
|
1693
|
+
if (offsetPx <= EDGE_ZONE_PX) return "start";
|
|
1694
|
+
if (offsetPx >= widthPx - EDGE_ZONE_PX) return "end";
|
|
1695
|
+
return null;
|
|
1696
|
+
}
|
|
1697
|
+
function beginDrag(options) {
|
|
1698
|
+
const { target, tool } = options;
|
|
1699
|
+
const origin = {};
|
|
1700
|
+
for (const clip of options.clips) {
|
|
1701
|
+
origin[clip.id] = { startTick: clip.startTick, endTick: clip.endTick, trackId: clip.trackId };
|
|
1702
|
+
}
|
|
1703
|
+
const base2 = {
|
|
1704
|
+
tool,
|
|
1705
|
+
startX: options.x,
|
|
1706
|
+
startY: options.y,
|
|
1707
|
+
startTick: options.tick,
|
|
1708
|
+
clipIds: options.clips.map((c) => c.id),
|
|
1709
|
+
origin,
|
|
1710
|
+
startTrackIndex: options.trackIndex,
|
|
1711
|
+
modifiers: options.modifiers ?? {},
|
|
1712
|
+
moved: false,
|
|
1713
|
+
deltaTicks: 0,
|
|
1714
|
+
deltaTracks: 0
|
|
1715
|
+
};
|
|
1716
|
+
if (tool === "hand") return { ...base2, kind: "pan" };
|
|
1717
|
+
if (tool === "razor") return null;
|
|
1718
|
+
switch (target.kind) {
|
|
1719
|
+
case "ruler":
|
|
1720
|
+
return { ...base2, kind: "scrub" };
|
|
1721
|
+
case "marker":
|
|
1722
|
+
return { ...base2, kind: "marker", markerId: target.markerId };
|
|
1723
|
+
case "trackDivider":
|
|
1724
|
+
return { ...base2, kind: "trackHeight", trackId: target.trackId, startHeightPx: options.startHeightPx };
|
|
1725
|
+
case "clipEdgeStart":
|
|
1726
|
+
return { ...base2, kind: "trimStart", edgeTick: options.tick };
|
|
1727
|
+
case "clipEdgeEnd":
|
|
1728
|
+
return { ...base2, kind: "trimEnd", edgeTick: options.tick };
|
|
1729
|
+
case "clip":
|
|
1730
|
+
return { ...base2, kind: "move" };
|
|
1731
|
+
case "lane":
|
|
1732
|
+
return {
|
|
1733
|
+
...base2,
|
|
1734
|
+
kind: "marquee",
|
|
1735
|
+
clipIds: [],
|
|
1736
|
+
marquee: { fromTick: options.tick, toTick: options.tick, fromY: options.y, toY: options.y }
|
|
1737
|
+
};
|
|
1738
|
+
default:
|
|
1739
|
+
return null;
|
|
1740
|
+
}
|
|
1741
|
+
}
|
|
1742
|
+
function passedThreshold(state, x, y) {
|
|
1743
|
+
if (state.moved) return true;
|
|
1744
|
+
const dx = x - state.startX;
|
|
1745
|
+
const dy = y - state.startY;
|
|
1746
|
+
const threshold = state.modifiers.copy ? COPY_THRESHOLD_SQ : DRAG_THRESHOLD_SQ;
|
|
1747
|
+
return dx * dx + dy * dy >= threshold;
|
|
1748
|
+
}
|
|
1749
|
+
function previewDrag(state, deltaTicks, deltaTracks, trackIds) {
|
|
1750
|
+
const clips = {};
|
|
1751
|
+
for (const id of state.clipIds) {
|
|
1752
|
+
const from = state.origin[id];
|
|
1753
|
+
if (!from) continue;
|
|
1754
|
+
if (state.kind === "move") {
|
|
1755
|
+
const currentIndex = trackIds.indexOf(from.trackId);
|
|
1756
|
+
const nextIndex = Math.max(0, Math.min(trackIds.length - 1, currentIndex + deltaTracks));
|
|
1757
|
+
clips[id] = {
|
|
1758
|
+
startTick: from.startTick + deltaTicks,
|
|
1759
|
+
endTick: from.endTick + deltaTicks,
|
|
1760
|
+
trackId: trackIds[nextIndex] ?? from.trackId
|
|
1761
|
+
};
|
|
1762
|
+
} else if (state.kind === "trimStart") {
|
|
1763
|
+
const startTick = Math.min(from.startTick + deltaTicks, from.endTick - 1);
|
|
1764
|
+
clips[id] = { startTick, endTick: from.endTick, trackId: from.trackId };
|
|
1765
|
+
} else if (state.kind === "trimEnd") {
|
|
1766
|
+
const endTick = Math.max(from.endTick + deltaTicks, from.startTick + 1);
|
|
1767
|
+
clips[id] = { startTick: from.startTick, endTick, trackId: from.trackId };
|
|
1768
|
+
} else {
|
|
1769
|
+
clips[id] = { ...from };
|
|
1770
|
+
}
|
|
1771
|
+
}
|
|
1772
|
+
return { clips, marquee: state.marquee };
|
|
1773
|
+
}
|
|
1774
|
+
function commitDrag(state, trimMode = "standard") {
|
|
1775
|
+
if (!state.moved) return null;
|
|
1776
|
+
switch (state.kind) {
|
|
1777
|
+
case "move": {
|
|
1778
|
+
if (state.deltaTicks === 0 && state.deltaTracks === 0) return null;
|
|
1779
|
+
const toTrackId = state.deltaTracks !== 0 ? state.origin[state.clipIds[0]]?.trackId : void 0;
|
|
1780
|
+
return state.modifiers.copy ? { kind: "duplicate", clipIds: state.clipIds, deltaTicks: state.deltaTicks } : { kind: "move", clipIds: state.clipIds, deltaTicks: state.deltaTicks, toTrackId };
|
|
1781
|
+
}
|
|
1782
|
+
case "trimStart":
|
|
1783
|
+
case "trimEnd": {
|
|
1784
|
+
if (state.deltaTicks === 0) return null;
|
|
1785
|
+
const clipId = state.clipIds[0];
|
|
1786
|
+
const from = clipId ? state.origin[clipId] : void 0;
|
|
1787
|
+
if (!clipId || !from) return null;
|
|
1788
|
+
const edge = state.kind === "trimStart" ? "start" : "end";
|
|
1789
|
+
const toTick3 = edge === "start" ? Math.min(from.startTick + state.deltaTicks, from.endTick - 1) : Math.max(from.endTick + state.deltaTicks, from.startTick + 1);
|
|
1790
|
+
return { kind: "trim", clipId, edge, toTick: toTick3, mode: trimMode };
|
|
1791
|
+
}
|
|
1792
|
+
case "trackHeight": {
|
|
1793
|
+
if (!state.trackId || state.startHeightPx === void 0) return null;
|
|
1794
|
+
return { kind: "setTrackHeight", trackId: state.trackId, heightPx: state.startHeightPx };
|
|
1795
|
+
}
|
|
1796
|
+
case "marker": {
|
|
1797
|
+
if (!state.markerId || state.deltaTicks === 0) return null;
|
|
1798
|
+
return { kind: "moveMarker", markerId: state.markerId, toTick: state.startTick + state.deltaTicks };
|
|
1799
|
+
}
|
|
1800
|
+
// A marquee, a scrub and a pan change the view or the selection, not the document.
|
|
1801
|
+
default:
|
|
1802
|
+
return null;
|
|
1803
|
+
}
|
|
1804
|
+
}
|
|
1805
|
+
function trimModeFor(tool) {
|
|
1806
|
+
if (tool === "ripple") return "ripple";
|
|
1807
|
+
if (tool === "roll") return "roll";
|
|
1808
|
+
return "standard";
|
|
1809
|
+
}
|
|
1810
|
+
function clipsInMarquee(marquee, rows, tracks, clips) {
|
|
1811
|
+
const fromTick = Math.min(marquee.fromTick, marquee.toTick);
|
|
1812
|
+
const toTick3 = Math.max(marquee.fromTick, marquee.toTick);
|
|
1813
|
+
const fromY = Math.min(marquee.fromY, marquee.toY);
|
|
1814
|
+
const toY = Math.max(marquee.fromY, marquee.toY);
|
|
1815
|
+
const out = [];
|
|
1816
|
+
for (let i = 0; i < tracks.length; i += 1) {
|
|
1817
|
+
const top = rows.offsets[i];
|
|
1818
|
+
const bottom = rows.offsets[i + 1];
|
|
1819
|
+
if (bottom <= fromY || top >= toY) continue;
|
|
1820
|
+
for (const clipId of tracks[i].clipIds) {
|
|
1821
|
+
const clip = clips[clipId];
|
|
1822
|
+
if (clip && clip.startTick < toTick3 && clip.endTick > fromTick) out.push(clipId);
|
|
1823
|
+
}
|
|
1824
|
+
}
|
|
1825
|
+
return out;
|
|
1826
|
+
}
|
|
1827
|
+
|
|
1828
|
+
// src/time/timeline/tiles.ts
|
|
1829
|
+
var DEFAULT_MAX_BYTES = 48 * 1024 * 1024;
|
|
1830
|
+
function zoomBucket(pxPerTick) {
|
|
1831
|
+
if (!Number.isFinite(pxPerTick) || pxPerTick <= 0) return 0;
|
|
1832
|
+
return Math.round(Math.log2(pxPerTick));
|
|
1833
|
+
}
|
|
1834
|
+
function tileKey(parts) {
|
|
1835
|
+
return [
|
|
1836
|
+
parts.sourceId,
|
|
1837
|
+
Math.round(parts.startTick),
|
|
1838
|
+
Math.round(parts.endTick),
|
|
1839
|
+
parts.zoomBucket,
|
|
1840
|
+
Math.round(parts.heightPx)
|
|
1841
|
+
].join("|");
|
|
1842
|
+
}
|
|
1843
|
+
function sourceRangeFor(clip, fromTick, toTick3) {
|
|
1844
|
+
if (typeof clip.sourceInTick !== "number") return null;
|
|
1845
|
+
const visibleStart = Math.max(clip.startTick, fromTick);
|
|
1846
|
+
const visibleEnd = Math.min(clip.endTick, toTick3);
|
|
1847
|
+
if (visibleEnd <= visibleStart) return null;
|
|
1848
|
+
const offset = visibleStart - clip.startTick;
|
|
1849
|
+
return {
|
|
1850
|
+
startTick: clip.sourceInTick + offset,
|
|
1851
|
+
endTick: clip.sourceInTick + offset + (visibleEnd - visibleStart)
|
|
1852
|
+
};
|
|
1853
|
+
}
|
|
1854
|
+
function alignToTileGrid(range, tileTicks) {
|
|
1855
|
+
const step = Math.max(1, Math.round(tileTicks));
|
|
1856
|
+
const first = Math.floor(range.startTick / step) * step;
|
|
1857
|
+
const out = [];
|
|
1858
|
+
for (let t = first; t < range.endTick && out.length < 256; t += step) {
|
|
1859
|
+
out.push({ startTick: t, endTick: t + step });
|
|
1860
|
+
}
|
|
1861
|
+
return out;
|
|
1862
|
+
}
|
|
1863
|
+
function tileWidthTicks(scale, random = Math.random) {
|
|
1864
|
+
const nominal = scale.pxToTicks(scale.lanePx);
|
|
1865
|
+
const multiplier = 1.4 + random() * 0.4;
|
|
1866
|
+
return Math.max(1, Math.round(nominal * multiplier));
|
|
1867
|
+
}
|
|
1868
|
+
var XenoTileCache = class {
|
|
1869
|
+
tiles = /* @__PURE__ */ new Map();
|
|
1870
|
+
maxBytes;
|
|
1871
|
+
random;
|
|
1872
|
+
clock = 0;
|
|
1873
|
+
bytes = 0;
|
|
1874
|
+
/** Keys already asked for, so a request is never sent twice while one is in flight. */
|
|
1875
|
+
pending = /* @__PURE__ */ new Set();
|
|
1876
|
+
constructor(options = {}) {
|
|
1877
|
+
this.maxBytes = Math.max(1, options.maxBytes ?? DEFAULT_MAX_BYTES);
|
|
1878
|
+
this.random = options.random ?? Math.random;
|
|
1879
|
+
}
|
|
1880
|
+
/** Bytes currently held. */
|
|
1881
|
+
get sizeBytes() {
|
|
1882
|
+
return this.bytes;
|
|
1883
|
+
}
|
|
1884
|
+
/** Tiles currently held. */
|
|
1885
|
+
get size() {
|
|
1886
|
+
return this.tiles.size;
|
|
1887
|
+
}
|
|
1888
|
+
/**
|
|
1889
|
+
* Find a usable tile for a request.
|
|
1890
|
+
*
|
|
1891
|
+
* 🔴 Satisfied by **containment**, not equality: a cached tile whose source range *covers* the
|
|
1892
|
+
* request, at the same zoom bucket and height, is a hit. That is what makes scrolling produce hits
|
|
1893
|
+
* instead of thrash, and it is why tiles are rendered wider than the viewport.
|
|
1894
|
+
*
|
|
1895
|
+
* @param request - Source identity, range, zoom bucket, height.
|
|
1896
|
+
* @returns A tile that covers it, or `null`.
|
|
1897
|
+
*/
|
|
1898
|
+
find(request) {
|
|
1899
|
+
const exact = this.tiles.get(tileKey(request));
|
|
1900
|
+
if (exact) {
|
|
1901
|
+
exact.usedAt = this.clock += 1;
|
|
1902
|
+
return exact;
|
|
1903
|
+
}
|
|
1904
|
+
for (const tile of this.tiles.values()) {
|
|
1905
|
+
if (tile.sourceId === request.sourceId && tile.zoomBucket === request.zoomBucket && tile.heightPx === request.heightPx && tile.startTick <= request.startTick && tile.endTick >= request.endTick) {
|
|
1906
|
+
tile.usedAt = this.clock += 1;
|
|
1907
|
+
return tile;
|
|
1908
|
+
}
|
|
1909
|
+
}
|
|
1910
|
+
return null;
|
|
1911
|
+
}
|
|
1912
|
+
/**
|
|
1913
|
+
* Record a tile the host returned.
|
|
1914
|
+
*
|
|
1915
|
+
* @param tile - Everything but the LRU stamp.
|
|
1916
|
+
* @returns The stored tile.
|
|
1917
|
+
*/
|
|
1918
|
+
put(tile) {
|
|
1919
|
+
this.pending.delete(tile.key);
|
|
1920
|
+
const existing = this.tiles.get(tile.key);
|
|
1921
|
+
if (existing) this.evictOne(existing);
|
|
1922
|
+
const bytes = tile.bytes ?? Math.max(
|
|
1923
|
+
1,
|
|
1924
|
+
Math.round((tile.endTick - tile.startTick) * 0 + tile.heightPx * 4 * estimateWidth(tile))
|
|
1925
|
+
);
|
|
1926
|
+
const stored = { ...tile, bytes, usedAt: this.clock += 1 };
|
|
1927
|
+
this.tiles.set(stored.key, stored);
|
|
1928
|
+
this.bytes += bytes;
|
|
1929
|
+
this.trim();
|
|
1930
|
+
return stored;
|
|
1931
|
+
}
|
|
1932
|
+
/** Has a request for this key already gone out? */
|
|
1933
|
+
isPending(key) {
|
|
1934
|
+
return this.pending.has(key);
|
|
1935
|
+
}
|
|
1936
|
+
/**
|
|
1937
|
+
* Mark a key as requested.
|
|
1938
|
+
*
|
|
1939
|
+
* Coalescing matters: without it, every frame of a scroll re-asks the host for the same tile, and
|
|
1940
|
+
* a decode queue that is already behind falls further behind.
|
|
1941
|
+
*
|
|
1942
|
+
* @param key - The tile key.
|
|
1943
|
+
* @returns `false` when a request was already outstanding.
|
|
1944
|
+
*/
|
|
1945
|
+
markPending(key) {
|
|
1946
|
+
if (this.pending.has(key)) return false;
|
|
1947
|
+
this.pending.add(key);
|
|
1948
|
+
return true;
|
|
1949
|
+
}
|
|
1950
|
+
/** Abandon an outstanding request — the tile scrolled out of view. */
|
|
1951
|
+
cancelPending(key) {
|
|
1952
|
+
this.pending.delete(key);
|
|
1953
|
+
}
|
|
1954
|
+
/** A tile width in ticks for the current zoom, jittered. */
|
|
1955
|
+
widthTicks(scale) {
|
|
1956
|
+
return tileWidthTicks(scale, this.random);
|
|
1957
|
+
}
|
|
1958
|
+
/** Drop everything for one source — it was edited, or its media changed. */
|
|
1959
|
+
invalidateSource(sourceId) {
|
|
1960
|
+
let removed = 0;
|
|
1961
|
+
for (const tile of [...this.tiles.values()]) {
|
|
1962
|
+
if (tile.sourceId === sourceId) {
|
|
1963
|
+
this.evictOne(tile);
|
|
1964
|
+
removed += 1;
|
|
1965
|
+
}
|
|
1966
|
+
}
|
|
1967
|
+
return removed;
|
|
1968
|
+
}
|
|
1969
|
+
/** Release everything, closing bitmaps. */
|
|
1970
|
+
clear() {
|
|
1971
|
+
for (const tile of [...this.tiles.values()]) this.evictOne(tile);
|
|
1972
|
+
this.pending.clear();
|
|
1973
|
+
}
|
|
1974
|
+
/** Evict least-recently-used tiles until inside the budget. */
|
|
1975
|
+
trim() {
|
|
1976
|
+
if (this.bytes <= this.maxBytes) return;
|
|
1977
|
+
const ordered = [...this.tiles.values()].sort((a, b) => a.usedAt - b.usedAt);
|
|
1978
|
+
for (const tile of ordered) {
|
|
1979
|
+
if (this.bytes <= this.maxBytes) break;
|
|
1980
|
+
this.evictOne(tile);
|
|
1981
|
+
}
|
|
1982
|
+
}
|
|
1983
|
+
/** Remove one tile and release its bitmap. */
|
|
1984
|
+
evictOne(tile) {
|
|
1985
|
+
this.tiles.delete(tile.key);
|
|
1986
|
+
this.bytes = Math.max(0, this.bytes - tile.bytes);
|
|
1987
|
+
const bitmap = tile.bitmap;
|
|
1988
|
+
if (bitmap && typeof bitmap.close === "function") {
|
|
1989
|
+
try {
|
|
1990
|
+
bitmap.close();
|
|
1991
|
+
} catch {
|
|
1992
|
+
}
|
|
1993
|
+
}
|
|
1994
|
+
}
|
|
1995
|
+
};
|
|
1996
|
+
function estimateWidth(tile) {
|
|
1997
|
+
return Math.max(1, Math.min(4096, Math.round(tile.endTick - tile.startTick)));
|
|
1998
|
+
}
|
|
1999
|
+
function blitTile(ctx, tile, x, y, width, height) {
|
|
2000
|
+
if (!tile?.bitmap) return false;
|
|
2001
|
+
try {
|
|
2002
|
+
if (typeof ImageData !== "undefined" && tile.bitmap instanceof ImageData) {
|
|
2003
|
+
ctx.putImageData(tile.bitmap, Math.round(x), Math.round(y));
|
|
2004
|
+
} else {
|
|
2005
|
+
ctx.drawImage(tile.bitmap, x, y, width, height);
|
|
2006
|
+
}
|
|
2007
|
+
return true;
|
|
2008
|
+
} catch {
|
|
2009
|
+
return false;
|
|
2010
|
+
}
|
|
2011
|
+
}
|
|
2012
|
+
|
|
2013
|
+
// src/time/timeline/controller.ts
|
|
2014
|
+
var EMPTY_ARRANGEMENT = {
|
|
2015
|
+
timebase: DEFAULT_TIMEBASE3,
|
|
2016
|
+
durationTicks: 0,
|
|
2017
|
+
tracks: [],
|
|
2018
|
+
clips: {},
|
|
2019
|
+
rev: 0
|
|
2020
|
+
};
|
|
2021
|
+
var TimelineController = class {
|
|
2022
|
+
host;
|
|
2023
|
+
listeners = /* @__PURE__ */ new Set();
|
|
2024
|
+
now;
|
|
2025
|
+
zoomBounds;
|
|
2026
|
+
crossTrackDrag;
|
|
2027
|
+
multiSelect;
|
|
2028
|
+
marqueeEnabled;
|
|
2029
|
+
virtualize;
|
|
2030
|
+
rulerPx;
|
|
2031
|
+
scale;
|
|
2032
|
+
arrangement = EMPTY_ARRANGEMENT;
|
|
2033
|
+
clock = IDLE_CLOCK3;
|
|
2034
|
+
tool;
|
|
2035
|
+
snapMode;
|
|
2036
|
+
selection = { clipIds: [] };
|
|
2037
|
+
/**
|
|
2038
|
+
* The live gesture.
|
|
2039
|
+
*
|
|
2040
|
+
* 🔴 A field on the controller, **not a closure local in an event handler**. The reference
|
|
2041
|
+
* implementation keeps drag bookkeeping in variables the move handler closes over, and loses the
|
|
2042
|
+
* commit whenever a re-render swaps the closure.
|
|
2043
|
+
*/
|
|
2044
|
+
drag = null;
|
|
2045
|
+
preview = null;
|
|
2046
|
+
tiles;
|
|
2047
|
+
emitPreview;
|
|
2048
|
+
emitViewport;
|
|
2049
|
+
snapshot = null;
|
|
2050
|
+
cachedVisible = null;
|
|
2051
|
+
constructor(options) {
|
|
2052
|
+
this.host = options.host;
|
|
2053
|
+
this.now = options.host.now ?? (() => Date.now());
|
|
2054
|
+
this.zoomBounds = options.zoom ?? DEFAULT_ZOOM;
|
|
2055
|
+
this.crossTrackDrag = options.crossTrackDrag ?? true;
|
|
2056
|
+
this.multiSelect = options.multiSelect ?? true;
|
|
2057
|
+
this.marqueeEnabled = options.marquee ?? true;
|
|
2058
|
+
this.virtualize = options.virtualize ?? true;
|
|
2059
|
+
this.rulerPx = options.rulerPx ?? 22;
|
|
2060
|
+
this.tool = options.tool ?? "select";
|
|
2061
|
+
this.snapMode = options.snapMode ?? "both";
|
|
2062
|
+
this.tiles = options.tileCache ?? new XenoTileCache();
|
|
2063
|
+
this.scale = new XenoTimeScale(
|
|
2064
|
+
{ pxPerTick: 0.5, scrollTick: 0, scrollTopPx: 0 },
|
|
2065
|
+
{ widthPx: 800, heightPx: 400, gutterPx: options.gutterPx ?? 140 }
|
|
2066
|
+
);
|
|
2067
|
+
const throttleMs = options.throttleMs ?? 100;
|
|
2068
|
+
const throttleClock = {
|
|
2069
|
+
now: this.now,
|
|
2070
|
+
setTimer: (cb, ms) => setTimeout(cb, ms),
|
|
2071
|
+
clearTimer: (handle) => clearTimeout(handle)
|
|
2072
|
+
};
|
|
2073
|
+
this.emitPreview = createThrottle3(
|
|
2074
|
+
(op) => this.host.emit("opPreview", op),
|
|
2075
|
+
throttleMs,
|
|
2076
|
+
throttleClock
|
|
2077
|
+
);
|
|
2078
|
+
this.emitViewport = createThrottle3(() => this.publishViewport(), throttleMs, throttleClock);
|
|
2079
|
+
}
|
|
2080
|
+
/* ── Subscription ──────────────────────────────────────────────────────── */
|
|
2081
|
+
subscribe = (listener) => {
|
|
2082
|
+
this.listeners.add(listener);
|
|
2083
|
+
return () => this.listeners.delete(listener);
|
|
2084
|
+
};
|
|
2085
|
+
getState = () => {
|
|
2086
|
+
if (!this.snapshot) {
|
|
2087
|
+
const visible = this.computeVisibleCached();
|
|
2088
|
+
this.snapshot = {
|
|
2089
|
+
scale: this.scale,
|
|
2090
|
+
arrangement: this.arrangement === EMPTY_ARRANGEMENT ? null : this.arrangement,
|
|
2091
|
+
timebase: this.arrangement.timebase,
|
|
2092
|
+
tool: this.tool,
|
|
2093
|
+
snap: this.snapConfig(),
|
|
2094
|
+
selection: this.selection,
|
|
2095
|
+
preview: this.preview,
|
|
2096
|
+
visible: visible.tracks,
|
|
2097
|
+
rows: visible.rows,
|
|
2098
|
+
playheadTick: this.playheadTick(),
|
|
2099
|
+
playing: this.clock.playing === true,
|
|
2100
|
+
durationTicks: this.arrangement.durationTicks
|
|
2101
|
+
};
|
|
2102
|
+
}
|
|
2103
|
+
return this.snapshot;
|
|
2104
|
+
};
|
|
2105
|
+
notify() {
|
|
2106
|
+
this.snapshot = null;
|
|
2107
|
+
for (const listener of this.listeners) listener();
|
|
2108
|
+
}
|
|
2109
|
+
/* ── Host input ────────────────────────────────────────────────────────── */
|
|
2110
|
+
/**
|
|
2111
|
+
* Install a new arrangement.
|
|
2112
|
+
*
|
|
2113
|
+
* The re-walk is gated on `rev`: an identical revision is a no-op, so a host that re-pushes on
|
|
2114
|
+
* every animation frame costs nothing.
|
|
2115
|
+
*
|
|
2116
|
+
* @param arrangement - The host's arrangement.
|
|
2117
|
+
* @returns Whether anything changed.
|
|
2118
|
+
*/
|
|
2119
|
+
setArrangement(arrangement) {
|
|
2120
|
+
if (arrangement.rev === this.arrangement.rev && arrangement === this.arrangement) return false;
|
|
2121
|
+
this.arrangement = {
|
|
2122
|
+
...arrangement,
|
|
2123
|
+
// A rejected timebase falls back rather than propagating `null` — the ruler must still draw.
|
|
2124
|
+
timebase: normalizeTimebase3(arrangement.timebase) ?? this.arrangement.timebase ?? DEFAULT_TIMEBASE3,
|
|
2125
|
+
markers: arrangement.markers ?? []
|
|
2126
|
+
};
|
|
2127
|
+
this.cachedVisible = null;
|
|
2128
|
+
const live = this.selection.clipIds.filter((id) => this.arrangement.clips[id]);
|
|
2129
|
+
if (live.length !== this.selection.clipIds.length) this.selection = { ...this.selection, clipIds: live };
|
|
2130
|
+
this.notify();
|
|
2131
|
+
this.emitViewport.call(void 0);
|
|
2132
|
+
return true;
|
|
2133
|
+
}
|
|
2134
|
+
/**
|
|
2135
|
+
* Install a playhead clock push.
|
|
2136
|
+
*
|
|
2137
|
+
* A malformed push is **ignored**, not installed: the last good clock keeps extrapolating, which
|
|
2138
|
+
* is far better than a playhead that jumps to zero because one message was wrong.
|
|
2139
|
+
*/
|
|
2140
|
+
setClock(clock) {
|
|
2141
|
+
const normalized = normalizeClock3(clock);
|
|
2142
|
+
if (!normalized) return;
|
|
2143
|
+
this.clock = normalized;
|
|
2144
|
+
this.notify();
|
|
2145
|
+
}
|
|
2146
|
+
/** Install an externally-driven selection. */
|
|
2147
|
+
setSelection(selection, emit = false) {
|
|
2148
|
+
const clipIds = selection.clipIds.filter((id) => this.arrangement.clips[id]);
|
|
2149
|
+
this.selection = { ...selection, clipIds };
|
|
2150
|
+
if (emit) this.host.emit("selection", this.selection);
|
|
2151
|
+
this.notify();
|
|
2152
|
+
}
|
|
2153
|
+
/** Record a tile the host returned. */
|
|
2154
|
+
setLaneTile(tile) {
|
|
2155
|
+
const parsed = parseTileKey(tile.key);
|
|
2156
|
+
if (!parsed) {
|
|
2157
|
+
this.tiles.cancelPending(tile.key);
|
|
2158
|
+
return;
|
|
2159
|
+
}
|
|
2160
|
+
this.tiles.put({ key: tile.key, bitmap: tile.bitmap, ...parsed });
|
|
2161
|
+
this.notify();
|
|
2162
|
+
}
|
|
2163
|
+
/** The tile cache, for the renderer. */
|
|
2164
|
+
get tileCache() {
|
|
2165
|
+
return this.tiles;
|
|
2166
|
+
}
|
|
2167
|
+
/* ── Geometry ──────────────────────────────────────────────────────────── */
|
|
2168
|
+
/** Tell the controller how big it is. */
|
|
2169
|
+
resize(widthPx, heightPx, gutterPx) {
|
|
2170
|
+
const next = this.scale.resized({ widthPx, heightPx, gutterPx });
|
|
2171
|
+
if (next.widthPx === this.scale.widthPx && next.heightPx === this.scale.heightPx && next.gutterPx === this.scale.gutterPx) {
|
|
2172
|
+
return;
|
|
2173
|
+
}
|
|
2174
|
+
this.scale = next;
|
|
2175
|
+
this.cachedVisible = null;
|
|
2176
|
+
this.notify();
|
|
2177
|
+
this.emitViewport.call(void 0);
|
|
2178
|
+
}
|
|
2179
|
+
/** The current scale. */
|
|
2180
|
+
get timeScale() {
|
|
2181
|
+
return this.scale;
|
|
2182
|
+
}
|
|
2183
|
+
/**
|
|
2184
|
+
* Zoom about a pointer.
|
|
2185
|
+
*
|
|
2186
|
+
* @param x - Container-relative pointer x.
|
|
2187
|
+
* @param factor - Zoom multiplier.
|
|
2188
|
+
*/
|
|
2189
|
+
zoomAt(x, factor) {
|
|
2190
|
+
this.scale = this.scale.with(
|
|
2191
|
+
zoomAboutCursor(this.scale, x, factor, this.zoomBounds, this.arrangement.durationTicks)
|
|
2192
|
+
);
|
|
2193
|
+
this.cachedVisible = null;
|
|
2194
|
+
this.notify();
|
|
2195
|
+
this.emitViewport.call(void 0);
|
|
2196
|
+
}
|
|
2197
|
+
/**
|
|
2198
|
+
* Handle a wheel event.
|
|
2199
|
+
*
|
|
2200
|
+
* @param event - Deltas, modifiers, and the container-relative x.
|
|
2201
|
+
*/
|
|
2202
|
+
wheel(event) {
|
|
2203
|
+
if (event.zoom) {
|
|
2204
|
+
this.zoomAt(event.x, wheelZoomFactor(event.deltaY ?? 0));
|
|
2205
|
+
return;
|
|
2206
|
+
}
|
|
2207
|
+
const dx = event.deltaX ?? 0;
|
|
2208
|
+
const dy = event.deltaY ?? 0;
|
|
2209
|
+
this.scrollBy(this.scale.pxToTicks(dx), dy);
|
|
2210
|
+
}
|
|
2211
|
+
/** Scroll by a tick delta and a pixel delta. */
|
|
2212
|
+
scrollBy(deltaTicks, deltaTopPx) {
|
|
2213
|
+
this.scrollTo(this.scale.scrollTick + deltaTicks, this.scale.scrollTopPx + deltaTopPx);
|
|
2214
|
+
}
|
|
2215
|
+
/** Scroll to an absolute position. The ONE place scroll is written. */
|
|
2216
|
+
scrollTo(scrollTick, scrollTopPx) {
|
|
2217
|
+
const rows = this.computeVisibleCached().rows;
|
|
2218
|
+
const maxTop = Math.max(0, rows.totalPx - Math.max(0, this.scale.heightPx - this.rulerPx));
|
|
2219
|
+
this.scale = this.scale.with({
|
|
2220
|
+
scrollTick: clampScroll(
|
|
2221
|
+
scrollTick,
|
|
2222
|
+
this.scale.pxPerTick,
|
|
2223
|
+
this.scale.lanePx,
|
|
2224
|
+
this.arrangement.durationTicks
|
|
2225
|
+
),
|
|
2226
|
+
scrollTopPx: Math.max(0, Math.min(scrollTopPx, maxTop))
|
|
2227
|
+
});
|
|
2228
|
+
this.cachedVisible = null;
|
|
2229
|
+
this.notify();
|
|
2230
|
+
this.emitViewport.call(void 0);
|
|
2231
|
+
}
|
|
2232
|
+
/** Fit the whole arrangement, or a range, into the lane. */
|
|
2233
|
+
zoomToFitRange(fromTick = 0, toTick3 = this.arrangement.durationTicks) {
|
|
2234
|
+
const fitted = zoomToFit(fromTick, toTick3, this.scale.lanePx, this.zoomBounds);
|
|
2235
|
+
this.scale = this.scale.with(fitted);
|
|
2236
|
+
this.cachedVisible = null;
|
|
2237
|
+
this.notify();
|
|
2238
|
+
this.emitViewport.call(void 0);
|
|
2239
|
+
}
|
|
2240
|
+
/* ── Tools and snapping ────────────────────────────────────────────────── */
|
|
2241
|
+
/** Change the active tool. Ignored mid-gesture — the mode is frozen at mousedown. */
|
|
2242
|
+
setTool(tool) {
|
|
2243
|
+
if (this.drag) return;
|
|
2244
|
+
this.tool = tool;
|
|
2245
|
+
this.notify();
|
|
2246
|
+
}
|
|
2247
|
+
/** Change snapping. */
|
|
2248
|
+
setSnapMode(mode) {
|
|
2249
|
+
this.snapMode = mode;
|
|
2250
|
+
this.notify();
|
|
2251
|
+
}
|
|
2252
|
+
/** The snap config for the current zoom, with the visible grid. */
|
|
2253
|
+
snapConfig(trimming = false) {
|
|
2254
|
+
const gridTicks = gridTicksFor(this.scale, this.arrangement.timebase);
|
|
2255
|
+
const mode = snapWouldFight(this.scale) ? "off" : this.snapMode;
|
|
2256
|
+
return {
|
|
2257
|
+
...DEFAULT_SNAP,
|
|
2258
|
+
mode,
|
|
2259
|
+
gridTicks,
|
|
2260
|
+
thresholdPx: trimming ? TRIM_SNAP_PX : DEFAULT_SNAP.thresholdPx
|
|
2261
|
+
};
|
|
2262
|
+
}
|
|
2263
|
+
/* ── Gestures ──────────────────────────────────────────────────────────── */
|
|
2264
|
+
/**
|
|
2265
|
+
* Begin a gesture.
|
|
2266
|
+
*
|
|
2267
|
+
* @param options - What was hit and where.
|
|
2268
|
+
* @returns Whether a gesture started.
|
|
2269
|
+
*/
|
|
2270
|
+
pointerDown(options) {
|
|
2271
|
+
const clips = options.clips ?? (options.target.clipId ? this.gestureClips(options.target.clipId, options.modifiers?.additive) : []);
|
|
2272
|
+
if (!this.marqueeEnabled && options.target.kind === "lane") return false;
|
|
2273
|
+
this.drag = beginDrag({ ...options, tool: this.tool, clips });
|
|
2274
|
+
if (!this.drag) return false;
|
|
2275
|
+
this.preview = null;
|
|
2276
|
+
this.notify();
|
|
2277
|
+
return true;
|
|
2278
|
+
}
|
|
2279
|
+
/**
|
|
2280
|
+
* Update the live gesture.
|
|
2281
|
+
*
|
|
2282
|
+
* @param x - Container-relative pointer x.
|
|
2283
|
+
* @param y - Container-relative pointer y.
|
|
2284
|
+
* @param modifiers - Modifiers **as of now** — the snap invert is re-read every move.
|
|
2285
|
+
*/
|
|
2286
|
+
pointerMove(x, y, modifiers = {}) {
|
|
2287
|
+
const drag = this.drag;
|
|
2288
|
+
if (!drag) return;
|
|
2289
|
+
if (!passedThreshold(drag, x, y)) return;
|
|
2290
|
+
drag.moved = true;
|
|
2291
|
+
const tick = this.scale.xToTick(x);
|
|
2292
|
+
if (drag.kind === "marquee") {
|
|
2293
|
+
drag.marquee = {
|
|
2294
|
+
fromTick: drag.startTick,
|
|
2295
|
+
toTick: tick,
|
|
2296
|
+
fromY: drag.startY + this.scale.scrollTopPx - this.rulerPx,
|
|
2297
|
+
toY: y + this.scale.scrollTopPx - this.rulerPx
|
|
2298
|
+
};
|
|
2299
|
+
this.preview = { clips: {}, marquee: drag.marquee };
|
|
2300
|
+
this.notify();
|
|
2301
|
+
return;
|
|
2302
|
+
}
|
|
2303
|
+
if (drag.kind === "pan") {
|
|
2304
|
+
this.scrollBy(-this.scale.pxToTicks(x - drag.startX), -(y - drag.startY));
|
|
2305
|
+
drag.startX = x;
|
|
2306
|
+
drag.startY = y;
|
|
2307
|
+
return;
|
|
2308
|
+
}
|
|
2309
|
+
if (drag.kind === "scrub") {
|
|
2310
|
+
const snapped2 = this.snapAbsolute(tick, [], modifiers.snapInvert === true);
|
|
2311
|
+
this.preview = { clips: {}, playheadTick: snapped2 };
|
|
2312
|
+
this.host.emit("seekPreview", { tick: snapped2 });
|
|
2313
|
+
this.notify();
|
|
2314
|
+
return;
|
|
2315
|
+
}
|
|
2316
|
+
const trimming = drag.kind === "trimStart" || drag.kind === "trimEnd";
|
|
2317
|
+
const config = withSnapModifier(this.snapConfig(trimming), modifiers.snapInvert === true);
|
|
2318
|
+
const rawDelta = tick - drag.startTick;
|
|
2319
|
+
const sourceTicks = this.gestureEdges(drag);
|
|
2320
|
+
const candidates = collectSnapTargets(
|
|
2321
|
+
this.arrangement,
|
|
2322
|
+
drag.clipIds,
|
|
2323
|
+
this.playheadTick(),
|
|
2324
|
+
{ startTick: this.scale.scrollTick, endTick: this.scale.endTick }
|
|
2325
|
+
);
|
|
2326
|
+
const snapped = snapDelta(sourceTicks, rawDelta, candidates, this.scale, config);
|
|
2327
|
+
drag.deltaTicks = snapped.deltaTicks;
|
|
2328
|
+
if (drag.kind === "move" && this.crossTrackDrag && !modifiers.constrain) {
|
|
2329
|
+
const rows = this.computeVisibleCached().rows;
|
|
2330
|
+
const fromRow = rowAtY(rows, drag.startY + this.scale.scrollTopPx - this.rulerPx);
|
|
2331
|
+
const toRow = rowAtY(rows, y + this.scale.scrollTopPx - this.rulerPx);
|
|
2332
|
+
drag.deltaTracks = fromRow >= 0 && toRow >= 0 ? toRow - fromRow : 0;
|
|
2333
|
+
} else {
|
|
2334
|
+
drag.deltaTracks = 0;
|
|
2335
|
+
}
|
|
2336
|
+
this.preview = previewDrag(
|
|
2337
|
+
drag,
|
|
2338
|
+
drag.deltaTicks,
|
|
2339
|
+
drag.deltaTracks,
|
|
2340
|
+
this.arrangement.tracks.map((t) => t.id)
|
|
2341
|
+
);
|
|
2342
|
+
const op = commitDrag(drag, trimModeFor(drag.tool));
|
|
2343
|
+
if (op) this.emitPreview.call(op);
|
|
2344
|
+
this.notify();
|
|
2345
|
+
}
|
|
2346
|
+
/**
|
|
2347
|
+
* Finish the gesture, emitting **at most one** op.
|
|
2348
|
+
*
|
|
2349
|
+
* @returns The op that was emitted, or `null`.
|
|
2350
|
+
*/
|
|
2351
|
+
pointerUp() {
|
|
2352
|
+
const drag = this.drag;
|
|
2353
|
+
this.drag = null;
|
|
2354
|
+
this.preview = null;
|
|
2355
|
+
if (!drag) return null;
|
|
2356
|
+
if (drag.kind === "marquee" && drag.marquee && drag.moved) {
|
|
2357
|
+
const rows = this.computeVisibleCached().rows;
|
|
2358
|
+
const ids = clipsInMarquee(drag.marquee, rows, this.arrangement.tracks, this.arrangement.clips);
|
|
2359
|
+
this.applySelection(ids, drag.modifiers.additive === true);
|
|
2360
|
+
this.notify();
|
|
2361
|
+
return null;
|
|
2362
|
+
}
|
|
2363
|
+
if (drag.kind === "scrub") {
|
|
2364
|
+
const tick = drag.startTick + drag.deltaTicks;
|
|
2365
|
+
this.host.emit("intent", { kind: "seek", tick: this.clampToDuration(tick) });
|
|
2366
|
+
this.notify();
|
|
2367
|
+
return null;
|
|
2368
|
+
}
|
|
2369
|
+
if (!drag.moved) {
|
|
2370
|
+
if (drag.kind === "move" && drag.clipIds.length > 0) {
|
|
2371
|
+
this.applySelection(drag.clipIds, drag.modifiers.additive === true);
|
|
2372
|
+
} else if (drag.kind === "marquee") {
|
|
2373
|
+
this.applySelection([], false);
|
|
2374
|
+
}
|
|
2375
|
+
this.notify();
|
|
2376
|
+
return null;
|
|
2377
|
+
}
|
|
2378
|
+
const op = commitDrag(drag, trimModeFor(drag.tool));
|
|
2379
|
+
if (op) this.host.emit("op", op);
|
|
2380
|
+
this.notify();
|
|
2381
|
+
return op;
|
|
2382
|
+
}
|
|
2383
|
+
/** Abandon the gesture. Nothing was mutated, so there is nothing to roll back. */
|
|
2384
|
+
cancelDrag() {
|
|
2385
|
+
if (!this.drag) return;
|
|
2386
|
+
this.drag = null;
|
|
2387
|
+
this.preview = null;
|
|
2388
|
+
this.notify();
|
|
2389
|
+
}
|
|
2390
|
+
/** Is a gesture live? */
|
|
2391
|
+
get dragging() {
|
|
2392
|
+
return this.drag !== null;
|
|
2393
|
+
}
|
|
2394
|
+
/* ── Direct operations ─────────────────────────────────────────────────── */
|
|
2395
|
+
/** Split the selection, or every clip, at a tick. */
|
|
2396
|
+
split(atTick, all = false) {
|
|
2397
|
+
const tick = this.clampToDuration(atTick);
|
|
2398
|
+
const op = all ? { kind: "splitAll", atTick: tick } : { kind: "split", clipIds: this.selection.clipIds, atTick: tick };
|
|
2399
|
+
if (!all && this.selection.clipIds.length === 0) return null;
|
|
2400
|
+
this.host.emit("op", op);
|
|
2401
|
+
return op;
|
|
2402
|
+
}
|
|
2403
|
+
/** Delete the selection. */
|
|
2404
|
+
deleteSelection(ripple = false) {
|
|
2405
|
+
if (this.selection.clipIds.length === 0) return null;
|
|
2406
|
+
const op = { kind: "delete", clipIds: this.selection.clipIds, ripple };
|
|
2407
|
+
this.host.emit("op", op);
|
|
2408
|
+
return op;
|
|
2409
|
+
}
|
|
2410
|
+
/** Emit any op. The single door for the view's buttons. */
|
|
2411
|
+
dispatchOp(op) {
|
|
2412
|
+
this.host.emit("op", op);
|
|
2413
|
+
}
|
|
2414
|
+
/* ── Selection ─────────────────────────────────────────────────────────── */
|
|
2415
|
+
applySelection(clipIds, additive) {
|
|
2416
|
+
const next = additive && this.multiSelect ? [.../* @__PURE__ */ new Set([...this.selection.clipIds, ...clipIds])] : [...new Set(clipIds)];
|
|
2417
|
+
this.selection = { ...this.selection, clipIds: this.multiSelect ? next : next.slice(0, 1) };
|
|
2418
|
+
this.host.emit("selection", this.selection);
|
|
2419
|
+
}
|
|
2420
|
+
/** Select a clip, or add it to the selection. */
|
|
2421
|
+
selectClip(clipId, additive = false) {
|
|
2422
|
+
this.applySelection([clipId], additive);
|
|
2423
|
+
this.notify();
|
|
2424
|
+
}
|
|
2425
|
+
/** Clear the selection. */
|
|
2426
|
+
clearSelection() {
|
|
2427
|
+
this.applySelection([], false);
|
|
2428
|
+
this.notify();
|
|
2429
|
+
}
|
|
2430
|
+
/**
|
|
2431
|
+
* Move the selection to an adjacent clip.
|
|
2432
|
+
*
|
|
2433
|
+
* Keyboard clip and track navigation, which none of the surveyed products has — and which is what
|
|
2434
|
+
* makes the panel usable without a mouse at all.
|
|
2435
|
+
*
|
|
2436
|
+
* @param direction - Where to go.
|
|
2437
|
+
* @returns The newly selected clip, or `null`.
|
|
2438
|
+
*/
|
|
2439
|
+
navigate(direction) {
|
|
2440
|
+
const current = this.selection.clipIds[0];
|
|
2441
|
+
const tracks = this.arrangement.tracks;
|
|
2442
|
+
if (tracks.length === 0) return null;
|
|
2443
|
+
const clip = current ? this.arrangement.clips[current] : void 0;
|
|
2444
|
+
let trackIndex = clip ? tracks.findIndex((t) => t.id === clip.trackId) : 0;
|
|
2445
|
+
if (trackIndex < 0) trackIndex = 0;
|
|
2446
|
+
if (direction === "up" || direction === "down") {
|
|
2447
|
+
const nextIndex = Math.max(0, Math.min(tracks.length - 1, trackIndex + (direction === "down" ? 1 : -1)));
|
|
2448
|
+
const track2 = tracks[nextIndex];
|
|
2449
|
+
const anchor = clip ? clip.startTick : this.playheadTick();
|
|
2450
|
+
const nearest = nearestClip(track2, this.arrangement.clips, anchor);
|
|
2451
|
+
if (!nearest) return null;
|
|
2452
|
+
this.applySelection([nearest.id], false);
|
|
2453
|
+
this.revealClip(nearest);
|
|
2454
|
+
return nearest;
|
|
2455
|
+
}
|
|
2456
|
+
const track = tracks[trackIndex];
|
|
2457
|
+
const ids = track.clipIds;
|
|
2458
|
+
const position = clip ? ids.indexOf(clip.id) : -1;
|
|
2459
|
+
const nextPosition = direction === "next" ? position + 1 : position - 1;
|
|
2460
|
+
const nextId = ids[nextPosition];
|
|
2461
|
+
if (!nextId) return null;
|
|
2462
|
+
const next = this.arrangement.clips[nextId];
|
|
2463
|
+
if (!next) return null;
|
|
2464
|
+
this.applySelection([next.id], false);
|
|
2465
|
+
this.revealClip(next);
|
|
2466
|
+
return next;
|
|
2467
|
+
}
|
|
2468
|
+
/** Scroll the minimum distance to bring a clip on screen. */
|
|
2469
|
+
revealClip(clip) {
|
|
2470
|
+
const scrollTick = scrollToReveal(this.scale, clip.startTick, 24, this.arrangement.durationTicks);
|
|
2471
|
+
if (scrollTick !== this.scale.scrollTick) {
|
|
2472
|
+
this.scrollTo(scrollTick, this.scale.scrollTopPx);
|
|
2473
|
+
} else {
|
|
2474
|
+
this.notify();
|
|
2475
|
+
}
|
|
2476
|
+
}
|
|
2477
|
+
/* ── Playhead ──────────────────────────────────────────────────────────── */
|
|
2478
|
+
/**
|
|
2479
|
+
* The playhead right now.
|
|
2480
|
+
*
|
|
2481
|
+
* Dead-reckoned from the last host push, and **clamped against the LIVE duration every read** —
|
|
2482
|
+
* an arrangement that shrinks while playing would otherwise leave the playhead extrapolating past
|
|
2483
|
+
* the end of a document that no longer goes that far.
|
|
2484
|
+
*/
|
|
2485
|
+
playheadTick() {
|
|
2486
|
+
return extrapolateTick3({
|
|
2487
|
+
clock: this.clock,
|
|
2488
|
+
now: this.now(),
|
|
2489
|
+
durationTicks: this.durationOrMax()
|
|
2490
|
+
});
|
|
2491
|
+
}
|
|
2492
|
+
clampToDuration(tick) {
|
|
2493
|
+
return clampTick3(tick, this.durationOrMax());
|
|
2494
|
+
}
|
|
2495
|
+
/** The duration to clamp against; an unset duration must not clamp everything to zero. */
|
|
2496
|
+
durationOrMax() {
|
|
2497
|
+
const duration = this.arrangement.durationTicks;
|
|
2498
|
+
return duration > 0 ? duration : Number.MAX_SAFE_INTEGER;
|
|
2499
|
+
}
|
|
2500
|
+
/* ── Tiles ─────────────────────────────────────────────────────────────── */
|
|
2501
|
+
/**
|
|
2502
|
+
* Ask the host for any lane tiles the visible clips need.
|
|
2503
|
+
*
|
|
2504
|
+
* Requests are coalesced by key, so a scroll does not re-ask for a tile already in flight.
|
|
2505
|
+
*
|
|
2506
|
+
* @returns The requests emitted.
|
|
2507
|
+
*/
|
|
2508
|
+
requestTiles() {
|
|
2509
|
+
const visible = this.computeVisibleCached();
|
|
2510
|
+
const bucket = zoomBucket(this.scale.pxPerTick);
|
|
2511
|
+
const out = [];
|
|
2512
|
+
for (const row of visible.tracks) {
|
|
2513
|
+
if (row.track.collapsed) continue;
|
|
2514
|
+
for (const clip of row.clips) {
|
|
2515
|
+
if (!clip.sourceId) continue;
|
|
2516
|
+
const range = sourceRangeFor(clip, this.scale.scrollTick, this.scale.endTick);
|
|
2517
|
+
if (!range) continue;
|
|
2518
|
+
const key = tileKey({
|
|
2519
|
+
sourceId: clip.sourceId,
|
|
2520
|
+
startTick: range.startTick,
|
|
2521
|
+
endTick: range.endTick,
|
|
2522
|
+
zoomBucket: bucket,
|
|
2523
|
+
heightPx: row.heightPx
|
|
2524
|
+
});
|
|
2525
|
+
if (this.tiles.find({ sourceId: clip.sourceId, ...range, zoomBucket: bucket, heightPx: row.heightPx })) {
|
|
2526
|
+
continue;
|
|
2527
|
+
}
|
|
2528
|
+
if (!this.tiles.markPending(key)) continue;
|
|
2529
|
+
const request = {
|
|
2530
|
+
key,
|
|
2531
|
+
clipId: clip.id,
|
|
2532
|
+
trackId: row.track.id,
|
|
2533
|
+
startTick: range.startTick,
|
|
2534
|
+
endTick: range.endTick,
|
|
2535
|
+
widthPx: Math.max(1, Math.round(this.scale.durationToPx(range.endTick - range.startTick))),
|
|
2536
|
+
heightPx: row.heightPx,
|
|
2537
|
+
dpr: 1
|
|
2538
|
+
};
|
|
2539
|
+
out.push(request);
|
|
2540
|
+
this.host.emit("laneTileRequest", request);
|
|
2541
|
+
}
|
|
2542
|
+
}
|
|
2543
|
+
return out;
|
|
2544
|
+
}
|
|
2545
|
+
/* ── Viewport ──────────────────────────────────────────────────────────── */
|
|
2546
|
+
publishViewport() {
|
|
2547
|
+
const visible = this.computeVisibleCached();
|
|
2548
|
+
this.host.emit("viewport", {
|
|
2549
|
+
startTick: Math.round(this.scale.scrollTick),
|
|
2550
|
+
endTick: Math.round(this.scale.endTick),
|
|
2551
|
+
pxPerTick: this.scale.pxPerTick,
|
|
2552
|
+
scrollTopPx: this.scale.scrollTopPx,
|
|
2553
|
+
trackIds: visible.tracks.map((row) => row.track.id)
|
|
2554
|
+
});
|
|
2555
|
+
}
|
|
2556
|
+
/* ── Internals ─────────────────────────────────────────────────────────── */
|
|
2557
|
+
/**
|
|
2558
|
+
* The visible set, recomputed only when something that affects it moved.
|
|
2559
|
+
*
|
|
2560
|
+
* Computing it once per read rather than once per access is what keeps a render from doing the
|
|
2561
|
+
* culling walk several times for the same frame.
|
|
2562
|
+
*/
|
|
2563
|
+
computeVisibleCached() {
|
|
2564
|
+
const key = `${this.scale.pxPerTick}|${this.scale.scrollTick}|${this.scale.scrollTopPx}|${this.scale.widthPx}|${this.scale.heightPx}`;
|
|
2565
|
+
if (this.cachedVisible && this.cachedVisible.rev === this.arrangement.rev && this.cachedVisible.key === key) {
|
|
2566
|
+
return this.cachedVisible.value;
|
|
2567
|
+
}
|
|
2568
|
+
const value = computeVisible(this.arrangement, this.scale, {
|
|
2569
|
+
overscan: this.virtualize ? 1 : Number.MAX_SAFE_INTEGER,
|
|
2570
|
+
rulerPx: this.rulerPx
|
|
2571
|
+
});
|
|
2572
|
+
this.cachedVisible = { rev: this.arrangement.rev, key, value };
|
|
2573
|
+
return value;
|
|
2574
|
+
}
|
|
2575
|
+
/** The clips a gesture on `clipId` should move. */
|
|
2576
|
+
gestureClips(clipId, additive) {
|
|
2577
|
+
const clip = this.arrangement.clips[clipId];
|
|
2578
|
+
if (!clip) return [];
|
|
2579
|
+
const selected = this.selection.clipIds;
|
|
2580
|
+
const ids = selected.includes(clipId) ? selected : additive ? [...selected, clipId] : [clipId];
|
|
2581
|
+
const out = [];
|
|
2582
|
+
for (const id of ids) {
|
|
2583
|
+
const found = this.arrangement.clips[id];
|
|
2584
|
+
if (!found) continue;
|
|
2585
|
+
out.push(found);
|
|
2586
|
+
for (const linked of found.linkedIds ?? []) {
|
|
2587
|
+
const partner = this.arrangement.clips[linked];
|
|
2588
|
+
if (partner && !ids.includes(linked)) out.push(partner);
|
|
2589
|
+
}
|
|
2590
|
+
}
|
|
2591
|
+
return out;
|
|
2592
|
+
}
|
|
2593
|
+
/** Every edge a gesture moves, for delta snapping. */
|
|
2594
|
+
gestureEdges(drag) {
|
|
2595
|
+
const out = [];
|
|
2596
|
+
for (const id of drag.clipIds) {
|
|
2597
|
+
const from = drag.origin[id];
|
|
2598
|
+
if (!from) continue;
|
|
2599
|
+
if (drag.kind === "trimStart") out.push(from.startTick);
|
|
2600
|
+
else if (drag.kind === "trimEnd") out.push(from.endTick);
|
|
2601
|
+
else {
|
|
2602
|
+
out.push(from.startTick);
|
|
2603
|
+
out.push(from.endTick);
|
|
2604
|
+
}
|
|
2605
|
+
}
|
|
2606
|
+
return out.length > 0 ? out : [drag.startTick];
|
|
2607
|
+
}
|
|
2608
|
+
/** Snap a bare tick, for the scrub. */
|
|
2609
|
+
snapAbsolute(tick, exclude, invert) {
|
|
2610
|
+
const config = withSnapModifier(this.snapConfig(), invert);
|
|
2611
|
+
const candidates = collectSnapTargets(this.arrangement, exclude, null, {
|
|
2612
|
+
startTick: this.scale.scrollTick,
|
|
2613
|
+
endTick: this.scale.endTick
|
|
2614
|
+
});
|
|
2615
|
+
const result = snapDelta([0], tick, candidates, this.scale, config);
|
|
2616
|
+
return this.clampToDuration(result.deltaTicks);
|
|
2617
|
+
}
|
|
2618
|
+
/* ── Persistence ───────────────────────────────────────────────────────── */
|
|
2619
|
+
/** Preferences only — never the arrangement, never the selection, never a tile. */
|
|
2620
|
+
serialize() {
|
|
2621
|
+
return {
|
|
2622
|
+
tool: this.tool,
|
|
2623
|
+
snapMode: this.snapMode,
|
|
2624
|
+
pxPerTick: this.scale.pxPerTick,
|
|
2625
|
+
scrollTick: Math.round(this.scale.scrollTick),
|
|
2626
|
+
scrollTopPx: Math.round(this.scale.scrollTopPx)
|
|
2627
|
+
};
|
|
2628
|
+
}
|
|
2629
|
+
/** Restore preferences. */
|
|
2630
|
+
deserialize(state) {
|
|
2631
|
+
if (state.tool) this.tool = state.tool;
|
|
2632
|
+
if (state.snapMode) this.snapMode = state.snapMode;
|
|
2633
|
+
this.scale = this.scale.with({
|
|
2634
|
+
pxPerTick: typeof state.pxPerTick === "number" && state.pxPerTick > 0 ? state.pxPerTick : this.scale.pxPerTick,
|
|
2635
|
+
scrollTick: typeof state.scrollTick === "number" ? state.scrollTick : this.scale.scrollTick,
|
|
2636
|
+
scrollTopPx: typeof state.scrollTopPx === "number" ? state.scrollTopPx : this.scale.scrollTopPx
|
|
2637
|
+
});
|
|
2638
|
+
this.cachedVisible = null;
|
|
2639
|
+
this.notify();
|
|
2640
|
+
}
|
|
2641
|
+
/** Release timers, listeners and every cached bitmap. */
|
|
2642
|
+
dispose() {
|
|
2643
|
+
this.emitPreview.cancel();
|
|
2644
|
+
this.emitViewport.cancel();
|
|
2645
|
+
this.listeners.clear();
|
|
2646
|
+
this.tiles.clear();
|
|
2647
|
+
this.drag = null;
|
|
2648
|
+
this.preview = null;
|
|
2649
|
+
}
|
|
2650
|
+
};
|
|
2651
|
+
function parseTileKey(key) {
|
|
2652
|
+
const parts = key.split("|");
|
|
2653
|
+
if (parts.length !== 5) return null;
|
|
2654
|
+
const [sourceId, startTick, endTick, bucket, heightPx] = parts;
|
|
2655
|
+
const numbers = [startTick, endTick, bucket, heightPx].map(Number);
|
|
2656
|
+
if (!sourceId || numbers.some((n) => !Number.isFinite(n))) return null;
|
|
2657
|
+
return {
|
|
2658
|
+
sourceId,
|
|
2659
|
+
startTick: numbers[0],
|
|
2660
|
+
endTick: numbers[1],
|
|
2661
|
+
zoomBucket: numbers[2],
|
|
2662
|
+
heightPx: numbers[3]
|
|
2663
|
+
};
|
|
2664
|
+
}
|
|
2665
|
+
function nearestClip(track, clips, tick) {
|
|
2666
|
+
let best = null;
|
|
2667
|
+
let bestDistance = Number.POSITIVE_INFINITY;
|
|
2668
|
+
for (const id of track.clipIds) {
|
|
2669
|
+
const clip = clips[id];
|
|
2670
|
+
if (!clip) continue;
|
|
2671
|
+
const distance = clip.startTick <= tick && clip.endTick > tick ? 0 : Math.min(Math.abs(clip.startTick - tick), Math.abs(clip.endTick - tick));
|
|
2672
|
+
if (distance < bestDistance) {
|
|
2673
|
+
bestDistance = distance;
|
|
2674
|
+
best = clip;
|
|
2675
|
+
}
|
|
2676
|
+
}
|
|
2677
|
+
return best;
|
|
2678
|
+
}
|
|
2679
|
+
|
|
2680
|
+
// src/time/timeline/manifest.ts
|
|
2681
|
+
var TIMELINE_PANEL_ID = "xeno.core.timeline";
|
|
2682
|
+
var timelineManifest = {
|
|
2683
|
+
id: TIMELINE_PANEL_ID,
|
|
2684
|
+
version: "0.1.0",
|
|
2685
|
+
title: "Timeline",
|
|
2686
|
+
icon: "timeline",
|
|
2687
|
+
description: "The arrangement core: a tempo-aware ruler with zoom-to-cursor, virtualized variable-height tracks, culled clip rects, move/trim/split as intents, snapping, marquee selection, an extrapolated playhead, and the host lane-tile round-trip.",
|
|
2688
|
+
defaultSlot: "bottom",
|
|
2689
|
+
inputs: [
|
|
2690
|
+
{
|
|
2691
|
+
id: "arrangement",
|
|
2692
|
+
name: "Arrangement",
|
|
2693
|
+
type: "object",
|
|
2694
|
+
description: "The host-owned arrangement: timebase, duration, tracks, clips, markers, rev. Read-only; the panel re-walks only when rev moves.",
|
|
2695
|
+
multiple: false
|
|
2696
|
+
},
|
|
2697
|
+
{
|
|
2698
|
+
id: "clock",
|
|
2699
|
+
name: "Clock",
|
|
2700
|
+
type: "object",
|
|
2701
|
+
description: "A XenoPlayheadClock push at 2-10Hz. The panel dead-reckons 60fps from it. `playing` is host-authoritative.",
|
|
2702
|
+
multiple: false
|
|
2703
|
+
},
|
|
2704
|
+
{
|
|
2705
|
+
id: "selection",
|
|
2706
|
+
name: "Selection",
|
|
2707
|
+
type: "object",
|
|
2708
|
+
description: "Externally-driven selection, for syncing with an inspector or a layers panel.",
|
|
2709
|
+
multiple: false
|
|
2710
|
+
},
|
|
2711
|
+
{
|
|
2712
|
+
id: "laneTile",
|
|
2713
|
+
name: "Lane Tile",
|
|
2714
|
+
type: "object",
|
|
2715
|
+
description: "The host's answer to a laneTileRequest. The panel BLITS it and never interprets it \u2014 which is why this panel needs no gpu, fs or lib capability.",
|
|
2716
|
+
multiple: true
|
|
2717
|
+
},
|
|
2718
|
+
{
|
|
2719
|
+
id: "overlays",
|
|
2720
|
+
name: "Overlays",
|
|
2721
|
+
type: "array",
|
|
2722
|
+
description: "Host-drawn time-range strips: cache segments, loop brace, punch region, warp markers.",
|
|
2723
|
+
multiple: false
|
|
2724
|
+
}
|
|
2725
|
+
],
|
|
2726
|
+
outputs: [
|
|
2727
|
+
{
|
|
2728
|
+
id: "op",
|
|
2729
|
+
name: "Operation",
|
|
2730
|
+
type: "object",
|
|
2731
|
+
description: "A XenoTimelineOp \u2014 the ONLY mutation channel, and an INTENT. The host may refuse, clamp or reinterpret it; the panel renders nothing until a new arrangement arrives."
|
|
2732
|
+
},
|
|
2733
|
+
{
|
|
2734
|
+
id: "opPreview",
|
|
2735
|
+
name: "Operation Preview",
|
|
2736
|
+
type: "object",
|
|
2737
|
+
description: "The live gesture, throttled. NEVER history \u2014 a drag produces many of these and exactly one `op`."
|
|
2738
|
+
},
|
|
2739
|
+
{
|
|
2740
|
+
id: "selection",
|
|
2741
|
+
name: "Selection",
|
|
2742
|
+
type: "object",
|
|
2743
|
+
description: "What is selected now."
|
|
2744
|
+
},
|
|
2745
|
+
{
|
|
2746
|
+
id: "intent",
|
|
2747
|
+
name: "Transport Intent",
|
|
2748
|
+
type: "object",
|
|
2749
|
+
description: "Transport intents from ruler scrubbing, in the SAME shape as xeno.core.transport emits \u2014 so a host wires one handler for both panels."
|
|
2750
|
+
},
|
|
2751
|
+
{
|
|
2752
|
+
id: "seekPreview",
|
|
2753
|
+
name: "Seek Preview",
|
|
2754
|
+
type: "object",
|
|
2755
|
+
description: "A throttled scrub position. No commit, no history."
|
|
2756
|
+
},
|
|
2757
|
+
{
|
|
2758
|
+
id: "laneTileRequest",
|
|
2759
|
+
name: "Lane Tile Request",
|
|
2760
|
+
type: "object",
|
|
2761
|
+
description: "A request for lane pixels over a SOURCE tick range. Answer on `laneTile`. Coalesced \u2014 the panel never re-asks for a tile already in flight."
|
|
2762
|
+
},
|
|
2763
|
+
{
|
|
2764
|
+
id: "viewport",
|
|
2765
|
+
name: "Viewport",
|
|
2766
|
+
type: "object",
|
|
2767
|
+
description: "What is on screen, so a host can prefetch decode and render caches. The panel owns scroll and zoom and EMITS them; it never reads them back."
|
|
2768
|
+
},
|
|
2769
|
+
{
|
|
2770
|
+
id: "contextMenuRequest",
|
|
2771
|
+
name: "Context Menu Request",
|
|
2772
|
+
type: "object",
|
|
2773
|
+
description: "A right-click, with what was under it. The host owns the menu."
|
|
2774
|
+
}
|
|
2775
|
+
],
|
|
2776
|
+
commands: [
|
|
2777
|
+
{
|
|
2778
|
+
id: "set_tool",
|
|
2779
|
+
title: "Set Tool",
|
|
2780
|
+
description: "Change the active tool. Ignored mid-gesture \u2014 the mode is frozen at mousedown.",
|
|
2781
|
+
parameters: { tool: { type: "string", description: "select|razor|ripple|roll|slip|slide|hand|zoom", required: true } }
|
|
2782
|
+
},
|
|
2783
|
+
{
|
|
2784
|
+
id: "set_snap",
|
|
2785
|
+
title: "Set Snapping",
|
|
2786
|
+
description: "off | grid | targets | both.",
|
|
2787
|
+
parameters: { mode: { type: "string", description: "Snap mode", required: true } }
|
|
2788
|
+
},
|
|
2789
|
+
{
|
|
2790
|
+
id: "zoom_to_fit",
|
|
2791
|
+
title: "Zoom to Fit",
|
|
2792
|
+
description: "Fit a tick range into the lane. Omit both for the whole arrangement.",
|
|
2793
|
+
parameters: {
|
|
2794
|
+
fromTick: { type: "number", description: "Range start" },
|
|
2795
|
+
toTick: { type: "number", description: "Range end" }
|
|
2796
|
+
}
|
|
2797
|
+
},
|
|
2798
|
+
{
|
|
2799
|
+
id: "split",
|
|
2800
|
+
title: "Split",
|
|
2801
|
+
description: "Split the selection at a tick. Emits an op; returns null when nothing is selected.",
|
|
2802
|
+
parameters: {
|
|
2803
|
+
atTick: { type: "number", description: "Where to cut", required: true },
|
|
2804
|
+
all: { type: "boolean", description: "Cut every clip, not just the selection" }
|
|
2805
|
+
}
|
|
2806
|
+
},
|
|
2807
|
+
{
|
|
2808
|
+
id: "delete_selection",
|
|
2809
|
+
title: "Delete Selection",
|
|
2810
|
+
description: "Delete the selected clips.",
|
|
2811
|
+
parameters: { ripple: { type: "boolean", description: "Close the gap" } }
|
|
2812
|
+
},
|
|
2813
|
+
{
|
|
2814
|
+
id: "select_clip",
|
|
2815
|
+
title: "Select Clip",
|
|
2816
|
+
description: "Select a clip, or add it to the selection.",
|
|
2817
|
+
parameters: {
|
|
2818
|
+
clipId: { type: "string", description: "Clip id", required: true },
|
|
2819
|
+
additive: { type: "boolean", description: "Add rather than replace" }
|
|
2820
|
+
}
|
|
2821
|
+
},
|
|
2822
|
+
{
|
|
2823
|
+
id: "clear_selection",
|
|
2824
|
+
title: "Clear Selection",
|
|
2825
|
+
description: "Deselect everything.",
|
|
2826
|
+
parameters: {}
|
|
2827
|
+
},
|
|
2828
|
+
{
|
|
2829
|
+
id: "navigate",
|
|
2830
|
+
title: "Navigate",
|
|
2831
|
+
description: "Move the selection to an adjacent clip and scroll it into view. next|previous|up|down.",
|
|
2832
|
+
parameters: { direction: { type: "string", description: "next|previous|up|down", required: true } }
|
|
2833
|
+
},
|
|
2834
|
+
{
|
|
2835
|
+
id: "scroll_to",
|
|
2836
|
+
title: "Scroll To",
|
|
2837
|
+
description: "Scroll to an absolute position.",
|
|
2838
|
+
parameters: {
|
|
2839
|
+
scrollTick: { type: "number", description: "Left edge tick" },
|
|
2840
|
+
scrollTopPx: { type: "number", description: "Vertical scroll" }
|
|
2841
|
+
}
|
|
2842
|
+
}
|
|
2843
|
+
],
|
|
2844
|
+
config: [
|
|
2845
|
+
{
|
|
2846
|
+
key: "tool",
|
|
2847
|
+
label: "Default tool",
|
|
2848
|
+
type: "select",
|
|
2849
|
+
defaultValue: "select",
|
|
2850
|
+
options: [
|
|
2851
|
+
{ label: "Select", value: "select" },
|
|
2852
|
+
{ label: "Razor", value: "razor" },
|
|
2853
|
+
{ label: "Ripple", value: "ripple" },
|
|
2854
|
+
{ label: "Roll", value: "roll" },
|
|
2855
|
+
{ label: "Slip", value: "slip" },
|
|
2856
|
+
{ label: "Slide", value: "slide" },
|
|
2857
|
+
{ label: "Hand", value: "hand" }
|
|
2858
|
+
],
|
|
2859
|
+
description: "Which tool a fresh panel starts with."
|
|
2860
|
+
},
|
|
2861
|
+
{
|
|
2862
|
+
key: "snapMode",
|
|
2863
|
+
label: "Snapping",
|
|
2864
|
+
type: "select",
|
|
2865
|
+
defaultValue: "both",
|
|
2866
|
+
options: [
|
|
2867
|
+
{ label: "Off", value: "off" },
|
|
2868
|
+
{ label: "Grid", value: "grid" },
|
|
2869
|
+
{ label: "Clips & markers", value: "targets" },
|
|
2870
|
+
{ label: "Both", value: "both" }
|
|
2871
|
+
],
|
|
2872
|
+
description: "What a drag snaps to. The threshold is in screen pixels, so it works at any zoom."
|
|
2873
|
+
},
|
|
2874
|
+
{
|
|
2875
|
+
key: "crossTrackDrag",
|
|
2876
|
+
label: "Allow cross-track drag",
|
|
2877
|
+
type: "boolean",
|
|
2878
|
+
defaultValue: true,
|
|
2879
|
+
description: "Whether a clip can be dragged onto another track."
|
|
2880
|
+
},
|
|
2881
|
+
{
|
|
2882
|
+
key: "multiSelect",
|
|
2883
|
+
label: "Multi-select",
|
|
2884
|
+
type: "boolean",
|
|
2885
|
+
defaultValue: true,
|
|
2886
|
+
description: "Allow more than one clip to be selected."
|
|
2887
|
+
},
|
|
2888
|
+
{
|
|
2889
|
+
key: "marquee",
|
|
2890
|
+
label: "Marquee select",
|
|
2891
|
+
type: "boolean",
|
|
2892
|
+
defaultValue: true,
|
|
2893
|
+
description: "Drag in empty lane space to select a rectangle of clips."
|
|
2894
|
+
},
|
|
2895
|
+
{
|
|
2896
|
+
key: "virtualize",
|
|
2897
|
+
label: "Virtualize",
|
|
2898
|
+
type: "boolean",
|
|
2899
|
+
defaultValue: true,
|
|
2900
|
+
description: "Render only visible rows and clips. On by default \u2014 50 tracks x 2000 clips is the parity floor."
|
|
2901
|
+
},
|
|
2902
|
+
{
|
|
2903
|
+
key: "throttleMs",
|
|
2904
|
+
label: "Preview throttle",
|
|
2905
|
+
type: "number",
|
|
2906
|
+
defaultValue: 100,
|
|
2907
|
+
description: "How often opPreview and viewport are emitted during a gesture, in ms.",
|
|
2908
|
+
placeholder: "100"
|
|
2909
|
+
},
|
|
2910
|
+
{
|
|
2911
|
+
key: "trackHeightPx",
|
|
2912
|
+
label: "Default track height",
|
|
2913
|
+
type: "number",
|
|
2914
|
+
defaultValue: 56,
|
|
2915
|
+
description: "Height of a track that does not declare one.",
|
|
2916
|
+
placeholder: "56"
|
|
2917
|
+
}
|
|
2918
|
+
],
|
|
2919
|
+
// The whole point of the tile round-trip.
|
|
2920
|
+
capabilities: ["storage.local"],
|
|
2921
|
+
sdk: "^1.0.0"
|
|
2922
|
+
};
|
|
2923
|
+
|
|
2924
|
+
// src/time/timeline/react/TimelinePanelView.tsx
|
|
2925
|
+
import { useCallback as useCallback2, useEffect as useEffect2, useRef as useRef2, useSyncExternalStore as useSyncExternalStore2 } from "react";
|
|
2926
|
+
import { EmptyState, StatusBar, Toolbar, ToolbarGroup } from "@xenosystem/workbench/primitives/react";
|
|
2927
|
+
import { formatTick as formatTick4 } from "@xenosystem/time-core";
|
|
2928
|
+
import { jsx as jsx3, jsxs as jsxs3 } from "react/jsx-runtime";
|
|
2929
|
+
var RULER_PX = 22;
|
|
2930
|
+
var TOOLS = [
|
|
2931
|
+
{ id: "select", label: "Select", key: "V" },
|
|
2932
|
+
{ id: "razor", label: "Razor", key: "C" },
|
|
2933
|
+
{ id: "ripple", label: "Ripple", key: "B" },
|
|
2934
|
+
{ id: "hand", label: "Hand", key: "H" }
|
|
2935
|
+
];
|
|
2936
|
+
function TimelinePanelView({ controller }) {
|
|
2937
|
+
const state = useSyncExternalStore2(controller.subscribe, controller.getState, controller.getState);
|
|
2938
|
+
const containerRef = useRef2(null);
|
|
2939
|
+
useEffect2(() => {
|
|
2940
|
+
const el = containerRef.current;
|
|
2941
|
+
if (!el) return;
|
|
2942
|
+
const measure = () => {
|
|
2943
|
+
const rect = el.getBoundingClientRect();
|
|
2944
|
+
controller.resize(rect.width, rect.height);
|
|
2945
|
+
};
|
|
2946
|
+
measure();
|
|
2947
|
+
if (typeof ResizeObserver === "undefined") return;
|
|
2948
|
+
const observer = new ResizeObserver(measure);
|
|
2949
|
+
observer.observe(el);
|
|
2950
|
+
return () => observer.disconnect();
|
|
2951
|
+
}, [controller]);
|
|
2952
|
+
const localPoint = useCallback2((event) => {
|
|
2953
|
+
const rect = containerRef.current?.getBoundingClientRect();
|
|
2954
|
+
return { x: event.clientX - (rect?.left ?? 0), y: event.clientY - (rect?.top ?? 0) };
|
|
2955
|
+
}, []);
|
|
2956
|
+
const onWheel = useCallback2(
|
|
2957
|
+
(event) => {
|
|
2958
|
+
const { x } = localPoint(event);
|
|
2959
|
+
controller.wheel({
|
|
2960
|
+
deltaX: event.deltaX,
|
|
2961
|
+
deltaY: event.deltaY,
|
|
2962
|
+
x,
|
|
2963
|
+
zoom: event.ctrlKey || event.altKey
|
|
2964
|
+
});
|
|
2965
|
+
},
|
|
2966
|
+
[controller, localPoint]
|
|
2967
|
+
);
|
|
2968
|
+
const onPointerDown = useCallback2(
|
|
2969
|
+
(event) => {
|
|
2970
|
+
const { x, y } = localPoint(event);
|
|
2971
|
+
const scale2 = state.scale;
|
|
2972
|
+
const tick = Math.round(scale2.xToTick(x));
|
|
2973
|
+
if (y < RULER_PX) {
|
|
2974
|
+
controller.pointerDown({
|
|
2975
|
+
target: { kind: "ruler" },
|
|
2976
|
+
x,
|
|
2977
|
+
y,
|
|
2978
|
+
tick,
|
|
2979
|
+
trackIndex: -1,
|
|
2980
|
+
modifiers: { additive: event.shiftKey }
|
|
2981
|
+
});
|
|
2982
|
+
event.target.setPointerCapture?.(event.pointerId);
|
|
2983
|
+
return;
|
|
2984
|
+
}
|
|
2985
|
+
const hit = hitTest(state, x, y);
|
|
2986
|
+
controller.pointerDown({
|
|
2987
|
+
target: hit.target,
|
|
2988
|
+
x,
|
|
2989
|
+
y,
|
|
2990
|
+
tick,
|
|
2991
|
+
trackIndex: hit.trackIndex,
|
|
2992
|
+
modifiers: { additive: event.shiftKey, copy: event.altKey }
|
|
2993
|
+
});
|
|
2994
|
+
event.target.setPointerCapture?.(event.pointerId);
|
|
2995
|
+
},
|
|
2996
|
+
[controller, localPoint, state]
|
|
2997
|
+
);
|
|
2998
|
+
const onPointerMove = useCallback2(
|
|
2999
|
+
(event) => {
|
|
3000
|
+
if (!controller.dragging) return;
|
|
3001
|
+
const { x, y } = localPoint(event);
|
|
3002
|
+
controller.pointerMove(x, y, { snapInvert: event.shiftKey, constrain: event.metaKey });
|
|
3003
|
+
},
|
|
3004
|
+
[controller, localPoint]
|
|
3005
|
+
);
|
|
3006
|
+
const onPointerUp = useCallback2(() => {
|
|
3007
|
+
controller.pointerUp();
|
|
3008
|
+
}, [controller]);
|
|
3009
|
+
const onKeyDown = useCallback2(
|
|
3010
|
+
(event) => {
|
|
3011
|
+
const map = {
|
|
3012
|
+
ArrowRight: "next",
|
|
3013
|
+
ArrowLeft: "previous",
|
|
3014
|
+
ArrowUp: "up",
|
|
3015
|
+
ArrowDown: "down"
|
|
3016
|
+
};
|
|
3017
|
+
const direction = map[event.key];
|
|
3018
|
+
if (direction) {
|
|
3019
|
+
event.preventDefault();
|
|
3020
|
+
controller.navigate(direction);
|
|
3021
|
+
return;
|
|
3022
|
+
}
|
|
3023
|
+
if (event.key === "Delete" || event.key === "Backspace") {
|
|
3024
|
+
event.preventDefault();
|
|
3025
|
+
controller.deleteSelection(event.shiftKey);
|
|
3026
|
+
return;
|
|
3027
|
+
}
|
|
3028
|
+
if (event.key === "Escape") controller.cancelDrag();
|
|
3029
|
+
},
|
|
3030
|
+
[controller]
|
|
3031
|
+
);
|
|
3032
|
+
const scale = state.scale;
|
|
3033
|
+
const ticks = state.arrangement ? rulerTicks(scale, state.timebase) : [];
|
|
3034
|
+
const selected = new Set(state.selection.clipIds);
|
|
3035
|
+
const preview = state.preview;
|
|
3036
|
+
return /* @__PURE__ */ jsxs3("div", { style: SHELL, children: [
|
|
3037
|
+
/* @__PURE__ */ jsx3(
|
|
3038
|
+
Toolbar,
|
|
3039
|
+
{
|
|
3040
|
+
left: /* @__PURE__ */ jsx3(ToolbarGroup, { children: TOOLS.map((tool) => /* @__PURE__ */ jsx3(
|
|
3041
|
+
"button",
|
|
3042
|
+
{
|
|
3043
|
+
type: "button",
|
|
3044
|
+
title: `${tool.label} (${tool.key})`,
|
|
3045
|
+
"aria-pressed": state.tool === tool.id,
|
|
3046
|
+
onClick: () => controller.setTool(tool.id),
|
|
3047
|
+
style: toolButton(state.tool === tool.id),
|
|
3048
|
+
children: tool.label
|
|
3049
|
+
},
|
|
3050
|
+
tool.id
|
|
3051
|
+
)) }),
|
|
3052
|
+
right: /* @__PURE__ */ jsxs3(ToolbarGroup, { end: true, children: [
|
|
3053
|
+
/* @__PURE__ */ jsx3(
|
|
3054
|
+
"button",
|
|
3055
|
+
{
|
|
3056
|
+
type: "button",
|
|
3057
|
+
title: "Snapping",
|
|
3058
|
+
"aria-pressed": state.snap.mode !== "off",
|
|
3059
|
+
onClick: () => controller.setSnapMode(state.snap.mode === "off" ? "both" : "off"),
|
|
3060
|
+
style: toolButton(state.snap.mode !== "off"),
|
|
3061
|
+
children: "Snap"
|
|
3062
|
+
}
|
|
3063
|
+
),
|
|
3064
|
+
/* @__PURE__ */ jsx3("button", { type: "button", title: "Zoom to fit", onClick: () => controller.zoomToFitRange(), style: toolButton(false), children: "Fit" })
|
|
3065
|
+
] })
|
|
3066
|
+
}
|
|
3067
|
+
),
|
|
3068
|
+
!state.arrangement || state.arrangement.tracks.length === 0 ? /* @__PURE__ */ jsx3(EmptyState, { title: "No arrangement", hint: "Wire a host arrangement into this panel's input." }) : /* @__PURE__ */ jsxs3(
|
|
3069
|
+
"div",
|
|
3070
|
+
{
|
|
3071
|
+
ref: containerRef,
|
|
3072
|
+
tabIndex: 0,
|
|
3073
|
+
role: "application",
|
|
3074
|
+
"aria-label": "Timeline",
|
|
3075
|
+
style: SURFACE,
|
|
3076
|
+
onWheel,
|
|
3077
|
+
onPointerDown,
|
|
3078
|
+
onPointerMove,
|
|
3079
|
+
onPointerUp,
|
|
3080
|
+
onPointerCancel: onPointerUp,
|
|
3081
|
+
onKeyDown,
|
|
3082
|
+
children: [
|
|
3083
|
+
/* @__PURE__ */ jsx3("div", { style: { ...RULER, height: RULER_PX }, children: ticks.map((tick) => /* @__PURE__ */ jsx3(
|
|
3084
|
+
"div",
|
|
3085
|
+
{
|
|
3086
|
+
style: {
|
|
3087
|
+
position: "absolute",
|
|
3088
|
+
left: Math.floor(tick.x),
|
|
3089
|
+
bottom: 0,
|
|
3090
|
+
width: 1,
|
|
3091
|
+
height: tick.weight === "major" ? 8 : tick.weight === "medium" ? 5 : 3,
|
|
3092
|
+
background: "var(--xeno-active)"
|
|
3093
|
+
},
|
|
3094
|
+
children: tick.label ? /* @__PURE__ */ jsx3("span", { style: TICK_LABEL, className: "tabular-nums", children: tick.label }) : null
|
|
3095
|
+
},
|
|
3096
|
+
tick.tick
|
|
3097
|
+
)) }),
|
|
3098
|
+
/* @__PURE__ */ jsxs3("div", { style: { position: "absolute", inset: `${RULER_PX}px 0 0 0`, overflow: "hidden" }, children: [
|
|
3099
|
+
state.visible.map((row) => {
|
|
3100
|
+
const top = row.topPx - scale.scrollTopPx;
|
|
3101
|
+
return /* @__PURE__ */ jsxs3("div", { style: { position: "absolute", left: 0, right: 0, top, height: row.heightPx }, children: [
|
|
3102
|
+
/* @__PURE__ */ jsx3("div", { style: { ...GUTTER, width: scale.gutterPx }, children: /* @__PURE__ */ jsx3("span", { style: TRACK_NAME, children: row.track.name }) }),
|
|
3103
|
+
/* @__PURE__ */ jsx3("div", { "data-canvas": "timeline-lane", style: { ...LANE, left: scale.gutterPx }, children: row.clips.map((clip) => {
|
|
3104
|
+
const shown = preview?.clips[clip.id] ?? clip;
|
|
3105
|
+
const x = scale.tickToX(shown.startTick);
|
|
3106
|
+
const width = Math.max(1, scale.durationToPx(shown.endTick - shown.startTick));
|
|
3107
|
+
return /* @__PURE__ */ jsx3(
|
|
3108
|
+
ClipRect,
|
|
3109
|
+
{
|
|
3110
|
+
clip,
|
|
3111
|
+
left: x - scale.gutterPx,
|
|
3112
|
+
width,
|
|
3113
|
+
selected: selected.has(clip.id)
|
|
3114
|
+
},
|
|
3115
|
+
clip.id
|
|
3116
|
+
);
|
|
3117
|
+
}) })
|
|
3118
|
+
] }, row.track.id);
|
|
3119
|
+
}),
|
|
3120
|
+
preview?.marquee ? /* @__PURE__ */ jsx3(
|
|
3121
|
+
"div",
|
|
3122
|
+
{
|
|
3123
|
+
style: {
|
|
3124
|
+
...MARQUEE,
|
|
3125
|
+
left: Math.min(scale.tickToX(preview.marquee.fromTick), scale.tickToX(preview.marquee.toTick)),
|
|
3126
|
+
width: Math.abs(
|
|
3127
|
+
scale.tickToX(preview.marquee.toTick) - scale.tickToX(preview.marquee.fromTick)
|
|
3128
|
+
),
|
|
3129
|
+
top: Math.min(preview.marquee.fromY, preview.marquee.toY) - scale.scrollTopPx,
|
|
3130
|
+
height: Math.abs(preview.marquee.toY - preview.marquee.fromY)
|
|
3131
|
+
}
|
|
3132
|
+
}
|
|
3133
|
+
) : null
|
|
3134
|
+
] }),
|
|
3135
|
+
/* @__PURE__ */ jsx3("div", { style: { ...PLAYHEAD, left: Math.floor(scale.tickToX(state.playheadTick)) } })
|
|
3136
|
+
]
|
|
3137
|
+
}
|
|
3138
|
+
),
|
|
3139
|
+
/* @__PURE__ */ jsx3(
|
|
3140
|
+
StatusBar,
|
|
3141
|
+
{
|
|
3142
|
+
left: state.arrangement ? `${state.arrangement.tracks.length} tracks` : void 0,
|
|
3143
|
+
right: /* @__PURE__ */ jsx3("span", { className: "tabular-nums", children: formatTick4(state.playheadTick, state.timebase) })
|
|
3144
|
+
}
|
|
3145
|
+
)
|
|
3146
|
+
] });
|
|
3147
|
+
}
|
|
3148
|
+
function ClipRect({
|
|
3149
|
+
clip,
|
|
3150
|
+
left,
|
|
3151
|
+
width,
|
|
3152
|
+
selected
|
|
3153
|
+
}) {
|
|
3154
|
+
return /* @__PURE__ */ jsx3(
|
|
3155
|
+
"div",
|
|
3156
|
+
{
|
|
3157
|
+
"data-clip-id": clip.id,
|
|
3158
|
+
title: clip.name ?? clip.id,
|
|
3159
|
+
style: {
|
|
3160
|
+
position: "absolute",
|
|
3161
|
+
left,
|
|
3162
|
+
width,
|
|
3163
|
+
top: 2,
|
|
3164
|
+
bottom: 2,
|
|
3165
|
+
// Rectangles with a corner radius. Never a circle.
|
|
3166
|
+
borderRadius: 2,
|
|
3167
|
+
background: selected ? "var(--xeno-control)" : "var(--xeno-border)",
|
|
3168
|
+
border: `1px solid ${selected ? "var(--xeno-active)" : "var(--xeno-control)"}`,
|
|
3169
|
+
overflow: "hidden",
|
|
3170
|
+
fontSize: 10,
|
|
3171
|
+
lineHeight: "14px",
|
|
3172
|
+
padding: width >= 40 ? "0 4px" : 0,
|
|
3173
|
+
whiteSpace: "nowrap",
|
|
3174
|
+
color: "var(--xeno-text)",
|
|
3175
|
+
boxSizing: "border-box"
|
|
3176
|
+
},
|
|
3177
|
+
children: width >= 40 ? clip.name ?? "" : null
|
|
3178
|
+
}
|
|
3179
|
+
);
|
|
3180
|
+
}
|
|
3181
|
+
function hitTest(state, x, y) {
|
|
3182
|
+
const scale = state.scale;
|
|
3183
|
+
if (x < scale.gutterPx) return { target: { kind: "lane" }, trackIndex: -1 };
|
|
3184
|
+
const contentY = y - RULER_PX + scale.scrollTopPx;
|
|
3185
|
+
for (const row of state.visible) {
|
|
3186
|
+
if (contentY < row.topPx || contentY >= row.topPx + row.heightPx) continue;
|
|
3187
|
+
for (const clip of row.clips) {
|
|
3188
|
+
const clipX = scale.tickToX(clip.startTick);
|
|
3189
|
+
const width = scale.durationToPx(clip.endTick - clip.startTick);
|
|
3190
|
+
if (x < clipX || x > clipX + width) continue;
|
|
3191
|
+
const zone = edgeZone(x - clipX, width);
|
|
3192
|
+
return {
|
|
3193
|
+
target: {
|
|
3194
|
+
kind: zone === "start" ? "clipEdgeStart" : zone === "end" ? "clipEdgeEnd" : "clip",
|
|
3195
|
+
clipId: clip.id,
|
|
3196
|
+
trackId: row.track.id
|
|
3197
|
+
},
|
|
3198
|
+
trackIndex: row.index
|
|
3199
|
+
};
|
|
3200
|
+
}
|
|
3201
|
+
return { target: { kind: "lane", trackId: row.track.id }, trackIndex: row.index };
|
|
3202
|
+
}
|
|
3203
|
+
return { target: { kind: "lane" }, trackIndex: -1 };
|
|
3204
|
+
}
|
|
3205
|
+
var SHELL = { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 };
|
|
3206
|
+
var SURFACE = {
|
|
3207
|
+
position: "relative",
|
|
3208
|
+
flex: 1,
|
|
3209
|
+
minHeight: 0,
|
|
3210
|
+
overflow: "hidden",
|
|
3211
|
+
outline: "none",
|
|
3212
|
+
userSelect: "none",
|
|
3213
|
+
touchAction: "none"
|
|
3214
|
+
};
|
|
3215
|
+
var RULER = {
|
|
3216
|
+
position: "absolute",
|
|
3217
|
+
top: 0,
|
|
3218
|
+
left: 0,
|
|
3219
|
+
right: 0,
|
|
3220
|
+
borderBottom: "1px solid var(--xeno-border)"
|
|
3221
|
+
};
|
|
3222
|
+
var TICK_LABEL = {
|
|
3223
|
+
position: "absolute",
|
|
3224
|
+
left: 3,
|
|
3225
|
+
top: -12,
|
|
3226
|
+
fontSize: 9,
|
|
3227
|
+
color: "var(--xeno-active)",
|
|
3228
|
+
whiteSpace: "nowrap"
|
|
3229
|
+
};
|
|
3230
|
+
var GUTTER = {
|
|
3231
|
+
position: "absolute",
|
|
3232
|
+
left: 0,
|
|
3233
|
+
top: 0,
|
|
3234
|
+
bottom: 0,
|
|
3235
|
+
borderRight: "1px solid var(--xeno-border)",
|
|
3236
|
+
borderBottom: "1px solid var(--xeno-border-subtle)",
|
|
3237
|
+
display: "flex",
|
|
3238
|
+
alignItems: "center",
|
|
3239
|
+
padding: "0 6px",
|
|
3240
|
+
boxSizing: "border-box"
|
|
3241
|
+
};
|
|
3242
|
+
var TRACK_NAME = {
|
|
3243
|
+
fontSize: 10,
|
|
3244
|
+
color: "var(--xeno-text)",
|
|
3245
|
+
overflow: "hidden",
|
|
3246
|
+
textOverflow: "ellipsis",
|
|
3247
|
+
whiteSpace: "nowrap"
|
|
3248
|
+
};
|
|
3249
|
+
var LANE = {
|
|
3250
|
+
position: "absolute",
|
|
3251
|
+
right: 0,
|
|
3252
|
+
top: 0,
|
|
3253
|
+
bottom: 0,
|
|
3254
|
+
borderBottom: "1px solid var(--xeno-border-subtle)"
|
|
3255
|
+
};
|
|
3256
|
+
var MARQUEE = {
|
|
3257
|
+
position: "absolute",
|
|
3258
|
+
border: "1px solid var(--xeno-active)",
|
|
3259
|
+
background: "var(--xeno-border-subtle)",
|
|
3260
|
+
pointerEvents: "none"
|
|
3261
|
+
};
|
|
3262
|
+
var PLAYHEAD = {
|
|
3263
|
+
position: "absolute",
|
|
3264
|
+
top: 0,
|
|
3265
|
+
bottom: 0,
|
|
3266
|
+
width: 1,
|
|
3267
|
+
background: "var(--xeno-text)",
|
|
3268
|
+
pointerEvents: "none"
|
|
3269
|
+
};
|
|
3270
|
+
function toolButton(active) {
|
|
3271
|
+
return {
|
|
3272
|
+
height: 18,
|
|
3273
|
+
padding: "0 6px",
|
|
3274
|
+
fontSize: 10,
|
|
3275
|
+
// Rectangles with a corner radius. No colour, no gradient, no shadow.
|
|
3276
|
+
borderRadius: 2,
|
|
3277
|
+
border: `1px solid ${active ? "var(--xeno-active)" : "var(--xeno-control)"}`,
|
|
3278
|
+
background: active ? "var(--xeno-control)" : "transparent",
|
|
3279
|
+
color: `${active ? "var(--xeno-text)" : "var(--xeno-muted)"}`,
|
|
3280
|
+
cursor: "pointer"
|
|
3281
|
+
};
|
|
3282
|
+
}
|
|
3283
|
+
|
|
3284
|
+
// src/time/timeline/panel.ts
|
|
3285
|
+
var TOOLS2 = [
|
|
3286
|
+
"select",
|
|
3287
|
+
"razor",
|
|
3288
|
+
"ripple",
|
|
3289
|
+
"roll",
|
|
3290
|
+
"slip",
|
|
3291
|
+
"slide",
|
|
3292
|
+
"hand",
|
|
3293
|
+
"zoom"
|
|
3294
|
+
];
|
|
3295
|
+
var SNAP_MODES = ["off", "grid", "targets", "both"];
|
|
3296
|
+
function createTimelinePanel(options = {}) {
|
|
3297
|
+
return {
|
|
3298
|
+
manifest: timelineManifest,
|
|
3299
|
+
activate(host) {
|
|
3300
|
+
const initial = host.config ?? {};
|
|
3301
|
+
const controller = new TimelineController({
|
|
3302
|
+
host: { emit: (portId, value) => host.emit(portId, value) },
|
|
3303
|
+
tool: toolOf(configString(initial, "tool", "select")),
|
|
3304
|
+
snapMode: snapOf(configString(initial, "snapMode", "both")),
|
|
3305
|
+
crossTrackDrag: configBool(initial, "crossTrackDrag", true),
|
|
3306
|
+
multiSelect: configBool(initial, "multiSelect", true),
|
|
3307
|
+
marquee: configBool(initial, "marquee", true),
|
|
3308
|
+
virtualize: configBool(initial, "virtualize", true),
|
|
3309
|
+
throttleMs: configNumber(initial, "throttleMs", 100)
|
|
3310
|
+
});
|
|
3311
|
+
const resolve = (config) => ({
|
|
3312
|
+
trackHeightPx: configNumber(config, "trackHeightPx", 56),
|
|
3313
|
+
virtualize: configBool(config, "virtualize", true)
|
|
3314
|
+
});
|
|
3315
|
+
let renderConfig = resolve(initial);
|
|
3316
|
+
let unrender = null;
|
|
3317
|
+
let root = null;
|
|
3318
|
+
let currentEl = null;
|
|
3319
|
+
const draw = (el) => {
|
|
3320
|
+
unrender?.();
|
|
3321
|
+
if (options.render) {
|
|
3322
|
+
unrender = options.render(el, { controller, config: renderConfig });
|
|
3323
|
+
return;
|
|
3324
|
+
}
|
|
3325
|
+
root = createRoot2(el);
|
|
3326
|
+
root.render(createElement2(TimelinePanelView, { controller, ...renderConfig }));
|
|
3327
|
+
unrender = () => {
|
|
3328
|
+
root?.unmount();
|
|
3329
|
+
root = null;
|
|
3330
|
+
};
|
|
3331
|
+
};
|
|
3332
|
+
const unbindConfig = bindConfig(host, (config) => {
|
|
3333
|
+
renderConfig = resolve(config);
|
|
3334
|
+
const tool = toolOf(configString(config, "tool", "select"));
|
|
3335
|
+
const snap = snapOf(configString(config, "snapMode", "both"));
|
|
3336
|
+
controller.setTool(tool);
|
|
3337
|
+
controller.setSnapMode(snap);
|
|
3338
|
+
if (currentEl) draw(currentEl);
|
|
3339
|
+
});
|
|
3340
|
+
return {
|
|
3341
|
+
render(el) {
|
|
3342
|
+
currentEl = el;
|
|
3343
|
+
draw(el);
|
|
3344
|
+
},
|
|
3345
|
+
onInput(portId, value) {
|
|
3346
|
+
if (portId === "arrangement") {
|
|
3347
|
+
if (hasFields(value, "tracks", "clips", "rev") && Array.isArray(value.tracks) && isRecord(value.clips)) {
|
|
3348
|
+
controller.setArrangement(value);
|
|
3349
|
+
}
|
|
3350
|
+
} else if (portId === "clock") {
|
|
3351
|
+
if (isRecord(value)) controller.setClock(value);
|
|
3352
|
+
} else if (portId === "selection") {
|
|
3353
|
+
if (isRecord(value) && Array.isArray(value.clipIds)) {
|
|
3354
|
+
controller.setSelection(value);
|
|
3355
|
+
}
|
|
3356
|
+
} else if (portId === "laneTile") {
|
|
3357
|
+
if (isRecord(value) && typeof value.key === "string") {
|
|
3358
|
+
controller.setLaneTile(value);
|
|
3359
|
+
}
|
|
3360
|
+
}
|
|
3361
|
+
},
|
|
3362
|
+
async onCommand(commandId, params) {
|
|
3363
|
+
switch (commandId) {
|
|
3364
|
+
case "set_tool":
|
|
3365
|
+
controller.setTool(toolOf(String(params.tool)));
|
|
3366
|
+
return true;
|
|
3367
|
+
case "set_snap":
|
|
3368
|
+
controller.setSnapMode(snapOf(String(params.mode)));
|
|
3369
|
+
return true;
|
|
3370
|
+
case "zoom_to_fit":
|
|
3371
|
+
controller.zoomToFitRange(
|
|
3372
|
+
typeof params.fromTick === "number" ? params.fromTick : 0,
|
|
3373
|
+
typeof params.toTick === "number" ? params.toTick : void 0
|
|
3374
|
+
);
|
|
3375
|
+
return true;
|
|
3376
|
+
case "split":
|
|
3377
|
+
return controller.split(Number(params.atTick), params.all === true);
|
|
3378
|
+
case "delete_selection":
|
|
3379
|
+
return controller.deleteSelection(params.ripple === true);
|
|
3380
|
+
case "select_clip":
|
|
3381
|
+
controller.selectClip(String(params.clipId), params.additive === true);
|
|
3382
|
+
return true;
|
|
3383
|
+
case "clear_selection":
|
|
3384
|
+
controller.clearSelection();
|
|
3385
|
+
return true;
|
|
3386
|
+
case "navigate":
|
|
3387
|
+
return controller.navigate(navigationOf(String(params.direction)));
|
|
3388
|
+
case "scroll_to":
|
|
3389
|
+
controller.scrollTo(
|
|
3390
|
+
typeof params.scrollTick === "number" ? params.scrollTick : controller.timeScale.scrollTick,
|
|
3391
|
+
typeof params.scrollTopPx === "number" ? params.scrollTopPx : controller.timeScale.scrollTopPx
|
|
3392
|
+
);
|
|
3393
|
+
return true;
|
|
3394
|
+
default:
|
|
3395
|
+
return;
|
|
3396
|
+
}
|
|
3397
|
+
},
|
|
3398
|
+
// 🔴 U6: preferences, never content. No arrangement, no selection, no tile.
|
|
3399
|
+
serialize() {
|
|
3400
|
+
return controller.serialize();
|
|
3401
|
+
},
|
|
3402
|
+
deserialize(state) {
|
|
3403
|
+
if (isRecord(state)) controller.deserialize(state);
|
|
3404
|
+
},
|
|
3405
|
+
// 🔴 U4: everything taken in activate is released here — listeners, timers, and every
|
|
3406
|
+
// cached ImageBitmap (whose pixels live outside the JS heap).
|
|
3407
|
+
dispose() {
|
|
3408
|
+
unbindConfig();
|
|
3409
|
+
currentEl = null;
|
|
3410
|
+
unrender?.();
|
|
3411
|
+
unrender = null;
|
|
3412
|
+
controller.dispose();
|
|
3413
|
+
}
|
|
3414
|
+
};
|
|
3415
|
+
}
|
|
3416
|
+
};
|
|
3417
|
+
}
|
|
3418
|
+
function toolOf(value) {
|
|
3419
|
+
return TOOLS2.includes(value) ? value : "select";
|
|
3420
|
+
}
|
|
3421
|
+
function snapOf(value) {
|
|
3422
|
+
return SNAP_MODES.includes(value) ? value : "both";
|
|
3423
|
+
}
|
|
3424
|
+
function navigationOf(value) {
|
|
3425
|
+
return value === "previous" || value === "up" || value === "down" ? value : "next";
|
|
3426
|
+
}
|
|
3427
|
+
var timelinePanel = createTimelinePanel();
|
|
3428
|
+
|
|
3429
|
+
// src/time/timeline/types.ts
|
|
3430
|
+
function clipsOverlap(a, b) {
|
|
3431
|
+
return a.startTick < b.endTick && b.startTick < a.endTick;
|
|
3432
|
+
}
|
|
3433
|
+
function clipDuration(clip) {
|
|
3434
|
+
return Math.max(0, clip.endTick - clip.startTick);
|
|
3435
|
+
}
|
|
3436
|
+
function hasSourceWindow(clip) {
|
|
3437
|
+
return typeof clip.sourceInTick === "number" && typeof clip.sourceOutTick === "number";
|
|
3438
|
+
}
|
|
3439
|
+
|
|
3440
|
+
// src/time/calendar/panel.ts
|
|
3441
|
+
import { createElement as createElement3 } from "react";
|
|
3442
|
+
import { createRoot as createRoot3 } from "react-dom/client";
|
|
3443
|
+
import {
|
|
3444
|
+
isRecord as isRecord2,
|
|
3445
|
+
bindConfig as bindConfig2
|
|
3446
|
+
} from "@xenosystem/panel-sdk";
|
|
3447
|
+
import { unwrapResult } from "@xenosystem/data-core";
|
|
3448
|
+
|
|
3449
|
+
// src/time/calendar/layout.ts
|
|
3450
|
+
import { bucketKey, formatValue, parseTime, zonedParts } from "@xenosystem/data-core";
|
|
3451
|
+
|
|
3452
|
+
// src/time/calendar/types.ts
|
|
3453
|
+
var ALL_CALENDAR_MODES = Object.freeze([
|
|
3454
|
+
"month",
|
|
3455
|
+
"week",
|
|
3456
|
+
"day",
|
|
3457
|
+
"agenda",
|
|
3458
|
+
"timeline"
|
|
3459
|
+
]);
|
|
3460
|
+
function daysInMonth(year, month) {
|
|
3461
|
+
return new Date(Date.UTC(year, month, 0)).getUTCDate();
|
|
3462
|
+
}
|
|
3463
|
+
function pad2(n) {
|
|
3464
|
+
return String(n).padStart(2, "0");
|
|
3465
|
+
}
|
|
3466
|
+
function dayKey(year, month, day) {
|
|
3467
|
+
return `${year}-${pad2(month)}-${pad2(day)}`;
|
|
3468
|
+
}
|
|
3469
|
+
function parseDayKey(key) {
|
|
3470
|
+
const m = /^(\d{4})-(\d{2})-(\d{2})$/.exec(key);
|
|
3471
|
+
if (!m) return null;
|
|
3472
|
+
return { year: Number(m[1]), month: Number(m[2]), day: Number(m[3]) };
|
|
3473
|
+
}
|
|
3474
|
+
function isoWeekday(key) {
|
|
3475
|
+
const parts = parseDayKey(key);
|
|
3476
|
+
if (!parts) return 1;
|
|
3477
|
+
const dow = new Date(Date.UTC(parts.year, parts.month - 1, parts.day)).getUTCDay();
|
|
3478
|
+
return dow === 0 ? 7 : dow;
|
|
3479
|
+
}
|
|
3480
|
+
function addDays(key, days) {
|
|
3481
|
+
const parts = parseDayKey(key);
|
|
3482
|
+
if (!parts) return key;
|
|
3483
|
+
const d = new Date(Date.UTC(parts.year, parts.month - 1, parts.day + days));
|
|
3484
|
+
return dayKey(d.getUTCFullYear(), d.getUTCMonth() + 1, d.getUTCDate());
|
|
3485
|
+
}
|
|
3486
|
+
function addMonths(key, months) {
|
|
3487
|
+
const parts = parseDayKey(key);
|
|
3488
|
+
if (!parts) return key;
|
|
3489
|
+
const total = parts.year * 12 + (parts.month - 1) + months;
|
|
3490
|
+
const year = Math.floor(total / 12);
|
|
3491
|
+
const month = total % 12 + 1;
|
|
3492
|
+
return dayKey(year, month, Math.min(parts.day, daysInMonth(year, month)));
|
|
3493
|
+
}
|
|
3494
|
+
function gridStart(monthKey, firstDayOfWeek) {
|
|
3495
|
+
const parts = parseDayKey(monthKey);
|
|
3496
|
+
if (!parts) return monthKey;
|
|
3497
|
+
const first = dayKey(parts.year, parts.month, 1);
|
|
3498
|
+
const weekday = isoWeekday(first);
|
|
3499
|
+
const offset = (weekday - firstDayOfWeek + 7) % 7;
|
|
3500
|
+
return addDays(first, -offset);
|
|
3501
|
+
}
|
|
3502
|
+
function statusTone(value) {
|
|
3503
|
+
const text = String(value ?? "").toLowerCase();
|
|
3504
|
+
if (/done|complete|published|paid|ok/.test(text)) return "success";
|
|
3505
|
+
if (/pending|draft|scheduled|review/.test(text)) return "warning";
|
|
3506
|
+
if (/fail|error|cancel|reject|overdue/.test(text)) return "error";
|
|
3507
|
+
return "neutral";
|
|
3508
|
+
}
|
|
3509
|
+
function columnMap(result) {
|
|
3510
|
+
return new Map((result?.columns ?? []).map((c) => [c.id, c]));
|
|
3511
|
+
}
|
|
3512
|
+
|
|
3513
|
+
// src/time/calendar/layout.ts
|
|
3514
|
+
function minutesOf(value, column, timeZone) {
|
|
3515
|
+
const parsed = parseTime(value, column?.type);
|
|
3516
|
+
if (!parsed || parsed.dateOnly) return null;
|
|
3517
|
+
const parts = zonedParts(parsed.epoch, timeZone);
|
|
3518
|
+
return parts.hour * 60 + parts.minute;
|
|
3519
|
+
}
|
|
3520
|
+
function deriveEvents(result, options) {
|
|
3521
|
+
const columns = columnMap(result);
|
|
3522
|
+
const startColumn = columns.get(options.dateColumnId);
|
|
3523
|
+
const endColumn = options.endDateColumnId ? columns.get(options.endDateColumnId) : void 0;
|
|
3524
|
+
const titleColumn = options.titleColumnId ? columns.get(options.titleColumnId) : result.columns.find((c) => c.role === "identifier") ?? result.columns.find((c) => c.type === "string");
|
|
3525
|
+
const events = [];
|
|
3526
|
+
for (const row of result.rows) {
|
|
3527
|
+
const rawStart = row.values[options.dateColumnId] ?? null;
|
|
3528
|
+
const parsedStart = parseTime(rawStart, startColumn?.type);
|
|
3529
|
+
if (!parsedStart) continue;
|
|
3530
|
+
const startKey = bucketKey(parsedStart, "day", options.timeZone);
|
|
3531
|
+
let endKey = startKey;
|
|
3532
|
+
if (options.endDateColumnId) {
|
|
3533
|
+
const parsedEnd = parseTime(row.values[options.endDateColumnId] ?? null, endColumn?.type);
|
|
3534
|
+
if (parsedEnd) endKey = bucketKey(parsedEnd, "day", options.timeZone);
|
|
3535
|
+
if (endKey < startKey) endKey = startKey;
|
|
3536
|
+
}
|
|
3537
|
+
const startMinutes = minutesOf(rawStart, startColumn, options.timeZone);
|
|
3538
|
+
const endMinutes = options.endDateColumnId ? minutesOf(row.values[options.endDateColumnId] ?? null, endColumn, options.timeZone) : null;
|
|
3539
|
+
const fields = {};
|
|
3540
|
+
for (const columnId of options.chipFields ?? []) {
|
|
3541
|
+
const column = columns.get(columnId);
|
|
3542
|
+
if (column) fields[columnId] = formatValue(row.values[columnId] ?? null, column);
|
|
3543
|
+
}
|
|
3544
|
+
events.push({
|
|
3545
|
+
id: row.id,
|
|
3546
|
+
title: titleColumn ? formatValue(row.values[titleColumn.id] ?? null, titleColumn) : row.id,
|
|
3547
|
+
startKey,
|
|
3548
|
+
endKey,
|
|
3549
|
+
startMinutes,
|
|
3550
|
+
endMinutes,
|
|
3551
|
+
multiDay: endKey !== startKey,
|
|
3552
|
+
// All-day when the source is a calendar date, or when no clock is present.
|
|
3553
|
+
allDay: startMinutes === null,
|
|
3554
|
+
fields: Object.keys(fields).length > 0 ? fields : void 0,
|
|
3555
|
+
status: options.statusColumnId ? row.values[options.statusColumnId] ?? null : void 0,
|
|
3556
|
+
row
|
|
3557
|
+
});
|
|
3558
|
+
}
|
|
3559
|
+
return events.sort((a, b) => {
|
|
3560
|
+
if (a.startKey !== b.startKey) return a.startKey < b.startKey ? -1 : 1;
|
|
3561
|
+
const am = a.startMinutes ?? -1;
|
|
3562
|
+
const bm = b.startMinutes ?? -1;
|
|
3563
|
+
if (am !== bm) return am - bm;
|
|
3564
|
+
return a.id < b.id ? -1 : 1;
|
|
3565
|
+
});
|
|
3566
|
+
}
|
|
3567
|
+
function buildCells(events, options) {
|
|
3568
|
+
const max = Math.max(1, options.maxChipsPerCell ?? 3);
|
|
3569
|
+
const cells = [];
|
|
3570
|
+
for (let i = 0; i < options.days; i += 1) {
|
|
3571
|
+
const key = addDays(options.start, i);
|
|
3572
|
+
const parts = key.split("-");
|
|
3573
|
+
const covering = events.filter((e) => e.startKey <= key && e.endKey >= key);
|
|
3574
|
+
cells.push({
|
|
3575
|
+
key,
|
|
3576
|
+
day: Number(parts[2]),
|
|
3577
|
+
inPeriod: options.periodMonth ? key.startsWith(options.periodMonth) : true,
|
|
3578
|
+
today: options.today === key,
|
|
3579
|
+
events: covering.slice(0, max),
|
|
3580
|
+
overflow: Math.max(0, covering.length - max)
|
|
3581
|
+
});
|
|
3582
|
+
}
|
|
3583
|
+
return cells;
|
|
3584
|
+
}
|
|
3585
|
+
function packOverlaps(events) {
|
|
3586
|
+
const timed = events.filter((e) => e.startMinutes !== null);
|
|
3587
|
+
const placed = [];
|
|
3588
|
+
for (const event of timed) {
|
|
3589
|
+
const start = event.startMinutes ?? 0;
|
|
3590
|
+
const end = event.endMinutes !== null && event.endMinutes > start ? event.endMinutes : start + 30;
|
|
3591
|
+
const taken = new Set(
|
|
3592
|
+
placed.filter((p) => p.start < end && p.end > start).map((p) => p.column)
|
|
3593
|
+
);
|
|
3594
|
+
let column = 0;
|
|
3595
|
+
while (taken.has(column)) column += 1;
|
|
3596
|
+
placed.push({ event, column, start, end });
|
|
3597
|
+
}
|
|
3598
|
+
return placed.map((p) => {
|
|
3599
|
+
const cluster = placed.filter((q) => q.start < p.end && q.end > p.start);
|
|
3600
|
+
const columns = Math.max(...cluster.map((q) => q.column)) + 1;
|
|
3601
|
+
return { event: p.event, column: p.column, columns };
|
|
3602
|
+
});
|
|
3603
|
+
}
|
|
3604
|
+
function todayKey(timeZone, now = Date.now()) {
|
|
3605
|
+
const parts = zonedParts(now, timeZone);
|
|
3606
|
+
return dayKey(parts.year, parts.month, parts.day);
|
|
3607
|
+
}
|
|
3608
|
+
function weekStart(key, firstDayOfWeek) {
|
|
3609
|
+
const offset = (isoWeekday(key) - firstDayOfWeek + 7) % 7;
|
|
3610
|
+
return addDays(key, -offset);
|
|
3611
|
+
}
|
|
3612
|
+
function rowInWindow(row, columnId, column, from, to) {
|
|
3613
|
+
const parsed = parseTime(row.values[columnId] ?? null, column?.type);
|
|
3614
|
+
if (!parsed) return false;
|
|
3615
|
+
const start = Date.parse(from);
|
|
3616
|
+
const end = Date.parse(to);
|
|
3617
|
+
return parsed.epoch >= start && parsed.epoch < end;
|
|
3618
|
+
}
|
|
3619
|
+
|
|
3620
|
+
// src/time/calendar/controller.ts
|
|
3621
|
+
var counter = 0;
|
|
3622
|
+
var defaultMakeId = () => `cal-${(counter += 1).toString(36)}`;
|
|
3623
|
+
function resolveZone(zone) {
|
|
3624
|
+
if (!zone || zone === "local") {
|
|
3625
|
+
try {
|
|
3626
|
+
return Intl.DateTimeFormat().resolvedOptions().timeZone || "UTC";
|
|
3627
|
+
} catch {
|
|
3628
|
+
return "UTC";
|
|
3629
|
+
}
|
|
3630
|
+
}
|
|
3631
|
+
return zone;
|
|
3632
|
+
}
|
|
3633
|
+
var CalendarController = class {
|
|
3634
|
+
host;
|
|
3635
|
+
makeId;
|
|
3636
|
+
now;
|
|
3637
|
+
listeners = /* @__PURE__ */ new Set();
|
|
3638
|
+
firstDayOfWeek;
|
|
3639
|
+
maxChipsPerCell;
|
|
3640
|
+
allowDrag;
|
|
3641
|
+
derive;
|
|
3642
|
+
result = null;
|
|
3643
|
+
viewMode;
|
|
3644
|
+
anchor;
|
|
3645
|
+
selection = [];
|
|
3646
|
+
window = null;
|
|
3647
|
+
snapshot = null;
|
|
3648
|
+
constructor(options) {
|
|
3649
|
+
this.host = options.host;
|
|
3650
|
+
this.makeId = options.host.makeId ?? defaultMakeId;
|
|
3651
|
+
this.now = options.host.now ?? (() => Date.now());
|
|
3652
|
+
this.firstDayOfWeek = clampWeekday(options.firstDayOfWeek ?? 1);
|
|
3653
|
+
this.maxChipsPerCell = Math.max(1, options.maxChipsPerCell ?? 3);
|
|
3654
|
+
this.allowDrag = options.allowDrag !== false;
|
|
3655
|
+
this.derive = {
|
|
3656
|
+
dateColumnId: options.dateColumnId ?? "",
|
|
3657
|
+
endDateColumnId: options.endDateColumnId,
|
|
3658
|
+
timeZone: resolveZone(options.timeZone),
|
|
3659
|
+
titleColumnId: options.titleColumnId,
|
|
3660
|
+
chipFields: options.chipFields,
|
|
3661
|
+
statusColumnId: options.statusColumnId
|
|
3662
|
+
};
|
|
3663
|
+
this.viewMode = options.initial?.viewMode ?? options.viewMode ?? "month";
|
|
3664
|
+
this.anchor = options.initial?.anchor ?? todayKey(this.derive.timeZone, this.now());
|
|
3665
|
+
if (options.initial?.selection) this.selection = [...options.initial.selection];
|
|
3666
|
+
}
|
|
3667
|
+
/* ── Subscription ──────────────────────────────────────────────────────── */
|
|
3668
|
+
subscribe = (listener) => {
|
|
3669
|
+
this.listeners.add(listener);
|
|
3670
|
+
return () => this.listeners.delete(listener);
|
|
3671
|
+
};
|
|
3672
|
+
getState = () => {
|
|
3673
|
+
if (!this.snapshot) {
|
|
3674
|
+
const events = this.events();
|
|
3675
|
+
this.snapshot = {
|
|
3676
|
+
viewMode: this.viewMode,
|
|
3677
|
+
window: this.window,
|
|
3678
|
+
cells: this.cells(events),
|
|
3679
|
+
events,
|
|
3680
|
+
selection: this.selection,
|
|
3681
|
+
empty: events.length === 0,
|
|
3682
|
+
partial: this.result?.status === "partial",
|
|
3683
|
+
errorMessage: this.result?.status === "error" ? this.result.error?.message ?? null : null,
|
|
3684
|
+
configProblem: this.configProblem()
|
|
3685
|
+
};
|
|
3686
|
+
}
|
|
3687
|
+
return this.snapshot;
|
|
3688
|
+
};
|
|
3689
|
+
notify() {
|
|
3690
|
+
this.snapshot = null;
|
|
3691
|
+
for (const listener of this.listeners) listener();
|
|
3692
|
+
}
|
|
3693
|
+
/** Why the panel cannot render, as a sentence. A blank grid explains nothing. */
|
|
3694
|
+
configProblem() {
|
|
3695
|
+
if (!this.derive.dateColumnId) return "Set a date column to place rows on the calendar.";
|
|
3696
|
+
if (!this.result) return null;
|
|
3697
|
+
const column = columnMap(this.result).get(this.derive.dateColumnId);
|
|
3698
|
+
if (!column) return `The date column \u201C${this.derive.dateColumnId}\u201D is not in this result.`;
|
|
3699
|
+
const temporal = column.type === "date" || column.type === "datetime";
|
|
3700
|
+
if (!temporal) {
|
|
3701
|
+
return `\u201C${column.name}\u201D is a ${column.type} column. Declare it as date or datetime.`;
|
|
3702
|
+
}
|
|
3703
|
+
return null;
|
|
3704
|
+
}
|
|
3705
|
+
/* ── Inputs ────────────────────────────────────────────────────────────── */
|
|
3706
|
+
/** Receive a result. */
|
|
3707
|
+
setResult(result) {
|
|
3708
|
+
this.result = result;
|
|
3709
|
+
this.notify();
|
|
3710
|
+
}
|
|
3711
|
+
/** Receive an externally set window (a host driving navigation). Does not re-emit. */
|
|
3712
|
+
setWindow(window) {
|
|
3713
|
+
this.window = window;
|
|
3714
|
+
const parts = window.from.slice(0, 10);
|
|
3715
|
+
if (parseDayKey(parts)) this.anchor = parts;
|
|
3716
|
+
this.notify();
|
|
3717
|
+
}
|
|
3718
|
+
/** Receive a host-driven selection. */
|
|
3719
|
+
setSelection(rowIds) {
|
|
3720
|
+
this.selection = [...rowIds];
|
|
3721
|
+
this.notify();
|
|
3722
|
+
}
|
|
3723
|
+
/* ── Derivation ────────────────────────────────────────────────────────── */
|
|
3724
|
+
/** Events derived from the current result. */
|
|
3725
|
+
events() {
|
|
3726
|
+
if (!this.result || !this.derive.dateColumnId) return [];
|
|
3727
|
+
if (this.configProblem()) return [];
|
|
3728
|
+
return deriveEvents(this.result, this.derive);
|
|
3729
|
+
}
|
|
3730
|
+
/** The visible day cells for the current mode. */
|
|
3731
|
+
cells(events = this.events()) {
|
|
3732
|
+
const geometry = this.geometry();
|
|
3733
|
+
if (!geometry) return [];
|
|
3734
|
+
return buildCells(events, {
|
|
3735
|
+
start: geometry.start,
|
|
3736
|
+
days: geometry.days,
|
|
3737
|
+
periodMonth: this.viewMode === "month" ? this.anchor.slice(0, 7) : null,
|
|
3738
|
+
today: todayKey(this.derive.timeZone, this.now()),
|
|
3739
|
+
maxChipsPerCell: this.maxChipsPerCell
|
|
3740
|
+
});
|
|
3741
|
+
}
|
|
3742
|
+
/** Grid geometry for the current mode. `null` for agenda/timeline, which are not grids. */
|
|
3743
|
+
geometry() {
|
|
3744
|
+
const parts = parseDayKey(this.anchor);
|
|
3745
|
+
if (!parts) return null;
|
|
3746
|
+
switch (this.viewMode) {
|
|
3747
|
+
case "month": {
|
|
3748
|
+
const start = gridStart(this.anchor, this.firstDayOfWeek);
|
|
3749
|
+
return { start, days: 42 };
|
|
3750
|
+
}
|
|
3751
|
+
case "week":
|
|
3752
|
+
return { start: weekStart(this.anchor, this.firstDayOfWeek), days: 7 };
|
|
3753
|
+
case "day":
|
|
3754
|
+
return { start: this.anchor, days: 1 };
|
|
3755
|
+
default:
|
|
3756
|
+
return null;
|
|
3757
|
+
}
|
|
3758
|
+
}
|
|
3759
|
+
/**
|
|
3760
|
+
* The window the current view covers.
|
|
3761
|
+
*
|
|
3762
|
+
* Agenda and timeline follow the month, so navigation stays meaningful in every mode.
|
|
3763
|
+
*/
|
|
3764
|
+
computeWindow() {
|
|
3765
|
+
if (!this.derive.dateColumnId) return null;
|
|
3766
|
+
const parts = parseDayKey(this.anchor);
|
|
3767
|
+
if (!parts) return null;
|
|
3768
|
+
let start;
|
|
3769
|
+
let days;
|
|
3770
|
+
if (this.viewMode === "week") {
|
|
3771
|
+
start = weekStart(this.anchor, this.firstDayOfWeek);
|
|
3772
|
+
days = 7;
|
|
3773
|
+
} else if (this.viewMode === "day") {
|
|
3774
|
+
start = this.anchor;
|
|
3775
|
+
days = 1;
|
|
3776
|
+
} else {
|
|
3777
|
+
start = dayKey(parts.year, parts.month, 1);
|
|
3778
|
+
days = daysInMonth(parts.year, parts.month);
|
|
3779
|
+
}
|
|
3780
|
+
return {
|
|
3781
|
+
from: `${start}T00:00:00`,
|
|
3782
|
+
// EXCLUSIVE end — an inclusive one double-counts the boundary day between two windows.
|
|
3783
|
+
to: `${addDays(start, days)}T00:00:00`,
|
|
3784
|
+
columnId: this.derive.dateColumnId,
|
|
3785
|
+
timeZone: this.derive.timeZone
|
|
3786
|
+
};
|
|
3787
|
+
}
|
|
3788
|
+
/**
|
|
3789
|
+
* Emit the window and the matching query.
|
|
3790
|
+
*
|
|
3791
|
+
* **This is the port that makes calendar a panel**: the visible range is the fetch key, so moving
|
|
3792
|
+
* a month is a re-fetch, not a re-projection.
|
|
3793
|
+
*/
|
|
3794
|
+
emitWindow(force = false) {
|
|
3795
|
+
const window = this.computeWindow();
|
|
3796
|
+
if (!window) return null;
|
|
3797
|
+
if (!force && this.window && JSON.stringify(window) === JSON.stringify(this.window)) {
|
|
3798
|
+
return null;
|
|
3799
|
+
}
|
|
3800
|
+
this.window = window;
|
|
3801
|
+
this.host.emit("window", window);
|
|
3802
|
+
const query = {
|
|
3803
|
+
window,
|
|
3804
|
+
sorts: [{ columnId: window.columnId, direction: "asc" }]
|
|
3805
|
+
};
|
|
3806
|
+
this.host.emit("queryRequest", { requestId: this.makeId(), reason: "window", query });
|
|
3807
|
+
this.notify();
|
|
3808
|
+
return window;
|
|
3809
|
+
}
|
|
3810
|
+
/* ── Navigation ────────────────────────────────────────────────────────── */
|
|
3811
|
+
/** Switch view mode and re-emit the window. */
|
|
3812
|
+
setViewMode(mode) {
|
|
3813
|
+
if (!ALL_CALENDAR_MODES.includes(mode)) return false;
|
|
3814
|
+
if (this.viewMode === mode) return true;
|
|
3815
|
+
this.viewMode = mode;
|
|
3816
|
+
this.emitWindow();
|
|
3817
|
+
this.notify();
|
|
3818
|
+
return true;
|
|
3819
|
+
}
|
|
3820
|
+
/** Step forward or back by one period. */
|
|
3821
|
+
navigate(direction) {
|
|
3822
|
+
if (this.viewMode === "week") this.anchor = addDays(this.anchor, 7 * direction);
|
|
3823
|
+
else if (this.viewMode === "day") this.anchor = addDays(this.anchor, direction);
|
|
3824
|
+
else this.anchor = addMonths(this.anchor, direction);
|
|
3825
|
+
this.emitWindow();
|
|
3826
|
+
}
|
|
3827
|
+
/** Jump to today, in the DECLARED zone — never the runtime's, unless that is what was declared. */
|
|
3828
|
+
goToToday() {
|
|
3829
|
+
this.anchor = todayKey(this.derive.timeZone, this.now());
|
|
3830
|
+
this.emitWindow();
|
|
3831
|
+
}
|
|
3832
|
+
/** Jump to a specific day. */
|
|
3833
|
+
goToDate(key) {
|
|
3834
|
+
if (!parseDayKey(key)) return false;
|
|
3835
|
+
this.anchor = key;
|
|
3836
|
+
this.emitWindow();
|
|
3837
|
+
return true;
|
|
3838
|
+
}
|
|
3839
|
+
/** The anchor day key. */
|
|
3840
|
+
anchorKey() {
|
|
3841
|
+
return this.anchor;
|
|
3842
|
+
}
|
|
3843
|
+
/* ── Interaction ───────────────────────────────────────────────────────── */
|
|
3844
|
+
/** Select events. */
|
|
3845
|
+
select(rowIds, additive = false) {
|
|
3846
|
+
const next = additive ? (() => {
|
|
3847
|
+
const set = new Set(this.selection);
|
|
3848
|
+
for (const id of rowIds) {
|
|
3849
|
+
if (set.has(id)) set.delete(id);
|
|
3850
|
+
else set.add(id);
|
|
3851
|
+
}
|
|
3852
|
+
return [...set];
|
|
3853
|
+
})() : [...rowIds];
|
|
3854
|
+
if (next.length === this.selection.length && next.every((id, i) => id === this.selection[i])) {
|
|
3855
|
+
return;
|
|
3856
|
+
}
|
|
3857
|
+
this.selection = next;
|
|
3858
|
+
this.host.emit("selection", next);
|
|
3859
|
+
this.notify();
|
|
3860
|
+
}
|
|
3861
|
+
/** Open an event. */
|
|
3862
|
+
activateRow(rowId) {
|
|
3863
|
+
if (!this.events().some((e) => e.id === rowId)) return false;
|
|
3864
|
+
this.host.emit("rowActivate", { rowId });
|
|
3865
|
+
return true;
|
|
3866
|
+
}
|
|
3867
|
+
/** Click an empty slot — the create affordance. */
|
|
3868
|
+
activateSlot(date, time) {
|
|
3869
|
+
if (!parseDayKey(date)) return false;
|
|
3870
|
+
this.host.emit("slotActivate", { date, time });
|
|
3871
|
+
return true;
|
|
3872
|
+
}
|
|
3873
|
+
/**
|
|
3874
|
+
* Drag-to-reschedule — an INTENT. The host writes and republishes.
|
|
3875
|
+
*
|
|
3876
|
+
* The new value carries the target day and, for a timed event, the original clock time: dropping
|
|
3877
|
+
* an event on a day should move the day, not silently reset it to midnight.
|
|
3878
|
+
*
|
|
3879
|
+
* @returns `true` when an intent was emitted.
|
|
3880
|
+
*/
|
|
3881
|
+
rescheduleTo(rowId, targetDate, targetMinutes) {
|
|
3882
|
+
if (!this.allowDrag) return false;
|
|
3883
|
+
const event = this.events().find((e) => e.id === rowId);
|
|
3884
|
+
if (!event || !parseDayKey(targetDate)) return false;
|
|
3885
|
+
if (event.startKey === targetDate && targetMinutes === void 0) return false;
|
|
3886
|
+
const minutes = targetMinutes ?? event.startMinutes;
|
|
3887
|
+
const value = minutes === null || minutes === void 0 ? targetDate : `${targetDate}T${pad(Math.floor(minutes / 60))}:${pad(minutes % 60)}:00`;
|
|
3888
|
+
const edit = {
|
|
3889
|
+
rowId,
|
|
3890
|
+
columnId: this.derive.dateColumnId,
|
|
3891
|
+
value,
|
|
3892
|
+
previousValue: event.row.values[this.derive.dateColumnId] ?? null,
|
|
3893
|
+
gestureId: this.makeId()
|
|
3894
|
+
};
|
|
3895
|
+
if (this.derive.endDateColumnId && event.multiDay) {
|
|
3896
|
+
const span = daysBetween(event.startKey, event.endKey);
|
|
3897
|
+
edit.endColumnId = this.derive.endDateColumnId;
|
|
3898
|
+
edit.endValue = addDays(targetDate, span);
|
|
3899
|
+
}
|
|
3900
|
+
this.host.emit("edit", edit);
|
|
3901
|
+
return true;
|
|
3902
|
+
}
|
|
3903
|
+
/** Ask the host to open a context menu. */
|
|
3904
|
+
requestContextMenu(rowIds, x, y) {
|
|
3905
|
+
this.host.emit("contextMenuRequest", { rowIds: [...rowIds], x, y });
|
|
3906
|
+
}
|
|
3907
|
+
/* ── Persistence ───────────────────────────────────────────────────────── */
|
|
3908
|
+
/** Serialize. View mode, anchor and selection — never a result, never events. */
|
|
3909
|
+
serialize() {
|
|
3910
|
+
return { viewMode: this.viewMode, anchor: this.anchor, selection: [...this.selection] };
|
|
3911
|
+
}
|
|
3912
|
+
/** Restore. Does not emit. */
|
|
3913
|
+
deserialize(state) {
|
|
3914
|
+
if (state.viewMode && ALL_CALENDAR_MODES.includes(state.viewMode)) this.viewMode = state.viewMode;
|
|
3915
|
+
if (state.anchor && parseDayKey(state.anchor)) this.anchor = state.anchor;
|
|
3916
|
+
if (state.selection) this.selection = [...state.selection];
|
|
3917
|
+
this.notify();
|
|
3918
|
+
}
|
|
3919
|
+
/** The resolved zone — exposed so a host can render it. */
|
|
3920
|
+
timeZone() {
|
|
3921
|
+
return this.derive.timeZone;
|
|
3922
|
+
}
|
|
3923
|
+
/** Drop listeners. */
|
|
3924
|
+
dispose() {
|
|
3925
|
+
this.listeners.clear();
|
|
3926
|
+
}
|
|
3927
|
+
};
|
|
3928
|
+
function pad(n) {
|
|
3929
|
+
return String(n).padStart(2, "0");
|
|
3930
|
+
}
|
|
3931
|
+
function clampWeekday(n) {
|
|
3932
|
+
const v = Math.floor(Number(n));
|
|
3933
|
+
return Number.isFinite(v) && v >= 1 && v <= 7 ? v : 1;
|
|
3934
|
+
}
|
|
3935
|
+
function daysBetween(from, to) {
|
|
3936
|
+
const a = parseDayKey(from);
|
|
3937
|
+
const b = parseDayKey(to);
|
|
3938
|
+
if (!a || !b) return 0;
|
|
3939
|
+
return Math.round(
|
|
3940
|
+
(Date.UTC(b.year, b.month - 1, b.day) - Date.UTC(a.year, a.month - 1, a.day)) / 864e5
|
|
3941
|
+
);
|
|
3942
|
+
}
|
|
3943
|
+
|
|
3944
|
+
// src/time/calendar/manifest.ts
|
|
3945
|
+
import { WELL_KNOWN_PORT_SCHEMAS as WELL_KNOWN_PORT_SCHEMAS2 } from "@xenosystem/panel-sdk";
|
|
3946
|
+
var CALENDAR_PANEL_ID = "xeno.core.calendar";
|
|
3947
|
+
var calendarManifest = {
|
|
3948
|
+
id: CALENDAR_PANEL_ID,
|
|
3949
|
+
version: "0.1.0",
|
|
3950
|
+
title: "Calendar",
|
|
3951
|
+
icon: "calendar",
|
|
3952
|
+
description: "Month, week, day, agenda and timeline views over a result set. The visible window is an output that drives the query, and every date is bucketed through a mandatory time zone.",
|
|
3953
|
+
defaultSlot: "center",
|
|
3954
|
+
inputs: [
|
|
3955
|
+
{
|
|
3956
|
+
id: "result",
|
|
3957
|
+
name: "Result",
|
|
3958
|
+
type: "object",
|
|
3959
|
+
schema: WELL_KNOWN_PORT_SCHEMAS2.RESULT_SET,
|
|
3960
|
+
description: "The rows to place. Identity comes from row ids, never from position.",
|
|
3961
|
+
multiple: false
|
|
3962
|
+
},
|
|
3963
|
+
{
|
|
3964
|
+
id: "window",
|
|
3965
|
+
name: "Window",
|
|
3966
|
+
type: "object",
|
|
3967
|
+
description: "An externally set window (a host driving navigation). Does not re-emit.",
|
|
3968
|
+
multiple: false
|
|
3969
|
+
},
|
|
3970
|
+
{
|
|
3971
|
+
id: "selection",
|
|
3972
|
+
name: "Selection",
|
|
3973
|
+
type: "array",
|
|
3974
|
+
description: "Host-driven selection.",
|
|
3975
|
+
multiple: false
|
|
3976
|
+
}
|
|
3977
|
+
],
|
|
3978
|
+
outputs: [
|
|
3979
|
+
{
|
|
3980
|
+
id: "window",
|
|
3981
|
+
name: "Window",
|
|
3982
|
+
type: "object",
|
|
3983
|
+
description: "THE discriminator: {from, to, columnId, timeZone}. Month navigation re-fetches \u2014 the window is the fetch key, not a projection."
|
|
3984
|
+
},
|
|
3985
|
+
{
|
|
3986
|
+
id: "queryRequest",
|
|
3987
|
+
name: "Query Request",
|
|
3988
|
+
type: "object",
|
|
3989
|
+
// The {requestId, query} ENVELOPE — what this port actually emits. It wore the bare-query
|
|
3990
|
+
// tag until that collision let a bare-query producer wire into `connectors.query`. (C2)
|
|
3991
|
+
schema: WELL_KNOWN_PORT_SCHEMAS2.QUERY_REQUEST,
|
|
3992
|
+
description: "The query the new window implies, carrying that window verbatim."
|
|
3993
|
+
},
|
|
3994
|
+
{ id: "selection", name: "Selection", type: "array", description: "Selected row ids." },
|
|
3995
|
+
{
|
|
3996
|
+
id: "edit",
|
|
3997
|
+
name: "Edit",
|
|
3998
|
+
type: "object",
|
|
3999
|
+
description: "A drag-to-reschedule INTENT. The host writes; one gestureId, one undo entry. A span keeps its duration."
|
|
4000
|
+
},
|
|
4001
|
+
{
|
|
4002
|
+
id: "slotActivate",
|
|
4003
|
+
name: "Slot Activated",
|
|
4004
|
+
type: "object",
|
|
4005
|
+
description: "An empty slot was clicked: {date, time?}. The create affordance."
|
|
4006
|
+
},
|
|
4007
|
+
{ id: "rowActivate", name: "Row Activated", type: "object", description: "An event was opened." },
|
|
4008
|
+
{
|
|
4009
|
+
id: "contextMenuRequest",
|
|
4010
|
+
name: "Context Menu Request",
|
|
4011
|
+
type: "object",
|
|
4012
|
+
description: "The host opens its own menu."
|
|
4013
|
+
}
|
|
4014
|
+
],
|
|
4015
|
+
commands: [
|
|
4016
|
+
{
|
|
4017
|
+
id: "set_view_mode",
|
|
4018
|
+
title: "Set View Mode",
|
|
4019
|
+
description: "month | week | day | agenda | timeline.",
|
|
4020
|
+
parameters: { mode: { type: "string", description: "View mode", required: true } }
|
|
4021
|
+
},
|
|
4022
|
+
{
|
|
4023
|
+
id: "navigate",
|
|
4024
|
+
title: "Navigate",
|
|
4025
|
+
description: "Step one period forward or back, re-emitting the window.",
|
|
4026
|
+
parameters: { direction: { type: "number", description: "1 or -1", required: true } }
|
|
4027
|
+
},
|
|
4028
|
+
{
|
|
4029
|
+
id: "go_to_today",
|
|
4030
|
+
title: "Go To Today",
|
|
4031
|
+
description: "Jump to today in the DECLARED zone.",
|
|
4032
|
+
parameters: {}
|
|
4033
|
+
},
|
|
4034
|
+
{
|
|
4035
|
+
id: "go_to_date",
|
|
4036
|
+
title: "Go To Date",
|
|
4037
|
+
description: "Jump to a day key (YYYY-MM-DD).",
|
|
4038
|
+
parameters: { date: { type: "string", description: "Day key", required: true } }
|
|
4039
|
+
},
|
|
4040
|
+
{
|
|
4041
|
+
id: "select_events",
|
|
4042
|
+
title: "Select Events",
|
|
4043
|
+
description: "Replace or toggle the selection.",
|
|
4044
|
+
parameters: {
|
|
4045
|
+
rowIds: { type: "array", description: "Row ids", required: true },
|
|
4046
|
+
additive: { type: "boolean", description: "Toggle membership" }
|
|
4047
|
+
}
|
|
4048
|
+
},
|
|
4049
|
+
{
|
|
4050
|
+
id: "reschedule",
|
|
4051
|
+
title: "Reschedule",
|
|
4052
|
+
description: "Move an event. Emits an intent; the host writes.",
|
|
4053
|
+
parameters: {
|
|
4054
|
+
rowId: { type: "string", description: "Row id", required: true },
|
|
4055
|
+
date: { type: "string", description: "Target day key", required: true },
|
|
4056
|
+
time: { type: "number", description: "Minutes from midnight" }
|
|
4057
|
+
}
|
|
4058
|
+
},
|
|
4059
|
+
{
|
|
4060
|
+
id: "get_events",
|
|
4061
|
+
title: "Get Events",
|
|
4062
|
+
description: "Return the derived events.",
|
|
4063
|
+
parameters: {}
|
|
4064
|
+
}
|
|
4065
|
+
],
|
|
4066
|
+
config: [
|
|
4067
|
+
{
|
|
4068
|
+
key: "viewMode",
|
|
4069
|
+
label: "View",
|
|
4070
|
+
type: "select",
|
|
4071
|
+
defaultValue: "month",
|
|
4072
|
+
options: [
|
|
4073
|
+
{ label: "Month", value: "month" },
|
|
4074
|
+
{ label: "Week", value: "week" },
|
|
4075
|
+
{ label: "Day", value: "day" },
|
|
4076
|
+
{ label: "Agenda", value: "agenda" },
|
|
4077
|
+
{ label: "Timeline", value: "timeline" }
|
|
4078
|
+
],
|
|
4079
|
+
description: "Timeline is the Gantt/duration-bar mode \u2014 anything with a time axis is calendar, per the family rule."
|
|
4080
|
+
},
|
|
4081
|
+
{
|
|
4082
|
+
key: "firstDayOfWeek",
|
|
4083
|
+
label: "Week starts on",
|
|
4084
|
+
type: "number",
|
|
4085
|
+
defaultValue: 1,
|
|
4086
|
+
description: "1 = Monday, 7 = Sunday. CONFIG, not a constant \u2014 post is Monday-first and notes is Sunday-first, so a calendar that assumes either is wrong for half its users.",
|
|
4087
|
+
placeholder: "1"
|
|
4088
|
+
},
|
|
4089
|
+
{
|
|
4090
|
+
key: "dateColumnId",
|
|
4091
|
+
label: "Date column",
|
|
4092
|
+
type: "text",
|
|
4093
|
+
description: "The start column. Required \u2014 without it the panel says so rather than rendering blank.",
|
|
4094
|
+
placeholder: "due_at"
|
|
4095
|
+
},
|
|
4096
|
+
{
|
|
4097
|
+
key: "endDateColumnId",
|
|
4098
|
+
label: "End column",
|
|
4099
|
+
type: "text",
|
|
4100
|
+
description: "For multi-day spans and durations.",
|
|
4101
|
+
placeholder: "ends_at"
|
|
4102
|
+
},
|
|
4103
|
+
{
|
|
4104
|
+
key: "timeZone",
|
|
4105
|
+
label: "Time zone",
|
|
4106
|
+
type: "text",
|
|
4107
|
+
defaultValue: "local",
|
|
4108
|
+
description: 'IANA zone, or "local". EXPLICIT by design: both shipped calendar bugs in the ecosystem are a zone nobody declared.',
|
|
4109
|
+
placeholder: "Europe/Bucharest"
|
|
4110
|
+
},
|
|
4111
|
+
{
|
|
4112
|
+
key: "maxChipsPerCell",
|
|
4113
|
+
label: "Chips per day",
|
|
4114
|
+
type: "number",
|
|
4115
|
+
defaultValue: 3,
|
|
4116
|
+
description: "Before an overflow indicator appears.",
|
|
4117
|
+
placeholder: "3"
|
|
4118
|
+
},
|
|
4119
|
+
{
|
|
4120
|
+
key: "chipFields",
|
|
4121
|
+
label: "Chip fields",
|
|
4122
|
+
type: "json",
|
|
4123
|
+
description: "Extra column ids to show on a chip.",
|
|
4124
|
+
placeholder: '["owner"]'
|
|
4125
|
+
},
|
|
4126
|
+
{
|
|
4127
|
+
key: "titleColumnId",
|
|
4128
|
+
label: "Title column",
|
|
4129
|
+
type: "text",
|
|
4130
|
+
description: "Chip title. Defaults to the first identifier column.",
|
|
4131
|
+
placeholder: "name"
|
|
4132
|
+
},
|
|
4133
|
+
{
|
|
4134
|
+
key: "statusColumnId",
|
|
4135
|
+
label: "Status column",
|
|
4136
|
+
type: "text",
|
|
4137
|
+
description: "Drives chip tone.",
|
|
4138
|
+
placeholder: "status"
|
|
4139
|
+
},
|
|
4140
|
+
{
|
|
4141
|
+
key: "businessHours",
|
|
4142
|
+
label: "Business hours",
|
|
4143
|
+
type: "json",
|
|
4144
|
+
description: "Minutes from midnight \u2014 shades the working day in week/day views.",
|
|
4145
|
+
placeholder: '{"start":540,"end":1080}'
|
|
4146
|
+
},
|
|
4147
|
+
{
|
|
4148
|
+
key: "slotMinutes",
|
|
4149
|
+
label: "Slot size",
|
|
4150
|
+
type: "number",
|
|
4151
|
+
defaultValue: 30,
|
|
4152
|
+
description: "Minutes per row in week/day views.",
|
|
4153
|
+
placeholder: "30"
|
|
4154
|
+
},
|
|
4155
|
+
{
|
|
4156
|
+
key: "allowDrag",
|
|
4157
|
+
label: "Allow reschedule",
|
|
4158
|
+
type: "boolean",
|
|
4159
|
+
defaultValue: true,
|
|
4160
|
+
description: "Permit drag-to-reschedule."
|
|
4161
|
+
},
|
|
4162
|
+
{
|
|
4163
|
+
key: "emptyHint",
|
|
4164
|
+
label: "Empty hint",
|
|
4165
|
+
type: "text",
|
|
4166
|
+
description: "Say how to add something, not that there is nothing.",
|
|
4167
|
+
placeholder: "Click a day to schedule something."
|
|
4168
|
+
}
|
|
4169
|
+
],
|
|
4170
|
+
capabilities: ["storage.local"],
|
|
4171
|
+
sdk: "^1.1.0"
|
|
4172
|
+
};
|
|
4173
|
+
|
|
4174
|
+
// src/time/calendar/react/CalendarPanelView.tsx
|
|
4175
|
+
import { useSyncExternalStore as useSyncExternalStore3 } from "react";
|
|
4176
|
+
import {
|
|
4177
|
+
Badge,
|
|
4178
|
+
EmptyState as EmptyState2,
|
|
4179
|
+
ErrorState,
|
|
4180
|
+
IconButton,
|
|
4181
|
+
Row,
|
|
4182
|
+
RowList,
|
|
4183
|
+
ScrollArea,
|
|
4184
|
+
SegmentedControl,
|
|
4185
|
+
StatusBar as StatusBar2,
|
|
4186
|
+
TextButton,
|
|
4187
|
+
Toolbar as Toolbar2,
|
|
4188
|
+
ToolbarGroup as ToolbarGroup2
|
|
4189
|
+
} from "@xenosystem/workbench/primitives/react";
|
|
4190
|
+
|
|
4191
|
+
// src/time/calendar/react/icons.tsx
|
|
4192
|
+
import { jsx as jsx4 } from "react/jsx-runtime";
|
|
4193
|
+
function ChevronGlyph({ direction }) {
|
|
4194
|
+
return /* @__PURE__ */ jsx4(
|
|
4195
|
+
"svg",
|
|
4196
|
+
{
|
|
4197
|
+
width: 11,
|
|
4198
|
+
height: 11,
|
|
4199
|
+
viewBox: "0 0 24 24",
|
|
4200
|
+
fill: "none",
|
|
4201
|
+
stroke: "currentColor",
|
|
4202
|
+
strokeWidth: "1.5",
|
|
4203
|
+
strokeLinecap: "round",
|
|
4204
|
+
strokeLinejoin: "round",
|
|
4205
|
+
"aria-hidden": "true",
|
|
4206
|
+
children: direction === "left" ? /* @__PURE__ */ jsx4("path", { d: "m15 18-6-6 6-6" }) : /* @__PURE__ */ jsx4("path", { d: "m9 18 6-6-6-6" })
|
|
4207
|
+
}
|
|
4208
|
+
);
|
|
4209
|
+
}
|
|
4210
|
+
|
|
4211
|
+
// src/time/calendar/react/CalendarPanelView.tsx
|
|
4212
|
+
import { Fragment, jsx as jsx5, jsxs as jsxs4 } from "react/jsx-runtime";
|
|
4213
|
+
var WEEKDAYS = ["Mon", "Tue", "Wed", "Thu", "Fri", "Sat", "Sun"];
|
|
4214
|
+
function CalendarPanelView({
|
|
4215
|
+
controller,
|
|
4216
|
+
firstDayOfWeek = 1,
|
|
4217
|
+
emptyHint
|
|
4218
|
+
}) {
|
|
4219
|
+
const state = useSyncExternalStore3(controller.subscribe, controller.getState, controller.getState);
|
|
4220
|
+
const toolbar = /* @__PURE__ */ jsx5(
|
|
4221
|
+
Toolbar2,
|
|
4222
|
+
{
|
|
4223
|
+
left: /* @__PURE__ */ jsxs4(Fragment, { children: [
|
|
4224
|
+
/* @__PURE__ */ jsx5(
|
|
4225
|
+
IconButton,
|
|
4226
|
+
{
|
|
4227
|
+
icon: /* @__PURE__ */ jsx5(ChevronGlyph, { direction: "left" }),
|
|
4228
|
+
label: "Previous",
|
|
4229
|
+
onClick: () => controller.navigate(-1)
|
|
4230
|
+
}
|
|
4231
|
+
),
|
|
4232
|
+
/* @__PURE__ */ jsx5(
|
|
4233
|
+
IconButton,
|
|
4234
|
+
{
|
|
4235
|
+
icon: /* @__PURE__ */ jsx5(ChevronGlyph, { direction: "right" }),
|
|
4236
|
+
label: "Next",
|
|
4237
|
+
onClick: () => controller.navigate(1)
|
|
4238
|
+
}
|
|
4239
|
+
),
|
|
4240
|
+
/* @__PURE__ */ jsx5(TextButton, { onClick: () => controller.goToToday(), children: "Today" }),
|
|
4241
|
+
/* @__PURE__ */ jsx5("span", { style: { fontSize: 11, opacity: 0.8 }, children: controller.anchorKey().slice(0, 7) })
|
|
4242
|
+
] }),
|
|
4243
|
+
right: /* @__PURE__ */ jsxs4(ToolbarGroup2, { end: true, children: [
|
|
4244
|
+
state.partial ? /* @__PURE__ */ jsx5(Badge, { tone: "warning", title: "Some measures have not finished syncing.", children: "partial" }) : null,
|
|
4245
|
+
/* @__PURE__ */ jsx5(Badge, { title: "Every date is bucketed in this zone.", children: controller.timeZone() }),
|
|
4246
|
+
/* @__PURE__ */ jsx5(
|
|
4247
|
+
SegmentedControl,
|
|
4248
|
+
{
|
|
4249
|
+
size: "sm",
|
|
4250
|
+
label: "View",
|
|
4251
|
+
options: [
|
|
4252
|
+
{ value: "month", label: "M" },
|
|
4253
|
+
{ value: "week", label: "W" },
|
|
4254
|
+
{ value: "day", label: "D" },
|
|
4255
|
+
{ value: "agenda", label: "Agenda" },
|
|
4256
|
+
{ value: "timeline", label: "Timeline" }
|
|
4257
|
+
],
|
|
4258
|
+
value: state.viewMode,
|
|
4259
|
+
onChange: (mode) => controller.setViewMode(mode)
|
|
4260
|
+
}
|
|
4261
|
+
)
|
|
4262
|
+
] })
|
|
4263
|
+
}
|
|
4264
|
+
);
|
|
4265
|
+
const body = (() => {
|
|
4266
|
+
if (state.configProblem) {
|
|
4267
|
+
return /* @__PURE__ */ jsx5(EmptyState2, { title: "Not configured", hint: state.configProblem });
|
|
4268
|
+
}
|
|
4269
|
+
if (state.errorMessage) {
|
|
4270
|
+
return /* @__PURE__ */ jsx5(ErrorState, { title: "Could not load events", message: state.errorMessage });
|
|
4271
|
+
}
|
|
4272
|
+
if (state.empty) {
|
|
4273
|
+
return /* @__PURE__ */ jsx5(
|
|
4274
|
+
EmptyState2,
|
|
4275
|
+
{
|
|
4276
|
+
title: "Nothing scheduled",
|
|
4277
|
+
hint: emptyHint ?? "Click a day to schedule something."
|
|
4278
|
+
}
|
|
4279
|
+
);
|
|
4280
|
+
}
|
|
4281
|
+
if (state.viewMode === "agenda" || state.viewMode === "timeline") {
|
|
4282
|
+
return /* @__PURE__ */ jsx5(ScrollArea, { children: /* @__PURE__ */ jsx5(RowList, { role: "listbox", "aria-label": "Events", children: state.events.map((event) => /* @__PURE__ */ jsx5(
|
|
4283
|
+
Row,
|
|
4284
|
+
{
|
|
4285
|
+
label: event.title,
|
|
4286
|
+
meta: event.multiDay ? `${event.startKey} \u2192 ${event.endKey}` : event.startKey,
|
|
4287
|
+
selected: state.selection.includes(event.id),
|
|
4288
|
+
onClick: () => controller.select([event.id]),
|
|
4289
|
+
onDoubleClick: () => controller.activateRow(event.id),
|
|
4290
|
+
trailing: event.status !== void 0 ? /* @__PURE__ */ jsx5(Badge, { tone: statusTone(event.status), children: String(event.status) }) : null
|
|
4291
|
+
},
|
|
4292
|
+
event.id
|
|
4293
|
+
)) }) });
|
|
4294
|
+
}
|
|
4295
|
+
const columns = state.viewMode === "day" ? 1 : 7;
|
|
4296
|
+
return /* @__PURE__ */ jsxs4(ScrollArea, { children: [
|
|
4297
|
+
columns === 7 ? /* @__PURE__ */ jsx5("div", { style: { display: "grid", gridTemplateColumns: "repeat(7, 1fr)" }, children: WEEKDAYS.map((_, i) => /* @__PURE__ */ jsx5(
|
|
4298
|
+
"div",
|
|
4299
|
+
{
|
|
4300
|
+
style: {
|
|
4301
|
+
fontSize: 9,
|
|
4302
|
+
textTransform: "uppercase",
|
|
4303
|
+
letterSpacing: "0.06em",
|
|
4304
|
+
opacity: 0.6,
|
|
4305
|
+
padding: "2px 4px"
|
|
4306
|
+
},
|
|
4307
|
+
children: WEEKDAYS[(firstDayOfWeek - 1 + i) % 7]
|
|
4308
|
+
},
|
|
4309
|
+
i
|
|
4310
|
+
)) }) : null,
|
|
4311
|
+
/* @__PURE__ */ jsx5("div", { style: { display: "grid", gridTemplateColumns: `repeat(${columns}, 1fr)`, gap: 1 }, children: state.cells.map((cell) => /* @__PURE__ */ jsxs4(
|
|
4312
|
+
"div",
|
|
4313
|
+
{
|
|
4314
|
+
onClick: () => controller.activateSlot(cell.key),
|
|
4315
|
+
style: {
|
|
4316
|
+
minHeight: 56,
|
|
4317
|
+
padding: 3,
|
|
4318
|
+
cursor: "pointer",
|
|
4319
|
+
opacity: cell.inPeriod ? 1 : 0.4,
|
|
4320
|
+
background: cell.today ? "var(--xeno-border-subtle)" : "var(--xeno-border-subtle)",
|
|
4321
|
+
boxShadow: cell.today ? "inset 0 0 0 1px var(--xeno-control)" : void 0
|
|
4322
|
+
},
|
|
4323
|
+
children: [
|
|
4324
|
+
/* @__PURE__ */ jsx5("div", { style: { fontSize: 9, opacity: 0.7, fontVariantNumeric: "tabular-nums" }, children: cell.day }),
|
|
4325
|
+
cell.events.map((event) => /* @__PURE__ */ jsx5(Chip, { event, controller, state }, event.id)),
|
|
4326
|
+
cell.overflow > 0 ? /* @__PURE__ */ jsxs4("div", { style: { fontSize: 9, opacity: 0.5 }, children: [
|
|
4327
|
+
"+",
|
|
4328
|
+
cell.overflow,
|
|
4329
|
+
" more"
|
|
4330
|
+
] }) : null
|
|
4331
|
+
]
|
|
4332
|
+
},
|
|
4333
|
+
cell.key
|
|
4334
|
+
)) })
|
|
4335
|
+
] });
|
|
4336
|
+
})();
|
|
4337
|
+
return /* @__PURE__ */ jsxs4("div", { style: { display: "flex", flexDirection: "column", height: "100%", minHeight: 0 }, children: [
|
|
4338
|
+
toolbar,
|
|
4339
|
+
body,
|
|
4340
|
+
/* @__PURE__ */ jsx5(
|
|
4341
|
+
StatusBar2,
|
|
4342
|
+
{
|
|
4343
|
+
left: `${state.events.length} event${state.events.length === 1 ? "" : "s"}`,
|
|
4344
|
+
right: state.selection.length > 0 ? `${state.selection.length} selected` : void 0
|
|
4345
|
+
}
|
|
4346
|
+
)
|
|
4347
|
+
] });
|
|
4348
|
+
}
|
|
4349
|
+
function Chip({
|
|
4350
|
+
event,
|
|
4351
|
+
controller,
|
|
4352
|
+
state
|
|
4353
|
+
}) {
|
|
4354
|
+
const selected = state.selection.includes(event.id);
|
|
4355
|
+
return /* @__PURE__ */ jsxs4(
|
|
4356
|
+
"div",
|
|
4357
|
+
{
|
|
4358
|
+
onClick: (e) => {
|
|
4359
|
+
e.stopPropagation();
|
|
4360
|
+
controller.select([event.id], e.ctrlKey || e.metaKey);
|
|
4361
|
+
},
|
|
4362
|
+
onDoubleClick: (e) => {
|
|
4363
|
+
e.stopPropagation();
|
|
4364
|
+
controller.activateRow(event.id);
|
|
4365
|
+
},
|
|
4366
|
+
onContextMenu: (e) => {
|
|
4367
|
+
e.preventDefault();
|
|
4368
|
+
e.stopPropagation();
|
|
4369
|
+
controller.requestContextMenu([event.id], e.clientX, e.clientY);
|
|
4370
|
+
},
|
|
4371
|
+
title: event.multiDay ? `${event.startKey} \u2192 ${event.endKey}` : event.title,
|
|
4372
|
+
style: {
|
|
4373
|
+
fontSize: 9,
|
|
4374
|
+
padding: "1px 3px",
|
|
4375
|
+
marginTop: 1,
|
|
4376
|
+
borderRadius: 2,
|
|
4377
|
+
background: selected ? "var(--xeno-control)" : "var(--xeno-border-subtle)",
|
|
4378
|
+
overflow: "hidden",
|
|
4379
|
+
textOverflow: "ellipsis",
|
|
4380
|
+
whiteSpace: "nowrap",
|
|
4381
|
+
cursor: "pointer"
|
|
4382
|
+
},
|
|
4383
|
+
children: [
|
|
4384
|
+
event.allDay ? null : /* @__PURE__ */ jsx5("span", { style: { opacity: 0.6, marginRight: 3, fontVariantNumeric: "tabular-nums" }, children: formatMinutes(event.startMinutes) }),
|
|
4385
|
+
event.title
|
|
4386
|
+
]
|
|
4387
|
+
}
|
|
4388
|
+
);
|
|
4389
|
+
}
|
|
4390
|
+
function formatMinutes(minutes) {
|
|
4391
|
+
if (minutes === null) return "";
|
|
4392
|
+
return `${String(Math.floor(minutes / 60)).padStart(2, "0")}:${String(minutes % 60).padStart(2, "0")}`;
|
|
4393
|
+
}
|
|
4394
|
+
|
|
4395
|
+
// src/time/calendar/panel.ts
|
|
4396
|
+
function createCalendarPanel(options = {}) {
|
|
4397
|
+
return {
|
|
4398
|
+
manifest: calendarManifest,
|
|
4399
|
+
activate(host) {
|
|
4400
|
+
const config = host.config ?? {};
|
|
4401
|
+
const firstDayOfWeek = numberConfig(config.firstDayOfWeek, 1);
|
|
4402
|
+
const controller = new CalendarController({
|
|
4403
|
+
host: { emit: (portId, value) => host.emit(portId, value) },
|
|
4404
|
+
viewMode: config.viewMode ?? "month",
|
|
4405
|
+
firstDayOfWeek,
|
|
4406
|
+
dateColumnId: typeof config.dateColumnId === "string" ? config.dateColumnId : void 0,
|
|
4407
|
+
endDateColumnId: typeof config.endDateColumnId === "string" ? config.endDateColumnId : void 0,
|
|
4408
|
+
timeZone: typeof config.timeZone === "string" ? config.timeZone : "local",
|
|
4409
|
+
maxChipsPerCell: numberConfig(config.maxChipsPerCell, 3),
|
|
4410
|
+
chipFields: parseJson(config.chipFields),
|
|
4411
|
+
titleColumnId: typeof config.titleColumnId === "string" ? config.titleColumnId : void 0,
|
|
4412
|
+
statusColumnId: typeof config.statusColumnId === "string" ? config.statusColumnId : void 0,
|
|
4413
|
+
slotMinutes: numberConfig(config.slotMinutes, 30),
|
|
4414
|
+
allowDrag: config.allowDrag !== false
|
|
4415
|
+
});
|
|
4416
|
+
const resolve = (config2) => ({
|
|
4417
|
+
firstDayOfWeek,
|
|
4418
|
+
emptyHint: typeof config2.emptyHint === "string" ? config2.emptyHint : void 0
|
|
4419
|
+
});
|
|
4420
|
+
let renderConfig = resolve(host.config ?? {});
|
|
4421
|
+
let unrender = null;
|
|
4422
|
+
let root = null;
|
|
4423
|
+
let currentEl = null;
|
|
4424
|
+
const draw = (el) => {
|
|
4425
|
+
unrender?.();
|
|
4426
|
+
if (options.render) {
|
|
4427
|
+
unrender = options.render(el, { controller, config: renderConfig });
|
|
4428
|
+
} else {
|
|
4429
|
+
root = createRoot3(el);
|
|
4430
|
+
root.render(createElement3(CalendarPanelView, { controller, ...renderConfig }));
|
|
4431
|
+
unrender = () => {
|
|
4432
|
+
root?.unmount();
|
|
4433
|
+
root = null;
|
|
4434
|
+
};
|
|
4435
|
+
}
|
|
4436
|
+
controller.emitWindow(true);
|
|
4437
|
+
};
|
|
4438
|
+
const unbindConfig = bindConfig2(host, (config2) => {
|
|
4439
|
+
renderConfig = resolve(config2);
|
|
4440
|
+
if (currentEl) draw(currentEl);
|
|
4441
|
+
});
|
|
4442
|
+
return {
|
|
4443
|
+
render(el) {
|
|
4444
|
+
currentEl = el;
|
|
4445
|
+
draw(el);
|
|
4446
|
+
},
|
|
4447
|
+
onInput(portId, value) {
|
|
4448
|
+
if (portId === "result") {
|
|
4449
|
+
const opened = unwrapResult(value);
|
|
4450
|
+
if (opened) controller.setResult(opened.result);
|
|
4451
|
+
} else if (portId === "window") {
|
|
4452
|
+
if (isRecord2(value)) controller.setWindow(value);
|
|
4453
|
+
} else if (portId === "selection") {
|
|
4454
|
+
controller.setSelection(Array.isArray(value) ? value : []);
|
|
4455
|
+
}
|
|
4456
|
+
},
|
|
4457
|
+
async onCommand(commandId, params) {
|
|
4458
|
+
switch (commandId) {
|
|
4459
|
+
case "set_view_mode":
|
|
4460
|
+
return controller.setViewMode(params.mode);
|
|
4461
|
+
case "navigate":
|
|
4462
|
+
controller.navigate(Number(params.direction) < 0 ? -1 : 1);
|
|
4463
|
+
return true;
|
|
4464
|
+
case "go_to_today":
|
|
4465
|
+
controller.goToToday();
|
|
4466
|
+
return true;
|
|
4467
|
+
case "go_to_date":
|
|
4468
|
+
return controller.goToDate(String(params.date));
|
|
4469
|
+
case "select_events":
|
|
4470
|
+
controller.select(
|
|
4471
|
+
Array.isArray(params.rowIds) ? params.rowIds : [],
|
|
4472
|
+
params.additive === true
|
|
4473
|
+
);
|
|
4474
|
+
return true;
|
|
4475
|
+
case "reschedule":
|
|
4476
|
+
return controller.rescheduleTo(
|
|
4477
|
+
String(params.rowId),
|
|
4478
|
+
String(params.date),
|
|
4479
|
+
typeof params.time === "number" ? params.time : void 0
|
|
4480
|
+
);
|
|
4481
|
+
case "get_events":
|
|
4482
|
+
return controller.events().map((e) => ({
|
|
4483
|
+
id: e.id,
|
|
4484
|
+
title: e.title,
|
|
4485
|
+
startKey: e.startKey,
|
|
4486
|
+
endKey: e.endKey,
|
|
4487
|
+
allDay: e.allDay,
|
|
4488
|
+
multiDay: e.multiDay
|
|
4489
|
+
}));
|
|
4490
|
+
default:
|
|
4491
|
+
return;
|
|
4492
|
+
}
|
|
4493
|
+
},
|
|
4494
|
+
serialize() {
|
|
4495
|
+
return controller.serialize();
|
|
4496
|
+
},
|
|
4497
|
+
deserialize(state) {
|
|
4498
|
+
controller.deserialize(state ?? {});
|
|
4499
|
+
},
|
|
4500
|
+
dispose() {
|
|
4501
|
+
unbindConfig();
|
|
4502
|
+
currentEl = null;
|
|
4503
|
+
unrender?.();
|
|
4504
|
+
unrender = null;
|
|
4505
|
+
controller.dispose();
|
|
4506
|
+
}
|
|
4507
|
+
};
|
|
4508
|
+
}
|
|
4509
|
+
};
|
|
4510
|
+
}
|
|
4511
|
+
function parseJson(value) {
|
|
4512
|
+
if (value === void 0 || value === null) return void 0;
|
|
4513
|
+
if (typeof value !== "string") return value;
|
|
4514
|
+
try {
|
|
4515
|
+
return JSON.parse(value);
|
|
4516
|
+
} catch {
|
|
4517
|
+
return void 0;
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
function numberConfig(value, fallback) {
|
|
4521
|
+
const n = typeof value === "number" ? value : Number(value);
|
|
4522
|
+
return Number.isFinite(n) && n > 0 ? n : fallback;
|
|
4523
|
+
}
|
|
4524
|
+
var calendarPanel = createCalendarPanel();
|
|
4525
|
+
var export_BAR_SUBDIVISION = format_exports.BAR_SUBDIVISION;
|
|
4526
|
+
var export_DEFAULT_TIMEBASE = types_exports.DEFAULT_TIMEBASE;
|
|
4527
|
+
var export_DISPLAY_CYCLE = format_exports.DISPLAY_CYCLE;
|
|
4528
|
+
var export_IDLE_CLOCK = types_exports.IDLE_CLOCK;
|
|
4529
|
+
var export_clampTick = types_exports.clampTick;
|
|
4530
|
+
var export_clockAt = clock_exports.clockAt;
|
|
4531
|
+
var export_createThrottle = throttle_exports.createThrottle;
|
|
4532
|
+
var export_displayLabel = format_exports.displayLabel;
|
|
4533
|
+
var export_effectiveFps = types_exports.effectiveFps;
|
|
4534
|
+
var export_extrapolateTick = clock_exports.extrapolateTick;
|
|
4535
|
+
var export_formatBars = format_exports.formatBars;
|
|
4536
|
+
var export_formatFrames = format_exports.formatFrames;
|
|
4537
|
+
var export_formatSeconds = format_exports.formatSeconds;
|
|
4538
|
+
var export_formatTick = format_exports.formatTick;
|
|
4539
|
+
var export_formatTimecode = format_exports.formatTimecode;
|
|
4540
|
+
var export_frameToTick = format_exports.frameToTick;
|
|
4541
|
+
var export_isAnimating = clock_exports.isAnimating;
|
|
4542
|
+
var export_nextDisplay = format_exports.nextDisplay;
|
|
4543
|
+
var export_normalizeClock = types_exports.normalizeClock;
|
|
4544
|
+
var export_normalizeTimebase = types_exports.normalizeTimebase;
|
|
4545
|
+
var export_secondsToTicks = types_exports.secondsToTicks;
|
|
4546
|
+
var export_tickToFrame = format_exports.tickToFrame;
|
|
4547
|
+
var export_ticksToBarsBeats = format_exports.ticksToBarsBeats;
|
|
4548
|
+
var export_ticksToSeconds = types_exports.ticksToSeconds;
|
|
4549
|
+
var export_toTick = types_exports.toTick;
|
|
4550
|
+
var export_wrapIntoLoop = clock_exports.wrapIntoLoop;
|
|
4551
|
+
export {
|
|
4552
|
+
ALL_CALENDAR_MODES,
|
|
4553
|
+
export_BAR_SUBDIVISION as BAR_SUBDIVISION,
|
|
4554
|
+
CALENDAR_PANEL_ID,
|
|
4555
|
+
CalendarController,
|
|
4556
|
+
CalendarPanelView,
|
|
4557
|
+
DEFAULT_SNAP,
|
|
4558
|
+
export_DEFAULT_TIMEBASE as DEFAULT_TIMEBASE,
|
|
4559
|
+
DEFAULT_ZOOM,
|
|
4560
|
+
export_DISPLAY_CYCLE as DISPLAY_CYCLE,
|
|
4561
|
+
DRAG_THRESHOLD_PX,
|
|
4562
|
+
EDGE_ZONE_MIN_CLIP_PX,
|
|
4563
|
+
EDGE_ZONE_PX,
|
|
4564
|
+
export_IDLE_CLOCK as IDLE_CLOCK,
|
|
4565
|
+
MIN_MAJOR_PX,
|
|
4566
|
+
MIN_MINOR_PX,
|
|
4567
|
+
TIMELINE_PANEL_ID,
|
|
4568
|
+
TRIM_SNAP_PX,
|
|
4569
|
+
TimelineController,
|
|
4570
|
+
TimelinePanelView,
|
|
4571
|
+
TransportPanelController,
|
|
4572
|
+
TransportPanelView,
|
|
4573
|
+
XenoTileCache,
|
|
4574
|
+
XenoTimeScale,
|
|
4575
|
+
addDays,
|
|
4576
|
+
addMonths,
|
|
4577
|
+
alignToTileGrid,
|
|
4578
|
+
beginDrag,
|
|
4579
|
+
blitTile,
|
|
4580
|
+
buildCells,
|
|
4581
|
+
buildRowLayout,
|
|
4582
|
+
calendarManifest,
|
|
4583
|
+
calendarPanel,
|
|
4584
|
+
chooseRung,
|
|
4585
|
+
clampScroll,
|
|
4586
|
+
export_clampTick as clampTick,
|
|
4587
|
+
clipDuration,
|
|
4588
|
+
clipsInMarquee,
|
|
4589
|
+
clipsOverlap,
|
|
4590
|
+
export_clockAt as clockAt,
|
|
4591
|
+
collectSnapTargets,
|
|
4592
|
+
commitDrag,
|
|
4593
|
+
computeVisible,
|
|
4594
|
+
createCalendarPanel,
|
|
4595
|
+
export_createThrottle as createThrottle,
|
|
4596
|
+
createTimelinePanel,
|
|
4597
|
+
createTransportPanel,
|
|
4598
|
+
dayKey,
|
|
4599
|
+
daysInMonth,
|
|
4600
|
+
dedupeByColumn,
|
|
4601
|
+
deriveEvents,
|
|
4602
|
+
export_displayLabel as displayLabel,
|
|
4603
|
+
edgeZone,
|
|
4604
|
+
export_effectiveFps as effectiveFps,
|
|
4605
|
+
export_extrapolateTick as extrapolateTick,
|
|
4606
|
+
export_formatBars as formatBars,
|
|
4607
|
+
export_formatFrames as formatFrames,
|
|
4608
|
+
export_formatSeconds as formatSeconds,
|
|
4609
|
+
export_formatTick as formatTick,
|
|
4610
|
+
export_formatTimecode as formatTimecode,
|
|
4611
|
+
export_frameToTick as frameToTick,
|
|
4612
|
+
gridStart,
|
|
4613
|
+
gridTicksFor,
|
|
4614
|
+
hasSourceWindow,
|
|
4615
|
+
export_isAnimating as isAnimating,
|
|
4616
|
+
isZoomGesture,
|
|
4617
|
+
isoWeekday,
|
|
4618
|
+
export_nextDisplay as nextDisplay,
|
|
4619
|
+
export_normalizeClock as normalizeClock,
|
|
4620
|
+
export_normalizeTimebase as normalizeTimebase,
|
|
4621
|
+
packOverlaps,
|
|
4622
|
+
parseDayKey,
|
|
4623
|
+
passedThreshold,
|
|
4624
|
+
previewDrag,
|
|
4625
|
+
renderTier,
|
|
4626
|
+
rowAtY,
|
|
4627
|
+
rowInWindow,
|
|
4628
|
+
rulerTicks,
|
|
4629
|
+
scrollToReveal,
|
|
4630
|
+
export_secondsToTicks as secondsToTicks,
|
|
4631
|
+
snapDelta,
|
|
4632
|
+
snapTick,
|
|
4633
|
+
snapWouldFight,
|
|
4634
|
+
sourceRangeFor,
|
|
4635
|
+
statusTone,
|
|
4636
|
+
export_tickToFrame as tickToFrame,
|
|
4637
|
+
ticksPerStep,
|
|
4638
|
+
export_ticksToBarsBeats as ticksToBarsBeats,
|
|
4639
|
+
export_ticksToSeconds as ticksToSeconds,
|
|
4640
|
+
tileKey,
|
|
4641
|
+
tileWidthTicks,
|
|
4642
|
+
timelineManifest,
|
|
4643
|
+
timelinePanel,
|
|
4644
|
+
export_toTick as toTick,
|
|
4645
|
+
todayKey,
|
|
4646
|
+
transportManifest,
|
|
4647
|
+
transportPanel,
|
|
4648
|
+
trimModeFor,
|
|
4649
|
+
visibleClips,
|
|
4650
|
+
visibleRows,
|
|
4651
|
+
weekStart,
|
|
4652
|
+
wheelZoomFactor,
|
|
4653
|
+
withSnapModifier,
|
|
4654
|
+
export_wrapIntoLoop as wrapIntoLoop,
|
|
4655
|
+
zoomAboutCursor,
|
|
4656
|
+
zoomBucket,
|
|
4657
|
+
zoomToFit
|
|
4658
|
+
};
|