gesso-framework 0.1.0 → 0.2.1

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/index.js CHANGED
@@ -1,7 +1,7 @@
1
1
  import { $ as InternalState, A as printPropValue, B as channel, C as captureConsole, D as formatNodePath, E as formatAge, F as applyPatch, G as mutate, H as viewKeys, I as applyPatches, J as select, K as Resource, L as diffProjection, M as provide, N as findUnplainPath, O as formatNodeReport, P as requirePlainData, Q as structurallyEqual, R as isChannelClientMessage, S as workerHandle, T as describeStream, U as debounced, V as defineChannel, W as throttled, X as computed, Y as ComputedCell, Z as derive, _ as isHubMessage, a as undoable, at as output, b as portHandle, c as classifyStorageError, d as storageReadValue, et as internalState, f as pick, g as APPLICATION_WORKER, h as serveChannels, i as OpfsStorage, it as isOutputTarget, j as ProvidedChannel, k as formatStream, l as storageErrorMessage, m as serve, n as persisted, nt as input, o as UndoStack, ot as outputTargetOf, p as pickKeys, q as resource, r as IndexedDbStorage, rt as into, s as MemoryStorage, st as withBodyOf, t as PersistedState, tt as InputCell, u as storageReadFailure, v as isPortErrorMessage, w as isConsoleEntryMessage, x as servePorts, y as isPortHandshake, z as isChannelHostMessage } from "./persisted-Ddb51avc.js";
2
2
  import { a as Each, i as show, n as getComponentMetadata, o as each, r as Show, t as createComponent } from "./createComponent-3n1ZSql8.js";
3
3
  import { BehaviorSubject, EMPTY, Subject, Subscription, distinctUntilChanged, map } from "rxjs";
4
- import { AnimationDriver, Box, Canvas2DRenderer, CanvasPlatformSurface, CanvasTextMeasurer, Constraints, DefaultImageResolver, DefaultVideoResolver, DirtyFlags, EnvironmentNotifier, FocusNotifier, IconRasterizer, LayoutEngine, LayoutInspector, LayoutNotifier, MARK_PREFIX, Stack, UI_ROLES, UI_SEMANTIC_STATES, UiAnimationFrameClock, UiEditingController, UiEnvironmentKeys, UiEventType, UiFindController, UiFocusManager, UiGestureRecognizer, UiGraph, UiGraphBuilder, UiHitTester, UiHostFrameClock, UiInputDispatcher, UiInsetRegistry, UiKeyboardController, UiNodeType, UiPlatformAdapter, UiPointerController, UiPointerEvent, UiScheduler, UiSelectionController, UiSharedElements, UiSpring, UiTimerFrameClock, UiTouchScroller, UiVirtualWindow, UiWheelController, VIRTUAL_INDEX_PROP, VIRTUAL_LEAD_PROP, VIRTUAL_WINDOW_PROP, WebGPURenderer, buildSemanticsTree, bumpFontStack, capturePointer, createCanvasSurface, createTween, createWebGPUSurface, defaultMotion, describeOverrides, diffSemantics, easings, formatExplanation, insetsEqual, isComponentLikeElement, isObservable, isWebGPUAvailable, linear, linkOf, markInstant, markNow, measure, measureSpan, motion, noInsets, observeViewportInsets, percent, performanceMarksEnabled, pointerDeviceOf, prepareInputSurface, propertyEffects, registerFontStack, resolveCursor, scrollbarThumb, setPerformanceMarks, setPerformanceMarks as setPerformanceMarks$1, textRunOfRecordId, themeTokens, touchActionFor, wheelDeltaYOf } from "gesso-core";
4
+ import { AnimationDriver, Box, Canvas2DRenderer, CanvasPlatformSurface, CanvasTextMeasurer, Constraints, DefaultImageResolver, DefaultVideoResolver, DirtyFlags, EnvironmentNotifier, FocusNotifier, IconRasterizer, LayoutEngine, LayoutInspector, LayoutNotifier, MARK_PREFIX, Stack, TEXT_RUN_ID_SEPARATOR, UI_ROLES, UI_SEMANTIC_STATES, UiAnimationFrameClock, UiEditingController, UiEnvironmentKeys, UiEventType, UiFindController, UiFocusManager, UiGestureRecognizer, UiGraph, UiGraphBuilder, UiHitTester, UiHostFrameClock, UiInputDispatcher, UiInsetRegistry, UiKeyboardController, UiNodeType, UiPlatformAdapter, UiPointerController, UiPointerEvent, UiScheduler, UiSelectionController, UiSharedElements, UiSpring, UiTimerFrameClock, UiTouchScroller, UiVirtualWindow, UiWheelController, VIRTUAL_INDEX_PROP, VIRTUAL_LEAD_PROP, VIRTUAL_WINDOW_PROP, WebGPURenderer, buildSemanticsSubtree, buildSemanticsTree, bumpFontStack, capturePointer, createCanvasSurface, createTween, createWebGPUSurface, defaultMotion, describeOverrides, diffSemantics, easings, formatExplanation, insetsEqual, isComponentLikeElement, isObservable, isWebGPUAvailable, linear, linkOf, markInstant, markNow, measure, measureSpan, motion, noInsets, observeViewportInsets, percent, performanceMarksEnabled, pointerDeviceOf, prepareInputSurface, propertyEffects, recordsEqual, registerFontStack, resolveCursor, scrollbarThumb, semanticsInertAbove, setPerformanceMarks, setPerformanceMarks as setPerformanceMarks$1, textRunOfRecordId, themeTokens, touchActionFor, wheelDeltaYOf } from "gesso-core";
5
5
  //#region src/Component.ts
