@atom-forge/brickee 0.0.0-stage → 0.0.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/CHANGELOG.md +42 -0
  2. package/LICENSE +21 -0
  3. package/README-AI.md +286 -0
  4. package/README.md +41 -2
  5. package/dist/Editor.svelte +1956 -0
  6. package/dist/Editor.svelte.d.ts +19 -0
  7. package/dist/LinkPrompt.svelte +42 -0
  8. package/dist/LinkPrompt.svelte.d.ts +8 -0
  9. package/dist/PluginHost.svelte +41 -0
  10. package/dist/PluginHost.svelte.d.ts +13 -0
  11. package/dist/Preview.svelte +182 -0
  12. package/dist/Preview.svelte.d.ts +32 -0
  13. package/dist/PropsBar.svelte +405 -0
  14. package/dist/PropsBar.svelte.d.ts +17 -0
  15. package/dist/code.d.ts +12 -0
  16. package/dist/code.js +15 -0
  17. package/dist/dom.d.ts +29 -0
  18. package/dist/dom.js +224 -0
  19. package/dist/i18n.d.ts +7 -0
  20. package/dist/i18n.js +18 -0
  21. package/dist/index.d.ts +13 -0
  22. package/dist/index.js +16 -0
  23. package/dist/kit/Empty.svelte +24 -0
  24. package/dist/kit/Empty.svelte.d.ts +8 -0
  25. package/dist/kit/Frame.svelte +76 -0
  26. package/dist/kit/Frame.svelte.d.ts +15 -0
  27. package/dist/kit/util.d.ts +13 -0
  28. package/dist/kit/util.js +28 -0
  29. package/dist/locales/hu.d.ts +2 -0
  30. package/dist/locales/hu.js +142 -0
  31. package/dist/markdown.d.ts +23 -0
  32. package/dist/markdown.js +219 -0
  33. package/dist/model.d.ts +21 -0
  34. package/dist/model.js +45 -0
  35. package/dist/palette.d.ts +19 -0
  36. package/dist/palette.js +23 -0
  37. package/dist/plugins/Gap.svelte +28 -0
  38. package/dist/plugins/Gap.svelte.d.ts +15 -0
  39. package/dist/plugins/GapPreview.svelte +17 -0
  40. package/dist/plugins/GapPreview.svelte.d.ts +5 -0
  41. package/dist/plugins/Map.svelte +47 -0
  42. package/dist/plugins/Map.svelte.d.ts +12 -0
  43. package/dist/plugins/MapPreview.svelte +26 -0
  44. package/dist/plugins/MapPreview.svelte.d.ts +5 -0
  45. package/dist/plugins/Table.svelte +537 -0
  46. package/dist/plugins/Table.svelte.d.ts +28 -0
  47. package/dist/plugins/TablePreview.svelte +62 -0
  48. package/dist/plugins/TablePreview.svelte.d.ts +5 -0
  49. package/dist/plugins/YouTube.svelte +52 -0
  50. package/dist/plugins/YouTube.svelte.d.ts +8 -0
  51. package/dist/plugins/YouTubePreview.svelte +76 -0
  52. package/dist/plugins/YouTubePreview.svelte.d.ts +5 -0
  53. package/dist/plugins/index.d.ts +20 -0
  54. package/dist/plugins/index.js +105 -0
  55. package/dist/plugins/util.d.ts +3 -0
  56. package/dist/plugins/util.js +8 -0
  57. package/dist/schema.d.ts +11 -0
  58. package/dist/schema.js +25 -0
  59. package/dist/scroll.d.ts +18 -0
  60. package/dist/scroll.js +57 -0
  61. package/dist/types.d.ts +132 -0
  62. package/dist/types.js +43 -0
  63. package/dist/view.d.ts +6 -0
  64. package/dist/view.js +11 -0
  65. package/docs/en/bundled-plugins.md +99 -0
  66. package/docs/en/documents-and-markdown.md +114 -0
  67. package/docs/en/editor-and-preview.md +139 -0
  68. package/docs/en/getting-started.md +85 -0
  69. package/docs/en/host-contexts.md +169 -0
  70. package/docs/en/index.md +38 -0
  71. package/docs/en/recipes-and-pitfalls.md +123 -0
  72. package/docs/en/server-side.md +95 -0
  73. package/docs/en/upgrading.md +71 -0
  74. package/docs/en/writing-plugins.md +405 -0
  75. package/docs/hu/bundled-plugins.md +99 -0
  76. package/docs/hu/documents-and-markdown.md +114 -0
  77. package/docs/hu/editor-and-preview.md +139 -0
  78. package/docs/hu/getting-started.md +85 -0
  79. package/docs/hu/host-contexts.md +169 -0
  80. package/docs/hu/index.md +38 -0
  81. package/docs/hu/recipes-and-pitfalls.md +123 -0
  82. package/docs/hu/server-side.md +95 -0
  83. package/docs/hu/upgrading.md +71 -0
  84. package/docs/hu/writing-plugins.md +405 -0
  85. package/package.json +114 -5
