presentation/widgets/surface.widget.ts
presentation/widgets/surface.widget.ts is a file in Bane's Lab Site. 198 lines of code and 41 definitions.
import {
COMMAND_CHAR_MS,
ENTRY_CHAR_MS,
FRAME_HOLD_MS,
LOOP_HOLD_MS,
OPENING_HOLD_MS,
OUTPUT_LINE_MS,
REWIND_LINE_MS,
TYPED_HOLD_MS,
} from "#configuration/constants/surface.constants";
import type { LineEdit, Segment, SurfaceRecording, SurfaceScript } from "#types/surface.types";
import { VenuePane, createPlaybackButton } from "#presentation/components/surface.component";
import { indentOf, lineEdit, outputLines, outputRest } from "#core/converters/surface.converter";
import type { Disposer } from "#types/base.types";
import type { PlayerParts } from "#types/figure.types";
import { REDUCED_MOTION_QUERY } from "#configuration/constants/diagram.constants";
import { ShellPane } from "#presentation/components/shell.component";
import { SurfaceTimer } from "#presentation/timers/surface.timer";
const prefersStill = function prefersStill(): boolean {
return typeof globalThis.matchMedia === "function" && globalThis.matchMedia(REDUCED_MOTION_QUERY).matches;
};
const typingSegment = function typingSegment(
length: number,
charMs: number,
begin: () => void,
render: (count: number, done: boolean) => void,
): Segment {
return {
advance: (progress) => {
render(Math.ceil(progress * length), progress >= 1);
},
begin,
ms: Math.max(length, 1) * charMs,
};
};
const commandSegments = function commandSegments(command: string, shell: ShellPane): Segment[] {
const typing = typingSegment(
command.length,
COMMAND_CHAR_MS,
() => {
shell.prompt();
},
(count, done) => {
shell.type(command.slice(0, count));
if (done) {
shell.typed();
}
},
);
return [typing, { ms: TYPED_HOLD_MS }];
};
const editSegments = function editSegments(edit: LineEdit, venue: VenuePane): Segment[] {
const removals = Array.from({ length: edit.removed }, (_, step): Segment => ({
begin: () => {
venue.remove(edit.at + edit.removed - 1 - step);
},
ms: REWIND_LINE_MS,
}));
const insertions = edit.inserted.map((line, offset) => {
const at = edit.at + offset;
const indent = indentOf(line.text);
return typingSegment(
line.text.length - indent,
ENTRY_CHAR_MS,
() => {
venue.insert(at, line);
},
(count, done) => {
venue.type(at, line.text.slice(0, indent + count), done);
},
);
});
return [...removals, ...insertions];
};
const scriptOf = function scriptOf(recording: SurfaceRecording, shell: ShellPane, venue: VenuePane): SurfaceScript {
const { frames, opening } = recording;
const outputs = new Map<string, string>();
const first = frames.at(0);
const openingFocus = first === undefined ? 0 : lineEdit(opening, first.lines).at;
let lines = opening;
const body = frames.flatMap((frame): Segment[] => {
const rest = outputRest(outputs.get(frame.command), frame.output);
const edit = lineEdit(lines, frame.lines);
const firstChanged = frame.lines.findIndex((line) => line.changed);
outputs.set(frame.command, frame.output);
lines = frame.lines.map((line) => line.text);
const lead: Segment[] =
rest === null
? commandSegments(frame.command, shell)
: [
{
begin: () => {
shell.resume(frame.command);
},
ms: TYPED_HOLD_MS,
},
];
const output = outputLines(rest ?? frame.output).map((line): Segment => ({
begin: () => {
shell.append(line);
},
ms: OUTPUT_LINE_MS,
}));
const hold: Segment = {
begin: () => {
venue.mark(frame.lines);
if (firstChanged !== -1 && edit.inserted.length === 0) {
venue.focus(firstChanged);
}
},
ms: FRAME_HOLD_MS,
};
return [
{
begin: () => {
venue.mark([]);
},
ms: 0,
},
...lead,
...editSegments(edit, venue),
...output,
hold,
];
});
const start: Segment = {
begin: () => {
shell.clear();
venue.show(opening);
venue.focus(openingFocus);
},
ms: OPENING_HOLD_MS,
};
const rewind: Segment = {
begin: () => {
shell.clear();
venue.mark([]);
},
ms: 0,
};
const back = lineEdit(
lines,
opening.map((text) => ({ changed: false, text })),
);
return {
segments: [start, ...body, { ms: LOOP_HOLD_MS }, rewind, ...editSegments(back, venue)],
settled: body.length + 1,
};
};
export class SurfacePlayer {
private readonly parts: PlayerParts;
private readonly timer: SurfaceTimer;
private readonly settled: number;
private readonly still = prefersStill();
private paused: boolean;
private visible = false;
public constructor(parts: PlayerParts, recording: SurfaceRecording) {
const shell = new ShellPane();
const venue = new VenuePane(recording.frames.at(0)?.surface ?? "");
parts.stage.replaceChildren(shell.element, venue.element);
const script = scriptOf(recording, shell, venue);
this.parts = parts;
this.timer = new SurfaceTimer(script.segments);
this.settled = script.settled;
this.paused = this.still;
}
public start(): Disposer {
this.timer.settle(this.still ? this.settled : 1);
const observer = new IntersectionObserver((entries) => {
this.visible = entries.some((entry) => entry.isIntersecting);
this.sync();
});
observer.observe(this.parts.figure);
this.renderControls();
return () => {
observer.disconnect();
this.timer.halt();
};
}
private sync(): void {
if (!this.paused && this.visible) {
this.timer.run();
} else {
this.timer.halt();
}
}
private toggle(): void {
this.paused = !this.paused;
this.renderControls();
this.sync();
}
private renderControls(): void {
const button = createPlaybackButton(this.paused, () => {
this.toggle();
});
this.parts.controls.replaceChildren(button);
}
}