terminoir 0.8.0 → 0.10.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 +1 -0
- package/dist/components/ListItem.svelte +13 -1
- package/dist/components/ListItem.svelte.d.ts +4 -0
- package/dist/components/MarkdownEditor.svelte +440 -0
- package/dist/components/MarkdownEditor.svelte.d.ts +18 -0
- package/dist/index.d.ts +1 -0
- package/dist/index.js +1 -0
- package/dist/utils/markdownEditor/blockDrag.d.ts +21 -0
- package/dist/utils/markdownEditor/blockDrag.js +34 -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/package.json +3 -2
package/README.md
CHANGED
|
@@ -153,6 +153,7 @@ Building blocks so apps compose screens without CSS of their own. See the demo's
|
|
|
153
153
|
| `DataTable` | Columns, `cell` snippet, clickable rows, `hideOnMobile`, empty state |
|
|
154
154
|
| `List`, `ListItem`, `DescriptionList`, `FormRow` | Rows with leading/trailing slots; key–value pairs; label-left form rows |
|
|
155
155
|
| `SearchPicker` | Debounced async combobox (`search(q)`), keyboard navigation; grows to fill a row after chips |
|
|
156
|
+
| `MarkdownEditor` | Notion-style editor that reads and writes markdown (Milkdown): `/` block menu, block handle (`+` and drag to move), checklists, markdown shortcuts, undo. `linkSearch` enables `[[` links written as `[[id\|label]]`; `onlinkopen` gets the clicked target. Outside `value` changes apply while it is not focused; `onchange` fires only after a user edit |
|
|
156
157
|
| `PropertyRow` | Compact icon + label row for inline-edited values (record properties); hover highlights the row |
|
|
157
158
|
| `ChipSelect`, `DateField` | Value shown as a coloured `Chip` that opens a grouped option list (`ChipSelectOption`, `ChipSelectGroup`); date shown as "Today" / "Tomorrow" / weekday / "Nov 12" (`dayLabel`), `deadline` tints past and today, opens the native picker, Backspace clears |
|
|
158
159
|
| `TitleInput`, `FileButton`, `RadioCards` | Borderless heading input; button that opens a file picker; card-style radio group |
|
|
@@ -10,6 +10,8 @@
|
|
|
10
10
|
class: className = '',
|
|
11
11
|
leading,
|
|
12
12
|
trailing,
|
|
13
|
+
expanded,
|
|
14
|
+
details,
|
|
13
15
|
children,
|
|
14
16
|
...rest
|
|
15
17
|
}: {
|
|
@@ -22,6 +24,10 @@
|
|
|
22
24
|
class?: string;
|
|
23
25
|
leading?: Snippet;
|
|
24
26
|
trailing?: Snippet;
|
|
27
|
+
/** For a row that opens `details`: whether they are shown (sets `aria-expanded`). */
|
|
28
|
+
expanded?: boolean;
|
|
29
|
+
/** Content under the row, such as the settings it opens. Shown only while `expanded` is not false. */
|
|
30
|
+
details?: Snippet;
|
|
25
31
|
/** Replaces title/description for custom content. */
|
|
26
32
|
children?: Snippet;
|
|
27
33
|
} & Record<string, unknown> = $props();
|
|
@@ -42,12 +48,15 @@
|
|
|
42
48
|
|
|
43
49
|
<li class={cn('terminoir-list-item', active && 'terminoir-list-item--active', className)} {...rest}>
|
|
44
50
|
{#if onclick}
|
|
45
|
-
<button type="button" class="terminoir-list-item__row terminoir-list-item__button terminoir-focusable" {onclick}>
|
|
51
|
+
<button type="button" class="terminoir-list-item__row terminoir-list-item__button terminoir-focusable" aria-expanded={expanded} {onclick}>
|
|
46
52
|
{@render body()}
|
|
47
53
|
</button>
|
|
48
54
|
{:else}
|
|
49
55
|
<div class="terminoir-list-item__row">{@render body()}</div>
|
|
50
56
|
{/if}
|
|
57
|
+
{#if details && expanded !== false}
|
|
58
|
+
<div class="terminoir-list-item__details">{@render details()}</div>
|
|
59
|
+
{/if}
|
|
51
60
|
</li>
|
|
52
61
|
|
|
53
62
|
<style>
|
|
@@ -103,6 +112,9 @@
|
|
|
103
112
|
text-overflow: ellipsis;
|
|
104
113
|
white-space: nowrap;
|
|
105
114
|
}
|
|
115
|
+
.terminoir-list-item__details {
|
|
116
|
+
padding: var(--space-1) 0.85rem var(--space-4) calc(0.85rem + 1.25rem + var(--space-3));
|
|
117
|
+
}
|
|
106
118
|
.terminoir-list-item__trailing {
|
|
107
119
|
display: inline-flex;
|
|
108
120
|
align-items: center;
|
|
@@ -9,6 +9,10 @@ type $$ComponentProps = {
|
|
|
9
9
|
class?: string;
|
|
10
10
|
leading?: Snippet;
|
|
11
11
|
trailing?: Snippet;
|
|
12
|
+
/** For a row that opens `details`: whether they are shown (sets `aria-expanded`). */
|
|
13
|
+
expanded?: boolean;
|
|
14
|
+
/** Content under the row, such as the settings it opens. Shown only while `expanded` is not false. */
|
|
15
|
+
details?: Snippet;
|
|
12
16
|
/** Replaces title/description for custom content. */
|
|
13
17
|
children?: Snippet;
|
|
14
18
|
} & Record<string, unknown>;
|
|
@@ -0,0 +1,440 @@
|
|
|
1
|
+
<script lang="ts">
|
|
2
|
+
import { onMount } from 'svelte';
|
|
3
|
+
import { defaultValueCtx, Editor, editorViewCtx, editorViewOptionsCtx, remarkStringifyOptionsCtx, rootCtx } from '@milkdown/kit/core';
|
|
4
|
+
import type { Ctx } from '@milkdown/kit/ctx';
|
|
5
|
+
import { block, BlockProvider } from '@milkdown/kit/plugin/block';
|
|
6
|
+
import { clipboard } from '@milkdown/kit/plugin/clipboard';
|
|
7
|
+
import { history } from '@milkdown/kit/plugin/history';
|
|
8
|
+
import { listener, listenerCtx } from '@milkdown/kit/plugin/listener';
|
|
9
|
+
import { commonmark } from '@milkdown/kit/preset/commonmark';
|
|
10
|
+
import { gfm } from '@milkdown/kit/preset/gfm';
|
|
11
|
+
import { TextSelection } from '@milkdown/kit/prose/state';
|
|
12
|
+
import { getMarkdown, replaceAll } from '@milkdown/kit/utils';
|
|
13
|
+
import { cn } from '../utils/cn.js';
|
|
14
|
+
import type { PickerOption } from '../utils/componentTypes.js';
|
|
15
|
+
import { moveIndex } from '../utils/listNav.js';
|
|
16
|
+
import { findDropTarget, moveBlock } from '../utils/markdownEditor/blockDrag.js';
|
|
17
|
+
import { matchBlocks, placeholderPlugin, taskListItemView, tidyMarkdown, type BlockItem } from '../utils/markdownEditor/blocks.js';
|
|
18
|
+
import { triggerPlugin, type Trigger } from '../utils/markdownEditor/triggers.js';
|
|
19
|
+
import { wikilink, wikilinkNode } from '../utils/markdownEditor/wikilink.js';
|
|
20
|
+
import NavIcon from './NavIcon.svelte';
|
|
21
|
+
|
|
22
|
+
type MenuEntry = { id: string; label: string; icon?: string; iconColor?: string; description?: string; choose: () => void };
|
|
23
|
+
|
|
24
|
+
const LINK_SEARCH_DEBOUNCE_MS = 120;
|
|
25
|
+
/** How far left of the text a dragged block can still be dropped (the handle's gutter), and above or below it. */
|
|
26
|
+
const DROP_MARGIN_PX = 72;
|
|
27
|
+
|
|
28
|
+
let {
|
|
29
|
+
value,
|
|
30
|
+
onchange,
|
|
31
|
+
label,
|
|
32
|
+
placeholder = 'Write, or type / for blocks',
|
|
33
|
+
linkSearch,
|
|
34
|
+
onlinkopen,
|
|
35
|
+
class: className = ''
|
|
36
|
+
}: {
|
|
37
|
+
/** Markdown. Outside changes are applied while the editor is not focused. */
|
|
38
|
+
value: string;
|
|
39
|
+
/** Called with the edited markdown (only after a user edit, never on load). */
|
|
40
|
+
onchange?: (markdown: string) => void;
|
|
41
|
+
/** Accessible name of the writing surface. */
|
|
42
|
+
label: string;
|
|
43
|
+
placeholder?: string;
|
|
44
|
+
/** Enables `[[` links: options for the typed text. A chosen option is written as `[[id|label]]`. */
|
|
45
|
+
linkSearch?: (query: string) => Promise<PickerOption[]>;
|
|
46
|
+
/** A `[[link]]` was clicked; receives its target (the part before `|`). */
|
|
47
|
+
onlinkopen?: (target: string) => void;
|
|
48
|
+
class?: string;
|
|
49
|
+
} = $props();
|
|
50
|
+
|
|
51
|
+
const uid = $props.id();
|
|
52
|
+
let root = $state<HTMLDivElement>();
|
|
53
|
+
let host = $state<HTMLDivElement>();
|
|
54
|
+
let handle = $state<HTMLDivElement>();
|
|
55
|
+
let editor: Editor | undefined;
|
|
56
|
+
let ready = $state(false);
|
|
57
|
+
let provider: BlockProvider | undefined;
|
|
58
|
+
/** Position of the block being dragged by its handle. */
|
|
59
|
+
let dragFrom: number | null = null;
|
|
60
|
+
/** Where it would land, as a line in the editor's own coordinates. */
|
|
61
|
+
let dropLine = $state<{ pos: number; top: number; left: number; width: number } | null>(null);
|
|
62
|
+
/** Normalized markdown of what the editor last loaded or emitted; changes equal to it are not edits. */
|
|
63
|
+
let known = '';
|
|
64
|
+
|
|
65
|
+
let trigger = $state<Trigger | null>(null);
|
|
66
|
+
let entries = $state<MenuEntry[]>([]);
|
|
67
|
+
let active = $state(0);
|
|
68
|
+
let searching = $state(false);
|
|
69
|
+
let searchSeq = 0;
|
|
70
|
+
|
|
71
|
+
function act<T>(fn: (ctx: Ctx) => T): T | undefined {
|
|
72
|
+
return editor?.action(fn);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
function replaceTrigger(run: (ctx: Ctx) => void) {
|
|
76
|
+
const range = trigger;
|
|
77
|
+
if (!range) return;
|
|
78
|
+
act((ctx) => {
|
|
79
|
+
const view = ctx.get(editorViewCtx);
|
|
80
|
+
view.dispatch(view.state.tr.delete(range.from, range.to));
|
|
81
|
+
run(ctx);
|
|
82
|
+
view.focus();
|
|
83
|
+
});
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
function chooseBlock(item: BlockItem) {
|
|
87
|
+
replaceTrigger((ctx) => item.run(ctx));
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
function chooseLink(option: PickerOption) {
|
|
91
|
+
const range = trigger;
|
|
92
|
+
if (!range) return;
|
|
93
|
+
act((ctx) => {
|
|
94
|
+
const view = ctx.get(editorViewCtx);
|
|
95
|
+
const link = wikilinkNode.type(ctx).create({ target: option.id, label: option.label });
|
|
96
|
+
const tr = view.state.tr.replaceWith(range.from, range.to, link);
|
|
97
|
+
view.dispatch(tr.insertText(' ', range.from + link.nodeSize));
|
|
98
|
+
view.focus();
|
|
99
|
+
});
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
function onKey(key: string): boolean {
|
|
103
|
+
if (key === 'ArrowDown' || key === 'ArrowUp') {
|
|
104
|
+
active = moveIndex(active, key === 'ArrowDown' ? 1 : -1, entries.length);
|
|
105
|
+
return true;
|
|
106
|
+
}
|
|
107
|
+
const entry = entries[active];
|
|
108
|
+
if (!entry) return false;
|
|
109
|
+
entry.choose();
|
|
110
|
+
return true;
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
$effect(() => {
|
|
114
|
+
const current = trigger;
|
|
115
|
+
if (!current) {
|
|
116
|
+
entries = [];
|
|
117
|
+
return;
|
|
118
|
+
}
|
|
119
|
+
if (current.kind === 'slash') {
|
|
120
|
+
entries = matchBlocks(current.query).map((item) => ({ id: item.id, label: item.label, icon: item.icon, choose: () => chooseBlock(item) }));
|
|
121
|
+
active = 0;
|
|
122
|
+
return;
|
|
123
|
+
}
|
|
124
|
+
const query = current.query.trim();
|
|
125
|
+
if (!query || !linkSearch) {
|
|
126
|
+
entries = [];
|
|
127
|
+
return;
|
|
128
|
+
}
|
|
129
|
+
const seq = ++searchSeq;
|
|
130
|
+
const timer = setTimeout(async () => {
|
|
131
|
+
searching = true;
|
|
132
|
+
try {
|
|
133
|
+
const found = await linkSearch(query);
|
|
134
|
+
if (seq !== searchSeq) return;
|
|
135
|
+
entries = found.map((option) => ({ ...option, choose: () => chooseLink(option) }));
|
|
136
|
+
active = 0;
|
|
137
|
+
} catch {
|
|
138
|
+
if (seq === searchSeq) entries = [];
|
|
139
|
+
} finally {
|
|
140
|
+
if (seq === searchSeq) searching = false;
|
|
141
|
+
}
|
|
142
|
+
}, LINK_SEARCH_DEBOUNCE_MS);
|
|
143
|
+
return () => clearTimeout(timer);
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
function emit(markdown: string) {
|
|
147
|
+
const tidy = tidyMarkdown(markdown);
|
|
148
|
+
if (tidy === known) return;
|
|
149
|
+
known = tidy;
|
|
150
|
+
onchange?.(tidy);
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Load outside changes, but never under the writer's caret. Also runs once the editor exists, for a value that changed while it was being created. */
|
|
154
|
+
$effect(() => {
|
|
155
|
+
const next = value;
|
|
156
|
+
if (!ready || tidyMarkdown(next) === known) return;
|
|
157
|
+
const focused = act((ctx) => ctx.get(editorViewCtx).hasFocus());
|
|
158
|
+
if (focused) return;
|
|
159
|
+
act(replaceAll(next));
|
|
160
|
+
known = tidyMarkdown(act(getMarkdown()) ?? next);
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
/** The handle's `+`: open the `/` menu on a new line under the hovered block. */
|
|
164
|
+
function addBlockBelow() {
|
|
165
|
+
const target = provider?.active;
|
|
166
|
+
if (!target) return;
|
|
167
|
+
act((ctx) => {
|
|
168
|
+
const view = ctx.get(editorViewCtx);
|
|
169
|
+
const at = target.$pos.pos + target.node.nodeSize;
|
|
170
|
+
const paragraph = view.state.schema.nodes.paragraph.create(null, view.state.schema.text('/'));
|
|
171
|
+
const tr = view.state.tr.insert(at, paragraph);
|
|
172
|
+
view.dispatch(tr.setSelection(TextSelection.create(tr.doc, at + 2)).scrollIntoView());
|
|
173
|
+
view.focus();
|
|
174
|
+
});
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
function nearEditor(event: DragEvent): boolean {
|
|
178
|
+
const rect = root!.getBoundingClientRect();
|
|
179
|
+
return (
|
|
180
|
+
event.clientX >= rect.left - DROP_MARGIN_PX &&
|
|
181
|
+
event.clientX <= rect.right &&
|
|
182
|
+
event.clientY >= rect.top - DROP_MARGIN_PX &&
|
|
183
|
+
event.clientY <= rect.bottom + DROP_MARGIN_PX
|
|
184
|
+
);
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Blocks land between their siblings, wherever along the line the pointer is (the gutter included). */
|
|
188
|
+
function onBlockDragOver(event: DragEvent) {
|
|
189
|
+
if (dragFrom === null) return;
|
|
190
|
+
if (!nearEditor(event)) {
|
|
191
|
+
dropLine = null;
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
event.preventDefault();
|
|
195
|
+
event.stopPropagation();
|
|
196
|
+
if (event.dataTransfer) event.dataTransfer.dropEffect = 'move';
|
|
197
|
+
const from = dragFrom;
|
|
198
|
+
const target = act((ctx) => findDropTarget(ctx.get(editorViewCtx), from, event.clientY));
|
|
199
|
+
const origin = root!.getBoundingClientRect();
|
|
200
|
+
dropLine = target ? { ...target, top: target.top - origin.top, left: target.left - origin.left } : null;
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
function onBlockDrop(event: DragEvent) {
|
|
204
|
+
const from = dragFrom;
|
|
205
|
+
const line = dropLine;
|
|
206
|
+
if (from === null || !line) return;
|
|
207
|
+
event.preventDefault();
|
|
208
|
+
event.stopPropagation();
|
|
209
|
+
act((ctx) => {
|
|
210
|
+
const view = ctx.get(editorViewCtx);
|
|
211
|
+
const tr = moveBlock(view.state, from, line.pos);
|
|
212
|
+
if (tr) view.dispatch(tr.scrollIntoView());
|
|
213
|
+
view.dragging = null;
|
|
214
|
+
view.focus();
|
|
215
|
+
});
|
|
216
|
+
endBlockDrag();
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
function startBlockDrag() {
|
|
220
|
+
dragFrom = provider?.active?.$pos.pos ?? null;
|
|
221
|
+
if (dragFrom === null) return;
|
|
222
|
+
document.addEventListener('dragover', onBlockDragOver, true);
|
|
223
|
+
document.addEventListener('drop', onBlockDrop, true);
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
function endBlockDrag() {
|
|
227
|
+
dragFrom = null;
|
|
228
|
+
dropLine = null;
|
|
229
|
+
document.removeEventListener('dragover', onBlockDragOver, true);
|
|
230
|
+
document.removeEventListener('drop', onBlockDrop, true);
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
function onHostClick(event: MouseEvent) {
|
|
234
|
+
const link = (event.target as HTMLElement).closest<HTMLElement>('[data-wikilink]');
|
|
235
|
+
if (link && onlinkopen) onlinkopen(link.dataset.wikilink ?? '');
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
onMount(() => {
|
|
239
|
+
let disposed = false;
|
|
240
|
+
const made = Editor.make()
|
|
241
|
+
.config((ctx) => {
|
|
242
|
+
ctx.set(rootCtx, host!);
|
|
243
|
+
ctx.set(defaultValueCtx, value);
|
|
244
|
+
ctx.update(remarkStringifyOptionsCtx, (options) => ({ ...options, bullet: '-' as const, rule: '-' as const }));
|
|
245
|
+
ctx.update(editorViewOptionsCtx, (options) => ({
|
|
246
|
+
...options,
|
|
247
|
+
attributes: { class: 'terminoir-md__surface', 'aria-label': label, 'aria-multiline': 'true', role: 'textbox' }
|
|
248
|
+
}));
|
|
249
|
+
ctx.get(listenerCtx).markdownUpdated((_, markdown) => emit(markdown));
|
|
250
|
+
})
|
|
251
|
+
.use(commonmark)
|
|
252
|
+
.use(gfm)
|
|
253
|
+
.use(history)
|
|
254
|
+
.use(listener)
|
|
255
|
+
.use(clipboard)
|
|
256
|
+
.use(block)
|
|
257
|
+
.use(wikilink)
|
|
258
|
+
.use(taskListItemView)
|
|
259
|
+
.use(placeholderPlugin(placeholder))
|
|
260
|
+
.use(triggerPlugin({ onchange: (next) => (trigger = next), onkey: onKey }, Boolean(linkSearch)));
|
|
261
|
+
|
|
262
|
+
void made.create().then((created) => {
|
|
263
|
+
if (disposed) {
|
|
264
|
+
void created.destroy();
|
|
265
|
+
return;
|
|
266
|
+
}
|
|
267
|
+
editor = created;
|
|
268
|
+
known = tidyMarkdown(act(getMarkdown()) ?? value);
|
|
269
|
+
provider = new BlockProvider({ ctx: created.ctx, content: handle!, root, getOffset: () => 6 });
|
|
270
|
+
provider.update();
|
|
271
|
+
ready = true;
|
|
272
|
+
});
|
|
273
|
+
|
|
274
|
+
return () => {
|
|
275
|
+
disposed = true;
|
|
276
|
+
endBlockDrag();
|
|
277
|
+
provider?.destroy();
|
|
278
|
+
void editor?.destroy();
|
|
279
|
+
};
|
|
280
|
+
});
|
|
281
|
+
</script>
|
|
282
|
+
|
|
283
|
+
<div class={cn('terminoir-md', className)} bind:this={root}>
|
|
284
|
+
<!-- Link chips are also reachable as text; the click only adds a shortcut to open them. -->
|
|
285
|
+
<!-- svelte-ignore a11y_click_events_have_key_events, a11y_no_static_element_interactions -->
|
|
286
|
+
<div bind:this={host} onclick={onHostClick}></div>
|
|
287
|
+
<div class="terminoir-md__handle" bind:this={handle} ondragstart={startBlockDrag} ondragend={endBlockDrag} role="presentation">
|
|
288
|
+
<button type="button" class="terminoir-md__handle-button" aria-label="Add block below" onmousedown={(e) => e.preventDefault()} onclick={addBlockBelow}>
|
|
289
|
+
<NavIcon name="plus" />
|
|
290
|
+
</button>
|
|
291
|
+
<span class="terminoir-md__handle-grip" aria-hidden="true" title="Drag to move"><NavIcon name="grip-vertical" /></span>
|
|
292
|
+
</div>
|
|
293
|
+
{#if dropLine}
|
|
294
|
+
<div class="terminoir-md__drop-line" aria-hidden="true" style:top={`${dropLine.top}px`} style:left={`${dropLine.left}px`} style:width={`${dropLine.width}px`}></div>
|
|
295
|
+
{/if}
|
|
296
|
+
</div>
|
|
297
|
+
|
|
298
|
+
{#if trigger && (entries.length || trigger.kind === 'link')}
|
|
299
|
+
<ul
|
|
300
|
+
id={`${uid}-menu`}
|
|
301
|
+
class="terminoir-md__menu glass-popover"
|
|
302
|
+
role="listbox"
|
|
303
|
+
aria-label={trigger.kind === 'slash' ? 'Blocks' : 'Link to'}
|
|
304
|
+
style:left={`${trigger.left}px`}
|
|
305
|
+
style:top={`${trigger.top + 4}px`}
|
|
306
|
+
>
|
|
307
|
+
{#each entries as entry, index (entry.id)}
|
|
308
|
+
<!-- Focus stays in the editor, which handles arrow keys and Enter. -->
|
|
309
|
+
<!-- svelte-ignore a11y_click_events_have_key_events -->
|
|
310
|
+
<li
|
|
311
|
+
role="option"
|
|
312
|
+
aria-selected={index === active}
|
|
313
|
+
class="terminoir-md__option"
|
|
314
|
+
class:terminoir-md__option--active={index === active}
|
|
315
|
+
onpointerdown={(event) => event.preventDefault()}
|
|
316
|
+
onpointerenter={() => (active = index)}
|
|
317
|
+
onclick={() => entry.choose()}
|
|
318
|
+
>
|
|
319
|
+
{#if entry.icon}<NavIcon name={entry.icon} color={entry.iconColor} />{/if}
|
|
320
|
+
<span class="terminoir-md__option-label">{entry.label}</span>
|
|
321
|
+
{#if entry.description}<span class="terminoir-md__option-description">{entry.description}</span>{/if}
|
|
322
|
+
</li>
|
|
323
|
+
{:else}
|
|
324
|
+
<li class="terminoir-md__empty" role="presentation">
|
|
325
|
+
{searching ? 'Searching…' : trigger.query.trim() ? 'No matches' : 'Type to search'}
|
|
326
|
+
</li>
|
|
327
|
+
{/each}
|
|
328
|
+
</ul>
|
|
329
|
+
{/if}
|
|
330
|
+
|
|
331
|
+
<style>
|
|
332
|
+
.terminoir-md { position: relative; }
|
|
333
|
+
.terminoir-md :global(.terminoir-md__surface) {
|
|
334
|
+
outline: none;
|
|
335
|
+
min-height: 6rem;
|
|
336
|
+
padding: var(--space-1) 0;
|
|
337
|
+
color: var(--foreground);
|
|
338
|
+
font-size: 0.9375rem;
|
|
339
|
+
line-height: 1.65;
|
|
340
|
+
white-space: pre-wrap;
|
|
341
|
+
word-wrap: break-word;
|
|
342
|
+
}
|
|
343
|
+
.terminoir-md :global(.terminoir-md__surface > * + *) { margin-top: var(--space-2); }
|
|
344
|
+
.terminoir-md :global(p) { margin: 0; }
|
|
345
|
+
.terminoir-md :global(h1) { font-size: 1.5rem; font-weight: 600; line-height: 1.3; margin: var(--space-5) 0 var(--space-2); }
|
|
346
|
+
.terminoir-md :global(h2) { font-size: 1.2rem; font-weight: 600; line-height: 1.35; margin: var(--space-4) 0 var(--space-2); }
|
|
347
|
+
.terminoir-md :global(h3) { font-size: 1.05rem; font-weight: 600; margin: var(--space-3) 0 var(--space-1); }
|
|
348
|
+
.terminoir-md :global(.terminoir-md__surface > :first-child) { margin-top: 0; }
|
|
349
|
+
.terminoir-md :global(ul),
|
|
350
|
+
.terminoir-md :global(ol) { margin: 0; padding-left: 1.5rem; }
|
|
351
|
+
.terminoir-md :global(li > div > p),
|
|
352
|
+
.terminoir-md :global(li > p) { margin: 0; }
|
|
353
|
+
.terminoir-md :global(li[data-item-type='task']) { list-style: none; display: flex; align-items: flex-start; gap: var(--space-2); margin-left: -1.5rem; }
|
|
354
|
+
.terminoir-md :global(li[data-item-type='task'] > .terminoir-md-task-body) { flex: 1; min-width: 0; }
|
|
355
|
+
.terminoir-md :global(li[data-item-type='task'][data-checked='true'] > .terminoir-md-task-body) { color: var(--muted-foreground); text-decoration: line-through; }
|
|
356
|
+
.terminoir-md :global(.terminoir-md-task-box) { margin-top: 0.4em; accent-color: var(--primary); cursor: pointer; }
|
|
357
|
+
.terminoir-md :global(blockquote) { margin: 0; padding-left: var(--space-3); border-left: 2px solid var(--border); color: var(--muted-foreground); }
|
|
358
|
+
.terminoir-md :global(code) { font-family: var(--font-mono); font-size: 0.875em; padding: 0.1em 0.3em; border-radius: 0.3em; background: var(--muted); }
|
|
359
|
+
.terminoir-md :global(pre) { font-family: var(--font-mono); font-size: 0.8125rem; padding: var(--space-3); border-radius: var(--radius-control); background: var(--muted); overflow-x: auto; }
|
|
360
|
+
.terminoir-md :global(pre code) { padding: 0; background: none; }
|
|
361
|
+
.terminoir-md :global(hr) { border: none; border-top: 1px solid var(--border); margin: var(--space-4) 0; }
|
|
362
|
+
.terminoir-md :global(a) { color: var(--primary); text-underline-offset: 2px; }
|
|
363
|
+
.terminoir-md :global(table) { border-collapse: collapse; font-size: 0.875rem; }
|
|
364
|
+
.terminoir-md :global(th),
|
|
365
|
+
.terminoir-md :global(td) { border: 1px solid var(--border); padding: var(--space-1) var(--space-2); }
|
|
366
|
+
.terminoir-md :global(.terminoir-md-wikilink) {
|
|
367
|
+
display: inline-block;
|
|
368
|
+
padding: 0 0.4em;
|
|
369
|
+
border-radius: var(--radius-pill);
|
|
370
|
+
background: color-mix(in srgb, var(--primary) 16%, transparent);
|
|
371
|
+
color: var(--primary);
|
|
372
|
+
cursor: pointer;
|
|
373
|
+
}
|
|
374
|
+
.terminoir-md :global(.terminoir-md-wikilink.ProseMirror-selectednode) { outline: 1px solid var(--ring); }
|
|
375
|
+
.terminoir-md :global(.terminoir-md--empty::before) {
|
|
376
|
+
content: attr(data-placeholder);
|
|
377
|
+
position: absolute;
|
|
378
|
+
color: var(--muted-foreground);
|
|
379
|
+
pointer-events: none;
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
.terminoir-md__handle {
|
|
383
|
+
position: absolute;
|
|
384
|
+
display: flex;
|
|
385
|
+
align-items: center;
|
|
386
|
+
gap: 0.1rem;
|
|
387
|
+
color: var(--muted-foreground);
|
|
388
|
+
font-size: 0.75rem;
|
|
389
|
+
transition: opacity var(--duration-fast) ease;
|
|
390
|
+
}
|
|
391
|
+
.terminoir-md__handle:global([data-show='false']) { opacity: 0; pointer-events: none; }
|
|
392
|
+
.terminoir-md__handle-button,
|
|
393
|
+
.terminoir-md__handle-grip {
|
|
394
|
+
display: grid;
|
|
395
|
+
place-items: center;
|
|
396
|
+
width: 1.35rem;
|
|
397
|
+
height: 1.35rem;
|
|
398
|
+
border-radius: 0.3rem;
|
|
399
|
+
border: none;
|
|
400
|
+
background: none;
|
|
401
|
+
color: inherit;
|
|
402
|
+
cursor: pointer;
|
|
403
|
+
}
|
|
404
|
+
.terminoir-md__handle-grip { cursor: grab; }
|
|
405
|
+
.terminoir-md__handle-button:hover,
|
|
406
|
+
.terminoir-md__handle-grip:hover { background: color-mix(in srgb, var(--foreground) 8%, transparent); color: var(--foreground); }
|
|
407
|
+
|
|
408
|
+
.terminoir-md__drop-line {
|
|
409
|
+
position: absolute;
|
|
410
|
+
height: 2px;
|
|
411
|
+
margin-top: -1px;
|
|
412
|
+
border-radius: 1px;
|
|
413
|
+
background: var(--primary);
|
|
414
|
+
pointer-events: none;
|
|
415
|
+
}
|
|
416
|
+
|
|
417
|
+
.terminoir-md__menu {
|
|
418
|
+
position: fixed;
|
|
419
|
+
z-index: 60;
|
|
420
|
+
width: 16rem;
|
|
421
|
+
max-height: 18rem;
|
|
422
|
+
overflow-y: auto;
|
|
423
|
+
margin: 0;
|
|
424
|
+
padding: var(--space-1);
|
|
425
|
+
list-style: none;
|
|
426
|
+
}
|
|
427
|
+
.terminoir-md__option {
|
|
428
|
+
display: flex;
|
|
429
|
+
align-items: center;
|
|
430
|
+
gap: var(--space-2);
|
|
431
|
+
padding: 0.45rem 0.6rem;
|
|
432
|
+
border-radius: calc(var(--radius-control) - 0.2rem);
|
|
433
|
+
font-size: 0.875rem;
|
|
434
|
+
cursor: pointer;
|
|
435
|
+
}
|
|
436
|
+
.terminoir-md__option--active { background: color-mix(in srgb, var(--foreground) 8%, transparent); }
|
|
437
|
+
.terminoir-md__option-label { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
438
|
+
.terminoir-md__option-description { font-size: 0.75rem; color: var(--muted-foreground); flex-shrink: 0; }
|
|
439
|
+
.terminoir-md__empty { padding: 0.5rem 0.6rem; font-size: 0.8125rem; color: var(--muted-foreground); }
|
|
440
|
+
</style>
|
|
@@ -0,0 +1,18 @@
|
|
|
1
|
+
import type { PickerOption } from '../utils/componentTypes.js';
|
|
2
|
+
type $$ComponentProps = {
|
|
3
|
+
/** Markdown. Outside changes are applied while the editor is not focused. */
|
|
4
|
+
value: string;
|
|
5
|
+
/** Called with the edited markdown (only after a user edit, never on load). */
|
|
6
|
+
onchange?: (markdown: string) => void;
|
|
7
|
+
/** Accessible name of the writing surface. */
|
|
8
|
+
label: string;
|
|
9
|
+
placeholder?: string;
|
|
10
|
+
/** Enables `[[` links: options for the typed text. A chosen option is written as `[[id|label]]`. */
|
|
11
|
+
linkSearch?: (query: string) => Promise<PickerOption[]>;
|
|
12
|
+
/** A `[[link]]` was clicked; receives its target (the part before `|`). */
|
|
13
|
+
onlinkopen?: (target: string) => void;
|
|
14
|
+
class?: string;
|
|
15
|
+
};
|
|
16
|
+
declare const MarkdownEditor: import("svelte").Component<$$ComponentProps, {}, "">;
|
|
17
|
+
type MarkdownEditor = ReturnType<typeof MarkdownEditor>;
|
|
18
|
+
export default MarkdownEditor;
|
package/dist/index.d.ts
CHANGED
|
@@ -79,6 +79,7 @@ export type { ChipSelectGroup, ChipSelectOption } from './utils/componentTypes.j
|
|
|
79
79
|
export { default as DateField } from './components/DateField.svelte';
|
|
80
80
|
export { dayLabel, dayUrgency, parseYmd, type DayUrgency } from './utils/dayLabel.js';
|
|
81
81
|
export { default as SearchPicker } from './components/SearchPicker.svelte';
|
|
82
|
+
export { default as MarkdownEditor } from './components/MarkdownEditor.svelte';
|
|
82
83
|
export { default as FileButton } from './components/FileButton.svelte';
|
|
83
84
|
export { default as TitleInput } from './components/TitleInput.svelte';
|
|
84
85
|
export type { PickerOption } from './utils/componentTypes.js';
|
package/dist/index.js
CHANGED
|
@@ -71,6 +71,7 @@ export { default as ChipSelect } from './components/ChipSelect.svelte';
|
|
|
71
71
|
export { default as DateField } from './components/DateField.svelte';
|
|
72
72
|
export { dayLabel, dayUrgency, parseYmd } from './utils/dayLabel.js';
|
|
73
73
|
export { default as SearchPicker } from './components/SearchPicker.svelte';
|
|
74
|
+
export { default as MarkdownEditor } from './components/MarkdownEditor.svelte';
|
|
74
75
|
export { default as FileButton } from './components/FileButton.svelte';
|
|
75
76
|
export { default as TitleInput } from './components/TitleInput.svelte';
|
|
76
77
|
export { default as RadioCards } from './components/RadioCards.svelte';
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
/** Moving a block by its drag handle: where it lands, and the transaction that moves it. */
|
|
2
|
+
import type { EditorState, Transaction } from '@milkdown/kit/prose/state';
|
|
3
|
+
import type { EditorView } from '@milkdown/kit/prose/view';
|
|
4
|
+
type Span = {
|
|
5
|
+
top: number;
|
|
6
|
+
bottom: number;
|
|
7
|
+
};
|
|
8
|
+
/** A place a dragged block can land: document position, and the line drawn there. */
|
|
9
|
+
export type DropTarget = {
|
|
10
|
+
pos: number;
|
|
11
|
+
top: number;
|
|
12
|
+
left: number;
|
|
13
|
+
width: number;
|
|
14
|
+
};
|
|
15
|
+
/** Index of the gap (0 = before the first block, n = after the last) nearest to `y`. */
|
|
16
|
+
export declare function dropGap(blocks: readonly Span[], y: number): number;
|
|
17
|
+
/** Moves the node at `from` to document position `to`; null when that would leave it in place. */
|
|
18
|
+
export declare function moveBlock(state: EditorState, from: number, to: number): Transaction | null;
|
|
19
|
+
/** The gap among the dragged block's siblings closest to `y`, in viewport coordinates. */
|
|
20
|
+
export declare function findDropTarget(view: EditorView, from: number, y: number): DropTarget | null;
|
|
21
|
+
export {};
|
|
@@ -0,0 +1,34 @@
|
|
|
1
|
+
/** Index of the gap (0 = before the first block, n = after the last) nearest to `y`. */
|
|
2
|
+
export function dropGap(blocks, y) {
|
|
3
|
+
const index = blocks.findIndex((block) => y < (block.top + block.bottom) / 2);
|
|
4
|
+
return index === -1 ? blocks.length : index;
|
|
5
|
+
}
|
|
6
|
+
/** Moves the node at `from` to document position `to`; null when that would leave it in place. */
|
|
7
|
+
export function moveBlock(state, from, to) {
|
|
8
|
+
const node = state.doc.nodeAt(from);
|
|
9
|
+
if (!node || (to >= from && to <= from + node.nodeSize))
|
|
10
|
+
return null;
|
|
11
|
+
const tr = state.tr.delete(from, from + node.nodeSize);
|
|
12
|
+
return tr.insert(tr.mapping.map(to), node);
|
|
13
|
+
}
|
|
14
|
+
/** The gap among the dragged block's siblings closest to `y`, in viewport coordinates. */
|
|
15
|
+
export function findDropTarget(view, from, y) {
|
|
16
|
+
const $from = view.state.doc.resolve(from);
|
|
17
|
+
const start = $from.start();
|
|
18
|
+
const siblings = [];
|
|
19
|
+
$from.parent.forEach((_, offset) => {
|
|
20
|
+
const dom = view.nodeDOM(start + offset);
|
|
21
|
+
if (dom instanceof HTMLElement)
|
|
22
|
+
siblings.push({ pos: start + offset, rect: dom.getBoundingClientRect() });
|
|
23
|
+
});
|
|
24
|
+
if (!siblings.length)
|
|
25
|
+
return null;
|
|
26
|
+
const gap = dropGap(siblings.map((s) => s.rect), y);
|
|
27
|
+
const { left, width } = siblings[0].rect;
|
|
28
|
+
if (gap < siblings.length) {
|
|
29
|
+
const { pos, rect } = siblings[gap];
|
|
30
|
+
const above = siblings[gap - 1]?.rect.bottom ?? rect.top;
|
|
31
|
+
return { pos, top: (above + rect.top) / 2, left, width };
|
|
32
|
+
}
|
|
33
|
+
return { pos: start + $from.parent.content.size, top: siblings[siblings.length - 1].rect.bottom, left, width };
|
|
34
|
+
}
|
|
@@ -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();
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "terminoir",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Terminoir
|
|
3
|
+
"version": "0.10.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": {
|