terminoir 0.7.0 → 0.9.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/README.md +6 -1
- package/dist/components/BucketGrid.svelte +109 -0
- package/dist/components/BucketGrid.svelte.d.ts +25 -0
- package/dist/components/ExpressionEditor.svelte +186 -0
- package/dist/components/ExpressionEditor.svelte.d.ts +17 -0
- package/dist/components/GridLayout.svelte +194 -0
- package/dist/components/GridLayout.svelte.d.ts +15 -0
- package/dist/components/MarkdownEditor.svelte +362 -0
- package/dist/components/MarkdownEditor.svelte.d.ts +18 -0
- package/dist/components/Timeline.svelte +130 -0
- package/dist/components/Timeline.svelte.d.ts +11 -0
- package/dist/index.d.ts +9 -0
- package/dist/index.js +6 -0
- package/dist/utils/expression.d.ts +49 -0
- package/dist/utils/expression.js +116 -0
- package/dist/utils/layout.d.ts +15 -0
- package/dist/utils/layout.js +27 -0
- package/dist/utils/markdownEditor/blocks.d.ts +27 -0
- package/dist/utils/markdownEditor/blocks.js +107 -0
- package/dist/utils/markdownEditor/triggers.d.ts +17 -0
- package/dist/utils/markdownEditor/triggers.js +69 -0
- package/dist/utils/markdownEditor/wikilink.d.ts +20 -0
- package/dist/utils/markdownEditor/wikilink.js +92 -0
- package/dist/utils/timeline.d.ts +36 -0
- package/dist/utils/timeline.js +48 -0
- package/package.json +3 -2
|
@@ -0,0 +1,116 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Typed expression trees for a block editor. The op catalogue (e.g. Oyster's
|
|
3
|
+
* `GET /expressions/ops`) says what each op returns and what its slots accept; the editor only
|
|
4
|
+
* offers ops whose result fits the slot, so users cannot build an ill-typed tree.
|
|
5
|
+
*/
|
|
6
|
+
/** A slot of `kind` accepts an op returning `returns` (`value` is the wildcard both ways, `ids` only fits `ids`). */
|
|
7
|
+
export function fits(kind, returns) {
|
|
8
|
+
if (kind === 'ids' || returns === 'ids')
|
|
9
|
+
return kind === returns;
|
|
10
|
+
return kind === 'value' || returns === 'value' || kind === returns;
|
|
11
|
+
}
|
|
12
|
+
export function opsFor(kind, ops) {
|
|
13
|
+
return ops.filter((op) => fits(kind, op.returns));
|
|
14
|
+
}
|
|
15
|
+
/** A fresh node for an op: empty single slots, one empty entry in list slots, blank params. */
|
|
16
|
+
export function emptyNode(op) {
|
|
17
|
+
const node = { op: op.op };
|
|
18
|
+
for (const slot of op.slots)
|
|
19
|
+
node[slot.name] = slot.many ? [] : null;
|
|
20
|
+
for (const param of op.params)
|
|
21
|
+
node[param] = param === 'options' ? [] : '';
|
|
22
|
+
return node;
|
|
23
|
+
}
|
|
24
|
+
/** Text typed into a param, as the value it stands for (numbers and true/false become typed). */
|
|
25
|
+
export function parseParam(param, text) {
|
|
26
|
+
if (param === 'options')
|
|
27
|
+
return text.split(',').map((s) => s.trim()).filter(Boolean);
|
|
28
|
+
const trimmed = text.trim();
|
|
29
|
+
if (param === 'key')
|
|
30
|
+
return trimmed;
|
|
31
|
+
if (trimmed === 'true' || trimmed === 'false')
|
|
32
|
+
return trimmed === 'true';
|
|
33
|
+
if (trimmed !== '' && !Number.isNaN(Number(trimmed)))
|
|
34
|
+
return Number(trimmed);
|
|
35
|
+
return text;
|
|
36
|
+
}
|
|
37
|
+
export function paramText(value) {
|
|
38
|
+
if (Array.isArray(value))
|
|
39
|
+
return value.join(', ');
|
|
40
|
+
return value == null ? '' : String(value);
|
|
41
|
+
}
|
|
42
|
+
/** True when every slot is filled and every key param names a field. */
|
|
43
|
+
export function isComplete(node, ops) {
|
|
44
|
+
if (!node || typeof node !== 'object')
|
|
45
|
+
return false;
|
|
46
|
+
const n = node;
|
|
47
|
+
const spec = ops.find((o) => o.op === n.op);
|
|
48
|
+
if (!spec)
|
|
49
|
+
return false;
|
|
50
|
+
if (spec.params.includes('key') && !n.key)
|
|
51
|
+
return false;
|
|
52
|
+
return spec.slots.every((slot) => {
|
|
53
|
+
const value = n[slot.name];
|
|
54
|
+
if (slot.many)
|
|
55
|
+
return Array.isArray(value) && value.length > 0 && value.every((v) => isComplete(v, ops));
|
|
56
|
+
return isComplete(value, ops);
|
|
57
|
+
});
|
|
58
|
+
}
|
|
59
|
+
/** Plain-English reading of a tree, built from op labels and slot names (no per-op templates). */
|
|
60
|
+
export function describe(node, ops, fields = []) {
|
|
61
|
+
if (!node || typeof node !== 'object')
|
|
62
|
+
return '…';
|
|
63
|
+
const n = node;
|
|
64
|
+
const spec = ops.find((o) => o.op === n.op);
|
|
65
|
+
if (!spec)
|
|
66
|
+
return n.op;
|
|
67
|
+
const label = spec.label.toLowerCase();
|
|
68
|
+
if ('key' in n && spec.params.includes('key')) {
|
|
69
|
+
const field = fields.find((f) => f.key === n.key);
|
|
70
|
+
const name = field?.label ?? (n.key ? String(n.key) : '…');
|
|
71
|
+
return spec.slots.length ? `${label} ${name}` : spec.op === 'field' ? name : `${label} ${name}`;
|
|
72
|
+
}
|
|
73
|
+
if (spec.params.includes('value') && !spec.slots.length)
|
|
74
|
+
return JSON.stringify(n.value ?? '');
|
|
75
|
+
const part = (v) => describe(v, ops, fields);
|
|
76
|
+
const names = spec.slots.map((s) => s.name);
|
|
77
|
+
if (names.length === 2 && names[0] === 'left')
|
|
78
|
+
return `${part(n.left)} ${label} ${part(n.right)}`;
|
|
79
|
+
const many = spec.slots.find((s) => s.many);
|
|
80
|
+
if (many) {
|
|
81
|
+
const items = (Array.isArray(n[many.name]) ? n[many.name] : []).map(part);
|
|
82
|
+
return items.length > 1 ? `(${items.join(` ${label} `)})` : (items[0] ?? '…');
|
|
83
|
+
}
|
|
84
|
+
const pieces = spec.slots.map((s) => part(n[s.name]));
|
|
85
|
+
const options = spec.params.includes('options') ? ` ${paramText(n.options)}` : '';
|
|
86
|
+
if (!pieces.length)
|
|
87
|
+
return label;
|
|
88
|
+
if (pieces.length > 1)
|
|
89
|
+
return `${label} ${pieces.join(', ')}`;
|
|
90
|
+
// "is empty", "is one of" read after their argument; "not", "days until" before it.
|
|
91
|
+
return label.startsWith('is ') ? `${pieces[0]} ${label}${options}` : `${label} ${pieces[0]}`;
|
|
92
|
+
}
|
|
93
|
+
/** A copy of `root` with the value at `path` replaced (`undefined` removes a list entry). */
|
|
94
|
+
export function setAt(root, path, value) {
|
|
95
|
+
if (!path.length)
|
|
96
|
+
return value;
|
|
97
|
+
const [head, ...rest] = path;
|
|
98
|
+
if (Array.isArray(root)) {
|
|
99
|
+
const next = root.slice();
|
|
100
|
+
const index = Number(head);
|
|
101
|
+
if (!rest.length && value === undefined)
|
|
102
|
+
next.splice(index, 1);
|
|
103
|
+
else
|
|
104
|
+
next[index] = setAt(root[index], rest, value);
|
|
105
|
+
return next;
|
|
106
|
+
}
|
|
107
|
+
const obj = (root && typeof root === 'object' ? root : {});
|
|
108
|
+
return { ...obj, [head]: setAt(obj[head], rest, value) };
|
|
109
|
+
}
|
|
110
|
+
/** Ops grouped by their `group`, in first-seen order, for an `<optgroup>` picker. */
|
|
111
|
+
export function groupOps(ops) {
|
|
112
|
+
const groups = new Map();
|
|
113
|
+
for (const op of ops)
|
|
114
|
+
groups.set(op.group, [...(groups.get(op.group) ?? []), op]);
|
|
115
|
+
return [...groups].map(([group, list]) => ({ group, ops: list }));
|
|
116
|
+
}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
/** Reading-order grid layout helpers: drag to reorder, drag an edge to change column span. */
|
|
2
|
+
export type GridItem = {
|
|
3
|
+
id: string;
|
|
4
|
+
span: number;
|
|
5
|
+
};
|
|
6
|
+
export declare function moveTo<T extends {
|
|
7
|
+
id: string;
|
|
8
|
+
}>(items: T[], id: string, index: number): T[];
|
|
9
|
+
/** Index of the item whose centre is nearest to a point (ties keep the earlier one). */
|
|
10
|
+
export declare function nearestIndex(centres: {
|
|
11
|
+
x: number;
|
|
12
|
+
y: number;
|
|
13
|
+
}[], x: number, y: number): number;
|
|
14
|
+
/** Column span after dragging an edge by `dx` pixels, snapped to whole columns and clamped. */
|
|
15
|
+
export declare function spanAfterDrag(span: number, dx: number, columnWidth: number, min: number, max: number): number;
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Reading-order grid layout helpers: drag to reorder, drag an edge to change column span. */
|
|
2
|
+
export function moveTo(items, id, index) {
|
|
3
|
+
const from = items.findIndex((i) => i.id === id);
|
|
4
|
+
if (from < 0)
|
|
5
|
+
return items;
|
|
6
|
+
const next = items.slice();
|
|
7
|
+
const [item] = next.splice(from, 1);
|
|
8
|
+
next.splice(Math.max(0, Math.min(index, next.length)), 0, item);
|
|
9
|
+
return next;
|
|
10
|
+
}
|
|
11
|
+
/** Index of the item whose centre is nearest to a point (ties keep the earlier one). */
|
|
12
|
+
export function nearestIndex(centres, x, y) {
|
|
13
|
+
let best = 0;
|
|
14
|
+
let bestDistance = Infinity;
|
|
15
|
+
centres.forEach((c, i) => {
|
|
16
|
+
const distance = (c.x - x) ** 2 + (c.y - y) ** 2;
|
|
17
|
+
if (distance < bestDistance)
|
|
18
|
+
[best, bestDistance] = [i, distance];
|
|
19
|
+
});
|
|
20
|
+
return best;
|
|
21
|
+
}
|
|
22
|
+
/** Column span after dragging an edge by `dx` pixels, snapped to whole columns and clamped. */
|
|
23
|
+
export function spanAfterDrag(span, dx, columnWidth, min, max) {
|
|
24
|
+
if (columnWidth <= 0)
|
|
25
|
+
return span;
|
|
26
|
+
return Math.max(min, Math.min(max, span + Math.round(dx / columnWidth)));
|
|
27
|
+
}
|
|
@@ -0,0 +1,27 @@
|
|
|
1
|
+
/** Block types offered by the `/` menu, task-list checkboxes, and markdown output tidying. */
|
|
2
|
+
import type { Ctx } from '@milkdown/kit/ctx';
|
|
3
|
+
import type { Node } from '@milkdown/kit/prose/model';
|
|
4
|
+
import { type EditorView } from '@milkdown/kit/prose/view';
|
|
5
|
+
export type BlockItem = {
|
|
6
|
+
id: string;
|
|
7
|
+
label: string;
|
|
8
|
+
icon: string;
|
|
9
|
+
/** Extra words the `/` query matches, e.g. "h1" or "todo". */
|
|
10
|
+
keywords: string;
|
|
11
|
+
run: (ctx: Ctx) => void;
|
|
12
|
+
};
|
|
13
|
+
export declare const BLOCK_ITEMS: BlockItem[];
|
|
14
|
+
export declare function matchBlocks(query: string): BlockItem[];
|
|
15
|
+
/** List items with a `checked` attribute (task lists) get a real checkbox. */
|
|
16
|
+
export declare const taskListItemView: import("@milkdown/kit/utils").$View<import("@milkdown/kit/utils").$Node, (initial: Node, view: EditorView, getPos: () => number | undefined) => {
|
|
17
|
+
dom: HTMLLIElement;
|
|
18
|
+
contentDOM: HTMLDivElement;
|
|
19
|
+
update(next: Node): boolean;
|
|
20
|
+
ignoreMutation: (mutation: {
|
|
21
|
+
target: globalThis.Node;
|
|
22
|
+
}) => boolean;
|
|
23
|
+
}>;
|
|
24
|
+
/** Hint text on an empty document. */
|
|
25
|
+
export declare function placeholderPlugin(text: string): import("@milkdown/kit/utils").$Prose;
|
|
26
|
+
/** Milkdown writes empty paragraphs as `<br />`; plain blank lines read better everywhere else. */
|
|
27
|
+
export declare function tidyMarkdown(markdown: string): string;
|
|
@@ -0,0 +1,107 @@
|
|
|
1
|
+
import { editorViewCtx } from '@milkdown/kit/core';
|
|
2
|
+
import { createCodeBlockCommand, insertHrCommand, listItemSchema, turnIntoTextCommand, wrapInBlockquoteCommand, wrapInBulletListCommand, wrapInHeadingCommand, wrapInOrderedListCommand } from '@milkdown/kit/preset/commonmark';
|
|
3
|
+
import { Plugin } from '@milkdown/kit/prose/state';
|
|
4
|
+
import { Decoration, DecorationSet } from '@milkdown/kit/prose/view';
|
|
5
|
+
import { $prose, $view, callCommand } from '@milkdown/kit/utils';
|
|
6
|
+
function markNearestListItemAsTask(ctx) {
|
|
7
|
+
const view = ctx.get(editorViewCtx);
|
|
8
|
+
const { $from } = view.state.selection;
|
|
9
|
+
for (let depth = $from.depth; depth > 0; depth--) {
|
|
10
|
+
const node = $from.node(depth);
|
|
11
|
+
if (node.type.name === 'list_item') {
|
|
12
|
+
view.dispatch(view.state.tr.setNodeMarkup($from.before(depth), undefined, { ...node.attrs, checked: false }));
|
|
13
|
+
return;
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
}
|
|
17
|
+
export const BLOCK_ITEMS = [
|
|
18
|
+
{ id: 'text', label: 'Text', icon: 'paragraph', keywords: 'paragraph plain', run: (ctx) => callCommand(turnIntoTextCommand.key)(ctx) },
|
|
19
|
+
{ id: 'h1', label: 'Heading 1', icon: 'heading', keywords: 'h1 title', run: (ctx) => callCommand(wrapInHeadingCommand.key, 1)(ctx) },
|
|
20
|
+
{ id: 'h2', label: 'Heading 2', icon: 'heading', keywords: 'h2 subtitle', run: (ctx) => callCommand(wrapInHeadingCommand.key, 2)(ctx) },
|
|
21
|
+
{ id: 'h3', label: 'Heading 3', icon: 'heading', keywords: 'h3', run: (ctx) => callCommand(wrapInHeadingCommand.key, 3)(ctx) },
|
|
22
|
+
{
|
|
23
|
+
id: 'todo',
|
|
24
|
+
label: 'Checklist',
|
|
25
|
+
icon: 'square-check',
|
|
26
|
+
keywords: 'todo task checkbox',
|
|
27
|
+
run: (ctx) => {
|
|
28
|
+
callCommand(wrapInBulletListCommand.key)(ctx);
|
|
29
|
+
markNearestListItemAsTask(ctx);
|
|
30
|
+
}
|
|
31
|
+
},
|
|
32
|
+
{ id: 'bullet', label: 'Bulleted list', icon: 'list-ul', keywords: 'ul unordered', run: (ctx) => callCommand(wrapInBulletListCommand.key)(ctx) },
|
|
33
|
+
{ id: 'number', label: 'Numbered list', icon: 'list-ol', keywords: 'ol ordered', run: (ctx) => callCommand(wrapInOrderedListCommand.key)(ctx) },
|
|
34
|
+
{ id: 'quote', label: 'Quote', icon: 'quote-left', keywords: 'blockquote', run: (ctx) => callCommand(wrapInBlockquoteCommand.key)(ctx) },
|
|
35
|
+
{ id: 'code', label: 'Code', icon: 'code', keywords: 'codeblock pre', run: (ctx) => callCommand(createCodeBlockCommand.key)(ctx) },
|
|
36
|
+
{ id: 'divider', label: 'Divider', icon: 'minus', keywords: 'hr rule separator', run: (ctx) => callCommand(insertHrCommand.key)(ctx) }
|
|
37
|
+
];
|
|
38
|
+
export function matchBlocks(query) {
|
|
39
|
+
const q = query.trim().toLowerCase();
|
|
40
|
+
if (!q)
|
|
41
|
+
return BLOCK_ITEMS;
|
|
42
|
+
return BLOCK_ITEMS.filter((item) => `${item.label} ${item.keywords}`.toLowerCase().includes(q));
|
|
43
|
+
}
|
|
44
|
+
/** List items with a `checked` attribute (task lists) get a real checkbox. */
|
|
45
|
+
export const taskListItemView = $view(listItemSchema.node, () => (initial, view, getPos) => {
|
|
46
|
+
let node = initial;
|
|
47
|
+
const dom = document.createElement('li');
|
|
48
|
+
const box = document.createElement('input');
|
|
49
|
+
box.type = 'checkbox';
|
|
50
|
+
box.contentEditable = 'false';
|
|
51
|
+
box.className = 'terminoir-md-task-box';
|
|
52
|
+
box.addEventListener('mousedown', (event) => event.preventDefault());
|
|
53
|
+
box.addEventListener('click', () => {
|
|
54
|
+
const pos = getPos();
|
|
55
|
+
if (pos === undefined)
|
|
56
|
+
return;
|
|
57
|
+
view.dispatch(view.state.tr.setNodeMarkup(pos, undefined, { ...node.attrs, checked: !node.attrs.checked }));
|
|
58
|
+
});
|
|
59
|
+
const contentDOM = document.createElement('div');
|
|
60
|
+
contentDOM.className = 'terminoir-md-task-body';
|
|
61
|
+
function render() {
|
|
62
|
+
const isTask = node.attrs.checked != null;
|
|
63
|
+
dom.dataset.itemType = isTask ? 'task' : '';
|
|
64
|
+
dom.dataset.checked = String(Boolean(node.attrs.checked));
|
|
65
|
+
box.checked = Boolean(node.attrs.checked);
|
|
66
|
+
box.hidden = !isTask;
|
|
67
|
+
}
|
|
68
|
+
dom.append(box, contentDOM);
|
|
69
|
+
render();
|
|
70
|
+
return {
|
|
71
|
+
dom,
|
|
72
|
+
contentDOM,
|
|
73
|
+
update(next) {
|
|
74
|
+
if (next.type !== node.type)
|
|
75
|
+
return false;
|
|
76
|
+
node = next;
|
|
77
|
+
render();
|
|
78
|
+
return true;
|
|
79
|
+
},
|
|
80
|
+
ignoreMutation: (mutation) => mutation.target === box
|
|
81
|
+
};
|
|
82
|
+
});
|
|
83
|
+
/** Hint text on an empty document. */
|
|
84
|
+
export function placeholderPlugin(text) {
|
|
85
|
+
return $prose(() => new Plugin({
|
|
86
|
+
props: {
|
|
87
|
+
decorations(state) {
|
|
88
|
+
const { doc } = state;
|
|
89
|
+
const only = doc.firstChild;
|
|
90
|
+
if (doc.childCount !== 1 || !only?.isTextblock || only.content.size)
|
|
91
|
+
return null;
|
|
92
|
+
return DecorationSet.create(doc, [
|
|
93
|
+
Decoration.node(0, only.nodeSize, { class: 'terminoir-md--empty', 'data-placeholder': text })
|
|
94
|
+
]);
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
}));
|
|
98
|
+
}
|
|
99
|
+
/** Milkdown writes empty paragraphs as `<br />`; plain blank lines read better everywhere else. */
|
|
100
|
+
export function tidyMarkdown(markdown) {
|
|
101
|
+
return markdown
|
|
102
|
+
.split('\n')
|
|
103
|
+
.filter((line) => line.trim() !== '<br />')
|
|
104
|
+
.join('\n')
|
|
105
|
+
.replace(/\n{3,}/g, '\n\n')
|
|
106
|
+
.trimEnd();
|
|
107
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
export type TriggerKind = 'slash' | 'link';
|
|
2
|
+
export type Trigger = {
|
|
3
|
+
kind: TriggerKind;
|
|
4
|
+
query: string;
|
|
5
|
+
/** Document range of the trigger text (`/qu` or `[[qu`), replaced when an item is chosen. */
|
|
6
|
+
from: number;
|
|
7
|
+
to: number;
|
|
8
|
+
/** Viewport position under the trigger, for the menu. */
|
|
9
|
+
left: number;
|
|
10
|
+
top: number;
|
|
11
|
+
};
|
|
12
|
+
export type TriggerHandlers = {
|
|
13
|
+
onchange: (trigger: Trigger | null) => void;
|
|
14
|
+
/** Return true when the open menu used the key. */
|
|
15
|
+
onkey: (key: string) => boolean;
|
|
16
|
+
};
|
|
17
|
+
export declare function triggerPlugin(handlers: TriggerHandlers, linksEnabled: boolean): import("@milkdown/kit/utils").$Prose;
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Watches the text before the caret for a menu trigger (`/` at a word start, or `[[`) and
|
|
3
|
+
* hands arrow/Enter/Tab/Escape to the open menu while focus stays in the editor.
|
|
4
|
+
*/
|
|
5
|
+
import { Plugin, PluginKey } from '@milkdown/kit/prose/state';
|
|
6
|
+
import { $prose } from '@milkdown/kit/utils';
|
|
7
|
+
const SLASH = /(?:^|\s)\/([\w-]{0,24})$/;
|
|
8
|
+
const LINK = /\[\[([^[\]\n]{0,60})$/;
|
|
9
|
+
const MENU_KEYS = new Set(['ArrowDown', 'ArrowUp', 'Enter', 'Tab', 'Escape']);
|
|
10
|
+
const ATOM = '';
|
|
11
|
+
function triggerAt(state, linksEnabled) {
|
|
12
|
+
const { selection } = state;
|
|
13
|
+
if (!selection.empty)
|
|
14
|
+
return null;
|
|
15
|
+
const { $from } = selection;
|
|
16
|
+
if (!$from.parent.isTextblock || $from.parent.type.spec.code)
|
|
17
|
+
return null;
|
|
18
|
+
const before = $from.parent.textBetween(0, $from.parentOffset, undefined, ATOM);
|
|
19
|
+
const link = linksEnabled ? LINK.exec(before) : null;
|
|
20
|
+
if (link)
|
|
21
|
+
return { kind: 'link', query: link[1], from: $from.pos - link[0].length, to: $from.pos };
|
|
22
|
+
const slash = SLASH.exec(before);
|
|
23
|
+
if (slash)
|
|
24
|
+
return { kind: 'slash', query: slash[1], from: $from.pos - slash[1].length - 1, to: $from.pos };
|
|
25
|
+
return null;
|
|
26
|
+
}
|
|
27
|
+
export function triggerPlugin(handlers, linksEnabled) {
|
|
28
|
+
const key = new PluginKey('terminoirTriggers');
|
|
29
|
+
/** A trigger closed with Escape stays closed until the caret leaves it. */
|
|
30
|
+
let dismissedFrom = null;
|
|
31
|
+
let open = false;
|
|
32
|
+
function report(view) {
|
|
33
|
+
const found = view.hasFocus() ? triggerAt(view.state, linksEnabled) : null;
|
|
34
|
+
if (!found || found.from === dismissedFrom) {
|
|
35
|
+
if (!found)
|
|
36
|
+
dismissedFrom = null;
|
|
37
|
+
if (open)
|
|
38
|
+
handlers.onchange(null);
|
|
39
|
+
open = false;
|
|
40
|
+
return;
|
|
41
|
+
}
|
|
42
|
+
const coords = view.coordsAtPos(found.from);
|
|
43
|
+
open = true;
|
|
44
|
+
handlers.onchange({ ...found, left: coords.left, top: coords.bottom });
|
|
45
|
+
}
|
|
46
|
+
return $prose(() => new Plugin({
|
|
47
|
+
key,
|
|
48
|
+
view: () => ({ update: report }),
|
|
49
|
+
props: {
|
|
50
|
+
handleKeyDown(view, event) {
|
|
51
|
+
if (!open || !MENU_KEYS.has(event.key))
|
|
52
|
+
return false;
|
|
53
|
+
if (event.key === 'Escape') {
|
|
54
|
+
dismissedFrom = triggerAt(view.state, linksEnabled)?.from ?? null;
|
|
55
|
+
report(view);
|
|
56
|
+
return true;
|
|
57
|
+
}
|
|
58
|
+
return handlers.onkey(event.key);
|
|
59
|
+
},
|
|
60
|
+
handleDOMEvents: {
|
|
61
|
+
blur(view) {
|
|
62
|
+
// Let a click on a menu item land before the menu disappears.
|
|
63
|
+
setTimeout(() => report(view), 150);
|
|
64
|
+
return false;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
}));
|
|
69
|
+
}
|
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
export declare const WIKILINK_NODE = "wikilink";
|
|
2
|
+
export type WikilinkAttrs = {
|
|
3
|
+
target: string;
|
|
4
|
+
label: string;
|
|
5
|
+
};
|
|
6
|
+
type MdastNode = {
|
|
7
|
+
type: string;
|
|
8
|
+
value?: string;
|
|
9
|
+
children?: MdastNode[];
|
|
10
|
+
data?: unknown;
|
|
11
|
+
};
|
|
12
|
+
export declare function formatWikilink({ target, label }: WikilinkAttrs): string;
|
|
13
|
+
/** Split one text value into text and wikilink pieces (empty when it has no links). */
|
|
14
|
+
export declare function splitWikilinks(text: string): MdastNode[];
|
|
15
|
+
export declare const remarkWikilink: import("@milkdown/kit/utils").$Remark<"remarkWikilink", unknown>;
|
|
16
|
+
export declare const wikilinkNode: import("@milkdown/kit/utils").$Node;
|
|
17
|
+
/** Typing the closing `]]` of `[[Something]]` turns it into a link. */
|
|
18
|
+
export declare const wikilinkInputRule: import("@milkdown/kit/utils").$InputRule;
|
|
19
|
+
export declare const wikilink: (import("@milkdown/kit/ctx").MilkdownPlugin | import("@milkdown/kit/utils").$Node | import("@milkdown/kit/utils").$Ctx<unknown, "remarkWikilink"> | import("@milkdown/kit/utils").$InputRule)[];
|
|
20
|
+
export {};
|
|
@@ -0,0 +1,92 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* `[[target]]` and `[[target|label]]` links as an inline atom, so they round-trip to markdown
|
|
3
|
+
* unescaped and render as chips. Parsing splits remark text nodes; stringifying writes them raw.
|
|
4
|
+
*/
|
|
5
|
+
import { InputRule } from '@milkdown/kit/prose/inputrules';
|
|
6
|
+
import { $inputRule, $node, $remark } from '@milkdown/kit/utils';
|
|
7
|
+
export const WIKILINK_NODE = 'wikilink';
|
|
8
|
+
const MDAST_TYPE = 'wikiLink';
|
|
9
|
+
const WIKILINK = /\[\[([^\]|\n]+)(?:\|([^\]\n]+))?\]\]/g;
|
|
10
|
+
export function formatWikilink({ target, label }) {
|
|
11
|
+
return label && label !== target ? `[[${target}|${label}]]` : `[[${target}]]`;
|
|
12
|
+
}
|
|
13
|
+
/** Split one text value into text and wikilink pieces (empty when it has no links). */
|
|
14
|
+
export function splitWikilinks(text) {
|
|
15
|
+
const parts = [];
|
|
16
|
+
let last = 0;
|
|
17
|
+
for (const match of text.matchAll(WIKILINK)) {
|
|
18
|
+
const start = match.index ?? 0;
|
|
19
|
+
if (start > last)
|
|
20
|
+
parts.push({ type: 'text', value: text.slice(last, start) });
|
|
21
|
+
const target = match[1].trim();
|
|
22
|
+
parts.push({ type: MDAST_TYPE, data: { target, label: (match[2] ?? target).trim() } });
|
|
23
|
+
last = start + match[0].length;
|
|
24
|
+
}
|
|
25
|
+
if (!parts.length)
|
|
26
|
+
return [];
|
|
27
|
+
if (last < text.length)
|
|
28
|
+
parts.push({ type: 'text', value: text.slice(last) });
|
|
29
|
+
return parts;
|
|
30
|
+
}
|
|
31
|
+
function splitTree(node) {
|
|
32
|
+
if (!node.children)
|
|
33
|
+
return;
|
|
34
|
+
node.children = node.children.flatMap((child) => {
|
|
35
|
+
if (child.type === 'text' && child.value) {
|
|
36
|
+
const parts = splitWikilinks(child.value);
|
|
37
|
+
return parts.length ? parts : [child];
|
|
38
|
+
}
|
|
39
|
+
splitTree(child);
|
|
40
|
+
return [child];
|
|
41
|
+
});
|
|
42
|
+
}
|
|
43
|
+
export const remarkWikilink = $remark('remarkWikilink', () => function () {
|
|
44
|
+
const data = this.data();
|
|
45
|
+
(data.toMarkdownExtensions ??= []).push({
|
|
46
|
+
handlers: { [MDAST_TYPE]: (node) => formatWikilink(node.data) }
|
|
47
|
+
});
|
|
48
|
+
return (tree) => splitTree(tree);
|
|
49
|
+
});
|
|
50
|
+
export const wikilinkNode = $node(WIKILINK_NODE, () => ({
|
|
51
|
+
inline: true,
|
|
52
|
+
group: 'inline',
|
|
53
|
+
atom: true,
|
|
54
|
+
selectable: true,
|
|
55
|
+
marks: '',
|
|
56
|
+
attrs: { target: { default: '' }, label: { default: '' } },
|
|
57
|
+
parseDOM: [
|
|
58
|
+
{
|
|
59
|
+
tag: 'span[data-wikilink]',
|
|
60
|
+
getAttrs: (dom) => {
|
|
61
|
+
const el = dom;
|
|
62
|
+
return { target: el.dataset.wikilink ?? '', label: el.textContent ?? '' };
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
],
|
|
66
|
+
toDOM: (node) => [
|
|
67
|
+
'span',
|
|
68
|
+
{ 'data-wikilink': node.attrs.target, class: 'terminoir-md-wikilink', role: 'link', title: node.attrs.target },
|
|
69
|
+
node.attrs.label || node.attrs.target
|
|
70
|
+
],
|
|
71
|
+
parseMarkdown: {
|
|
72
|
+
match: (node) => node.type === MDAST_TYPE,
|
|
73
|
+
runner: (state, node, type) => {
|
|
74
|
+
state.addNode(type, node.data);
|
|
75
|
+
}
|
|
76
|
+
},
|
|
77
|
+
toMarkdown: {
|
|
78
|
+
match: (node) => node.type.name === WIKILINK_NODE,
|
|
79
|
+
runner: (state, node) => {
|
|
80
|
+
state.addNode(MDAST_TYPE, undefined, undefined, { data: { target: node.attrs.target, label: node.attrs.label } });
|
|
81
|
+
}
|
|
82
|
+
}
|
|
83
|
+
}));
|
|
84
|
+
/** Typing the closing `]]` of `[[Something]]` turns it into a link. */
|
|
85
|
+
export const wikilinkInputRule = $inputRule((ctx) => new InputRule(/\[\[([^\]|\n]+)(?:\|([^\]\n]+))?\]\]$/, (state, match, start, end) => {
|
|
86
|
+
const target = match[1].trim();
|
|
87
|
+
if (!target)
|
|
88
|
+
return null;
|
|
89
|
+
const node = wikilinkNode.type(ctx).create({ target, label: (match[2] ?? target).trim() });
|
|
90
|
+
return state.tr.replaceWith(start, end, node);
|
|
91
|
+
}));
|
|
92
|
+
export const wikilink = [remarkWikilink, wikilinkNode, wikilinkInputRule].flat();
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
export type TimelineItem = {
|
|
2
|
+
id: string;
|
|
3
|
+
title: string;
|
|
4
|
+
start: Date;
|
|
5
|
+
/** Inclusive last day; defaults to `start` (a one-day bar). */
|
|
6
|
+
end?: Date;
|
|
7
|
+
/** Palette name or CSS colour for the bar. */
|
|
8
|
+
color?: string;
|
|
9
|
+
/** Rows sharing a group sit under one heading. */
|
|
10
|
+
group?: string;
|
|
11
|
+
};
|
|
12
|
+
export type TimelineBar = {
|
|
13
|
+
item: TimelineItem;
|
|
14
|
+
left: number;
|
|
15
|
+
width: number;
|
|
16
|
+
};
|
|
17
|
+
export type TimelineRow = {
|
|
18
|
+
group: string | null;
|
|
19
|
+
bars: TimelineBar[];
|
|
20
|
+
};
|
|
21
|
+
export type TimelineScale = {
|
|
22
|
+
start: Date;
|
|
23
|
+
days: number;
|
|
24
|
+
ticks: {
|
|
25
|
+
date: Date;
|
|
26
|
+
left: number;
|
|
27
|
+
}[];
|
|
28
|
+
};
|
|
29
|
+
/** The day range covering every bar (padded a day each side) and evenly spaced day ticks. */
|
|
30
|
+
export declare function timelineScale(items: TimelineItem[], today?: Date): TimelineScale;
|
|
31
|
+
/** One bar per item as left/width percentages of the scale; ends before starts are clamped. */
|
|
32
|
+
export declare function timelineBar(item: TimelineItem, scale: TimelineScale): TimelineBar;
|
|
33
|
+
/** Bars in groups (first-seen order; ungrouped first), each group sorted by start. */
|
|
34
|
+
export declare function timelineRows(items: TimelineItem[], scale: TimelineScale): TimelineRow[];
|
|
35
|
+
/** Where today falls on the scale (percent), or null when outside it. */
|
|
36
|
+
export declare function todayOffset(scale: TimelineScale, today?: Date): number | null;
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import { addDays, startOfDay } from './calendar.js';
|
|
2
|
+
const DAY_MS = 86_400_000;
|
|
3
|
+
const PAD_DAYS = 1;
|
|
4
|
+
const MAX_TICKS = 12;
|
|
5
|
+
function dayIndex(from, d) {
|
|
6
|
+
return Math.round((startOfDay(d).getTime() - from.getTime()) / DAY_MS);
|
|
7
|
+
}
|
|
8
|
+
/** The day range covering every bar (padded a day each side) and evenly spaced day ticks. */
|
|
9
|
+
export function timelineScale(items, today = new Date()) {
|
|
10
|
+
const starts = items.map((i) => startOfDay(i.start).getTime());
|
|
11
|
+
const ends = items.map((i) => startOfDay(i.end ?? i.start).getTime());
|
|
12
|
+
const first = starts.length ? Math.min(...starts) : startOfDay(today).getTime();
|
|
13
|
+
const last = ends.length ? Math.max(...ends) : first;
|
|
14
|
+
const start = addDays(new Date(first), -PAD_DAYS);
|
|
15
|
+
const days = Math.round((last - first) / DAY_MS) + 1 + 2 * PAD_DAYS;
|
|
16
|
+
const step = Math.max(1, Math.ceil(days / MAX_TICKS));
|
|
17
|
+
const ticks = [];
|
|
18
|
+
for (let i = 0; i < days; i += step)
|
|
19
|
+
ticks.push({ date: addDays(start, i), left: (i / days) * 100 });
|
|
20
|
+
return { start, days, ticks };
|
|
21
|
+
}
|
|
22
|
+
/** One bar per item as left/width percentages of the scale; ends before starts are clamped. */
|
|
23
|
+
export function timelineBar(item, scale) {
|
|
24
|
+
const from = dayIndex(scale.start, item.start);
|
|
25
|
+
const to = Math.max(from, dayIndex(scale.start, item.end ?? item.start));
|
|
26
|
+
return { item, left: (from / scale.days) * 100, width: ((to - from + 1) / scale.days) * 100 };
|
|
27
|
+
}
|
|
28
|
+
/** Bars in groups (first-seen order; ungrouped first), each group sorted by start. */
|
|
29
|
+
export function timelineRows(items, scale) {
|
|
30
|
+
const groups = new Map();
|
|
31
|
+
for (const item of items) {
|
|
32
|
+
const key = item.group ?? null;
|
|
33
|
+
groups.set(key, [...(groups.get(key) ?? []), item]);
|
|
34
|
+
}
|
|
35
|
+
const keys = [...groups.keys()].sort((a, b) => (a === null ? -1 : b === null ? 1 : 0));
|
|
36
|
+
return keys.map((group) => ({
|
|
37
|
+
group,
|
|
38
|
+
bars: (groups.get(group) ?? [])
|
|
39
|
+
.slice()
|
|
40
|
+
.sort((a, b) => a.start.getTime() - b.start.getTime())
|
|
41
|
+
.map((item) => timelineBar(item, scale)),
|
|
42
|
+
}));
|
|
43
|
+
}
|
|
44
|
+
/** Where today falls on the scale (percent), or null when outside it. */
|
|
45
|
+
export function todayOffset(scale, today = new Date()) {
|
|
46
|
+
const i = dayIndex(scale.start, today);
|
|
47
|
+
return i < 0 || i >= scale.days ? null : ((i + 0.5) / scale.days) * 100;
|
|
48
|
+
}
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terminoir",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Terminoir
|
|
3
|
+
"version": "0.9.0",
|
|
4
|
+
"description": "Terminoir — Liquid Glass design tokens and Svelte 5 UI components.",
|
|
5
5
|
"license": "Apache-2.0",
|
|
6
6
|
"type": "module",
|
|
7
7
|
"style": "./css/index.css",
|
|
@@ -85,6 +85,7 @@
|
|
|
85
85
|
"@fontsource/source-code-pro": "^5.2.5",
|
|
86
86
|
"@fontsource/source-sans-3": "^5.2.5",
|
|
87
87
|
"@fontsource/source-serif-4": "^5.2.5",
|
|
88
|
+
"@milkdown/kit": "7.23.0",
|
|
88
89
|
"@surdeddd/liquidglass": "^0.12.0"
|
|
89
90
|
},
|
|
90
91
|
"devDependencies": {
|