pi-openui 0.2.0 → 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 +59 -0
- package/README.md +40 -1
- package/package.json +3 -2
- package/src/blocks/code.ts +16 -3
- package/src/blocks/core.ts +78 -27
- package/src/blocks/diagrams.ts +8 -26
- package/src/blocks/fallback.ts +25 -0
- package/src/blocks/gesture.ts +112 -0
- package/src/blocks/interact.ts +55 -3
- package/src/blocks/markdown.ts +13 -3
- package/src/blocks/pan.ts +139 -0
- package/src/blocks/panblock.ts +44 -0
- package/src/blocks/render.ts +25 -17
- package/src/blocks/sequence.ts +4 -4
- package/src/blocks/table.ts +122 -14
- package/src/blocks/types.ts +14 -2
- package/src/blocks/util.ts +5 -0
- package/src/diagram/dag.ts +4 -3
- package/src/diagram/erd.ts +4 -2
- package/src/diagram/infra.ts +3 -2
- package/src/lang/docs.ts +38 -14
- package/src/lang/library.ts +122 -19
- package/src/lang/samples.ts +134 -0
- package/src/pi/config.ts +185 -0
- package/src/pi/events.ts +150 -0
- package/src/pi/extension.ts +63 -18
- package/src/pi/widget.ts +67 -14
package/src/blocks/interact.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
// Interaction logic, independent of any terminal: hit testing, activation, keyboard navigation, pan reveal.
|
|
2
2
|
import { buildMessage, progress, toggleOption } from "./choice.ts";
|
|
3
|
+
import { DEFAULT_FINE_STEP, fineStepPan, stepPan } from "./pan.ts";
|
|
3
4
|
import type { Block, Target, WState } from "./types.ts";
|
|
4
5
|
|
|
5
6
|
export interface Env {
|
|
@@ -18,6 +19,8 @@ const area = (t: Target) => t.rect.w * t.rect.h;
|
|
|
18
19
|
* items, the notes tray, and tabs (so you can reach content on another tab). Buttons, choices and the like do not fire.
|
|
19
20
|
*/
|
|
20
21
|
export function isLive(t: Target, notes: boolean): boolean {
|
|
22
|
+
if (t.kind === "panarea") return false; // found with panAreaAt, never selected
|
|
23
|
+
if (t.kind === "pan") return true; // a clipped table needs its bar in notes mode too, to reach the columns a note is about
|
|
21
24
|
if (!notes) return t.kind !== "item";
|
|
22
25
|
return t.kind === "item" || t.kind === "tab" || t.kind === "note" || t.kind === "noteadd" || t.kind === "noteclear" || t.kind === "notes";
|
|
23
26
|
}
|
|
@@ -29,7 +32,7 @@ export function targetAt(block: Block, x: number, y: number, notes = false): Tar
|
|
|
29
32
|
for (const t of block.targets) {
|
|
30
33
|
if (!isLive(t, notes)) continue;
|
|
31
34
|
const r = t.rect;
|
|
32
|
-
const inRect = x >= r.x && y >= r.y && x < r.x + r.w && y < r.y + r.h && r.x + r.w > 0 && r.x < block.w;
|
|
35
|
+
const inRect = x >= r.x && y >= r.y && x < r.x + r.w && y < r.y + r.h && r.x + r.w > 0 && r.x < block.w && x >= (t.pan?.min ?? 0);
|
|
33
36
|
const inCells = !!t.cells && t.cells.some(([cx, cy]) => cx === x && cy === y);
|
|
34
37
|
if (!inRect && !inCells) continue;
|
|
35
38
|
const score = inCells ? 0 : area(t);
|
|
@@ -41,6 +44,40 @@ export function targetAt(block: Block, x: number, y: number, notes = false): Tar
|
|
|
41
44
|
return best;
|
|
42
45
|
}
|
|
43
46
|
|
|
47
|
+
/** One step on a bar: a fine one (`sc.step` cells, anywhere) or a page (three quarters of the pane, to a column edge in a table). */
|
|
48
|
+
function movePan(sc: NonNullable<Target["scroll"]>, cur: number, dir: -1 | 1, page: boolean): number {
|
|
49
|
+
return page ? stepPan(cur, dir, sc.total, sc.view, sc.edges) : fineStepPan(cur, dir, sc.total, sc.view, sc.step ?? DEFAULT_FINE_STEP);
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
/**
|
|
53
|
+
* An arrow key while the pan bar is the focused target: scroll. Either arrow scrolls whichever part of the bar has the focus,
|
|
54
|
+
* because Left and Right mean left and right, not "activate this button". False when something else is focused, or when the
|
|
55
|
+
* block is already at that end (the caller then lets the key move the focus as usual). `page` is a page (Shift+arrow, PageUp,
|
|
56
|
+
* PageDown); without it a fine step.
|
|
57
|
+
*/
|
|
58
|
+
export function panKey(block: Block, state: WState, focusId: string | null, key: "left" | "right", page = false): boolean {
|
|
59
|
+
const t = focusId ? block.targets.find((x) => x.id === focusId) : undefined;
|
|
60
|
+
const sc = t?.kind === "pan" ? t.scroll : undefined;
|
|
61
|
+
if (!sc) return false;
|
|
62
|
+
const cur = state.pan[sc.key] ?? 0;
|
|
63
|
+
const next = movePan(sc, cur, key === "left" ? -1 : 1, page);
|
|
64
|
+
if (next === cur) return false;
|
|
65
|
+
state.pan[sc.key] = next;
|
|
66
|
+
return true;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
/** The pan area under a point: what a wheel or a drag over a clipped block should move. The smallest one when they nest. */
|
|
70
|
+
export function panAreaAt(block: Block, x: number, y: number): Target | null {
|
|
71
|
+
let best: Target | null = null;
|
|
72
|
+
for (const t of block.targets) {
|
|
73
|
+
if (t.kind !== "panarea") continue;
|
|
74
|
+
const r = t.rect;
|
|
75
|
+
if (x < r.x || y < r.y || x >= r.x + r.w || y >= r.y + r.h) continue;
|
|
76
|
+
if (!best || area(t) < area(best)) best = t;
|
|
77
|
+
}
|
|
78
|
+
return best;
|
|
79
|
+
}
|
|
80
|
+
|
|
44
81
|
export function readingOrder(block: Block, notes = false): Target[] {
|
|
45
82
|
return block.targets.filter((t) => isLive(t, notes)).sort((a, b) => a.rect.y - b.rect.y || a.rect.x - b.rect.x || a.id.localeCompare(b.id));
|
|
46
83
|
}
|
|
@@ -197,11 +234,21 @@ export function activate(t: Target, state: WState, env: Env): boolean {
|
|
|
197
234
|
if (t.copy !== undefined) env.copy(t.copy);
|
|
198
235
|
state.copied = t.id;
|
|
199
236
|
return true;
|
|
237
|
+
case "pan": {
|
|
238
|
+
// the arrows step; the track is for the pointer (the widget places the thumb where it was pressed)
|
|
239
|
+
const sc = t.scroll;
|
|
240
|
+
if (!sc || sc.part === "track") return false;
|
|
241
|
+
const cur = state.pan[sc.key] ?? 0;
|
|
242
|
+
const next = movePan(sc, cur, sc.part === "left" || sc.part === "pageleft" ? -1 : 1, sc.part === "pageleft" || sc.part === "pageright");
|
|
243
|
+
if (next === cur) return false;
|
|
244
|
+
state.pan[sc.key] = next;
|
|
245
|
+
return true;
|
|
246
|
+
}
|
|
200
247
|
case "node":
|
|
201
248
|
case "image":
|
|
202
249
|
case "tip": {
|
|
203
250
|
const tip = t.tip;
|
|
204
|
-
if (!tip || !(tip.detail || tip.code || tip.link)) return false;
|
|
251
|
+
if (!tip || !(tip.detail || tip.code || tip.link || tip.full !== undefined)) return false;
|
|
205
252
|
state.tip = state.tip === t.id ? null : t.id;
|
|
206
253
|
return true;
|
|
207
254
|
}
|
|
@@ -215,7 +262,8 @@ export function reveal(t: Target, state: WState): boolean {
|
|
|
215
262
|
if (!p) return false;
|
|
216
263
|
const cur = state.pan[p.key] ?? 0;
|
|
217
264
|
let next = cur;
|
|
218
|
-
|
|
265
|
+
const left = p.min ?? 0; // columns kept in place at the left cover the first `min` cells of the view
|
|
266
|
+
if (p.x < cur + left) next = Math.max(0, p.x - left - 2);
|
|
219
267
|
else if (p.x + p.w > cur + p.view) next = Math.min(p.total - p.view, p.x + p.w - p.view + 2);
|
|
220
268
|
if (next === cur) return false;
|
|
221
269
|
state.pan[p.key] = next;
|
|
@@ -250,6 +298,9 @@ export function describe(t: Target): string {
|
|
|
250
298
|
case "image":
|
|
251
299
|
bits.push(t.label);
|
|
252
300
|
break;
|
|
301
|
+
case "pan":
|
|
302
|
+
bits.push(t.label, t.scroll?.part === "track" ? "click to jump, or drag" : t.scroll?.part === "pageleft" || t.scroll?.part === "pageright" ? "a page at a time" : `${t.scroll?.step ?? DEFAULT_FINE_STEP} cells at a time`);
|
|
303
|
+
break;
|
|
253
304
|
case "item":
|
|
254
305
|
bits.push(t.label, "Enter to write a note");
|
|
255
306
|
break;
|
|
@@ -270,5 +321,6 @@ export function describe(t: Target): string {
|
|
|
270
321
|
}
|
|
271
322
|
if (t.tip?.hint) bits.push(t.tip.hint);
|
|
272
323
|
if (t.tip && (t.tip.detail || t.tip.code || t.tip.link)) bits.push("Enter for details");
|
|
324
|
+
else if (t.tip?.full !== undefined) bits.push("tap to read all"); // short: this line is cut at the width of the pane
|
|
273
325
|
return bits.filter(Boolean).join(" · ");
|
|
274
326
|
}
|
package/src/blocks/markdown.ts
CHANGED
|
@@ -2,7 +2,9 @@ import { textWidth } from "../diagram/canvas.ts";
|
|
|
2
2
|
import { imageLine } from "../lang/md.ts";
|
|
3
3
|
import { codeBlock } from "./code.ts";
|
|
4
4
|
import { image } from "./core.ts";
|
|
5
|
-
import {
|
|
5
|
+
import { panBlock } from "./panblock.ts";
|
|
6
|
+
import { withTips, type Anchor } from "./core.ts";
|
|
7
|
+
import { cutCells, tableBlock, tablePan } from "./table.ts";
|
|
6
8
|
import type { Block, Ctx } from "./types.ts";
|
|
7
9
|
import { cell, fromRows, vstack, wrapRuns, textCells, type Run, padRow } from "./util.ts";
|
|
8
10
|
|
|
@@ -46,6 +48,7 @@ export function markdownBlock(src: string, width: number, ctx?: Ctx, path = ""):
|
|
|
46
48
|
const blocks: Block[] = [];
|
|
47
49
|
let i = 0;
|
|
48
50
|
let pictures = 0;
|
|
51
|
+
let wide = 0; // code fences and tables: each scrolls on its own
|
|
49
52
|
const para: string[] = [];
|
|
50
53
|
const flush = () => {
|
|
51
54
|
if (!para.length) return;
|
|
@@ -61,7 +64,7 @@ export function markdownBlock(src: string, width: number, ctx?: Ctx, path = ""):
|
|
|
61
64
|
i++;
|
|
62
65
|
while (i < lines.length && !/^\s*```\s*$/.test(lines[i])) body.push(lines[i++]);
|
|
63
66
|
i++;
|
|
64
|
-
blocks.push(codeBlock(body.join("\n"), fence[1] || undefined, undefined, width, null, false));
|
|
67
|
+
blocks.push(codeBlock(body.join("\n"), fence[1] || undefined, undefined, width, null, false, ctx ? { ctx, key: `pan:${path}.w${wide++}` } : undefined));
|
|
65
68
|
continue;
|
|
66
69
|
}
|
|
67
70
|
const pic = ctx ? imageLine(ln) : null;
|
|
@@ -100,7 +103,14 @@ export function markdownBlock(src: string, width: number, ctx?: Ctx, path = ""):
|
|
|
100
103
|
i += 2;
|
|
101
104
|
const body: string[][] = [];
|
|
102
105
|
while (i < lines.length && lines[i].includes("|") && lines[i].trim()) body.push(cells(lines[i++]));
|
|
103
|
-
|
|
106
|
+
if (ctx) {
|
|
107
|
+
const t = tableBlock(head, body, width, { minCol: ctx.services.settings?.tableMinCol ?? 12 });
|
|
108
|
+
const id = `${path}.w${wide++}`;
|
|
109
|
+
const shown = panBlock(t.block, width, `pan:${id}`, ctx, tablePan(t, width));
|
|
110
|
+
// a cell cut to fit can be read whole, as in a Table (only where there is a context to hold targets)
|
|
111
|
+
const anchors = new Map<string, Anchor>(cutCells(t, head, body).map((c) => [c.key, { rect: c.rect, full: c.full, label: c.label }]));
|
|
112
|
+
blocks.push(anchors.size ? withTips({ type: "MarkdownTable", props: {}, line: 0 }, shown, anchors, id, ctx, width) : shown);
|
|
113
|
+
} else blocks.push(tableBlock(head, body, width).block);
|
|
104
114
|
continue;
|
|
105
115
|
}
|
|
106
116
|
const li = /^(\s*)([-*+]|\d+[.)])\s+(.*)$/.exec(ln);
|
|
@@ -0,0 +1,139 @@
|
|
|
1
|
+
// Panning a block that is wider than its pane: the maths, and the one-row bar that tells the reader it can be scrolled.
|
|
2
|
+
// Pure: no widget, no terminal, no pi.
|
|
3
|
+
import type { Cell, Rect } from "../diagram/canvas.ts";
|
|
4
|
+
import { cell, textCells } from "./util.ts";
|
|
5
|
+
|
|
6
|
+
/** One step of a tap or a key: three quarters of the view, so a little of what was on screen stays as a landmark. */
|
|
7
|
+
export const panStep = (view: number): number => Math.max(1, Math.floor((view * 3) / 4));
|
|
8
|
+
|
|
9
|
+
/** The offset kept inside 0..total-view. A block that fits has nothing to pan. */
|
|
10
|
+
export function clampPan(pan: number, total: number, view: number): number {
|
|
11
|
+
const max = Math.max(0, Math.floor(total) - Math.floor(view));
|
|
12
|
+
return Math.max(0, Math.min(Math.round(pan), max));
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
/** Where the thumb sits in a track `track` cells long: as long as the view is of the whole, at least one cell. */
|
|
16
|
+
export function thumbCells(track: number, pan: number, total: number, view: number): { start: number; len: number } {
|
|
17
|
+
if (track <= 0) return { start: 0, len: 0 };
|
|
18
|
+
if (total <= view) return { start: 0, len: track };
|
|
19
|
+
const len = Math.max(1, Math.min(track, Math.round((track * view) / total)));
|
|
20
|
+
const max = total - view;
|
|
21
|
+
const start = Math.round(((track - len) * clampPan(pan, total, view)) / max);
|
|
22
|
+
return { start: Math.max(0, Math.min(track - len, start)), len };
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
/** The offset that puts the middle of the thumb under cell `x` (a tap or a drag on the track). */
|
|
26
|
+
export function panFromTrack(x: number, track: { x: number; w: number }, total: number, view: number): number {
|
|
27
|
+
const { len } = thumbCells(track.w, 0, total, view);
|
|
28
|
+
const room = track.w - len;
|
|
29
|
+
if (room <= 0 || total <= view) return 0;
|
|
30
|
+
const start = Math.max(0, Math.min(room, x - track.x - len / 2));
|
|
31
|
+
return clampPan((start / room) * (total - view), total, view);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* One tap on an arrow. Plain: `panStep` either way. With `edges` (the left edges of a table's columns, in block cells): the
|
|
36
|
+
* farthest edge within one step, so a column is never left cut in half after a tap, and a left tap undoes a right tap.
|
|
37
|
+
*/
|
|
38
|
+
export function stepPan(pan: number, dir: -1 | 1, total: number, view: number, edges?: number[]): number {
|
|
39
|
+
const step = panStep(view);
|
|
40
|
+
let to = pan + dir * step;
|
|
41
|
+
if (edges?.length) {
|
|
42
|
+
const hit = dir === 1 ? edges.filter((e) => e > pan && e <= pan + step) : edges.filter((e) => e < pan && e >= pan - step);
|
|
43
|
+
if (hit.length) to = dir === 1 ? Math.max(...hit) : Math.min(...hit);
|
|
44
|
+
}
|
|
45
|
+
return clampPan(to, total, view);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Cells one tap on a fine arrow moves, unless the user's `scroll.step` says otherwise (src/pi/config.ts). */
|
|
49
|
+
export const DEFAULT_FINE_STEP = 8;
|
|
50
|
+
|
|
51
|
+
/** One tap on a fine arrow: exactly `cells`, clamped. No snapping: this is for reading along. */
|
|
52
|
+
export function fineStepPan(pan: number, dir: -1 | 1, total: number, view: number, cells: number): number {
|
|
53
|
+
return clampPan(pan + dir * Math.max(1, Math.round(cells)), total, view);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
export interface BarLayout {
|
|
57
|
+
/** the outer arrows, a page at a time; absent when the bar is too narrow for them */
|
|
58
|
+
pageLeft?: Rect;
|
|
59
|
+
/** the inner arrows, a few cells at a time */
|
|
60
|
+
left: Rect;
|
|
61
|
+
track: Rect;
|
|
62
|
+
right: Rect;
|
|
63
|
+
pageRight?: Rect;
|
|
64
|
+
/** the range shown, e.g. `1–63/175`; empty when the bar is too narrow for it */
|
|
65
|
+
label: string;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const ARROW = 3; // a finger needs more than one cell: ` ‹ `
|
|
69
|
+
const MIN_TRACK = 6;
|
|
70
|
+
|
|
71
|
+
/** ` « ‹ ████░░░░ 1–63/175 › » `: page arrows outside, fine arrows inside, the track and the range between. */
|
|
72
|
+
export function barLayout(width: number, pan: number, total: number, view: number): BarLayout {
|
|
73
|
+
const paged = width - 4 * ARROW >= MIN_TRACK;
|
|
74
|
+
const side = paged ? 2 * ARROW : ARROW;
|
|
75
|
+
const mid = Math.max(0, width - 2 * side);
|
|
76
|
+
let label = `${pan + 1}–${Math.min(total, pan + view)}/${total}`;
|
|
77
|
+
let track = mid - (label.length + 2);
|
|
78
|
+
if (track < MIN_TRACK) {
|
|
79
|
+
label = "";
|
|
80
|
+
track = mid;
|
|
81
|
+
}
|
|
82
|
+
const at = (x: number): Rect => ({ x, y: 0, w: ARROW, h: 1 });
|
|
83
|
+
return {
|
|
84
|
+
...(paged ? { pageLeft: at(0), pageRight: at(width - ARROW) } : {}),
|
|
85
|
+
left: at(paged ? ARROW : 0),
|
|
86
|
+
right: at(width - side),
|
|
87
|
+
track: { x: side, y: 0, w: Math.max(0, track), h: 1 },
|
|
88
|
+
label,
|
|
89
|
+
};
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
/** ` « ‹ ████░░░░░░░░ 1–63/175 › » `: exactly `width` cells. An arrow that cannot move is dim. */
|
|
93
|
+
export function barRow(width: number, pan: number, total: number, view: number): Cell[] {
|
|
94
|
+
const l = barLayout(width, pan, total, view);
|
|
95
|
+
const canLeft = pan > 0;
|
|
96
|
+
const canRight = pan < total - view;
|
|
97
|
+
const t = thumbCells(l.track.w, pan, total, view);
|
|
98
|
+
const arrow = (glyph: string, on: boolean): Cell[] => [cell(" "), cell(glyph, on ? "btn" : "text.muted"), cell(" ")];
|
|
99
|
+
const row: Cell[] = [...(l.pageLeft ? arrow("«", canLeft) : []), ...arrow("‹", canLeft)];
|
|
100
|
+
for (let i = 0; i < l.track.w; i++) row.push(i >= t.start && i < t.start + t.len ? cell("█", "btn") : cell("░", "text.muted"));
|
|
101
|
+
if (l.label) row.push(cell(" "), ...textCells(l.label, "text.muted"), cell(" "));
|
|
102
|
+
const right: Cell[] = [...arrow("›", canRight), ...(l.pageRight ? arrow("»", canRight) : [])];
|
|
103
|
+
while (row.length < width - right.length) row.push(cell(" "));
|
|
104
|
+
return [...row, ...right].slice(0, width);
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
/** Pinned columns never take more than this share of the pane: a pinned part that fills it would leave nothing to scroll. */
|
|
108
|
+
export const PIN_SHARE = 0.5;
|
|
109
|
+
export const pinBudget = (view: number): number => Math.floor(view * PIN_SHARE);
|
|
110
|
+
|
|
111
|
+
/** How many cells at the left stay put while the rest scrolls: `frozen` when it is within the budget, else none. */
|
|
112
|
+
export const frozenFor = (frozen: number, view: number): number => (frozen > 0 && frozen <= pinBudget(view) ? frozen : 0);
|
|
113
|
+
|
|
114
|
+
/**
|
|
115
|
+
* Move an anchor (a rect and perhaps a badge position) to where its column is after the block was scrolled by `dx` with `frozen`
|
|
116
|
+
* columns kept in place. What is behind the frozen part is cut off, so a badge or a hit area never lands on the frozen column;
|
|
117
|
+
* what is entirely behind it keeps a rect one cell wide at the edge with no width (nothing to hit, nothing to draw).
|
|
118
|
+
*/
|
|
119
|
+
export function shiftAnchor<T extends { rect: Rect; badge?: [number, number] }>(a: T, dx: number, frozen: number): void {
|
|
120
|
+
const r = a.rect;
|
|
121
|
+
if (!dx) return;
|
|
122
|
+
if (!frozen) {
|
|
123
|
+
a.rect = { ...r, x: r.x - dx };
|
|
124
|
+
if (a.badge) a.badge = [a.badge[0] - dx, a.badge[1]];
|
|
125
|
+
return;
|
|
126
|
+
}
|
|
127
|
+
if (r.x < frozen) {
|
|
128
|
+
if (r.x + r.w > frozen) a.rect = { ...r, w: r.w - dx }; // a row across the frozen part: its far end moves
|
|
129
|
+
return; // in the frozen part: stays
|
|
130
|
+
}
|
|
131
|
+
let x = r.x - dx;
|
|
132
|
+
let w = r.w;
|
|
133
|
+
if (x < frozen) {
|
|
134
|
+
w -= frozen - x;
|
|
135
|
+
x = frozen;
|
|
136
|
+
}
|
|
137
|
+
a.rect = { ...r, x, w: Math.max(0, w) };
|
|
138
|
+
if (a.badge) a.badge = a.badge[0] - dx >= frozen && w > 0 ? [a.badge[0] - dx, a.badge[1]] : undefined;
|
|
139
|
+
}
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
// The one place a block wider than its pane is cropped, scrolled and given its pan bar (see pan.ts for the maths).
|
|
2
|
+
import type { Ctx, Block, Target } from "./types.ts";
|
|
3
|
+
import { barLayout, barRow, clampPan, DEFAULT_FINE_STEP, frozenFor } from "./pan.ts";
|
|
4
|
+
import { cell, padRow } from "./util.ts";
|
|
5
|
+
|
|
6
|
+
/**
|
|
7
|
+
* Crop a block wider than `width` to a window starting at the saved pan offset, and put a pan bar under it: the one place that
|
|
8
|
+
* says "there is more" and the buttons that move it. A block that fits is returned as it is, with no bar.
|
|
9
|
+
* `opts.edges` are the left edges of a table's columns, so a tap lands on a column. `opts.frozen` is how many cells at the left
|
|
10
|
+
* stay in place while the rest scrolls (a table's first column), when that is at most 40% of the view.
|
|
11
|
+
*/
|
|
12
|
+
export function panBlock(inner: Block, width: number, key: string, ctx: Ctx, opts: { edges?: number[]; frozen?: number } = {}): Block {
|
|
13
|
+
if (inner.w <= width) {
|
|
14
|
+
delete ctx.state.pan[key]; // it fits now (the pane grew): nothing is left to scroll, and a later narrow pane starts at the left
|
|
15
|
+
return inner;
|
|
16
|
+
}
|
|
17
|
+
const pan = clampPan(ctx.state.pan[key] ?? 0, inner.w, width);
|
|
18
|
+
ctx.state.pan[key] = pan;
|
|
19
|
+
const F = frozenFor(opts.frozen ?? 0, width);
|
|
20
|
+
const rows = inner.rows.map((r) => {
|
|
21
|
+
const full = padRow(r, inner.w);
|
|
22
|
+
// the window starts after the frozen cells: at pan 0 this is the plain crop
|
|
23
|
+
const seg = full.slice(F + pan, pan + width);
|
|
24
|
+
if (seg.length && seg[0].ch === "") seg[0] = cell(" ");
|
|
25
|
+
if (seg.length && pan + width < inner.w && seg[seg.length - 1].ch !== "" && full[pan + width]?.ch === "") seg[seg.length - 1] = cell(" ");
|
|
26
|
+
return F ? full.slice(0, F).concat(seg) : seg;
|
|
27
|
+
});
|
|
28
|
+
const targets: Target[] = inner.targets.map((t) => {
|
|
29
|
+
if (F && t.rect.x < F) return t; // in the frozen part, or a row across it: always where it is
|
|
30
|
+
return { ...t, rect: { ...t.rect, x: t.rect.x - pan }, pan: { key, x: t.rect.x, w: t.rect.w, view: width, total: inner.w, ...(F ? { min: F } : {}) } };
|
|
31
|
+
});
|
|
32
|
+
const y = rows.length;
|
|
33
|
+
const bar = barLayout(width, pan, inner.w, width);
|
|
34
|
+
const step = ctx.services.settings?.scrollStep ?? DEFAULT_FINE_STEP;
|
|
35
|
+
type Part = "left" | "right" | "pageleft" | "pageright" | "track";
|
|
36
|
+
const scroll = (part: Part) => ({ key, part, total: inner.w, view: width, edges: opts.edges, step });
|
|
37
|
+
const part = (id: Part, label: string, r: { x: number; w: number }): Target => ({ id: `bar:${key}:${id}`, kind: "pan", rect: { x: r.x, y, w: r.w, h: 1 }, label, scroll: scroll(id) });
|
|
38
|
+
if (bar.pageLeft) targets.push(part("pageleft", "page left", bar.pageLeft));
|
|
39
|
+
targets.push(part("left", "scroll left", bar.left), part("track", "scroll", bar.track), part("right", "scroll right", bar.right));
|
|
40
|
+
if (bar.pageRight) targets.push(part("pageright", "page right", bar.pageRight));
|
|
41
|
+
// the area carries the numbers a wheel or a drag needs (`part` means nothing here)
|
|
42
|
+
targets.push({ id: `area:${key}`, kind: "panarea", rect: { x: 0, y: 0, w: width, h: y + 1 }, label: "", scroll: scroll("track") });
|
|
43
|
+
return { w: width, rows: [...rows, barRow(width, pan, inner.w, width)], targets };
|
|
44
|
+
}
|
package/src/blocks/render.ts
CHANGED
|
@@ -9,7 +9,8 @@ import { markdownBlock } from "./markdown.ts";
|
|
|
9
9
|
import { sequence } from "./sequence.ts";
|
|
10
10
|
import { collectChoices } from "./choice.ts";
|
|
11
11
|
import { decorateNotes, widgetTitle } from "./notes.ts";
|
|
12
|
-
import {
|
|
12
|
+
import { frozenFor, shiftAnchor } from "./pan.ts";
|
|
13
|
+
import { cutCells, pinIndexes, tableBlock, tablePan } from "./table.ts";
|
|
13
14
|
import type { Block, Ctx, Services, WState } from "./types.ts";
|
|
14
15
|
import { blank, textBlock, vstack } from "./util.ts";
|
|
15
16
|
|
|
@@ -34,9 +35,17 @@ export function renderTree(root: El, width: number, state: WState, services: Ser
|
|
|
34
35
|
return { block, warnings };
|
|
35
36
|
}
|
|
36
37
|
|
|
38
|
+
/**
|
|
39
|
+
* The net under every component. Each one shortens its own text or scrolls on its own, so nothing is wider than its pane today; a
|
|
40
|
+
* component that is wider anyway (a new one, a layout that cannot give) gets the pan bar here, never a silent cut by the widget.
|
|
41
|
+
*/
|
|
42
|
+
export function guardWidth(block: Block, width: number, path: string, ctx: Ctx): Block {
|
|
43
|
+
return block.w > width ? panBlock(block, width, `pan:${path}~`, ctx) : block;
|
|
44
|
+
}
|
|
45
|
+
|
|
37
46
|
export function renderEl(el: El, width: number, path: string, ctx: Ctx): Block {
|
|
38
47
|
try {
|
|
39
|
-
const block = renderInner(el, width, path, ctx);
|
|
48
|
+
const block = guardWidth(renderInner(el, width, path, ctx), width, path, ctx);
|
|
40
49
|
addWholeItem(el, block, path, ctx);
|
|
41
50
|
return block;
|
|
42
51
|
} catch (e) {
|
|
@@ -68,7 +77,7 @@ function renderInner(el: El, width: number, path: string, ctx: Ctx): Block {
|
|
|
68
77
|
return markdownBlock(String(p.text ?? ""), width, ctx, path);
|
|
69
78
|
case "CodeBlock": {
|
|
70
79
|
const id = `copy:${path}`;
|
|
71
|
-
return codeBlock(String(p.code ?? ""), p.language, p.title, width, id, ctx.state.copied === id);
|
|
80
|
+
return codeBlock(String(p.code ?? ""), p.language, p.title, width, id, ctx.state.copied === id, { ctx, key: `pan:${path}` });
|
|
72
81
|
}
|
|
73
82
|
case "Metric": {
|
|
74
83
|
const m = metric(el, width, path);
|
|
@@ -79,23 +88,23 @@ function renderInner(el: El, width: number, path: string, ctx: Ctx): Block {
|
|
|
79
88
|
return withTips(el, m.block, m.anchors, path, ctx, width);
|
|
80
89
|
}
|
|
81
90
|
case "Table": {
|
|
82
|
-
const t = tableBlock(p.columns ?? [], p.rows ?? [], width);
|
|
83
|
-
const anchors = new Map<string, Anchor>();
|
|
84
91
|
const cols: string[] = (p.columns ?? []).map(String);
|
|
92
|
+
const t = tableBlock(p.columns ?? [], p.rows ?? [], width, { minCol: ctx.services.settings?.tableMinCol ?? 12, pin: pinIndexes(p.pin, cols) });
|
|
93
|
+
const anchors = new Map<string, Anchor>();
|
|
85
94
|
const pad = (c: { x: number; w: number }, y: number): [number, number] => [c.x + c.w, y];
|
|
86
95
|
const lastCol = t.anchors.cols[t.anchors.cols.length - 1];
|
|
87
96
|
t.anchors.rows.forEach((r, i) => anchors.set(`row:${i + 1}`, { rect: r, badge: lastCol ? pad(lastCol, r.y) : undefined }));
|
|
88
|
-
|
|
89
|
-
t.anchors.
|
|
90
|
-
|
|
91
|
-
|
|
92
|
-
const
|
|
93
|
-
|
|
94
|
-
|
|
95
|
-
a.rect = { ...a.rect, x: a.rect.x - dx };
|
|
96
|
-
if (a.badge) a.badge = [a.badge[0] - dx, a.badge[1]];
|
|
97
|
-
}
|
|
97
|
+
// the columns may be drawn in another order (pinned ones first): the names follow them
|
|
98
|
+
t.anchors.cols.forEach((r, i) => anchors.set(`col:${cols[t.order[i]]}`, { rect: r, badge: pad(r, r.y) }));
|
|
99
|
+
t.anchors.cells.forEach((rs, ri) => rs.forEach((r, ci) => anchors.set(`cell:${ri + 1}.${cols[t.order[ci]]}`, { rect: r, badge: pad(r, r.y) })));
|
|
100
|
+
// a cell that was cut to fit can be read whole: it gets a target (and a panel) of its own
|
|
101
|
+
for (const c of cutCells(t, cols, p.rows ?? [])) {
|
|
102
|
+
const a = anchors.get(c.key);
|
|
103
|
+
if (a) Object.assign(a, { full: c.full, label: c.label });
|
|
98
104
|
}
|
|
105
|
+
const key = `pan:${path}`;
|
|
106
|
+
const pb = panBlock(t.block, width, key, ctx, tablePan(t, width));
|
|
107
|
+
if (pb !== t.block) for (const [, a] of anchors) shiftAnchor(a, ctx.state.pan[key] ?? 0, frozenFor(t.frozen, width));
|
|
99
108
|
return withTips(el, pb, anchors, path, ctx, width);
|
|
100
109
|
}
|
|
101
110
|
case "BarChart": {
|
|
@@ -135,8 +144,7 @@ function renderInner(el: El, width: number, path: string, ctx: Ctx): Block {
|
|
|
135
144
|
const s = sequence(el, width, path);
|
|
136
145
|
const b = panBlock(s.block, width, `pan:${path}`, ctx);
|
|
137
146
|
if (b !== s.block) {
|
|
138
|
-
const
|
|
139
|
-
const dx = pan ? pan.x - b.targets[0].rect.x : 0;
|
|
147
|
+
const dx = ctx.state.pan[`pan:${path}`] ?? 0;
|
|
140
148
|
for (const [, a] of s.anchors) {
|
|
141
149
|
a.rect = { ...a.rect, x: a.rect.x - dx };
|
|
142
150
|
if (a.badge) a.badge = [a.badge[0] - dx, a.badge[1]];
|
package/src/blocks/sequence.ts
CHANGED
|
@@ -86,14 +86,14 @@ export function sequence(el: El, width: number, path: string): { block: Block; a
|
|
|
86
86
|
return { heads, x, total, margin };
|
|
87
87
|
};
|
|
88
88
|
let lay = layout(48, 22);
|
|
89
|
-
for (const [cap, hc] of [[36, 22], [28, 18], [20, 14]
|
|
89
|
+
for (const [cap, hc] of [[36, 22], [28, 18], [20, 14]] as const) {
|
|
90
90
|
if (lay.total <= width) break;
|
|
91
91
|
lay = layout(cap, hc);
|
|
92
92
|
}
|
|
93
|
-
|
|
94
|
-
|
|
93
|
+
// nothing fits even with the shortest labels: it will scroll, so scroll to whole labels instead of cutting them as well
|
|
94
|
+
if (lay.total > width) lay = layout(48, 22);
|
|
95
95
|
const { heads, x, total } = lay;
|
|
96
|
-
const msgCap =
|
|
96
|
+
const msgCap = 48;
|
|
97
97
|
// rows
|
|
98
98
|
const H0 = 3;
|
|
99
99
|
let y = H0;
|
package/src/blocks/table.ts
CHANGED
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
import { textWidth, truncateText, type Cell, type Rect } from "../diagram/canvas.ts";
|
|
2
2
|
import type { Block } from "./types.ts";
|
|
3
|
+
import { frozenFor, pinBudget } from "./pan.ts";
|
|
3
4
|
import { cell, fromRows, padRow, textCells } from "./util.ts";
|
|
4
5
|
|
|
5
6
|
export interface TableAnchors {
|
|
@@ -10,23 +11,81 @@ export interface TableAnchors {
|
|
|
10
11
|
|
|
11
12
|
const NUMERIC = /^[-+]?[\d.,]+\s*(%|ms|s|kb|mb|gb|tb|x)?$/i;
|
|
12
13
|
|
|
13
|
-
|
|
14
|
+
/**
|
|
15
|
+
* `opts.minCol` is the user's minimum column width. With it, a table that does not fit shrinks its widest columns only down to
|
|
16
|
+
* min(natural, minCol); if even that does not fit, every column takes its natural width and the block is wider than the pane (the
|
|
17
|
+
* caller scrolls it): a table that scrolls cuts nothing, whatever a cell holds. Without it (a markdown table, which has nowhere to scroll) the old rule stands: columns shrink
|
|
18
|
+
* to three characters and no further. `edges` are the left borders of the columns, so a scroll step can land on one.
|
|
19
|
+
*
|
|
20
|
+
* `opts.pin` lists the columns (indexes) that stay in place while the table scrolls; left out, nothing is pinned and the whole table
|
|
21
|
+
* scrolls. Only a table that is wider than the pane has pinned columns, and then they are drawn first, in the order they have in the
|
|
22
|
+
* table (`order` says which column is where). They take at most half the pane (`pinBudget`): they are considered in the order
|
|
23
|
+
* listed, and one that does not fit is skipped. It is never squeezed: a pinned column that lost text could not be scrolled to.
|
|
24
|
+
* `frozen` is their width with their borders, 0 when nothing is pinned.
|
|
25
|
+
*/
|
|
26
|
+
export interface TableBlock {
|
|
27
|
+
block: Block;
|
|
28
|
+
anchors: TableAnchors;
|
|
29
|
+
edges: number[];
|
|
30
|
+
frozen: number;
|
|
31
|
+
order: number[];
|
|
32
|
+
/** per body row and drawn column: the cell was cut to fit (it ends in an ellipsis). Only a table that fits by squeezing cuts anything. */
|
|
33
|
+
cut: boolean[][];
|
|
34
|
+
/** per drawn column: the header was cut */
|
|
35
|
+
headCut: boolean[];
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
export function tableBlock(columns: string[], rows: Array<Array<string | number>>, width: number, opts: { minCol?: number; pin?: number[] } = {}): TableBlock {
|
|
14
39
|
const n = Math.max(1, columns.length);
|
|
15
40
|
const str = (v: string | number) => String(v ?? "").replace(/\s+/g, " ").trim();
|
|
16
|
-
const
|
|
17
|
-
const
|
|
18
|
-
const
|
|
19
|
-
const nat =
|
|
41
|
+
const data0 = rows.map((r) => columns.map((_, i) => str(r[i] ?? "")));
|
|
42
|
+
const heads0 = columns.map(str);
|
|
43
|
+
const numeric0 = heads0.map((_, i) => data0.length > 0 && data0.every((r) => r[i] === "" || NUMERIC.test(r[i])));
|
|
44
|
+
const nat = heads0.map((h, i) => Math.max(textWidth(h), ...data0.map((r) => textWidth(r[i])), 1));
|
|
20
45
|
const avail = Math.max(n, width - (3 * n + 1));
|
|
21
|
-
const
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
sum
|
|
46
|
+
const sum = (a: number[]) => a.reduce((x, y) => x + y, 0);
|
|
47
|
+
const minCol = opts.minCol ?? 3;
|
|
48
|
+
const floors = nat.map((x) => Math.min(x, minCol));
|
|
49
|
+
let w = nat.slice();
|
|
50
|
+
let order = heads0.map((_, i) => i);
|
|
51
|
+
let frozen = 0;
|
|
52
|
+
if (sum(w) > avail) {
|
|
53
|
+
if (sum(floors) <= avail || opts.minCol === undefined) {
|
|
54
|
+
// shrink the widest column, one character at a time, never below its floor
|
|
55
|
+
let total = sum(w);
|
|
56
|
+
while (total > avail) {
|
|
57
|
+
let k = -1;
|
|
58
|
+
for (let i = 0; i < n; i++) if (w[i] > floors[i] && (k < 0 || w[i] > w[k])) k = i;
|
|
59
|
+
if (k < 0) break;
|
|
60
|
+
w[k]--;
|
|
61
|
+
total--;
|
|
62
|
+
}
|
|
63
|
+
} else {
|
|
64
|
+
// the floors do not fit either: every column at its natural width, the table is wider than the pane and scrolls, and what
|
|
65
|
+
// the model asked to pin is pinned if there is room for it
|
|
66
|
+
w = nat.slice();
|
|
67
|
+
const budget = pinBudget(width);
|
|
68
|
+
const chosen: number[] = [];
|
|
69
|
+
let used = 1; // the left border
|
|
70
|
+
for (const i of opts.pin ?? []) {
|
|
71
|
+
if (!Number.isInteger(i) || i < 0 || i >= n || chosen.includes(i)) continue;
|
|
72
|
+
if (used + w[i] + 3 > budget) continue; // not squeezed: text that is cut here could never be scrolled to
|
|
73
|
+
used += w[i] + 3;
|
|
74
|
+
chosen.push(i);
|
|
75
|
+
}
|
|
76
|
+
if (chosen.length && chosen.length < n) {
|
|
77
|
+
chosen.sort((a, b) => a - b);
|
|
78
|
+
order = [...chosen, ...order.filter((i) => !chosen.includes(i))];
|
|
79
|
+
frozen = used;
|
|
80
|
+
}
|
|
81
|
+
}
|
|
29
82
|
}
|
|
83
|
+
// from here on the columns are in the order they are drawn
|
|
84
|
+
const by = <T>(a: T[]): T[] => order.map((i) => a[i]);
|
|
85
|
+
const heads = by(heads0);
|
|
86
|
+
const data = data0.map(by);
|
|
87
|
+
const numeric = by(numeric0);
|
|
88
|
+
w = by(w);
|
|
30
89
|
const total = w.reduce((a, b) => a + b, 0) + 3 * n + 1;
|
|
31
90
|
const line = (l: string, m: string, r: string): Cell[] => {
|
|
32
91
|
const out: Cell[] = [cell(l, "table.border")];
|
|
@@ -66,5 +125,54 @@ export function tableBlock(columns: string[], rows: Array<Array<string | number>
|
|
|
66
125
|
rows: data.map((_, ri) => ({ x: 0, y: 3 + ri, w: total, h: 1 })),
|
|
67
126
|
cells: data.map((_, ri) => w.map((cw, i) => ({ x: colX[i], y: 3 + ri, w: cw, h: 1 }))),
|
|
68
127
|
};
|
|
69
|
-
return {
|
|
128
|
+
return {
|
|
129
|
+
block: fromRows(total, out.map((r) => padRow(r, total))),
|
|
130
|
+
anchors,
|
|
131
|
+
edges: colX.map((x) => x - 2),
|
|
132
|
+
frozen,
|
|
133
|
+
order,
|
|
134
|
+
cut: data.map((r) => r.map((v, i) => textWidth(v) > w[i])),
|
|
135
|
+
headCut: heads.map((h, i) => textWidth(h) > w[i]),
|
|
136
|
+
};
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
/**
|
|
140
|
+
* How `panBlock` should scroll a table. With the first column frozen the window starts F cells after the offset, so an offset that
|
|
141
|
+
* puts a column border there is the border's position minus F (and the start, 0, stays a place to stop). Without that a step lands
|
|
142
|
+
* in the middle of a column.
|
|
143
|
+
*/
|
|
144
|
+
export function tablePan(t: { edges: number[]; frozen: number }, width: number): { edges: number[]; frozen: number } {
|
|
145
|
+
const F = frozenFor(t.frozen, width);
|
|
146
|
+
return { edges: F ? [0, ...t.edges.map((e) => e - F).filter((e) => e > 0)] : t.edges, frozen: t.frozen };
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
/**
|
|
150
|
+
* The columns a Table's `pin` names, as indexes: by header, the way the table draws it (spacing collapsed). Undefined when the
|
|
151
|
+
* model did not say (the first column is pinned then); a name that matches nothing is left out.
|
|
152
|
+
*/
|
|
153
|
+
export function pinIndexes(pin: unknown, columns: string[]): number[] | undefined {
|
|
154
|
+
if (!Array.isArray(pin)) return undefined;
|
|
155
|
+
const norm = (v: unknown) => String(v ?? "").replace(/\s+/g, " ").trim();
|
|
156
|
+
const heads = columns.map(norm);
|
|
157
|
+
return pin.map((name) => heads.indexOf(norm(name))).filter((i) => i >= 0);
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
/**
|
|
161
|
+
* The cells a table cut to fit, each with what it takes to read it whole: the anchor key a tooltip would use (`cell:3.Notes`,
|
|
162
|
+
* `col:Notes`), where it is drawn, its full text as it was written, and a short name for the hover line. For a Table and a
|
|
163
|
+
* Markdown table alike.
|
|
164
|
+
*/
|
|
165
|
+
export function cutCells(t: TableBlock, columns: string[], rows: Array<Array<string | number>>): Array<{ key: string; rect: Rect; full: string; label: string }> {
|
|
166
|
+
const out: Array<{ key: string; rect: Rect; full: string; label: string }> = [];
|
|
167
|
+
const head = columns.map((c) => String(c ?? ""));
|
|
168
|
+
t.headCut.forEach((cut, ci) => {
|
|
169
|
+
if (cut) out.push({ key: `col:${head[t.order[ci]]}`, rect: t.anchors.cols[ci], full: head[t.order[ci]].trim(), label: `column ${head[t.order[ci]].replace(/\s+/g, " ").trim()}` });
|
|
170
|
+
});
|
|
171
|
+
t.cut.forEach((row, ri) =>
|
|
172
|
+
row.forEach((cut, ci) => {
|
|
173
|
+
const col = t.order[ci];
|
|
174
|
+
if (cut) out.push({ key: `cell:${ri + 1}.${head[col]}`, rect: t.anchors.cells[ri][ci], full: String(rows[ri]?.[col] ?? "").trim(), label: `${head[col].replace(/\s+/g, " ").trim()}, row ${ri + 1}` });
|
|
175
|
+
}),
|
|
176
|
+
);
|
|
177
|
+
return out;
|
|
70
178
|
}
|
package/src/blocks/types.ts
CHANGED
|
@@ -10,6 +10,8 @@ export interface Tip {
|
|
|
10
10
|
tone?: string;
|
|
11
11
|
link?: string;
|
|
12
12
|
linkLabel?: string;
|
|
13
|
+
/** the whole text of a cell that was cut to fit the pane: the panel shows it as plain text, under whatever else the tip says */
|
|
14
|
+
full?: string;
|
|
13
15
|
}
|
|
14
16
|
|
|
15
17
|
export type TargetKind =
|
|
@@ -33,7 +35,11 @@ export type TargetKind =
|
|
|
33
35
|
| "noteadd"
|
|
34
36
|
| "noteclear"
|
|
35
37
|
/** the footer chip that turns notes mode on and off */
|
|
36
|
-
| "notes"
|
|
38
|
+
| "notes"
|
|
39
|
+
/** a part of the pan bar under a block that is wider than its pane: the left arrow, the right arrow or the track */
|
|
40
|
+
| "pan"
|
|
41
|
+
/** the area of a clipped block, body and bar. Never reachable: the widget asks which one is under a wheel or a drag */
|
|
42
|
+
| "panarea";
|
|
37
43
|
|
|
38
44
|
/** What a note says about the element it is attached to. All words are already cleaned for a message the user will send. */
|
|
39
45
|
export interface ItemInfo {
|
|
@@ -87,7 +93,11 @@ export interface Target {
|
|
|
87
93
|
/** for kind "image": where the picture goes and how big it is, in cells */
|
|
88
94
|
image?: { key: string; cols: number; rows: number };
|
|
89
95
|
/** a diagram that may be wider than its view: focusing this target should reveal it */
|
|
90
|
-
|
|
96
|
+
/** `min` is where the scrolling part begins when the first columns are frozen: what is left of it is behind them */
|
|
97
|
+
pan?: { key: string; x: number; w: number; view: number; total: number; min?: number };
|
|
98
|
+
/** for kind "pan": which part of which bar this is, and what it needs to move the offset */
|
|
99
|
+
/** `left`/`right` are the fine arrows (`step` cells), `pageleft`/`pageright` the page arrows (three quarters of the pane, to a column edge) */
|
|
100
|
+
scroll?: { key: string; part: "left" | "right" | "pageleft" | "pageright" | "track"; total: number; view: number; edges?: number[]; step?: number };
|
|
91
101
|
}
|
|
92
102
|
|
|
93
103
|
export interface Block {
|
|
@@ -129,6 +139,8 @@ export interface ImageService {
|
|
|
129
139
|
|
|
130
140
|
export interface Services {
|
|
131
141
|
images?: ImageService;
|
|
142
|
+
/** the user's settings (src/pi/config.ts); the defaults when absent */
|
|
143
|
+
settings?: { tableMinCol: number; scrollStep: number };
|
|
132
144
|
}
|
|
133
145
|
|
|
134
146
|
export interface Ctx {
|