6
6
  /**
7
7
  * Base class for all Gesso framework components.
@@ -1057,6 +1057,7 @@ var ShellService = class {
1057
1057
  handler = null;
1058
1058
  scheme = internalState("light");
1059
1059
  insets = internalState(noInsets);
1060
+ isFullscreen = internalState(false);
1060
1061
  /** Popups asked for and not yet answered, by the id sent with each. */
1061
1062
  popups = /* @__PURE__ */ new Map();
1062
1063
  nextPopupId = 1;
@@ -1156,6 +1157,35 @@ var ShellService = class {
1156
1157
  });
1157
1158
  }
1158
1159
  /**
1160
+ * Whether the application's surface is filling the screen.
1161
+ *
1162
+ * Read rather than assumed: the person can leave fullscreen with
1163
+ * Escape, which no request here hears about, and a button that
1164
+ * tracked its own last press would then point the wrong way. The
1165
+ * shell reports the real state and this follows it.
1166
+ */
1167
+ fullscreen = this.isFullscreen;
1168
+ /**
1169
+ * Asks the shell to fill the screen, or to stop.
1170
+ *
1171
+ * A request rather than a call, for the reason the clipboard is one:
1172
+ * the Fullscreen API is the document's, and the thread this runs on
1173
+ * may not have a document. It can also be refused outright, because
1174
+ * browsers only grant it during a gesture, which is why nothing here
1175
+ * returns a promise pretending otherwise. Watch `fullscreen` for what
1176
+ * actually happened.
1177
+ */
1178
+ requestFullscreen(enter) {
1179
+ this.handler?.({
1180
+ type: "fullscreen",
1181
+ enter
1182
+ });
1183
+ }
1184
+ /** Called by the runtime when the shell reports the state. Not for applications. */
1185
+ applyFullscreen(active) {
1186
+ if (this.isFullscreen.value !== active) this.isFullscreen.value = active;
1187
+ }
1188
+ /**
1159
1189
  * Opens a sized window and answers whether the browser allowed it.
1160
1190
  *
1161
1191
  * Separate from `openUrl` because the two differ in three ways that
@@ -3065,6 +3095,12 @@ var GessoRuntime = class {
3065
3095
  replicas = [];
3066
3096
  phaseTimings = emptyPhaseTimings();
3067
3097
  started = false;
3098
+ /**
3099
+ * Whether the engine holds a laid-out tree, so `resize` knows which
3100
+ * path it is on. Set by the first resize and by the first frame that
3101
+ * lays out, whichever comes first; see `resize`.
3102
+ */
3103
+ laidOutOnce = false;
3068
3104
  constructor(options) {
3069
3105
  this.services = options.services ?? new ServiceRegistry();
3070
3106
  this.channels = options.channels ?? new ChannelRegistry();
@@ -3150,6 +3186,12 @@ var GessoRuntime = class {
3150
3186
  y: record.scrollY
3151
3187
  };
3152
3188
  },
3189
+ viewport: () => ({
3190
+ x: 0,
3191
+ y: 0,
3192
+ width: this.width,
3193
+ height: this.height
3194
+ }),
3153
3195
  onLayout: (node, listener) => this.layoutNotifier.add(node, listener)
3154
3196
  },
3155
3197
  animations: this.animations,
@@ -3323,8 +3365,12 @@ var GessoRuntime = class {
3323
3365
  this.renderer.resize(width, height, dpr);
3324
3366
  this.constraints = Constraints.loose(width, height);
3325
3367
  if (this.root === void 0) return;
3326
- this.engine.layout(this.root, this.constraints);
3327
- this.graph.markDirty(this.root, DirtyFlags.Paint);
3368
+ if (this.laidOutOnce) this.graph.markDirty(this.root, DirtyFlags.Layout | DirtyFlags.Paint);
3369
+ else {
3370
+ this.laidOutOnce = true;
3371
+ this.engine.layout(this.root, this.constraints);
3372
+ this.graph.markDirty(this.root, DirtyFlags.Paint);
3373
+ }
3328
3374
  if (this.started) this.scheduler.flush(now());
3329
3375
  }
3330
3376
  /**
@@ -3614,6 +3660,17 @@ var GessoRuntime = class {
3614
3660
  * `AnimationDriver.setHidden` for why an entrance frozen at its
3615
3661
  * first value is a hole in the page rather than a paused animation.
3616
3662
  */
