@atom-forge/brickee 0.0.0-stage → 0.0.1

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