@hajimism/dek 0.2.0 → 0.2.2
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 +32 -0
- package/README.ja.md +3 -1
- package/README.md +3 -1
- package/package.json +3 -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/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/serve.ts +1 -1
- package/src/cli/show.ts +17 -2
- package/src/cli/text.ts +43 -0
- package/src/cli/theme.ts +7 -1
- package/src/core/annotation-rows.ts +176 -0
- package/src/core/annotations.ts +362 -0
- package/src/core/chrome.ts +3 -1
- package/src/core/diagnostic.ts +1 -0
- package/src/core/document.ts +17 -2
- package/src/core/html-scan.ts +54 -0
- package/src/core/html.ts +144 -14
- 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 +7 -0
- package/src/core/playwright-module.d.ts +4 -1
- package/src/core/playwright-visual.ts +74 -1
- package/src/core/playwright.ts +51 -1
- 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/sync.ts +13 -5
- 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 +144 -0
- package/src/runtime/annotate-place.ts +46 -0
- package/src/runtime/annotate-view.ts +752 -0
- package/src/runtime/annotate.ts +19 -0
- package/src/runtime/browser.ts +5 -1
- package/src/runtime/live.ts +1 -0
- package/src/runtime/player.ts +15 -7
- package/src/runtime/routes.ts +3 -0
- package/src/server/auth.ts +3 -1
- package/src/server/dev.ts +2 -1
- package/src/server/pages.ts +2 -1
- package/src/server/routes.ts +77 -4
package/src/core/zip.ts
ADDED
|
@@ -0,0 +1,77 @@
|
|
|
1
|
+
/** One file of an archive: its path inside it, with `/` between folders, and its bytes. */
|
|
2
|
+
export type ZipFile = { path: string; data: Uint8Array };
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* A ZIP archive of `files`, in order, each stored as it is. What dek packs is pictures, already
|
|
6
|
+
* compressed, and a little XML, so deflating would buy little and cost a dependency or a
|
|
7
|
+
* compressor to keep. Paths are UTF-8, and the time is fixed, so the same files make the same
|
|
8
|
+
* bytes.
|
|
9
|
+
*/
|
|
10
|
+
export function zipStored(files: readonly ZipFile[]): Uint8Array {
|
|
11
|
+
const encoder = new TextEncoder();
|
|
12
|
+
const locals: Uint8Array[] = [];
|
|
13
|
+
const centrals: Uint8Array[] = [];
|
|
14
|
+
let offset = 0;
|
|
15
|
+
for (const file of files) {
|
|
16
|
+
const name = encoder.encode(file.path);
|
|
17
|
+
const crc = Bun.hash.crc32(file.data);
|
|
18
|
+
const size = file.data.length;
|
|
19
|
+
|
|
20
|
+
const local = new Uint8Array(30 + name.length);
|
|
21
|
+
const head = new DataView(local.buffer);
|
|
22
|
+
head.setUint32(0, 0x04034b50, true);
|
|
23
|
+
writeCommon(head, 4, { crc, size });
|
|
24
|
+
head.setUint16(26, name.length, true);
|
|
25
|
+
local.set(name, 30);
|
|
26
|
+
locals.push(local, file.data);
|
|
27
|
+
|
|
28
|
+
const central = new Uint8Array(46 + name.length);
|
|
29
|
+
const entry = new DataView(central.buffer);
|
|
30
|
+
entry.setUint32(0, 0x02014b50, true);
|
|
31
|
+
entry.setUint16(4, 20, true);
|
|
32
|
+
writeCommon(entry, 6, { crc, size });
|
|
33
|
+
entry.setUint16(28, name.length, true);
|
|
34
|
+
entry.setUint32(42, offset, true);
|
|
35
|
+
central.set(name, 46);
|
|
36
|
+
centrals.push(central);
|
|
37
|
+
|
|
38
|
+
offset += local.length + size;
|
|
39
|
+
}
|
|
40
|
+
const directorySize = centrals.reduce((sum, part) => sum + part.length, 0);
|
|
41
|
+
const end = new Uint8Array(22);
|
|
42
|
+
const tail = new DataView(end.buffer);
|
|
43
|
+
tail.setUint32(0, 0x06054b50, true);
|
|
44
|
+
tail.setUint16(8, files.length, true);
|
|
45
|
+
tail.setUint16(10, files.length, true);
|
|
46
|
+
tail.setUint32(12, directorySize, true);
|
|
47
|
+
tail.setUint32(16, offset, true);
|
|
48
|
+
return concat([...locals, ...centrals, end]);
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** 1980-01-01 00:00, the earliest time a ZIP can say. */
|
|
52
|
+
const DOS_DATE = (0 << 9) | (1 << 5) | 1;
|
|
53
|
+
|
|
54
|
+
/**
|
|
55
|
+
* What the local header and the central directory entry share, from the version needed on: no
|
|
56
|
+
* flags but UTF-8 names, stored, the fixed time, the CRC, and the size twice.
|
|
57
|
+
*/
|
|
58
|
+
function writeCommon(view: DataView, at: number, { crc, size }: { crc: number; size: number }) {
|
|
59
|
+
view.setUint16(at, 20, true);
|
|
60
|
+
view.setUint16(at + 2, 1 << 11, true);
|
|
61
|
+
view.setUint16(at + 4, 0, true);
|
|
62
|
+
view.setUint16(at + 6, 0, true);
|
|
63
|
+
view.setUint16(at + 8, DOS_DATE, true);
|
|
64
|
+
view.setUint32(at + 10, crc, true);
|
|
65
|
+
view.setUint32(at + 14, size, true);
|
|
66
|
+
view.setUint32(at + 18, size, true);
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
function concat(parts: readonly Uint8Array[]): Uint8Array {
|
|
70
|
+
const out = new Uint8Array(parts.reduce((sum, part) => sum + part.length, 0));
|
|
71
|
+
let at = 0;
|
|
72
|
+
for (const part of parts) {
|
|
73
|
+
out.set(part, at);
|
|
74
|
+
at += part.length;
|
|
75
|
+
}
|
|
76
|
+
return out;
|
|
77
|
+
}
|
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
/// <reference lib="dom" />
|
|
2
|
+
// What annotate mode can point at: the elements of the slide on screen under the pointer, what
|
|
3
|
+
// each is called, and where it is on the slide. Only elements the dev server stamped with where
|
|
4
|
+
// they are written count, so every one can be named by its file and line.
|
|
5
|
+
|
|
6
|
+
import { type Box, cutText } from "../core/annotation-rows.ts";
|
|
7
|
+
import { deckStops, positionFromHash } from "../core/position.ts";
|
|
8
|
+
import { type BeatRef, stepKey } from "../core/step.ts";
|
|
9
|
+
|
|
10
|
+
/** The attribute the dev server writes on each start tag of a slide: `<line>:<column>`. */
|
|
11
|
+
export const SOURCE_ATTR = "data-dek-source";
|
|
12
|
+
|
|
13
|
+
/** The classes the dev server saw written on each stamped tag, before any script added more. */
|
|
14
|
+
const CLASS_ATTR = "data-dek-class";
|
|
15
|
+
|
|
16
|
+
type Point = { x: number; y: number };
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* The points tried around the pointer: the pointer itself first, then rings out to a few pixels,
|
|
20
|
+
* so a line one pixel thick is found from beside it as well as on it.
|
|
21
|
+
*/
|
|
22
|
+
export function ringPoints(at: Point): Point[] {
|
|
23
|
+
const points = [at];
|
|
24
|
+
for (let radius = 1; radius <= 5; radius++) {
|
|
25
|
+
for (let step = 0; step < 8; step++) {
|
|
26
|
+
const angle = (step * Math.PI) / 4;
|
|
27
|
+
points.push({ x: at.x + radius * Math.cos(angle), y: at.y + radius * Math.sin(angle) });
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return points;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The elements of `slide` that can be picked at `at`: what is under the pointer, then what is
|
|
35
|
+
* only near it, each before anything it is inside, and the slide itself last. A line beside the
|
|
36
|
+
* pointer so comes before the drawing it is in, which is under the pointer and easy to hit.
|
|
37
|
+
* `elementsAt` is the document's `elementsFromPoint`, every element at a point, topmost first.
|
|
38
|
+
*/
|
|
39
|
+
export function candidatesAt(
|
|
40
|
+
at: Point,
|
|
41
|
+
slide: Element,
|
|
42
|
+
elementsAt: (x: number, y: number) => readonly Element[],
|
|
43
|
+
): Element[] {
|
|
44
|
+
const found: Element[] = [];
|
|
45
|
+
for (const point of ringPoints(at)) {
|
|
46
|
+
for (const el of elementsAt(point.x, point.y)) {
|
|
47
|
+
if (el === slide || found.includes(el) || !isCandidate(el, slide)) {
|
|
48
|
+
continue;
|
|
49
|
+
}
|
|
50
|
+
const outer = found.findIndex((other) => other.contains(el));
|
|
51
|
+
found.splice(outer < 0 ? found.length : outer, 0, el);
|
|
52
|
+
}
|
|
53
|
+
}
|
|
54
|
+
return isCandidate(slide, slide) ? [...found, slide] : found;
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
function isCandidate(el: Element, slide: Element): boolean {
|
|
58
|
+
return el.hasAttribute(SOURCE_ATTR) && slide.contains(el) && isShown(el, slide);
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether `el` shows: neither it nor anything it is in on the slide is transparent or hidden. */
|
|
62
|
+
function isShown(el: Element, slide: Element): boolean {
|
|
63
|
+
const view = el.ownerDocument.defaultView;
|
|
64
|
+
if (!view) {
|
|
65
|
+
return true;
|
|
66
|
+
}
|
|
67
|
+
if (view.getComputedStyle(el).visibility === "hidden") {
|
|
68
|
+
return false;
|
|
69
|
+
}
|
|
70
|
+
for (let node: Element | null = el; node; node = node.parentElement) {
|
|
71
|
+
if (view.getComputedStyle(node).opacity === "0") {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
if (node === slide) {
|
|
75
|
+
break;
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
return true;
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
/**
|
|
82
|
+
* What a note says an element is: where it is written, its tag and its classes as written, and
|
|
83
|
+
* its text cut short. The classes the player or a slide script added since, such as
|
|
84
|
+
* `is-current`, are in no file to find. The slide's text is the whole slide, which names
|
|
85
|
+
* nothing, so it has none.
|
|
86
|
+
*/
|
|
87
|
+
export function describeElement(
|
|
88
|
+
el: Element,
|
|
89
|
+
slide: Element,
|
|
90
|
+
): { source: string; name: string; text: string } {
|
|
91
|
+
const classes = (el.getAttribute(CLASS_ATTR) ?? "").split(" ").filter(Boolean);
|
|
92
|
+
return {
|
|
93
|
+
source: el.getAttribute(SOURCE_ATTR) ?? "",
|
|
94
|
+
name: [el.tagName.toLowerCase(), ...classes].join("."),
|
|
95
|
+
text: el === slide ? "" : cutText(el.textContent ?? ""),
|
|
96
|
+
};
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
/** Where `el` is on the slide, in the slide's own pixels, whatever the stage scales it to. */
|
|
100
|
+
export function slideBox(el: Element, deck: HTMLElement): Box {
|
|
101
|
+
const scale = deckScale(deck);
|
|
102
|
+
const origin = deck.getBoundingClientRect();
|
|
103
|
+
const rect = el.getBoundingClientRect();
|
|
104
|
+
return {
|
|
105
|
+
x: Math.round((rect.left - origin.left) / scale),
|
|
106
|
+
y: Math.round((rect.top - origin.top) / scale),
|
|
107
|
+
width: Math.round(rect.width / scale),
|
|
108
|
+
height: Math.round(rect.height / scale),
|
|
109
|
+
};
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
/** A point in the window as a point on the slide, in the slide's own pixels. */
|
|
113
|
+
export function slidePoint(at: Point, deck: HTMLElement): Point {
|
|
114
|
+
const scale = deckScale(deck);
|
|
115
|
+
const origin = deck.getBoundingClientRect();
|
|
116
|
+
return {
|
|
117
|
+
x: Math.round((at.x - origin.left) / scale),
|
|
118
|
+
y: Math.round((at.y - origin.top) / scale),
|
|
119
|
+
};
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
/** A point on the slide, in its own pixels, as a point in the window. */
|
|
123
|
+
export function windowPoint(at: Point, deck: HTMLElement): Point {
|
|
124
|
+
const scale = deckScale(deck);
|
|
125
|
+
const origin = deck.getBoundingClientRect();
|
|
126
|
+
return { x: origin.left + at.x * scale, y: origin.top + at.y * scale };
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function deckScale(deck: HTMLElement): number {
|
|
130
|
+
const width = deck.getBoundingClientRect().width;
|
|
131
|
+
return deck.offsetWidth > 0 && width > 0 ? width / deck.offsetWidth : 1;
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
/** The slide on screen and its beat, as the hash says and `dekc shot --step` takes it. */
|
|
135
|
+
export function currentStop(
|
|
136
|
+
hash: string,
|
|
137
|
+
slides: ReadonlyArray<{ slug: string; beats: BeatRef[] }>,
|
|
138
|
+
): { slideIndex: number; step: string } {
|
|
139
|
+
const pos = positionFromHash(hash, deckStops(slides));
|
|
140
|
+
return {
|
|
141
|
+
slideIndex: pos.slideIndex,
|
|
142
|
+
step: stepKey(slides[pos.slideIndex]?.beats ?? [], pos.beatIndex),
|
|
143
|
+
};
|
|
144
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
// Where annotate mode's popup goes: beside what was picked, never over it, inside the window.
|
|
2
|
+
|
|
3
|
+
type Rect = { left: number; top: number; right: number; bottom: number };
|
|
4
|
+
type Size = { width: number; height: number };
|
|
5
|
+
|
|
6
|
+
/** How far the popup keeps from what was picked and from the window's edges. */
|
|
7
|
+
const GAP = 8;
|
|
8
|
+
|
|
9
|
+
/**
|
|
10
|
+
* The popup's top left corner for a note on `picked`, the box around every element picked:
|
|
11
|
+
* under it, else above it, else to its right, else to its left, on the first side it fits on
|
|
12
|
+
* whole. Covering what the note is about would hide what the human is pointing at. When no side
|
|
13
|
+
* fits, it goes under, held inside the window.
|
|
14
|
+
*/
|
|
15
|
+
export function placePopup(
|
|
16
|
+
picked: Rect,
|
|
17
|
+
size: Size,
|
|
18
|
+
viewport: Size,
|
|
19
|
+
): { left: number; top: number } {
|
|
20
|
+
const clampLeft = (left: number): number =>
|
|
21
|
+
Math.max(GAP, Math.min(left, viewport.width - size.width - GAP));
|
|
22
|
+
const clampTop = (top: number): number =>
|
|
23
|
+
Math.max(GAP, Math.min(top, viewport.height - size.height - GAP));
|
|
24
|
+
const fitsTop = (top: number): boolean =>
|
|
25
|
+
top >= GAP && top + size.height <= viewport.height - GAP;
|
|
26
|
+
const fitsLeft = (left: number): boolean =>
|
|
27
|
+
left >= GAP && left + size.width <= viewport.width - GAP;
|
|
28
|
+
|
|
29
|
+
const below = picked.bottom + GAP;
|
|
30
|
+
if (fitsTop(below)) {
|
|
31
|
+
return { left: clampLeft(picked.left), top: below };
|
|
32
|
+
}
|
|
33
|
+
const above = picked.top - GAP - size.height;
|
|
34
|
+
if (fitsTop(above)) {
|
|
35
|
+
return { left: clampLeft(picked.left), top: above };
|
|
36
|
+
}
|
|
37
|
+
const right = picked.right + GAP;
|
|
38
|
+
if (fitsLeft(right)) {
|
|
39
|
+
return { left: right, top: clampTop(picked.top) };
|
|
40
|
+
}
|
|
41
|
+
const left = picked.left - GAP - size.width;
|
|
42
|
+
if (fitsLeft(left)) {
|
|
43
|
+
return { left, top: clampTop(picked.top) };
|
|
44
|
+
}
|
|
45
|
+
return { left: clampLeft(picked.left), top: clampTop(below) };
|
|
46
|
+
}
|