3663
+ /**
3664
+ * The shell says the surface entered or left fullscreen.
3665
+ *
3666
+ * Reported rather than inferred from the request that asked for it,
3667
+ * because the person can leave with Escape and a browser can refuse
3668
+ * to enter at all; a control that tracked its own last press would
3669
+ * then point the wrong way.
3670
+ */
3671
+ setFullscreen(active) {
3672
+ this.services.get(ShellService).applyFullscreen(active);
3673
+ }
3617
3674
  setVisible(visible) {
3618
3675
  this.input.editing.setVisible(visible);
3619
3676
  if (visible === this.visible) return;
@@ -4284,17 +4341,12 @@ var GessoRuntime = class {
4284
4341
  * to say. `UiSemanticsUpdate` explains why they travel together
4285
4342
  * anyway.
4286
4343
  */
4287
- updateSemantics(rebuild, moved) {
4344
+ updateSemantics(frame, rebuild, moved) {
4288
4345
  let patches = EMPTY_PATCHES;
4289
4346
  const listener = this.semanticsListener;
4290
4347
  if (rebuild) {
4291
4348
  if (listener === null) this.semanticsStale = true;
4292
- else {
4293
- const next = buildSemanticsTree(this.layoutRoot());
4294
- patches = diffSemantics(this.semantics, next);
4295
- this.semantics = next;
4296
- this.semanticsStale = false;
4297
- }
4349
+ else patches = this.rescopeSemantics(frame) ?? this.rebuildSemantics();
4298
4350
  }
4299
4351
  if (listener === null) return;
4300
4352
  const boxes = moved || patches.length > 0 ? this.collectSemanticsBoxes() : EMPTY_BOXES;
@@ -4311,23 +4363,160 @@ var GessoRuntime = class {
4311
4363
  boxes
4312
4364
  });
4313
4365
  }
4366
+ /** Walks the whole tree and diffs it against the last one. */
4367
+ rebuildSemantics() {
4368
+ const next = buildSemanticsTree(this.layoutRoot());
4369
+ const patches = diffSemantics(this.semantics, next);
4370
+ this.semantics = next;
4371
+ this.semanticsStale = false;
4372
+ return patches;
4373
+ }
4374
+ /**
4375
+ * The patches for a frame that changed what some nodes *mean*
4376
+ * without changing which nodes there are — or null when that cannot
4377
+ * be established cheaply, and the whole tree has to be walked.
4378
+ *
4379
+ * This is the common case and it used to cost the uncommon one. A
4380
+ * bound label is `text`, `text` marks semantics dirty, and the phase
4381
+ * answered every one of them by rebuilding the tree from the root
4382
+ * and diffing two maps: on the benchmark list, 2 ms on any frame a
4383
+ * clock ticked, next to 0.03 ms for the layout of the same change.
4384
+ *
4385
+ * So the walk is rooted at what actually changed. For each dirty
4386
+ * node the nearest ancestor *carrying a record* is found — a
4387
+ * `Button`'s label lives on the button, not on the `Text` inside it
4388
+ * that a parent claimed — and only that node's subtree is walked.
4389
+ * `buildSemanticsSubtree` explains why the owner has to be a record
4390
+ * holder for the indices to come out the same.
4391
+ *
4392
+ * It gives up, and says so with null, whenever the *shape* of the
4393
+ * tree could have moved:
4394
+ *
4395
+ * - a frame that changed the tree's children, which is a structural
4396
+ * change by definition;
4397
+ * - a dirty node with no record-holding ancestor, whose records sit
4398
+ * at the top level and are numbered among everything else there;
4399
+ * - an owner that is no longer describable, whose children would
4400
+ * reattach further up;
4401
+ * - a subtree that gained or lost a record, because `this.semantics`
4402
+ * is insertion-ordered and the diff promises adds in document
4403
+ * order — splicing one into the middle of a Map cannot preserve
4404
+ * that, and a mirror appending a child before its parent is a
4405
+ * worse bug than a slow frame.
4406
+ *
4407
+ * Each of those falls back to the full rebuild, which is correct by
4408
+ * construction. Nothing here decides what a record says; it only
4409
+ * decides how much of the tree has to be asked.
4410
+ */
4411
+ rescopeSemantics(frame) {
4412
+ if (this.semantics.size === 0 || this.semanticsStale) return null;
4413
+ const owners = /* @__PURE__ */ new Set();
4414
+ for (const [node, flags] of frame.entries()) {
4415
+ if ((flags & DirtyFlags.Children) !== 0) return null;
4416
+ if ((flags & DirtyFlags.Semantics) === 0) continue;
4417
+ const owner = this.semanticsOwnerOf(node);
4418
+ if (owner === null) return null;
4419
+ owners.add(owner);
4420
+ if (owners.size > SCOPED_SEMANTICS_OWNERS) return null;
4421
+ }
4422
+ if (owners.size === 0) return null;
4423
+ const updates = [];
4424
+ for (const owner of owners) {
4425
+ const previous = this.semantics.get(owner.id);
4426
+ const subtree = buildSemanticsSubtree(owner, previous.parent, previous.index, semanticsInertAbove(owner));
4427
+ if (subtree === null) return null;
4428
+ let covered = 0;
4429
+ for (const id of this.semanticIdsUnder(owner)) {
4430
+ const next = subtree.get(id);
4431
+ if (next === void 0) return null;
4432
+ covered++;
4433
+ const before = this.semantics.get(id);
4434
+ if (!recordsEqual(before, next)) updates.push(next);
4435
+ }
4436
+ if (covered !== subtree.size) return null;
4437
+ }
4438
+ const patches = [];
4439
+ for (const record of updates) {
4440
+ this.semantics.set(record.id, record);
4441
+ patches.push({
4442
+ op: "update",
4443
+ node: record
4444
+ });
4445
+ }
4446
+ return patches;
4447
+ }
4314
4448
  /**
4315
- * The mirrored nodes whose box differs from the one last sent.
4449
+ * The nearest node at or above `node` that holds a semantics record,
4450
+ * or null when nothing above it does.
4451
+ */
4452
+ semanticsOwnerOf(node) {
4453
+ for (let current = node; current !== null; current = current.parent) if (this.semantics.has(current.id)) return current;
4454
+ return null;
4455
+ }
4456
+ /**
4457
+ * The ids in the current tree that belong to `node`'s subtree.
4458
+ *
4459
+ * Read off the graph rather than remembered, which is sound here
4460
+ * precisely because the caller has established that no node's
4461
+ * children changed this frame.
4462
+ *
4463
+ * A text run is not a node and has no id of its own: it borrows its
4464
+ * paragraph's and adds its position, numbered from zero without
4465
+ * gaps. So the runs of a node are found by counting up until one is
4466
+ * missing, rather than by scanning every key for the prefix.
4467
+ */
4468
+ semanticIdsUnder(node) {
4469
+ const ids = [];
4470
+ const stack = [node];
4471
+ while (stack.length > 0) {
4472
+ const current = stack.pop();
4473
+ if (this.semantics.has(current.id)) {
4474
+ ids.push(current.id);
4475
+ for (let run = 0;; run++) {
4476
+ const id = `${current.id}${TEXT_RUN_ID_SEPARATOR}${run}`;
4477
+ if (!this.semantics.has(id)) break;
4478
+ ids.push(id);
4479
+ }
4480
+ }
4481
+ for (let child = current.firstChild; child !== null; child = child.nextSibling) stack.push(child);
4482
+ }
4483
+ return ids;
4484
+ }
4485
+ /**
4486
+ * The mirrored nodes whose box differs from the one last sent, and
4487
+ * which are on screen.
4488
+ *
4489
+ * This runs on every frame that laid out, and `frameNeedsLayout`
4490
+ * counts a transform — so it runs on every scrolled frame, where
4491
+ * every box beneath the scroll container has moved. Whatever it
4492
+ * returns crosses to the shell and is written as four inline styles
4493
+ * per element, on the main thread, before the next paint. That is
4494
+ * the one per-frame cost this architecture puts back on the thread
4495
+ * it exists to keep free, and it was proportional to the whole
4496
+ * mounted tree.
4497
+ *
4498
+ * So it is bounded by the viewport instead. A node scrolled out of
4499
+ * sight keeps the last box the mirror was told about; there is
4500
+ * nothing on screen for it to be wrong about, and the sweep picks it
4501
+ * up again on the frame it comes back, because the box it is
4502
+ * compared against is still the stale one. The focused node is
4503
+ * always included whether or not it is on screen: the focus ring is
4504
+ * drawn from its rectangle, and focus moving to something off screen
4505
+ * is answered by `scrollIntoView`, whose own frame reports it.
4316
4506
  *
4317
- * Bounded by the semantics tree, which is bounded by the *mounted*
4318
- * nodes — so a 100k-row list costs the fifteen rows it has mounted,
4319
- * the same bound the semantics tree walk has.
4320
4507
  * Ids that have left the tree are dropped here rather than tracked,
4321
4508
  * since a removal patch has already told the mirror about them.
4322
4509
  */
4323
4510
  collectSemanticsBoxes() {
4324
4511
  const changed = [];
4512
+ const focused = this.focusManager.focusedNode?.id;
4325
4513
  for (const id of this.semantics.keys()) {
4326
4514
  const node = this.graph.getNode(id);
4327
4515
  if (node === void 0 || this.engine.recordFor(node) === void 0) continue;
4328
4516
  const box = this.engine.visibleBox(node);
4329
4517
  const last = this.semanticsBoxes.get(id);
4330
4518
  if (last !== void 0 && boxesEqual(last, box)) continue;
4519
+ if (id !== focused && !this.onScreen(box)) continue;
4331
4520
  this.semanticsBoxes.set(id, box);
4332
4521
  changed.push({
4333
4522
  id,
@@ -4339,6 +4528,15 @@ var GessoRuntime = class {
4339
4528
  }
4340
4529
  return changed;
4341
4530
  }
4531
+ /**
4532
+ * Whether a box in viewport coordinates overlaps the canvas at all.
4533
+ *
4534
+ * Generous by a margin, so that something a scroll is about to bring
4535
+ * in has its rectangle before it arrives rather than one frame after.
4536
+ */
4537
+ onScreen(box) {
4538
+ return box.x < this.width + SEMANTICS_VIEWPORT_MARGIN && box.y < this.height + SEMANTICS_VIEWPORT_MARGIN && box.x + box.width > -SEMANTICS_VIEWPORT_MARGIN && box.y + box.height > -SEMANTICS_VIEWPORT_MARGIN;
4539
+ }
4342
4540
  /** Scrolls every scroll container above `node` so a node-local box is visible. */
4343
4541
  revealBox(node, box) {
4344
4542
  for (const adjustment of this.engine.revealAdjustments(node, 0, box)) {
@@ -4508,7 +4706,10 @@ var GessoRuntime = class {
4508
4706
  this.focusManager.settleScope();
4509
4707
  const laidOut = frameNeedsLayout(frame);
4510
4708
  this.phaseTimings.layout = this.timePhase(() => laidOut, () => this.engine.layoutForFrame(frame, this.constraints, root));
4511
- if (laidOut) this.inspector.recordLayout(started);
4709
+ if (laidOut) {
4710
+ this.laidOutOnce = true;
4711
+ this.inspector.recordLayout(started);
4712
+ }
4512
4713
  if (!this.layoutNotifier.isEmpty()) this.layoutNotifier.notify((node) => {
4513
4714
  const record = this.engine.recordFor(node);
4514
4715
  return {
@@ -4523,7 +4724,7 @@ var GessoRuntime = class {
4523
4724
  }
4524
4725
  this.sendEditingState();
4525
4726
  const rebuildSemantics = frameNeedsSemantics(frame);
4526
- this.phaseTimings.semantics = this.timePhase(() => rebuildSemantics || this.semanticsListener !== null && (laidOut || this.focusManager.focusedNode?.id !== this.lastFocusedId), () => this.updateSemantics(rebuildSemantics, laidOut));
4727
+ this.phaseTimings.semantics = this.timePhase(() => rebuildSemantics || this.semanticsListener !== null && (laidOut || this.focusManager.focusedNode?.id !== this.lastFocusedId), () => this.updateSemantics(frame, rebuildSemantics, laidOut));
4527
4728
  if (this.renderer.backend === "webgpu" && this.renderer.isLost) this.fallBackToCanvas2D(this.renderer);
4528
4729
  const overlay = this.inspector.hasOverlay ? this.inspector.overlay(started) : null;
4529
4730
  this.gpuTimings = null;
@@ -4823,6 +5024,24 @@ function frameNeedsLayout(frame) {
4823
5024
  * a closed dialog leaves the tree.
4824
5025
  */
4825
5026
  /** Shared empties, so a frame that changed nothing allocates nothing. */
5027
+ /**
5028
+ * How many separate subtrees a scoped semantics update will walk
5029
+ * before it gives up and walks the whole tree once.
5030
+ *
5031
+ * A frame that re-meant a handful of nodes is the case this exists
5032
+ * for — a clock, a counter, a row whose label changed. A frame that
5033
+ * re-meant a hundred has almost certainly re-meant a shared ancestor's
5034
+ * worth of them, and the subtrees start to overlap, so one walk of
5035
+ * everything is both cheaper and simpler than many walks of parts.
5036
+ */
5037
+ const SCOPED_SEMANTICS_OWNERS = 32;
5038
+ /**
5039
+ * How far outside the canvas a mirrored box is still worth reporting.
5040
+ *
5041
+ * One screenful of slack, so a fling that covers a lot of ground in
5042
+ * one frame still hands the mirror the rows it is about to show.
5043
+ */
5044
+ const SEMANTICS_VIEWPORT_MARGIN = 400;
4826
5045
  const EMPTY_PATCHES = [];
4827
5046
  const EMPTY_BOXES = [];
4828
5047
  function boxesEqual(a, b) {
@@ -6117,6 +6336,113 @@ function observeReducedMotion(onChange) {
6117
6336
  return observeMediaQuery("(prefers-reduced-motion: reduce)", onChange);
6118
6337
  }
6119
6338
  //#endregion
6339
+ //#region src/app/fullscreen.ts
6340
+ /**
6341
+ * Enters or leaves fullscreen on an element, across the prefixes that
6342
+ * are still out there.
6343
+ *
6344
+ * Deliberately fire-and-forget. `requestFullscreen` returns a promise
6345
+ * that rejects when the browser refuses -- it only grants fullscreen
6346
+ * during a gesture -- and there is nothing useful to do about that
6347
+ * here: whether it worked is reported back by the `fullscreenchange`
6348
+ * event, which is also the only thing that hears about the person
6349
+ * pressing Escape.
6350
+ */
6351
+ function setElementFullscreen(element, enter) {
6352
+ const document = documentOf(element);
6353
+ if (document === null) return;
6354
+ const anyDocument = document;
6355
+ if (enter) {
6356
+ const target = element;
6357
+ try {
6358
+ if (typeof target.requestFullscreen === "function") target.requestFullscreen().catch(() => {});
6359
+ else target.webkitRequestFullscreen?.();
6360
+ } catch {}
6361
+ return;
6362
+ }
6363
+ try {
6364
+ if (typeof document.exitFullscreen === "function") document.exitFullscreen().catch(() => {});
6365
+ else anyDocument.webkitExitFullscreen?.();
6366
+ } catch {}
6367
+ }
6368
+ /**
6369
+ * The document an element belongs to, or null where there is not one.
6370
+ *
6371
+ * A canvas double in a spec has no `ownerDocument`, and neither has an
6372
+ * `OffscreenCanvas`. Falling back to the ambient `document` covers the
6373
+ * first case where a real one exists, and answering null covers the
6374
+ * rest: fullscreen is a thing a page has, and somewhere without a page
6375
+ * should decline rather than throw.
6376
+ */
6377
+ function documentOf(element) {
6378
+ const owner = element.ownerDocument ?? null;
6379
+ if (owner !== null && typeof owner.addEventListener === "function") return owner;
6380
+ const ambient = typeof globalThis.document === "undefined" ? null : globalThis.document;
6381
+ return ambient !== null && typeof ambient.addEventListener === "function" ? ambient : null;
6382
+ }
6383
+ /** Whether anything in the element's document is currently fullscreen. */
6384
+ function isDocumentFullscreen(element) {
6385
+ const document = documentOf(element);
6386
+ if (document === null) return false;
6387
+ return (document.fullscreenElement ?? document.webkitFullscreenElement ?? null) !== null;
6388
+ }
6389
+ /** Calls back whenever the document enters or leaves fullscreen. */
6390
+ function observeFullscreen(element, onChange) {
6391
+ const document = documentOf(element);
6392
+ if (document === null) return () => {};
6393
+ const report = () => onChange(isDocumentFullscreen(element));
6394
+ document.addEventListener("fullscreenchange", report);
6395
+ document.addEventListener("webkitfullscreenchange", report);
6396
+ return () => {
6397
+ document.removeEventListener("fullscreenchange", report);
6398
+ document.removeEventListener("webkitfullscreenchange", report);
6399
+ };
6400
+ }
6401
+ /**
6402
+ * The box an application should lay itself out in, given whether its
6403
+ * canvas is currently filling the screen.
6404
+ *
6405
+ * **A `ResizeObserver` on the host does not see this happen**, and
6406
+ * that is the whole reason this exists. The fullscreen API lifts the
6407
+ * *canvas* out of the page and stretches it to the screen; the host
6408
+ * around it keeps the size it always had, so the observer never fires,
6409
+ * the runtime keeps laying out at the old size, and the browser scales
6410
+ * the result up. The picture looks right, and every coordinate is
6411
+ * wrong by the ratio between the two: a press near the bottom of a
6412
+ * fullscreen clip lands somewhere near the middle of the layout.
6413
+ *
6414
+ * So the size is read from the canvas while it is fullscreen and from
6415
+ * the host when it is not. On the way *out* the canvas has not been
6416
+ * put back yet when the event fires, which is why the host is the
6417
+ * right answer there rather than simply always asking the canvas.
6418
+ */
6419
+ function surfaceBox(canvas, host, active) {
6420
+ const box = (active ? canvas : host).getBoundingClientRect();
6421
+ return box.width > 0 && box.height > 0 ? {
6422
+ width: box.width,
6423
+ height: box.height
6424
+ } : null;
6425
+ }
6426
+ /**
6427
+ * Runs a callback once the browser has actually applied the new
6428
+ * geometry.
6429
+ *
6430
+ * `fullscreenchange` fires *before* the new size is in the layout, so
6431
+ * reading a box from inside it gives the size the element had a moment
6432
+ * ago. Measured here: entering fullscreen reported the canvas as its
6433
+ * old preview size, the surface stayed that size, and the browser
6434
+ * stretched it to the screen, which is precisely the thing
6435
+ * `surfaceBox` exists to prevent. Two frames, because the first is the
6436
+ * one the change lands on.
6437
+ */
6438
+ function afterLayout(run) {
6439
+ if (typeof globalThis.requestAnimationFrame !== "function") {
6440
+ run();
6441
+ return;
6442
+ }
6443
+ globalThis.requestAnimationFrame(() => globalThis.requestAnimationFrame(run));
6444
+ }
6445
+ //#endregion
6120
6446
  //#region src/app/shellHistory.ts
6121
6447
  function createShellHistory(options = {}, host = typeof window === "undefined" ? void 0 : window) {
6122
6448
  const mode = options.mode ?? (host === void 0 ? "memory" : "path");
@@ -6367,6 +6693,8 @@ var WorkerApp = class {
6367
6693
  * what the cache costs.
6368
6694
  */
6369
6695
  canvasOrigin = null;
6696
+ /** Whether the canvas is filling the screen; see the resize observer. */
6697
+ fullscreen = false;
6370
6698
  /**
6371
6699
  * The hover move being held for this frame, and the frame holding
6372
6700
  * it. See `flushPendingMove`.
@@ -6758,6 +7086,10 @@ var WorkerApp = class {
6758
7086
  openUrlWith(this.options.onOpenUrl, message.url);
6759
7087
  return;
6760
7088
  }
7089
+ if (message.type === "fullscreen") {
7090
+ if (this.canvas !== void 0) setElementFullscreen(this.canvas, message.enter);
7091
+ return;
7092
+ }
6761
7093
  if (message.type === "popup") {
6762
7094
  this.openPopup(message);
6763
7095
  return;
@@ -6847,6 +7179,7 @@ var WorkerApp = class {
6847
7179
  const entry = entries[0];
6848
7180
  if (entry === void 0) return;
6849
7181
  this.canvasOrigin = null;
7182
+ if (this.fullscreen) return;
6850
7183
  const { width, height } = entry.contentRect;
6851
7184
  if (width > 0 && height > 0) this.requestResize({
6852
7185
  width,
@@ -7050,6 +7383,26 @@ var WorkerApp = class {
7050
7383
  visible: document.visibilityState !== "hidden"
7051
7384
  });
7052
7385
  };
7386
+ const detachFullscreen = observeFullscreen(canvas, (active) => {
7387
+ this.canvasOrigin = null;
7388
+ this.fullscreen = active;
7389
+ this.post({
7390
+ type: "fullscreenChanged",
7391
+ active
7392
+ });
7393
+ afterLayout(() => {
7394
+ this.canvasOrigin = null;
7395
+ const box = this.host === void 0 ? null : surfaceBox(canvas, this.host, active);
7396
+ if (box !== null) this.requestResize({
7397
+ ...box,
7398
+ dpr: window.devicePixelRatio || 1
7399
+ });
7400
+ });
7401
+ });
7402
+ this.post({
7403
+ type: "fullscreenChanged",
7404
+ active: isDocumentFullscreen(canvas)
7405
+ });
7053
7406
  onVisibilityChange();
7054
7407
  const detachReducedMotion = observeReducedMotion((reduced) => {
7055
7408
  this.post({
@@ -7147,6 +7500,7 @@ var WorkerApp = class {
7147
7500
  window.addEventListener("resize", onCanvasMayHaveMoved);
7148
7501
  return () => {
7149
7502
  detachReducedMotion();
7503
+ detachFullscreen();
7150
7504
  canvas.removeEventListener("mousedown", onMouseDown);
7151
7505
  document.removeEventListener("visibilitychange", onVisibilityChange);
7152
7506
  canvas.removeEventListener("pointerdown", onPointerDown);
@@ -7258,6 +7612,8 @@ var GessoApp = class {
7258
7612
  mirror = null;
7259
7613
  history = null;
7260
7614
  detachVisibility = null;
7615
+ detachFullscreen = null;
7616
+ fullscreen = false;
7261
7617
  detachReducedMotion = null;
7262
7618
  /** Stops watching `prefers-color-scheme`; null while overridden. */
7263
7619
  detachColorScheme = null;
@@ -7421,6 +7777,8 @@ var GessoApp = class {
7421
7777
  this.mirror = null;
7422
7778
  this.detachVisibility?.();
7423
7779
  this.detachVisibility = null;
7780
+ this.detachFullscreen?.();
7781
+ this.detachFullscreen = null;
7424
7782
  this.detachReducedMotion?.();
7425
7783
  this.detachReducedMotion = null;
7426
7784
  this.detachColorScheme?.();
@@ -7462,6 +7820,15 @@ var GessoApp = class {
7462
7820
  const onVisibility = () => this.runtime.setVisible(document.visibilityState !== "hidden");
7463
7821
  document.addEventListener("visibilitychange", onVisibility);
7464
7822
  this.detachVisibility = () => document.removeEventListener("visibilitychange", onVisibility);
7823
+ this.detachFullscreen = observeFullscreen(canvas, (active) => {
7824
+ this.fullscreen = active;
7825
+ this.runtime.setFullscreen(active);
7826
+ afterLayout(() => {
7827
+ const box = surfaceBox(canvas, this.host, active);
7828
+ if (box !== null) this.resize(box.width, box.height);
7829
+ });
7830
+ });
7831
+ this.runtime.setFullscreen(isDocumentFullscreen(canvas));
7465
7832
  }
7466
7833
  this.detachReducedMotion = observeReducedMotion((reduced) => this.runtime.setReducedMotion(reduced));
7467
7834
  this.setColorScheme(this.colorSchemePreference);
@@ -7526,6 +7893,10 @@ var GessoApp = class {
7526
7893
  writeClipboard(request.text, this.canvas.ownerDocument);
7527
7894
  return;
7528
7895
  }
7896
+ if (request.type === "fullscreen") {
7897
+ setElementFullscreen(this.canvas, request.enter);
7898
+ return;
7899
+ }
7529
7900
  const view = this.canvas.ownerDocument.defaultView;
7530
7901
  if (request.type === "storage") {
7531
7902
  this.runtime.settleStorage(request.id, performShellStorage(request, () => view?.localStorage));
@@ -7549,6 +7920,7 @@ var GessoApp = class {
7549
7920
  this.resizeObserver = new ResizeObserver((entries) => {
7550
7921
  const entry = entries[0];
7551
7922
  if (entry === void 0) return;
7923
+ if (this.fullscreen) return;
7552
7924
  this.resize(entry.contentRect.width, entry.contentRect.height);
7553
7925
  });
7554
7926
  this.resizeObserver.observe(this.host);
@@ -7937,6 +8309,89 @@ var ShellStorage = class {
7937
8309
  }
7938
8310
  };
7939
8311
  //#endregion
8312
+ //#region src/app/videoClock.ts
8313
+ /**
8314
+ * A clip's position, taken from the sound playing beside it.
8315
+ *
8316
+ * **This is the clock inversion, and it is worth being explicit about
8317
+ * why it exists.** Everywhere else in this framework the animation
8318
+ * driver owns time: a video is a pure function of a position and the
8319
+ * position comes from a tween, which is what lets a clip be scheduled,
8320
+ * paced and stopped by exactly the machinery every other animated
8321
+ * thing uses. That arrangement is correct right up until the clip has
8322
+ * sound, and then it is exactly backwards.
8323
+ *
8324
+ * The asymmetry is in what the two media can survive. Video drops
8325
+ * frames: an application that stalled for 50ms shows the picture it
8326
+ * should be showing now and throws away the ones it missed, and nobody
8327
+ * can tell. Audio cannot do either — a gap is audible, and resampling
8328
+ * to catch up changes the pitch — so the sound plays at its own rate
8329
+ * whatever else is happening, and anything that must agree with it has
8330
+ * to follow. Which means the picture follows the sound, and a tween
8331
+ * driving the picture independently would drift against it within
8332
+ * seconds. There is no arrangement in which both lead.
8333
+ *
8334
+ * **And the sound cannot be played here.** `AudioContext` does not
8335
+ * exist on a worker, which is where this framework decodes and draws.
8336
+ * So the sound is the shell's to play — `AudioService` already does
8337
+ * exactly that, reporting where it has got to about once a second and
8338
+ * extrapolating in between on the animation driver — and what crosses
8339
+ * the thread boundary is one number. That is the whole of this file:
8340
+ * `AudioService` is already the right shape, and this is the adapter
8341
+ * that lets `videoSource` read it.
8342
+ *
8343
+ * ```ts
8344
+ * // The same file, played twice: the shell for its sound, the worker
8345
+ * // for its pictures. A `<video>` element does this internally and
8346
+ * // calls it one thing.
8347
+ * audio.load('clip.mp4', { autoplay: false });
8348
+ * Video({ src: 'clip.mp4', clock: audioClock(audio, 'clip.mp4') });
8349
+ * ```
8350
+ *
8351
+ * `source` is checked on every read, and that check is not
8352
+ * decoration: `AudioService` is one playback for the whole
8353
+ * application, so a screen that starts a podcast while this clip is
8354
+ * still mounted would otherwise drive the picture from the podcast's
8355
+ * position. A clock whose source is no longer loaded reports that it
8356
+ * is not running and holds the position it last had, which shows a
8357
+ * still frame rather than a clip that has jumped somewhere absurd.
8358
+ */
8359
+ function audioClock(audio, source) {
8360
+ let held = 0;
8361
+ let listeners = /* @__PURE__ */ new Set();
8362
+ let wasRunning = false;
8363
+ let subscription = null;
8364
+ const mine = () => audio.state.value.src === source;
8365
+ const running = () => mine() && audio.state.value.status === "playing";
8366
+ const announce = () => {
8367
+ const now = running();
8368
+ if (now === wasRunning) return;
8369
+ wasRunning = now;
8370
+ for (const listener of listeners) listener();
8371
+ };
8372
+ return {
8373
+ positionSeconds() {
8374
+ if (!mine()) return held;
8375
+ held = audio.state.value.position;
8376
+ return held;
8377
+ },
8378
+ get running() {
8379
+ return running();
8380
+ },
8381
+ onChange(listener) {
8382
+ listeners.add(listener);
8383
+ subscription ??= audio.state.subscribe(() => announce());
8384
+ return () => {
8385
+ listeners.delete(listener);
8386
+ if (listeners.size > 0) return;
8387
+ subscription?.unsubscribe();
8388
+ subscription = null;
8389
+ listeners = /* @__PURE__ */ new Set();
8390
+ };
8391
+ }
8392
+ };
8393
+ }
8394
+ //#endregion
7940
8395
  //#region src/Presence.ts
7941
8396
  const DEFAULT_EXIT_TIMEOUT_MS = 2e3;
7942
8397
  function Presence(inputs, ctx) {
@@ -8518,6 +8973,9 @@ var RenderWorkerApp = class {
8518
8973
  case "tick":
8519
8974
  this.clock?.tick(message.time);
8520
8975
  break;
8976
+ case "fullscreenChanged":
8977
+ runtime.setFullscreen(message.active);
8978
+ return;
8521
8979
  case "visibility":
8522
8980
  runtime.setVisible(message.visible);
8523
8981
  break;
@@ -8652,6 +9110,10 @@ var RenderWorkerApp = class {
8652
9110
  type: "openUrl",
8653
9111
  url: request.url
8654
9112
  });
9113
+ else if (request.type === "fullscreen") this.host.postMessage({
9114
+ type: "fullscreen",
9115
+ enter: request.enter
9116
+ });
8655
9117
  else if (request.type === "popup") this.host.postMessage({
8656
9118
  type: "popup",
8657
9119
  id: request.id,
@@ -8715,6 +9177,6 @@ function locationOf(event) {
8715
9177
  return `${event.filename}:${line}:${column}`;
8716
9178
  }
8717
9179
  //#endregion
8718
- export { APPLICATION_WORKER, AnimationService, AudioService, AudioSink, BoundsCell, Channel, ChannelRegistry, ChannelReplica, Component, ComponentHost, ComponentHostResolver, ComputedCell, Define, Each, EditingProxy, FindService, FocusService, FontService, FrameService, GessoApp, GessoAppBuilder, GessoRuntime, IndexedDbStorage, Inject, Input, InputCell, InternalState, MARK_PREFIX, MediaService, MemoryStorage, OpfsStorage, Output, OverlayLayer, OverlayService, PersistedState, Presence, ProvidedChannel, RenderWorkerApp, Resource, RouteState, RouterOutlet, RouterService, SemanticsMirror, ServiceRegistry, ShellService, ShellStorage, Show, ThemeTokenCell, UI_FRAME_PHASES, UI_ROLES, UI_SEMANTIC_STATES, UndoStack, WorkerApp, applyPatch, applyPatches, bind, bounds, buildPath, channel, classifyStorageError, computed, controlled, createApp, createChannelRegistry, createComponent, createShellHistory, debounced, defineChannel, derive, describeStream, diffProjection, each, findUnplainPath, formatAge, formatNodePath, formatNodeReport, formatStream, formatUrl, input, internalState, into, isChannelClientMessage, isChannelHostMessage, isClassComponent, isComponentElement, isHubMessage, isOutputTarget, isPortErrorMessage, isPortHandshake, markInstant, markNow, measureSpan, mutate, observeColorScheme, observeMediaQuery, observeReducedMotion, output, parseUrl, performShellStorage, performanceMarksEnabled, persisted, pick, pickKeys, portHandle, printPropValue, provide, registerUndoShortcuts, renderRoot, requirePlainData, resource, route, select, serve, serveChannels, servePorts, setPerformanceMarks, shellStorageDenied, show, storageErrorMessage, storageReadFailure, storageReadValue, structurallyEqual, themeTokenCell, throttled, to, treeText, undoable, viewKeys, workerHandle, writeClipboard };
9180
+ export { APPLICATION_WORKER, AnimationService, AudioService, AudioSink, BoundsCell, Channel, ChannelRegistry, ChannelReplica, Component, ComponentHost, ComponentHostResolver, ComputedCell, Define, Each, EditingProxy, FindService, FocusService, FontService, FrameService, GessoApp, GessoAppBuilder, GessoRuntime, IndexedDbStorage, Inject, Input, InputCell, InternalState, MARK_PREFIX, MediaService, MemoryStorage, OpfsStorage, Output, OverlayLayer, OverlayService, PersistedState, Presence, ProvidedChannel, RenderWorkerApp, Resource, RouteState, RouterOutlet, RouterService, SemanticsMirror, ServiceRegistry, ShellService, ShellStorage, Show, ThemeTokenCell, UI_FRAME_PHASES, UI_ROLES, UI_SEMANTIC_STATES, UndoStack, WorkerApp, applyPatch, applyPatches, audioClock, bind, bounds, buildPath, channel, classifyStorageError, computed, controlled, createApp, createChannelRegistry, createComponent, createShellHistory, debounced, defineChannel, derive, describeStream, diffProjection, each, findUnplainPath, formatAge, formatNodePath, formatNodeReport, formatStream, formatUrl, input, internalState, into, isChannelClientMessage, isChannelHostMessage, isClassComponent, isComponentElement, isHubMessage, isOutputTarget, isPortErrorMessage, isPortHandshake, markInstant, markNow, measureSpan, mutate, observeColorScheme, observeMediaQuery, observeReducedMotion, output, parseUrl, performShellStorage, performanceMarksEnabled, persisted, pick, pickKeys, portHandle, printPropValue, provide, registerUndoShortcuts, renderRoot, requirePlainData, resource, route, select, serve, serveChannels, servePorts, setPerformanceMarks, shellStorageDenied, show, storageErrorMessage, storageReadFailure, storageReadValue, structurallyEqual, themeTokenCell, throttled, to, treeText, undoable, viewKeys, workerHandle, writeClipboard };
8719
9181
 
8720
9182
  //# sourceMappingURL=index.js.map