@hajimism/dek 0.2.1 → 0.3.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/CHANGELOG.md +46 -0
- package/README.ja.md +3 -1
- package/README.md +3 -1
- package/package.json +4 -1
- package/skills/dek/SKILL.md +44 -0
- package/src/cli/annotations.ts +26 -0
- package/src/cli/commands.ts +66 -0
- package/src/cli/contract.ts +75 -4
- package/src/cli/env-file.ts +78 -0
- package/src/cli/files.ts +3 -3
- package/src/cli/flags.ts +1 -1
- package/src/cli/format.ts +3 -0
- package/src/cli/pptx.ts +20 -0
- package/src/cli/show.ts +17 -2
- package/src/cli/text.ts +43 -0
- package/src/cli/theme.ts +7 -1
- package/src/cli.ts +5 -1
- package/src/core/annotation-rows.ts +176 -0
- package/src/core/annotations.ts +362 -0
- package/src/core/diagnostic.ts +1 -0
- package/src/core/github.ts +27 -24
- package/src/core/html-scan.ts +54 -0
- package/src/core/html.ts +53 -8
- package/src/core/lint/slide-html.ts +92 -2
- package/src/core/lint.ts +8 -1
- package/src/core/live-protocol.ts +4 -1
- package/src/core/page.ts +6 -0
- package/src/core/playwright-module.d.ts +7 -3
- package/src/core/playwright-visual.ts +76 -1
- package/src/core/playwright.ts +65 -2
- package/src/core/pptx-package.ts +312 -0
- package/src/core/pptx-text.ts +363 -0
- package/src/core/pptx.ts +75 -0
- package/src/core/slide-measure.ts +1 -0
- package/src/core/slide-script-eval.ts +2 -1
- package/src/core/spawn.ts +8 -1
- package/src/core/sync.ts +33 -6
- package/src/core/theme-excerpt.ts +61 -9
- package/src/core/theme-facts.ts +12 -2
- package/src/core/zip.ts +77 -0
- package/src/runtime/annotate-pick.ts +2 -6
- package/src/runtime/annotate-view.ts +115 -101
- package/src/runtime/annotate.ts +5 -1
- package/src/runtime/browser.ts +5 -1
- package/src/runtime/live.ts +1 -0
- package/src/runtime/routes.ts +3 -0
- package/src/server/auth.ts +3 -1
- package/src/server/routes.ts +68 -1
- package/src/video/worker.ts +2 -0
- package/tsconfig.json +26 -0
- package/src/runtime/annotate-notes.ts +0 -207
package/src/runtime/browser.ts
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/// <reference lib="dom" />
|
|
2
2
|
|
|
3
3
|
import { isLiveEvent } from "../core/live-protocol.ts";
|
|
4
|
-
import { PAGE_ID, readPageConfig } from "../core/page.ts";
|
|
4
|
+
import { ANNOTATIONS_CHANGED, PAGE_ID, readPageConfig } from "../core/page.ts";
|
|
5
5
|
import { deckStops, moveTarget, positionsEqual } from "../core/position.ts";
|
|
6
6
|
import type { PresenterSlide } from "../core/presenter-state.ts";
|
|
7
7
|
import type { Position } from "../core/step.ts";
|
|
@@ -185,6 +185,10 @@ if (dataEl?.textContent) {
|
|
|
185
185
|
}
|
|
186
186
|
return;
|
|
187
187
|
}
|
|
188
|
+
if (raw.type === "annotations") {
|
|
189
|
+
document.dispatchEvent(new Event(ANNOTATIONS_CHANGED));
|
|
190
|
+
return;
|
|
191
|
+
}
|
|
188
192
|
const event = await hydrateLiveEvent(raw, location.pathname);
|
|
189
193
|
if (!event) {
|
|
190
194
|
location.reload();
|
package/src/runtime/live.ts
CHANGED
package/src/runtime/routes.ts
CHANGED
|
@@ -20,6 +20,8 @@ export type DeckRoute =
|
|
|
20
20
|
| { kind: "goto" }
|
|
21
21
|
/** The beats the presenter marked to rewrite: listed, and one marked or unmarked. */
|
|
22
22
|
| { kind: "marks" }
|
|
23
|
+
/** The notes annotate mode keeps on elements of the slides: listed, and one added or changed. */
|
|
24
|
+
| { kind: "annotations" }
|
|
23
25
|
/** A file under the deck's `assets/`, decoded. */
|
|
24
26
|
| { kind: "asset"; path: string };
|
|
25
27
|
|
|
@@ -34,6 +36,7 @@ const FIXED: Record<Fixed, string> = {
|
|
|
34
36
|
current: "/current",
|
|
35
37
|
goto: "/goto",
|
|
36
38
|
marks: "/marks",
|
|
39
|
+
annotations: "/annotations",
|
|
37
40
|
};
|
|
38
41
|
|
|
39
42
|
/** The path of `route` below its deck's prefix. */
|
package/src/server/auth.ts
CHANGED
|
@@ -86,7 +86,7 @@ export type Exposure = "audience" | "presenter";
|
|
|
86
86
|
|
|
87
87
|
/**
|
|
88
88
|
* What each dev server route reveals. The script, its notes, the voice read from it, moving the
|
|
89
|
-
* deck,
|
|
89
|
+
* deck, the marks left on its beats, and the annotations on its slides belong to the presenter. The server routes only by these names, so a new route does
|
|
90
90
|
* not compile until it is placed here.
|
|
91
91
|
*/
|
|
92
92
|
export const ROUTE_EXPOSURE = {
|
|
@@ -101,6 +101,7 @@ export const ROUTE_EXPOSURE = {
|
|
|
101
101
|
voice: "presenter",
|
|
102
102
|
control: "presenter",
|
|
103
103
|
marks: "presenter",
|
|
104
|
+
annotations: "presenter",
|
|
104
105
|
missing: "audience",
|
|
105
106
|
} as const satisfies Record<string, Exposure>;
|
|
106
107
|
|
|
@@ -117,6 +118,7 @@ export const EVENT_EXPOSURE: Record<LiveEventType, Exposure> = {
|
|
|
117
118
|
"reload-script": "audience",
|
|
118
119
|
diagnostics: "presenter",
|
|
119
120
|
timeline: "presenter",
|
|
121
|
+
annotations: "presenter",
|
|
120
122
|
};
|
|
121
123
|
|
|
122
124
|
/** True when a request cleared for `seen` may read what `exposure` names. */
|
package/src/server/routes.ts
CHANGED
|
@@ -1,5 +1,7 @@
|
|
|
1
1
|
import { existsSync } from "node:fs";
|
|
2
|
-
import { join } from "node:path";
|
|
2
|
+
import { join, relative } from "node:path";
|
|
3
|
+
import type { AnnotationRow } from "../core/annotation-rows.ts";
|
|
4
|
+
import { AnnotationOp, applyAnnotationOp, listAnnotations } from "../core/annotations.ts";
|
|
3
5
|
import { fileInside, readTheme } from "../core/assets.ts";
|
|
4
6
|
import { DekError, errorFields } from "../core/error.ts";
|
|
5
7
|
import { escapeAttr, escapeHtml } from "../core/escape.ts";
|
|
@@ -116,6 +118,16 @@ export function routeRequest(req: Request, ctx: RouteContext): Routed {
|
|
|
116
118
|
respond: () =>
|
|
117
119
|
deck ? marksResponse(req, ctx.project().root, deck) : jsonNotFound(deckName),
|
|
118
120
|
};
|
|
121
|
+
case "annotations":
|
|
122
|
+
return {
|
|
123
|
+
route: "annotations",
|
|
124
|
+
respond: () =>
|
|
125
|
+
deck
|
|
126
|
+
? annotationsResponse(req, ctx.project().root, deck, () =>
|
|
127
|
+
ctx.hub.emit({ type: "annotations", deck: deckName }),
|
|
128
|
+
)
|
|
129
|
+
: jsonNotFound(deckName),
|
|
130
|
+
};
|
|
119
131
|
case "asset": {
|
|
120
132
|
const asset = dir && safeDeckAsset(dir, route.path);
|
|
121
133
|
return asset
|
|
@@ -217,6 +229,61 @@ async function marksResponse(req: Request, root: string, deck: ProjectDeck): Pro
|
|
|
217
229
|
}
|
|
218
230
|
}
|
|
219
231
|
|
|
232
|
+
/**
|
|
233
|
+
* The deck's annotations: GET lists them as the files have them now, and a POST of an operation
|
|
234
|
+
* adds, edits, removes, clears, or puts back. Every change is told to the deck's other pages,
|
|
235
|
+
* so tabs and devices annotating one deck show the same notes. One that names nothing on the
|
|
236
|
+
* slide as it is now, sent from a page an edit left behind, is refused.
|
|
237
|
+
*/
|
|
238
|
+
async function annotationsResponse(
|
|
239
|
+
req: Request,
|
|
240
|
+
root: string,
|
|
241
|
+
deck: ProjectDeck,
|
|
242
|
+
changed: () => void,
|
|
243
|
+
): Promise<Response> {
|
|
244
|
+
const rows = (annotations: AnnotationRow[]) =>
|
|
245
|
+
annotations.map((row) => ({
|
|
246
|
+
...row,
|
|
247
|
+
targets: row.targets.map((target) => ({ ...target, path: relative(root, target.path) })),
|
|
248
|
+
changed: row.changed.map((path) => relative(root, path)),
|
|
249
|
+
}));
|
|
250
|
+
try {
|
|
251
|
+
if (req.method === "GET") {
|
|
252
|
+
return jsonResponse({ ok: true, annotations: rows(listAnnotations(root, deck)) });
|
|
253
|
+
}
|
|
254
|
+
if (req.method !== "POST") {
|
|
255
|
+
return new Response("Method not allowed", { status: 405 });
|
|
256
|
+
}
|
|
257
|
+
let body: unknown;
|
|
258
|
+
try {
|
|
259
|
+
body = await req.json();
|
|
260
|
+
} catch {
|
|
261
|
+
body = undefined;
|
|
262
|
+
}
|
|
263
|
+
const op = AnnotationOp.safeParse(body);
|
|
264
|
+
if (!op.success) {
|
|
265
|
+
return jsonResponse(
|
|
266
|
+
{
|
|
267
|
+
ok: false,
|
|
268
|
+
error: {
|
|
269
|
+
message:
|
|
270
|
+
'the request names no operation: send {"op": "add" | "edit" | "remove" | "clear" | "restore", …}',
|
|
271
|
+
},
|
|
272
|
+
},
|
|
273
|
+
400,
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
const done = applyAnnotationOp(root, deck, op.data);
|
|
277
|
+
changed();
|
|
278
|
+
return jsonResponse({ ok: true, ...done, annotations: rows(done.annotations) });
|
|
279
|
+
} catch (error) {
|
|
280
|
+
if (!(error instanceof DekError)) {
|
|
281
|
+
throw error;
|
|
282
|
+
}
|
|
283
|
+
return jsonResponse({ ok: false, error: errorFields(error) }, 409);
|
|
284
|
+
}
|
|
285
|
+
}
|
|
286
|
+
|
|
220
287
|
function jsonNotFound(deckName: string): Response {
|
|
221
288
|
return jsonResponse({ ok: false, error: { message: `deck "${deckName}" not found` } }, 404);
|
|
222
289
|
}
|
package/src/video/worker.ts
CHANGED
|
@@ -21,8 +21,10 @@ async function capture(
|
|
|
21
21
|
request: VideoCaptureRequest,
|
|
22
22
|
): Promise<VideoCaptureResponse> {
|
|
23
23
|
mkdirSync(request.outDir, { recursive: true });
|
|
24
|
+
// A deck is self-contained (DEK020), so the page needs no network.
|
|
24
25
|
const page = await browser.newPage({
|
|
25
26
|
viewport: { width: request.viewport.width, height: request.viewport.height },
|
|
27
|
+
offline: true,
|
|
26
28
|
});
|
|
27
29
|
await loadVideoDoc(page, request.html);
|
|
28
30
|
|
package/tsconfig.json
ADDED
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
{
|
|
2
|
+
"compilerOptions": {
|
|
3
|
+
"lib": ["ESNext"],
|
|
4
|
+
"target": "ESNext",
|
|
5
|
+
"module": "Preserve",
|
|
6
|
+
"moduleDetection": "force",
|
|
7
|
+
"allowJs": true,
|
|
8
|
+
"types": ["bun"],
|
|
9
|
+
|
|
10
|
+
"moduleResolution": "bundler",
|
|
11
|
+
"allowImportingTsExtensions": true,
|
|
12
|
+
"verbatimModuleSyntax": true,
|
|
13
|
+
"noEmit": true,
|
|
14
|
+
|
|
15
|
+
"strict": true,
|
|
16
|
+
"skipLibCheck": true,
|
|
17
|
+
"noFallthroughCasesInSwitch": true,
|
|
18
|
+
"noUncheckedIndexedAccess": true,
|
|
19
|
+
"noImplicitOverride": true,
|
|
20
|
+
|
|
21
|
+
"noUnusedLocals": false,
|
|
22
|
+
"noUnusedParameters": false,
|
|
23
|
+
"noPropertyAccessFromIndexSignature": false
|
|
24
|
+
},
|
|
25
|
+
"include": ["src", "tests"]
|
|
26
|
+
}
|
|
@@ -1,207 +0,0 @@
|
|
|
1
|
-
// What annotate mode keeps and hands over: notes on elements of a slide, each a snapshot of what
|
|
2
|
-
// the page showed when it was written, and the Markdown an agent is given to act on them. No DOM
|
|
3
|
-
// here, so all of it is decided by these functions alone.
|
|
4
|
-
|
|
5
|
-
import { type BeatRef, resolveStop } from "../core/step.ts";
|
|
6
|
-
|
|
7
|
-
/** A box on the slide, in logical pixels. */
|
|
8
|
-
export type Box = { x: number; y: number; width: number; height: number };
|
|
9
|
-
|
|
10
|
-
/** One element a note is about, as the page showed it when the note was written. */
|
|
11
|
-
export type NoteTarget = {
|
|
12
|
-
/** `<line>:<column>` of its start tag in `slides/<slug>.html`. */
|
|
13
|
-
source: string;
|
|
14
|
-
/** Its tag and its own classes, as `div.chevron`. */
|
|
15
|
-
name: string;
|
|
16
|
-
/** Its text, whitespace collapsed and cut short; empty for a shape. */
|
|
17
|
-
text: string;
|
|
18
|
-
box: Box;
|
|
19
|
-
/** Where the slide itself was clicked, when the note is about a place rather than an element. */
|
|
20
|
-
point?: { x: number; y: number };
|
|
21
|
-
};
|
|
22
|
-
|
|
23
|
-
/** One note, on one slide at one beat. */
|
|
24
|
-
export type Note = {
|
|
25
|
-
slug: string;
|
|
26
|
-
/** The stop as `dekc shot --step` takes it: `0` as the slide arrives, else the beat. */
|
|
27
|
-
step: string;
|
|
28
|
-
targets: NoteTarget[];
|
|
29
|
-
/** What the human wrote; it may be empty, when the words go in the chat. */
|
|
30
|
-
text: string;
|
|
31
|
-
/** Written before an edit that took its element away, so its line is as it was then. */
|
|
32
|
-
stale?: true;
|
|
33
|
-
};
|
|
34
|
-
|
|
35
|
-
/** A slide as the page lists it. */
|
|
36
|
-
export type SlideInfo = { slug: string; title: string; beats: BeatRef[] };
|
|
37
|
-
|
|
38
|
-
/**
|
|
39
|
-
* The notes in the order they are handed over: by slide and beat in talk order, then as they
|
|
40
|
-
* were written. Markers on the page are numbered in this order too.
|
|
41
|
-
*/
|
|
42
|
-
export function sortNotes<T extends Note>(slides: readonly SlideInfo[], notes: readonly T[]): T[] {
|
|
43
|
-
const order = (slug: string): number => slides.findIndex((slide) => slide.slug === slug);
|
|
44
|
-
const stop = (note: Note): number =>
|
|
45
|
-
resolveStop(slides[order(note.slug)]?.beats ?? [], note.step) ?? Number.MAX_SAFE_INTEGER;
|
|
46
|
-
return notes
|
|
47
|
-
.map((note, index) => ({ note, index }))
|
|
48
|
-
.sort(
|
|
49
|
-
(a, b) =>
|
|
50
|
-
order(a.note.slug) - order(b.note.slug) || stop(a.note) - stop(b.note) || a.index - b.index,
|
|
51
|
-
)
|
|
52
|
-
.map(({ note }) => note);
|
|
53
|
-
}
|
|
54
|
-
|
|
55
|
-
/**
|
|
56
|
-
* The notes as one Markdown block for an agent: grouped by slide and beat in talk order, each
|
|
57
|
-
* element with the file and line it is written at, and how to see the slide at that beat.
|
|
58
|
-
*/
|
|
59
|
-
export function formatNotes(
|
|
60
|
-
deck: string,
|
|
61
|
-
slides: readonly SlideInfo[],
|
|
62
|
-
notes: readonly Note[],
|
|
63
|
-
): string {
|
|
64
|
-
if (notes.length === 0) {
|
|
65
|
-
return "";
|
|
66
|
-
}
|
|
67
|
-
const groups = new Map<string, Note[]>();
|
|
68
|
-
for (const note of sortNotes(slides, notes)) {
|
|
69
|
-
const key = `${note.slug}\n${note.step}`;
|
|
70
|
-
groups.set(key, [...(groups.get(key) ?? []), note]);
|
|
71
|
-
}
|
|
72
|
-
|
|
73
|
-
const lines = [
|
|
74
|
-
`## Notes on decks/${deck}`,
|
|
75
|
-
"",
|
|
76
|
-
"Boxes and points are in the slide's own pixels, from its top left.",
|
|
77
|
-
];
|
|
78
|
-
let number = 0;
|
|
79
|
-
for (const group of groups.values()) {
|
|
80
|
-
const first = group[0];
|
|
81
|
-
if (!first) {
|
|
82
|
-
continue;
|
|
83
|
-
}
|
|
84
|
-
const index = slides.findIndex((slide) => slide.slug === first.slug);
|
|
85
|
-
const title = slides[index]?.title;
|
|
86
|
-
lines.push(
|
|
87
|
-
"",
|
|
88
|
-
`### Slide ${index + 1} of ${slides.length}, ${first.slug}${title ? ` (${JSON.stringify(title)})` : ""}, ${beatLabel(first.step)}`,
|
|
89
|
-
);
|
|
90
|
-
for (const note of group) {
|
|
91
|
-
number++;
|
|
92
|
-
const where = (target: NoteTarget): string =>
|
|
93
|
-
`${describe(target)} at decks/${deck}/slides/${note.slug}.html:${target.source} (${measure(target)})${note.stale ? ", as written before an edit; the line may have moved" : ""}`;
|
|
94
|
-
lines.push("");
|
|
95
|
-
if (note.targets.length === 1 && note.targets[0]) {
|
|
96
|
-
lines.push(`${number}. ${where(note.targets[0])}`);
|
|
97
|
-
} else {
|
|
98
|
-
lines.push(`${number}. ${note.targets.length} elements:`);
|
|
99
|
-
lines.push(...note.targets.map((target) => ` - ${where(target)}`));
|
|
100
|
-
}
|
|
101
|
-
if (note.text.trim() !== "") {
|
|
102
|
-
lines.push(
|
|
103
|
-
...note.text
|
|
104
|
-
.trim()
|
|
105
|
-
.split(/\r?\n/)
|
|
106
|
-
.map((line) => ` > ${line}`.trimEnd()),
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
}
|
|
110
|
-
lines.push("", `To see it: \`dekc shot ${deck} ${first.slug} --step ${first.step}\``);
|
|
111
|
-
}
|
|
112
|
-
return `${lines.join("\n")}\n`;
|
|
113
|
-
}
|
|
114
|
-
|
|
115
|
-
function beatLabel(step: string): string {
|
|
116
|
-
if (step === "0") {
|
|
117
|
-
return "as it arrives";
|
|
118
|
-
}
|
|
119
|
-
return /^\d+$/.test(step) ? `at beat ${step}` : `at beat \`${step}\``;
|
|
120
|
-
}
|
|
121
|
-
|
|
122
|
-
function describe(target: NoteTarget): string {
|
|
123
|
-
return target.text === "" ? target.name : `${target.name} ${JSON.stringify(target.text)}`;
|
|
124
|
-
}
|
|
125
|
-
|
|
126
|
-
function measure(target: NoteTarget): string {
|
|
127
|
-
if (target.point) {
|
|
128
|
-
return `point ${target.point.x},${target.point.y}`;
|
|
129
|
-
}
|
|
130
|
-
const { x, y, width, height } = target.box;
|
|
131
|
-
return `box ${x},${y} ${width}×${height}`;
|
|
132
|
-
}
|
|
133
|
-
|
|
134
|
-
/** An element on the slide as it is now, to match a note's target against. */
|
|
135
|
-
type Present = { source: string; name: string; text: string };
|
|
136
|
-
|
|
137
|
-
/**
|
|
138
|
-
* The element a note's target is now, after an edit replaced its slide: the one at the same
|
|
139
|
-
* place with the same name and text; else the only one with that name and text, wherever it
|
|
140
|
-
* moved; else the one at the same place with the same name, whose text was rewritten, as an
|
|
141
|
-
* agent does when it acts on the note. None when it cannot be told.
|
|
142
|
-
*/
|
|
143
|
-
export function reanchor<T extends Present>(
|
|
144
|
-
target: NoteTarget,
|
|
145
|
-
present: readonly T[],
|
|
146
|
-
): T | undefined {
|
|
147
|
-
const same = present.find(
|
|
148
|
-
(el) => el.source === target.source && el.name === target.name && el.text === target.text,
|
|
149
|
-
);
|
|
150
|
-
if (same) {
|
|
151
|
-
return same;
|
|
152
|
-
}
|
|
153
|
-
const moved = present.filter((el) => el.name === target.name && el.text === target.text);
|
|
154
|
-
if (moved.length === 1) {
|
|
155
|
-
return moved[0];
|
|
156
|
-
}
|
|
157
|
-
return present.find((el) => el.source === target.source && el.name === target.name);
|
|
158
|
-
}
|
|
159
|
-
|
|
160
|
-
/** The notes kept in storage, or none when what is there is not notes. */
|
|
161
|
-
export function readNotes(raw: string | null): Note[] {
|
|
162
|
-
let parsed: unknown;
|
|
163
|
-
try {
|
|
164
|
-
parsed = JSON.parse(raw ?? "");
|
|
165
|
-
} catch {
|
|
166
|
-
return [];
|
|
167
|
-
}
|
|
168
|
-
if (!isRecord(parsed) || parsed.version !== 1 || !Array.isArray(parsed.notes)) {
|
|
169
|
-
return [];
|
|
170
|
-
}
|
|
171
|
-
return parsed.notes.filter(isNote);
|
|
172
|
-
}
|
|
173
|
-
|
|
174
|
-
/** The notes as storage keeps them. */
|
|
175
|
-
export function writeNotes(notes: readonly Note[]): string {
|
|
176
|
-
return JSON.stringify({ version: 1, notes });
|
|
177
|
-
}
|
|
178
|
-
|
|
179
|
-
function isNote(value: unknown): value is Note {
|
|
180
|
-
return (
|
|
181
|
-
isRecord(value) &&
|
|
182
|
-
typeof value.slug === "string" &&
|
|
183
|
-
typeof value.step === "string" &&
|
|
184
|
-
typeof value.text === "string" &&
|
|
185
|
-
(value.stale === undefined || value.stale === true) &&
|
|
186
|
-
Array.isArray(value.targets) &&
|
|
187
|
-
value.targets.length > 0 &&
|
|
188
|
-
value.targets.every(isTarget)
|
|
189
|
-
);
|
|
190
|
-
}
|
|
191
|
-
|
|
192
|
-
function isTarget(value: unknown): value is NoteTarget {
|
|
193
|
-
return (
|
|
194
|
-
isRecord(value) &&
|
|
195
|
-
typeof value.source === "string" &&
|
|
196
|
-
typeof value.name === "string" &&
|
|
197
|
-
typeof value.text === "string" &&
|
|
198
|
-
isRecord(value.box) &&
|
|
199
|
-
[value.box.x, value.box.y, value.box.width, value.box.height].every(Number.isFinite) &&
|
|
200
|
-
(value.point === undefined ||
|
|
201
|
-
(isRecord(value.point) && Number.isFinite(value.point.x) && Number.isFinite(value.point.y)))
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
|
|
205
|
-
function isRecord(value: unknown): value is Record<string, unknown> {
|
|
206
|
-
return typeof value === "object" && value !== null && !Array.isArray(value);
|
|
207
|
-
}
|