@@ -0,0 +1,537 @@
1
+ <script lang="ts" module>
2
+ export type Align = 'left' | 'center' | 'right';
3
+
4
+ export interface TableProps extends Record<string, unknown> {
5
+ /** Rows of cells; every cell is inline markdown (**bold**, links…). */
6
+ rows: string[][];
7
+ /** The header (first row) and the footer (last row, e.g. sums). */
8
+ heads?: Head[];
9
+ /** Before `heads`: 'yes' when the first row is the header. */
10
+ header?: 'yes' | 'no';
11
+ /** Text alignment per column (missing: left). */
12
+ align?: Align[];
13
+ }
14
+
15
+ export type Head = 'top' | 'bottom';
16
+ export const HEADS: Head[] = ['top', 'bottom'];
17
+
18
+ /** The header and footer rows of a table (an old table's header: 'yes' is the first row). */
19
+ export function tableHeads(p: { heads?: unknown; header?: unknown }): Set<Head> {
20
+ if (Array.isArray(p.heads)) return new Set(p.heads.filter((h): h is Head => HEADS.includes(h as Head)));
21
+ return new Set(p.header === 'yes' ? ['top'] : []);
22
+ }
23
+
24
+ /** Whether row `r` is the header or the footer, or null. A one-row table has neither. */
25
+ export function rowHead(heads: Set<Head>, r: number, height: number): Head | null {
26
+ if (height < 2) return null;
27
+ if (heads.has('top') && r === 0) return 'top';
28
+ if (heads.has('bottom') && r === height - 1) return 'bottom';
29
+ return null;
30
+ }
31
+
32
+ /** Moves item `from` to the slot before `slot` (0…length). */
33
+ export function moveTo<T>(list: T[], from: number, slot: number): T[] {
34
+ const out = [...list];
35
+ const [item] = out.splice(from, 1);
36
+ out.splice(slot > from ? slot - 1 : slot, 0, item);
37
+ return out;
38
+ }
39
+
40
+ /** Every row as long as the longest one. */
41
+ export const square = (rows: string[][]) => {
42
+ const width = Math.max(1, ...rows.map((r) => r.length));
43
+ return rows.map((r) => [...r, ...Array(width - r.length).fill('')]);
44
+ };
45
+ </script>
46
+
47
+ <script lang="ts">
48
+ import { useText } from '../i18n';
49
+ import {
50
+ BetweenHorizontalEnd,
51
+ BetweenHorizontalStart,
52
+ BetweenVerticalEnd,
53
+ BetweenVerticalStart,
54
+ Columns3,
55
+ Rows3,
56
+ GripHorizontal,
57
+ GripVertical,
58
+ Plus,
59
+ TextAlignCenter,
60
+ TextAlignEnd,
61
+ TextAlignStart,
62
+ Trash2
63
+ } from '@lucide/svelte';
64
+ import { tick } from 'svelte';
65
+ import type { PluginViewProps } from '../types';
66
+
67
+ let { props = $bindable(), readonly }: PluginViewProps<TableProps> = $props();
68
+ const tr = useText();
69
+
70
+ let root: HTMLDivElement;
71
+ const rows = $derived(square(props.rows?.length ? props.rows : [['']]));
72
+ const width = $derived(rows[0].length);
73
+ const align = $derived(Array.from({ length: width }, (_, c) => props.align?.[c] ?? 'left'));
74
+ /** The cell being edited: the toolbar acts on its row and column. */
75
+ let at = $state<{ r: number; c: number } | null>(null);
76
+ const heads = $derived(tableHeads(props));
77
+ const headOf = (r: number) => rowHead(heads, r, rows.length);
78
+ /* Dragging a row or a column by its grip: `to` is the slot it would go to (0…count), shown as a blue line. */
79
+ let drag = $state<{ kind: 'row' | 'col'; from: number; to: number } | null>(null);
80
+
81
+ function slotAt(kind: 'row' | 'col', e: PointerEvent) {
82
+ const cells = [...root.querySelectorAll<HTMLElement>(kind === 'row' ? 'tr[data-r]' : 'td[data-grip-c]')];
83
+ const i = cells.findIndex((el) => {
84
+ const b = el.getBoundingClientRect();
85
+ return kind === 'row' ? e.clientY < b.top + b.height / 2 : e.clientX < b.left + b.width / 2;
86
+ });
87
+ return i < 0 ? cells.length : i;
88
+ }
89
+
90
+ function startDrag(e: PointerEvent, kind: 'row' | 'col', from: number) {
91
+ if (readonly || e.button !== 0) return;
92
+ // not the editor's own block drag or selection
93
+ e.preventDefault();
94
+ e.stopPropagation();
95
+ // the toolbar stays as it is while dragging (its height would shift the table under the pointer)
96
+ drag = { kind, from, to: from };
97
+ const move = (ev: PointerEvent) => drag && (drag.to = slotAt(kind, ev));
98
+ const up = () => {
99
+ removeEventListener('pointermove', move);
100
+ removeEventListener('pointerup', up);
101
+ const d = drag;
102
+ drag = null;
103
+ at = null;
104
+ if (!d || d.to === d.from || d.to === d.from + 1) return;
105
+ if (d.kind === 'row') setRows(moveTo(rows, d.from, d.to));
106
+ else {
107
+ setRows(rows.map((row) => moveTo(row, d.from, d.to)));
108
+ set({ align: moveTo(align, d.from, d.to) });
109
+ }
110
+ };
111
+ addEventListener('pointermove', move);
112
+ addEventListener('pointerup', up);
113
+ }
114
+ /** The drop line of a row or column: before it, after it (the last one), or none. */
115
+ function dropMark(kind: 'row' | 'col', i: number, count: number) {
116
+ if (drag?.kind !== kind || drag.to === drag.from || drag.to === drag.from + 1) return null;
117
+ return drag.to === i ? 'before' : drag.to === count && i === count - 1 ? 'after' : null;
118
+ }
119
+
120
+ const set = (next: Partial<TableProps>) => (props = { ...props, ...next });
121
+ const setRows = (next: string[][]) => set({ rows: next });
122
+
123
+ async function focusCell(r: number, c: number, caret: 'start' | 'end' = 'end') {
124
+ await tick();
125
+ const cell = root.querySelector<HTMLTextAreaElement>(`textarea[data-cell="${r}:${c}"]`);
126
+ if (!cell) return;
127
+ cell.focus();
128
+ const pos = caret === 'start' ? 0 : cell.value.length;
129
+ cell.setSelectionRange(pos, pos);
130
+ }
131
+
132
+ const edit = (r: number, c: number, v: string) => setRows(rows.map((row, i) => (i === r ? row.map((x, j) => (j === c ? v : x)) : row)));
133
+
134
+ function insertRow(at: number) {
135
+ setRows([...rows.slice(0, at), Array(width).fill(''), ...rows.slice(at)]);
136
+ }
137
+ function insertCol(at: number) {
138
+ setRows(rows.map((row) => [...row.slice(0, at), '', ...row.slice(at)]));
139
+ set({ align: [...align.slice(0, at), 'left', ...align.slice(at)] });
140
+ }
141
+ function dropRow(r: number) {
142
+ if (rows.length > 1) setRows(rows.filter((_, i) => i !== r));
143
+ at = null;
144
+ }
145
+ function dropCol(c: number) {
146
+ if (width > 1) {
147
+ setRows(rows.map((row) => row.filter((_, j) => j !== c)));
148
+ set({ align: align.filter((_, j) => j !== c) });
149
+ }
150
+ at = null;
151
+ }
152
+ const setAlign = (c: number, a: Align) => set({ align: align.map((x, j) => (j === c ? a : x)) });
153
+
154
+ function onKey(e: KeyboardEvent, r: number, c: number) {
155
+ const t = e.currentTarget as HTMLTextAreaElement;
156
+ const last = r === rows.length - 1;
157
+ if (e.key === 'Enter' && !e.shiftKey) {
158
+ // Enter goes down a row (a new one after the last); Shift+Enter breaks the line inside the cell.
159
+ e.preventDefault();
160
+ if (last) insertRow(rows.length);
161
+ focusCell(r + 1, c);
162
+ } else if (e.key === 'Tab' && !e.shiftKey && last && c === width - 1) {
163
+ e.preventDefault();
164
+ insertRow(rows.length);
165
+ focusCell(r + 1, 0);
166
+ } else if (e.key === 'ArrowUp' && r > 0 && !t.value.slice(0, t.selectionStart).includes('\n')) {
167
+ e.preventDefault();
168
+ focusCell(r - 1, c);
169
+ } else if (e.key === 'ArrowDown' && !last && !t.value.slice(t.selectionEnd).includes('\n')) {
170
+ e.preventDefault();
171
+ focusCell(r + 1, c, 'start');
172
+ }
173
+ }
174
+
175
+ /** Cells copied from a spreadsheet (tab-separated lines) fill the table from this cell on, growing it as needed. */
176
+ function onPaste(e: ClipboardEvent, r: number, c: number) {
177
+ const text = e.clipboardData?.getData('text/plain') ?? '';
178
+ if (!text.includes('\t')) return;
179
+ e.preventDefault();
180
+ const grid = text.replace(/\r/g, '').replace(/\n$/, '').split('\n').map((line) => line.split('\t'));
181
+ const next = square(rows.map((row) => [...row]));
182
+ const need = Math.max(width, c + Math.max(...grid.map((g) => g.length)));
183
+ while (next.length < r + grid.length) next.push([]);
184
+ grid.forEach((cells, i) => cells.forEach((v, j) => (next[r + i][c + j] = v.trim())));
185
+ setRows(square(next.map((row) => [...row, ...Array(Math.max(0, need - row.length)).fill('')])));
186
+ set({ align: Array.from({ length: need }, (_, j) => align[j] ?? 'left') });
187
+ }
188
+
189
+ // A cell is at least as tall as its text (min-height); the CSS stretches it to the tallest cell of its row. The text
190
+ // parameter re-fits it when the text changes from outside (undo, a paste).
191
+ function autosize(node: HTMLTextAreaElement, _text: string) {
192
+ const fit = () => {
193
+ node.style.height = '0';
194
+ node.style.minHeight = `${node.scrollHeight}px`;
195
+ node.style.height = '';
196
+ };
197
+ fit();
198
+ node.addEventListener('input', fit);
199
+ return { update: fit, destroy: () => node.removeEventListener('input', fit) };
200
+ }
201
+
202
+ const ALIGNS: { value: Align; icon: typeof TextAlignStart; label: string }[] = [
203
+ { value: 'left', icon: TextAlignStart, label: 'Align column left' },
204
+ { value: 'center', icon: TextAlignCenter, label: 'Center column' },
205
+ { value: 'right', icon: TextAlignEnd, label: 'Align column right' }
206
+ ];
207
+ </script>
208
+
209
+ <div
210
+ class="table"
211
+ class:editing={!!at}
212
+ bind:this={root}
213
+ onfocusout={(e) => {
214
+ if (!root.contains(e.relatedTarget as Node)) at = null;
215
+ }}
216
+ >
217
+ {#if !readonly}
218
+ <div class="bar" role="toolbar" aria-label={tr('Table')}>
219
+ {#if at}
220
+ {@const { r, c } = at}
221
+ <div class="seg">
222
+ <span class="label" title={tr('Row')}><Rows3 size={14} /></span>
223
+ <button type="button" title={tr('Insert a row above')} onmousedown={(e) => e.preventDefault()} onclick={() => { insertRow(r); focusCell(r, c); }}><BetweenHorizontalStart size={15} /></button>
224
+ <button type="button" title={tr('Insert a row below')} onmousedown={(e) => e.preventDefault()} onclick={() => { insertRow(r + 1); focusCell(r + 1, c); }}><BetweenHorizontalEnd size={15} /></button>
225
+ <button type="button" class="danger" title={tr('Delete this row')} disabled={rows.length < 2} onclick={() => dropRow(r)}><Trash2 size={14} /></button>
226
+ </div>
227
+ <div class="seg">
228
+ <span class="label" title={tr('Column')}><Columns3 size={14} /></span>
229
+ <button type="button" title={tr('Insert a column left')} onmousedown={(e) => e.preventDefault()} onclick={() => { insertCol(c); focusCell(r, c); }}><BetweenVerticalStart size={15} /></button>
230
+ <button type="button" title={tr('Insert a column right')} onmousedown={(e) => e.preventDefault()} onclick={() => { insertCol(c + 1); focusCell(r, c + 1); }}><BetweenVerticalEnd size={15} /></button>
231
+ <button type="button" class="danger" title={tr('Delete this column')} disabled={width < 2} onclick={() => dropCol(c)}><Trash2 size={14} /></button>
232
+ </div>
233
+ <div class="seg">
234
+ {#each ALIGNS as a (a.value)}
235
+ <button type="button" title={tr(a.label)} class:on={align[c] === a.value} onmousedown={(e) => e.preventDefault()} onclick={() => setAlign(c, a.value)}><a.icon size={15} /></button>
236
+ {/each}
237
+ </div>
238
+ {:else}
239
+ <span class="idle">{tr('Click a cell to edit its row and column')}</span>
240
+ {/if}
241
+ </div>
242
+ {/if}
243
+ <div class="grid">
244
+ <div class="frame">
245
+ <table class:dragging={!!drag}>
246
+ {#if !readonly}
247
+ <!-- the column grips -->
248
+ <thead>
249
+ <tr class="grips">
250
+ <td class="corner"></td>
251
+ {#each rows[0] as _, c (c)}
252
+ <td data-grip-c={c} class="cgrip {dropMark('col', c, width) ?? ''}">
253
+ <button type="button" class="grip" title={tr('Drag to move the column')} aria-label={tr('Drag to move the column')} onpointerdown={(e) => startDrag(e, 'col', c)} onmousedown={(e) => e.stopPropagation()}><GripHorizontal size={12} /></button>
254
+ </td>
255
+ {/each}
256
+ </tr>
257
+ </thead>
258
+ {/if}
259
+ <tbody>
260
+ {#each rows as row, r (r)}
261
+ <tr data-r={r} class="{dropMark('row', r, rows.length) ?? ''} h-{headOf(r) ?? 'none'}" class:row-on={at?.r === r} class:moving={drag?.kind === 'row' && drag.from === r}>
262
+ {#if !readonly}
263
+ <td class="rgrip">
264
+ <button type="button" class="grip" title={tr('Drag to move the row')} aria-label={tr('Drag to move the row')} onpointerdown={(e) => startDrag(e, 'row', r)} onmousedown={(e) => e.stopPropagation()}><GripVertical size={12} /></button>
265
+ </td>
266
+ {/if}
267
+ {#each row as cell, c (c)}
268
+ <td
269
+ class={dropMark('col', c, width) ? 'c-' + dropMark('col', c, width) : ''}
270
+ class:col-on={at?.c === c}
271
+ class:moving={drag?.kind === 'col' && drag.from === c}
272
+ >
273
+ <textarea
274
+ rows="1"
275
+ value={cell}
276
+ {readonly}
277
+ placeholder={headOf(r) ? tr('Heading') : ''}
278
+ data-cell="{r}:{c}"
279
+ style:text-align={align[c]}
280
+ aria-label={tr('Row {r}, column {c}', { r: r + 1, c: c + 1 })}
281
+ use:autosize={cell}
282
+ onfocus={() => (at = { r, c })}
283
+ oninput={(e) => edit(r, c, e.currentTarget.value)}
284
+ onkeydown={(e) => onKey(e, r, c)}
285
+ onpaste={(e) => onPaste(e, r, c)}
286
+ ></textarea>
287
+ </td>
288
+ {/each}
289
+ </tr>
290
+ {/each}
291
+ </tbody>
292
+ </table>
293
+ </div>
294
+ {#if !readonly}
295
+ <button type="button" class="add-col" title={tr('Add a column')} aria-label={tr('Add a column')} onclick={() => { insertCol(width); focusCell(0, width); }}><Plus size={14} /></button>
296
+ <button type="button" class="add-row" title={tr('Add a row')} aria-label={tr('Add a row')} onclick={() => { insertRow(rows.length); focusCell(rows.length, 0); }}><Plus size={14} /></button>
297
+ {/if}
298
+ </div>
299
+ </div>
300
+
301
+ <style>
302
+ .table {
303
+ font: 15px/1.45 system-ui, -apple-system, 'Segoe UI', sans-serif;
304
+ }
305
+ /* The row and column tools of the cell being edited; when it wraps, it pushes the table down. */
306
+ .bar {
307
+ margin: 0 0 8px;
308
+ display: flex;
309
+ flex-wrap: wrap;
310
+ align-items: center;
311
+ gap: 6px;
312
+ min-height: 28px;
313
+ }
314
+ .seg {
315
+ display: inline-flex;
316
+ align-items: center;
317
+ gap: 1px;
318
+ padding: 2px;
319
+ border-radius: 8px;
320
+ background: var(--color-muted, #f4f4f6);
321
+ }
322
+ .idle {
323
+ font-size: 12px;
324
+ color: var(--color-muted-contrast, #a0a0a8);
325
+ }
326
+ .label {
327
+ display: inline-grid;
328
+ place-items: center;
329
+ padding: 0 4px 0 5px;
330
+ color: var(--color-muted-contrast, #a0a0a8);
331
+ }
332
+ .bar button {
333
+ display: inline-grid;
334
+ place-items: center;
335
+ height: 24px;
336
+ min-width: 26px;
337
+ padding: 0 5px;
338
+ border: 0;
339
+ border-radius: 6px;
340
+ background: none;
341
+ color: var(--color-surface-contrast, #4b4b55);
342
+ cursor: pointer;
343
+ }
344
+ .bar button:hover:not(:disabled) {
345
+ background: var(--color-surface, #fff);
346
+ color: var(--color-surface-contrast, #1d1d1f);
347
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
348
+ }
349
+ .bar button.on {
350
+ background: var(--color-surface, #fff);
351
+ color: #2563eb;
352
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.08);
353
+ }
354
+ .bar button.danger:hover:not(:disabled) {
355
+ color: #dc2626;
356
+ }
357
+ .bar button:disabled {
358
+ opacity: 0.3;
359
+ cursor: default;
360
+ }
361
+ .bar .toggle {
362
+ display: inline-flex;
363
+ gap: 5px;
364
+ height: 28px;
365
+ padding: 0 10px;
366
+ border: 1px solid var(--color-frame, #e5e5ea);
367
+ border-radius: 8px;
368
+ font-size: 12px;
369
+ font-weight: 500;
370
+ }
371
+ .bar .toggle.on {
372
+ border-color: color-mix(in srgb, #3b82f6 35%, transparent);
373
+ background: color-mix(in srgb, #3b82f6 8%, transparent);
374
+ box-shadow: none;
375
+ }
376
+
377
+ /* The table, with a + bar on the right (column) and below (row) that shows on hover. */
378
+ .grid {
379
+ display: grid;
380
+ grid-template-columns: minmax(0, 1fr) 18px;
381
+ grid-template-rows: auto 18px;
382
+ gap: 4px;
383
+ }
384
+ .frame {
385
+ overflow-x: auto;
386
+ border: 1px solid var(--color-frame, #e3e3e8);
387
+ border-radius: 10px;
388
+ background: var(--color-surface, #fff);
389
+ }
390
+ table {
391
+ width: 100%;
392
+ border-collapse: collapse;
393
+ }
394
+ /* The 1px height lets the editor inside fill the whole cell (height: 100% of the row's tallest cell). */
395
+ td {
396
+ height: 1px;
397
+ padding: 0;
398
+ border-right: 1px solid var(--color-frame, #ececf0);
399
+ border-bottom: 1px solid var(--color-frame, #ececf0);
400
+ vertical-align: top;
401
+ transition: background 0.1s;
402
+ }
403
+ td:last-child {
404
+ border-right: 0;
405
+ }
406
+ tr:last-child td {
407
+ border-bottom: 0;
408
+ }
409
+ tr.row-on td,
410
+ td.col-on {
411
+ background: color-mix(in srgb, #3b82f6 4%, transparent);
412
+ }
413
+ /* The header and the footer as on the site: a band above, the sums bold on grey behind a thicker line. */
414
+ tr.h-top td:not(.rgrip) {
415
+ background: var(--color-muted, #f1f1f4);
416
+ border-bottom: 2px solid var(--color-frame, #d8d8de);
417
+ }
418
+ tr.h-bottom td:not(.rgrip) {
419
+ background: var(--color-muted, #f1f1f4);
420
+ border-top: 2px solid var(--color-frame, #d8d8de);
421
+ }
422
+ textarea {
423
+ display: block;
424
+ width: 100%;
425
+ height: 100%;
426
+ min-width: 6em;
427
+ box-sizing: border-box;
428
+ padding: 8px 11px;
429
+ border: 0;
430
+ border-radius: 0;
431
+ background: none;
432
+ resize: none;
433
+ font: inherit;
434
+ color: inherit;
435
+ outline: none;
436
+ }
437
+ textarea::placeholder {
438
+ color: var(--color-muted-contrast, #b8b8c0);
439
+ }
440
+ textarea:focus {
441
+ background: var(--color-surface, #fff);
442
+ box-shadow: inset 0 0 0 2px #3b82f6;
443
+ }
444
+ tr.h-top textarea {
445
+ font-weight: 600;
446
+ font-size: 13px;
447
+ letter-spacing: 0.04em;
448
+ text-transform: uppercase;
449
+ color: var(--color-surface-contrast, #3a3a44);
450
+ }
451
+ tr.h-bottom textarea {
452
+ font-weight: 600;
453
+ color: var(--color-surface-contrast, #1d1d1f);
454
+ }
455
+ /* grips: a thin row above the columns and a thin column before the rows, shown on hover */
456
+ td.corner,
457
+ td.rgrip {
458
+ width: 14px;
459
+ border-right: 1px solid var(--color-frame, #ececf0);
460
+ }
461
+ tr.grips td {
462
+ height: 14px;
463
+ border-bottom: 1px solid var(--color-frame, #ececf0);
464
+ }
465
+ .grip {
466
+ display: grid;
467
+ place-items: center;
468
+ width: 100%;
469
+ height: 100%;
470
+ min-height: 14px;
471
+ padding: 0;
472
+ border: 0;
473
+ background: none;
474
+ color: var(--color-muted-contrast, #b0b0b8);
475
+ cursor: grab;
476
+ opacity: 0;
477
+ touch-action: none;
478
+ transition: opacity 0.12s;
479
+ }
480
+ .table:hover .grip,
481
+ table.dragging .grip {
482
+ opacity: 1;
483
+ }
484
+ .grip:hover {
485
+ color: #2563eb;
486
+ background: color-mix(in srgb, #3b82f6 9%, transparent);
487
+ }
488
+ table.dragging,
489
+ table.dragging * {
490
+ cursor: grabbing !important;
491
+ user-select: none;
492
+ }
493
+ .moving {
494
+ opacity: 0.45;
495
+ }
496
+ tr.before td {
497
+ box-shadow: inset 0 2px 0 #3b82f6;
498
+ }
499
+ tr.after td {
500
+ box-shadow: inset 0 -2px 0 #3b82f6;
501
+ }
502
+ td.before,
503
+ td.c-before {
504
+ box-shadow: inset 2px 0 0 #3b82f6;
505
+ }
506
+ td.after,
507
+ td.c-after {
508
+ box-shadow: inset -2px 0 0 #3b82f6;
509
+ }
510
+ .add-col,
511
+ .add-row {
512
+ display: grid;
513
+ place-items: center;
514
+ padding: 0;
515
+ border: 0;
516
+ border-radius: 6px;
517
+ background: var(--color-muted, #f4f4f6);
518
+ color: var(--color-muted-contrast, #8a8a94);
519
+ cursor: pointer;
520
+ opacity: 0;
521
+ transition: opacity 0.12s, background 0.12s;
522
+ }
523
+ .add-row {
524
+ grid-column: 1;
525
+ }
526
+ .table:hover .add-col,
527
+ .table:hover .add-row,
528
+ .table.editing .add-col,
529
+ .table.editing .add-row {
530
+ opacity: 1;
531
+ }
532
+ .add-col:hover,
533
+ .add-row:hover {
534
+ background: color-mix(in srgb, #3b82f6 12%, transparent);
535
+ color: #2563eb;
536
+ }
537
+ </style>
@@ -0,0 +1,28 @@
1
+ export type Align = 'left' | 'center' | 'right';
2
+ export interface TableProps extends Record<string, unknown> {
3
+ /** Rows of cells; every cell is inline markdown (**bold**, links…). */
4
+ rows: string[][];
5
+ /** The header (first row) and the footer (last row, e.g. sums). */
6
+ heads?: Head[];
7
+ /** Before `heads`: 'yes' when the first row is the header. */
8
+ header?: 'yes' | 'no';
9
+ /** Text alignment per column (missing: left). */
10
+ align?: Align[];
11
+ }
12
+ export type Head = 'top' | 'bottom';
13
+ export declare const HEADS: Head[];
14
+ /** The header and footer rows of a table (an old table's header: 'yes' is the first row). */
15
+ export declare function tableHeads(p: {
16
+ heads?: unknown;
17
+ header?: unknown;
18
+ }): Set<Head>;
19
+ /** Whether row `r` is the header or the footer, or null. A one-row table has neither. */
20
+ export declare function rowHead(heads: Set<Head>, r: number, height: number): Head | null;
21
+ /** Moves item `from` to the slot before `slot` (0…length). */
22
+ export declare function moveTo<T>(list: T[], from: number, slot: number): T[];
23
+ /** Every row as long as the longest one. */
24
+ export declare const square: (rows: string[][]) => any[][];
25
+ import type { PluginViewProps } from '../types';
26
+ declare const Table: import("svelte").Component<PluginViewProps<TableProps>, {}, "props">;
27
+ type Table = ReturnType<typeof Table>;
28
+ export default Table;
@@ -0,0 +1,62 @@
1
+ <svelte:options css="injected" />
2
+
3
+ <script lang="ts">
4
+ import { inline } from '../markdown';
5
+ import type { PluginPreviewProps } from '../types';
6
+ import { rowHead, square, tableHeads, type TableProps } from './Table.svelte';
7
+
8
+ let { props }: PluginPreviewProps<TableProps> = $props();
9
+ const rows = $derived(square(props.rows ?? []).filter((r) => r.some((c) => c.trim())));
10
+ const heads = $derived(tableHeads(props));
11
+ const align = (c: number) => (props.align?.[c] === 'center' || props.align?.[c] === 'right' ? props.align[c] : undefined);
12
+ </script>
13
+
14
+ {#if rows.length}
15
+ <div class="wrap">
16
+ <table>
17
+ <tbody>
18
+ {#each rows as row, r (r)}
19
+ <tr>
20
+ {#each row as cell, c (c)}
21
+ {@const head = rowHead(heads, r, rows.length)}
22
+ {#if head}
23
+ <th class={head} scope={head === 'top' ? 'col' : undefined} style:text-align={align(c)}>{@html inline(cell)}</th>
24
+ {:else}
25
+ <td style:text-align={align(c)}>{@html inline(cell)}</td>
26
+ {/if}
27
+ {/each}
28
+ </tr>
29
+ {/each}
30
+ </tbody>
31
+ </table>
32
+ </div>
33
+ {/if}
34
+
35
+ <style>
36
+ /* Wide tables scroll on their own instead of the page. */
37
+ .wrap {
38
+ margin: 0 0 1em;
39
+ overflow-x: auto;
40
+ }
41
+ table {
42
+ width: 100%;
43
+ border-collapse: collapse;
44
+ font: 15px/1.5 system-ui, sans-serif;
45
+ }
46
+ th,
47
+ td {
48
+ padding: 0.45em 0.7em;
49
+ border-bottom: 1px solid color-mix(in srgb, currentColor 15%, transparent);
50
+ text-align: left;
51
+ vertical-align: top;
52
+ }
53
+ th {
54
+ font-weight: 600;
55
+ }
56
+ th.top {
57
+ border-bottom-width: 2px;
58
+ }
59
+ th.bottom {
60
+ border-top: 2px solid color-mix(in srgb, currentColor 22%, transparent);
61
+ }
62
+ </style>
@@ -0,0 +1,5 @@
1
+ import type { PluginPreviewProps } from '../types';
2
+ import { type TableProps } from './Table.svelte';
3
+ declare const TablePreview: import("svelte").Component<PluginPreviewProps<TableProps>, {}, "">;
4
+ type TablePreview = ReturnType<typeof TablePreview>;
5
+ export default TablePreview;
@@ -0,0 +1,52 @@
1
+ <script lang="ts" module>
2
+ export interface YouTubeProps extends Record<string, unknown> {
3
+ id: string;
4
+ title: string;
5
+ }
6
+ </script>
7
+
8
+ <script lang="ts">
9
+ import { Play, SquarePlay } from '@lucide/svelte';
10
+ import type { PluginViewProps } from '../types';
11
+ import Empty from '../kit/Empty.svelte';
12
+
13
+ let { props = $bindable() }: PluginViewProps<YouTubeProps> = $props();
14
+ const id = $derived(/^[\w-]{11}$/.test(props.id ?? '') ? props.id : '');
15
+ </script>
16
+
17
+ {#if id}
18
+ <div class="yt">
19
+ <img src="https://i.ytimg.com/vi/{id}/hqdefault.jpg" alt={props.title} draggable="false" />
20
+ <span class="play"><Play size={22} /></span>
21
+ </div>
22
+ {:else}
23
+ <Empty icon={SquarePlay} text="Paste a YouTube link above" />
24
+ {/if}
25
+
26
+ <style>
27
+ .yt {
28
+ position: relative;
29
+ border-radius: 10px;
30
+ overflow: hidden;
31
+ background: #000;
32
+ }
33
+ img {
34
+ display: block;
35
+ width: 100%;
36
+ aspect-ratio: 16 / 9;
37
+ object-fit: cover;
38
+ opacity: 0.85;
39
+ }
40
+ .play {
41
+ position: absolute;
42
+ inset: 0;
43
+ margin: auto;
44
+ width: 56px;
45
+ height: 40px;
46
+ display: grid;
47
+ place-items: center;
48
+ border-radius: 10px;
49
+ background: #dc2626;
50
+ color: #fff;
51
+ }
52
+ </style>
@@ -0,0 +1,8 @@
1
+ export interface YouTubeProps extends Record<string, unknown> {
2
+ id: string;
3
+ title: string;
4
+ }
5
+ import type { PluginViewProps } from '../types';
6
+ declare const YouTube: import("svelte").Component<PluginViewProps<YouTubeProps>, {}, "props">;
7
+ type YouTube = ReturnType<typeof YouTube>;
8
+ export default YouTube;