@pixerate/editor-svelte 0.18.0 → 0.20.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.
Files changed (81) hide show
  1. package/README.md +26 -2
  2. package/dist/EditableTextNodeEditor.svelte +19 -3
  3. package/dist/EditableTextNodeEditor.svelte.d.ts +5 -1
  4. package/dist/EditableTextNodeEditor.svelte.d.ts.map +1 -1
  5. package/dist/OutlinePanel.svelte +85 -0
  6. package/dist/OutlinePanel.svelte.d.ts +25 -0
  7. package/dist/OutlinePanel.svelte.d.ts.map +1 -0
  8. package/dist/TemplateRenderer.svelte +54 -7
  9. package/dist/TemplateRenderer.svelte.d.ts +8 -1
  10. package/dist/TemplateRenderer.svelte.d.ts.map +1 -1
  11. package/dist/VariableForm.svelte +86 -0
  12. package/dist/VariableForm.svelte.d.ts +21 -0
  13. package/dist/VariableForm.svelte.d.ts.map +1 -0
  14. package/dist/agentTools.svelte.d.ts +12 -0
  15. package/dist/agentTools.svelte.d.ts.map +1 -0
  16. package/dist/agentTools.svelte.js +17 -0
  17. package/dist/canvas/CanvasGuidesLayer.svelte +52 -0
  18. package/dist/canvas/CanvasGuidesLayer.svelte.d.ts +16 -0
  19. package/dist/canvas/CanvasGuidesLayer.svelte.d.ts.map +1 -0
  20. package/dist/canvas/createCanvasArrange.svelte.d.ts +63 -0
  21. package/dist/canvas/createCanvasArrange.svelte.d.ts.map +1 -0
  22. package/dist/canvas/createCanvasArrange.svelte.js +60 -0
  23. package/dist/canvas/createCanvasScenes.svelte.d.ts +51 -0
  24. package/dist/canvas/createCanvasScenes.svelte.d.ts.map +1 -0
  25. package/dist/canvas/createCanvasScenes.svelte.js +62 -0
  26. package/dist/canvas/index.d.ts +4 -0
  27. package/dist/canvas/index.d.ts.map +1 -1
  28. package/dist/canvas/index.js +5 -0
  29. package/dist/fileImport.svelte.d.ts +25 -0
  30. package/dist/fileImport.svelte.d.ts.map +1 -0
  31. package/dist/fileImport.svelte.js +40 -0
  32. package/dist/history/createHistoryShortcuts.d.ts +1 -1
  33. package/dist/history/createHistoryShortcuts.d.ts.map +1 -1
  34. package/dist/history/history.svelte.d.ts +1 -1
  35. package/dist/history/history.svelte.d.ts.map +1 -1
  36. package/dist/history/history.svelte.js +1 -1
  37. package/dist/index.d.ts +11 -0
  38. package/dist/index.d.ts.map +1 -1
  39. package/dist/index.js +6 -0
  40. package/dist/outline.svelte.d.ts +18 -0
  41. package/dist/outline.svelte.d.ts.map +1 -0
  42. package/dist/outline.svelte.js +27 -0
  43. package/dist/review/CommentButton.svelte +32 -0
  44. package/dist/review/CommentButton.svelte.d.ts +10 -0
  45. package/dist/review/CommentButton.svelte.d.ts.map +1 -0
  46. package/dist/review/CommentsPanel.svelte +226 -0
  47. package/dist/review/CommentsPanel.svelte.d.ts +18 -0
  48. package/dist/review/CommentsPanel.svelte.d.ts.map +1 -0
  49. package/dist/review/ReviewToolbar.svelte +86 -0
  50. package/dist/review/ReviewToolbar.svelte.d.ts +9 -0
  51. package/dist/review/ReviewToolbar.svelte.d.ts.map +1 -0
  52. package/dist/review/index.d.ts +12 -0
  53. package/dist/review/index.d.ts.map +1 -0
  54. package/dist/review/index.js +4 -0
  55. package/dist/review/review.svelte.d.ts +40 -0
  56. package/dist/review/review.svelte.d.ts.map +1 -0
  57. package/dist/review/review.svelte.js +104 -0
  58. package/dist/spreadsheet/FormulaBar.svelte +14 -6
  59. package/dist/spreadsheet/FormulaBar.svelte.d.ts.map +1 -1
  60. package/dist/spreadsheet/FormulaHints.svelte +149 -0
  61. package/dist/spreadsheet/FormulaHints.svelte.d.ts +14 -0
  62. package/dist/spreadsheet/FormulaHints.svelte.d.ts.map +1 -0
  63. package/dist/spreadsheet/SpreadsheetContextMenu.svelte +60 -0
  64. package/dist/spreadsheet/SpreadsheetContextMenu.svelte.d.ts +11 -0
  65. package/dist/spreadsheet/SpreadsheetContextMenu.svelte.d.ts.map +1 -0
  66. package/dist/spreadsheet/SpreadsheetEditor.svelte +172 -19
  67. package/dist/spreadsheet/SpreadsheetEditor.svelte.d.ts +1 -1
  68. package/dist/spreadsheet/SpreadsheetEditor.svelte.d.ts.map +1 -1
  69. package/dist/spreadsheet/SpreadsheetGroupRow.svelte +55 -0
  70. package/dist/spreadsheet/SpreadsheetGroupRow.svelte.d.ts +14 -0
  71. package/dist/spreadsheet/SpreadsheetGroupRow.svelte.d.ts.map +1 -0
  72. package/dist/spreadsheet/index.d.ts +6 -0
  73. package/dist/spreadsheet/index.d.ts.map +1 -1
  74. package/dist/spreadsheet/index.js +4 -0
  75. package/dist/spreadsheet/spreadsheet.svelte.d.ts +43 -5
  76. package/dist/spreadsheet/spreadsheet.svelte.d.ts.map +1 -1
  77. package/dist/spreadsheet/spreadsheet.svelte.js +16 -2
  78. package/dist/spreadsheet/tableInteractions.d.ts +28 -0
  79. package/dist/spreadsheet/tableInteractions.d.ts.map +1 -0
  80. package/dist/spreadsheet/tableInteractions.js +61 -0
  81. package/package.json +15 -3
@@ -0,0 +1,149 @@
1
+ <script lang="ts">
2
+ import { tick } from 'svelte';
3
+ import { applyFormulaSuggestion, getFormulaHints } from '@pixerate/editor/spreadsheet';
4
+
5
+ interface Props {
6
+ /** The input being edited (a cell editor or the formula bar). */
7
+ input?: HTMLInputElement | null;
8
+ /** The input's current value. */
9
+ value?: string;
10
+ /** Called with the new value when a suggestion is accepted. */
11
+ onAccept?: (value: string) => void;
12
+ /** Maximum number of function suggestions (default 8). */
13
+ limit?: number;
14
+ }
15
+
16
+ let { input = null, value = '', onAccept, limit = 8 }: Props = $props();
17
+
18
+ let caret = $state<number | null>(null);
19
+ let focused = $state(false);
20
+ let selected = $state(0);
21
+ let dismissed = $state(false);
22
+ let position = $state<{ top: number; left: number; width: number } | null>(null);
23
+
24
+ let hints = $derived(getFormulaHints(value, caret ?? value.length, { limit }));
25
+ let suggestions = $derived(dismissed ? [] : hints.suggestions);
26
+ let selectedIndex = $derived(Math.max(0, Math.min(selected, suggestions.length - 1)));
27
+ let visible = $derived(
28
+ focused && value.trimStart().startsWith('=') && (suggestions.length > 0 || !!hints.activeCall)
29
+ );
30
+
31
+ async function accept(index: number) {
32
+ const suggestion = suggestions[index];
33
+ if (!suggestion || !input) return;
34
+ const next = applyFormulaSuggestion(value, caret ?? value.length, suggestion.name);
35
+ caret = next.caret;
36
+ selected = 0;
37
+ onAccept?.(next.value);
38
+ // Wait for the new value to reach the input, then put the caret inside "(".
39
+ await tick();
40
+ if (input.value === next.value) input.setSelectionRange(next.caret, next.caret);
41
+ }
42
+
43
+ $effect(() => {
44
+ const el = input;
45
+ if (!el) return;
46
+ const syncCaret = () => (caret = el.selectionStart ?? el.value.length);
47
+ const onInput = () => {
48
+ dismissed = false;
49
+ selected = 0;
50
+ syncCaret();
51
+ };
52
+ const onFocus = () => {
53
+ focused = true;
54
+ syncCaret();
55
+ };
56
+ const onBlur = () => (focused = false);
57
+ // A listener on the input runs before Svelte's delegated handlers (on the
58
+ // root), so stopping propagation keeps these keys away from the grid.
59
+ const onKeyDown = (e: KeyboardEvent) => {
60
+ if (suggestions.length === 0) return;
61
+ if (e.key === 'ArrowDown' || e.key === 'ArrowUp') {
62
+ e.preventDefault();
63
+ e.stopPropagation();
64
+ const step = e.key === 'ArrowDown' ? 1 : -1;
65
+ selected = (selectedIndex + step + suggestions.length) % suggestions.length;
66
+ } else if (e.key === 'Tab' && !e.shiftKey) {
67
+ e.preventDefault();
68
+ e.stopPropagation();
69
+ accept(selectedIndex);
70
+ } else if (e.key === 'Escape') {
71
+ e.preventDefault();
72
+ e.stopPropagation();
73
+ dismissed = true;
74
+ }
75
+ };
76
+
77
+ el.addEventListener('input', onInput);
78
+ el.addEventListener('keyup', syncCaret);
79
+ el.addEventListener('click', syncCaret);
80
+ el.addEventListener('select', syncCaret);
81
+ el.addEventListener('focus', onFocus);
82
+ el.addEventListener('blur', onBlur);
83
+ el.addEventListener('keydown', onKeyDown);
84
+ if (typeof document !== 'undefined' && document.activeElement === el) onFocus();
85
+ return () => {
86
+ el.removeEventListener('input', onInput);
87
+ el.removeEventListener('keyup', syncCaret);
88
+ el.removeEventListener('click', syncCaret);
89
+ el.removeEventListener('select', syncCaret);
90
+ el.removeEventListener('focus', onFocus);
91
+ el.removeEventListener('blur', onBlur);
92
+ el.removeEventListener('keydown', onKeyDown);
93
+ };
94
+ });
95
+
96
+ $effect(() => {
97
+ if (!visible || !input) return;
98
+ void value;
99
+ const rect = input.getBoundingClientRect();
100
+ position = { top: rect.bottom + 2, left: rect.left, width: rect.width };
101
+ });
102
+ </script>
103
+
104
+ {#if visible}
105
+ <!-- svelte-ignore a11y_no_static_element_interactions -->
106
+ <div
107
+ data-formula-hints=""
108
+ class="rounded-md border border-border bg-popover text-popover-foreground shadow-md text-xs font-sans overflow-hidden"
109
+ style="position: fixed; top: {position?.top ?? 0}px; left: {position?.left ?? 0}px; min-width: {Math.max(260, position?.width ?? 0)}px; max-width: 440px; z-index: 50; visibility: {position ? 'visible' : 'hidden'};"
110
+ onmousedown={(e) => e.preventDefault()}
111
+ >
112
+ {#if hints.activeCall}
113
+ <div data-formula-signature="" class="px-2 py-1.5 border-b border-border bg-muted/30">
114
+ <div class="font-mono">
115
+ {#each hints.activeCall.parts as part, i (i)}
116
+ <span data-active={part.active ? '' : undefined} class={part.active ? 'font-bold text-primary underline' : ''}>{part.text}</span>
117
+ {/each}
118
+ </div>
119
+ <div class="mt-0.5 text-muted-foreground">
120
+ {#if hints.activeCall.argument}
121
+ <span class="font-semibold">{hints.activeCall.argument.name}</span>: {hints.activeCall.argument.description}
122
+ {:else}
123
+ {hints.activeCall.description}
124
+ {/if}
125
+ </div>
126
+ </div>
127
+ {/if}
128
+ {#if suggestions.length > 0}
129
+ <ul role="listbox" aria-label="Formula suggestions" class="max-h-64 overflow-auto m-0 list-none p-0 py-1">
130
+ {#each suggestions as s, i (s.name)}
131
+ <li
132
+ role="option"
133
+ aria-selected={i === selectedIndex}
134
+ class="cursor-pointer px-2 py-1 {i === selectedIndex ? 'bg-primary/10' : 'hover:bg-muted/50'}"
135
+ onmouseenter={() => (selected = i)}
136
+ onmousedown={(e) => {
137
+ // Keep focus in the input so the edit isn't committed by blur.
138
+ e.preventDefault();
139
+ accept(i);
140
+ }}
141
+ >
142
+ <div class="font-mono font-semibold text-foreground">{s.signature}</div>
143
+ <div class="text-muted-foreground truncate">{s.description}</div>
144
+ </li>
145
+ {/each}
146
+ </ul>
147
+ {/if}
148
+ </div>
149
+ {/if}
@@ -0,0 +1,14 @@
1
+ interface Props {
2
+ /** The input being edited (a cell editor or the formula bar). */
3
+ input?: HTMLInputElement | null;
4
+ /** The input's current value. */
5
+ value?: string;
6
+ /** Called with the new value when a suggestion is accepted. */
7
+ onAccept?: (value: string) => void;
8
+ /** Maximum number of function suggestions (default 8). */
9
+ limit?: number;
10
+ }
11
+ declare const FormulaHints: import("svelte").Component<Props, {}, "">;
12
+ type FormulaHints = ReturnType<typeof FormulaHints>;
13
+ export default FormulaHints;
14
+ //# sourceMappingURL=FormulaHints.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"FormulaHints.svelte.d.ts","sourceRoot":"","sources":["../../src/spreadsheet/FormulaHints.svelte.ts"],"names":[],"mappings":"AAOE,UAAU,KAAK;IACb,iEAAiE;IACjE,KAAK,CAAC,EAAE,gBAAgB,GAAG,IAAI,CAAC;IAChC,iCAAiC;IACjC,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,+DAA+D;IAC/D,QAAQ,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;IACnC,0DAA0D;IAC1D,KAAK,CAAC,EAAE,MAAM,CAAC;CAChB;AAoIH,QAAA,MAAM,YAAY,2CAAwC,CAAC;AAC3D,KAAK,YAAY,GAAG,UAAU,CAAC,OAAO,YAAY,CAAC,CAAC;AACpD,eAAe,YAAY,CAAC"}
@@ -0,0 +1,60 @@
1
+ <script lang="ts">
2
+ import type { SpreadsheetMenuItem } from '@pixerate/editor/spreadsheet';
3
+
4
+ interface Props {
5
+ items: SpreadsheetMenuItem[];
6
+ x: number;
7
+ y: number;
8
+ onClose: () => void;
9
+ }
10
+
11
+ let { items, x, y, onClose }: Props = $props();
12
+ let menuEl = $state<HTMLDivElement | null>(null);
13
+
14
+ // Close on outside press or Escape.
15
+ $effect(() => {
16
+ const handlePointer = (e: MouseEvent) => {
17
+ if (!menuEl?.contains(e.target as Node)) onClose();
18
+ };
19
+ const handleKey = (e: KeyboardEvent) => {
20
+ if (e.key === 'Escape') onClose();
21
+ };
22
+ window.addEventListener('mousedown', handlePointer);
23
+ window.addEventListener('keydown', handleKey);
24
+ return () => {
25
+ window.removeEventListener('mousedown', handlePointer);
26
+ window.removeEventListener('keydown', handleKey);
27
+ };
28
+ });
29
+ </script>
30
+
31
+ <!-- The table context menu (sort, filter, freeze, hide, merge, categories, transpose). -->
32
+ <div
33
+ bind:this={menuEl}
34
+ role="menu"
35
+ aria-label="Table actions"
36
+ tabindex="-1"
37
+ class="fixed z-50 min-w-[14rem] rounded-md border border-border bg-popover py-1 text-xs text-popover-foreground shadow-lg"
38
+ style="left: {x}px; top: {y}px; background: var(--popover, var(--background, #fff));"
39
+ oncontextmenu={(e) => e.preventDefault()}
40
+ onkeydown={(e) => e.stopPropagation()}
41
+ >
42
+ {#each items as item (item.id)}
43
+ {#if item.separatorBefore}
44
+ <div role="separator" class="my-1 h-px bg-border"></div>
45
+ {/if}
46
+ <button
47
+ type="button"
48
+ role="menuitem"
49
+ data-action={item.id}
50
+ disabled={item.disabled}
51
+ class="block w-full px-3 py-1 text-left hover:bg-muted disabled:cursor-default disabled:opacity-40 disabled:hover:bg-transparent"
52
+ onclick={() => {
53
+ onClose();
54
+ item.run();
55
+ }}
56
+ >
57
+ {item.label}
58
+ </button>
59
+ {/each}
60
+ </div>
@@ -0,0 +1,11 @@
1
+ import type { SpreadsheetMenuItem } from '@pixerate/editor/spreadsheet';
2
+ interface Props {
3
+ items: SpreadsheetMenuItem[];
4
+ x: number;
5
+ y: number;
6
+ onClose: () => void;
7
+ }
8
+ declare const SpreadsheetContextMenu: import("svelte").Component<Props, {}, "">;
9
+ type SpreadsheetContextMenu = ReturnType<typeof SpreadsheetContextMenu>;
10
+ export default SpreadsheetContextMenu;
11
+ //# sourceMappingURL=SpreadsheetContextMenu.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SpreadsheetContextMenu.svelte.d.ts","sourceRoot":"","sources":["../../src/spreadsheet/SpreadsheetContextMenu.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAGtE,UAAU,KAAK;IACb,KAAK,EAAE,mBAAmB,EAAE,CAAC;IAC7B,CAAC,EAAE,MAAM,CAAC;IACV,CAAC,EAAE,MAAM,CAAC;IACV,OAAO,EAAE,MAAM,IAAI,CAAC;CACrB;AA0CH,QAAA,MAAM,sBAAsB,2CAAwC,CAAC;AACrE,KAAK,sBAAsB,GAAG,UAAU,CAAC,OAAO,sBAAsB,CAAC,CAAC;AACxE,eAAe,sBAAsB,CAAC"}
@@ -1,8 +1,25 @@
1
1
  <script lang="ts">
2
2
  import type { Snippet } from 'svelte';
3
- import { indexToColName, type SpreadsheetColumn, type SpreadsheetRow, type CellData } from '@pixerate/editor';
3
+ import {
4
+ indexToColName,
5
+ computeFillTarget,
6
+ getSpreadsheetMenuItems,
7
+ normalizeRange,
8
+ rangeContains,
9
+ type SpreadsheetColumn,
10
+ type SpreadsheetRow,
11
+ type CellData,
12
+ type CellRange,
13
+ type SpreadsheetMenuItem,
14
+ type SpreadsheetViewColumn,
15
+ type SpreadsheetViewRow
16
+ } from '@pixerate/editor/spreadsheet';
4
17
  import FormulaBar from './FormulaBar.svelte';
18
+ import SpreadsheetContextMenu from './SpreadsheetContextMenu.svelte';
19
+ import SpreadsheetGroupRow from './SpreadsheetGroupRow.svelte';
20
+ import FormulaHints from './FormulaHints.svelte';
5
21
  import { createReactiveSpreadsheet } from './spreadsheet.svelte.js';
22
+ import { attachSpreadsheetClipboard } from './tableInteractions.js';
6
23
 
7
24
  interface Props {
8
25
  sheetState?: ReturnType<typeof createReactiveSpreadsheet>;
@@ -33,11 +50,75 @@
33
50
  let isReadOnly = $derived(readOnly || readonly);
34
51
 
35
52
  let gridContainer = $state<HTMLDivElement | null>(null);
53
+ let cellInput = $state<HTMLInputElement | null>(null);
36
54
  let resizingColId = $state<string | null>(null);
37
55
  let hoveredColId = $state<string | null>(null);
38
56
  let resizeStartX = $state(0);
39
57
  let resizeStartWidth = $state(0);
40
58
 
59
+ // --- Table operations: clipboard, fill handle, context menu, frozen panes ---
60
+
61
+ // Sizes of the sticky header row and row-number column, for frozen-pane offsets.
62
+ const COLUMN_HEADER_HEIGHT = 28;
63
+ const ROW_HEADER_WIDTH = 48;
64
+
65
+ let copyShortcutFallback: () => void = () => {};
66
+ $effect(() => {
67
+ const clipboard = attachSpreadsheetClipboard(() => gridContainer, sheetState, () => isReadOnly);
68
+ copyShortcutFallback = clipboard.copyShortcutFallback;
69
+ return () => clipboard.destroy();
70
+ });
71
+
72
+ let fillDrag = $state.raw<{ source: CellRange; target: CellRange | null } | null>(null);
73
+ let menu = $state.raw<{ x: number; y: number; items: SpreadsheetMenuItem[] } | null>(null);
74
+ let filteredColIds = $derived.by(() => {
75
+ const filter = sheetState.document.table?.filter;
76
+ return new Set(filter?.enabled ? filter.rules.map((r) => r.colId) : []);
77
+ });
78
+ let selection = $derived(sheetState.selectedRange ? normalizeRange(sheetState.selectedRange) : null);
79
+
80
+ /** Fill handle: press, move over cells, release to autofill (one undo step). */
81
+ function startFill(e: MouseEvent) {
82
+ e.preventDefault();
83
+ e.stopPropagation();
84
+ const range = sheetState.selectedRange;
85
+ if (!range) return;
86
+ fillDrag = { source: normalizeRange(range), target: null };
87
+ const finish = () => {
88
+ const current = fillDrag;
89
+ fillDrag = null;
90
+ if (current?.target) sheetState.autofill(current.source, current.target);
91
+ };
92
+ window.addEventListener('mouseup', finish, { once: true });
93
+ }
94
+
95
+ function extendFill(row: number, col: number) {
96
+ if (!fillDrag) return;
97
+ fillDrag = { source: fillDrag.source, target: computeFillTarget(fillDrag.source, { row, col }) };
98
+ }
99
+
100
+ function handleCellContextMenu(rIdx: number, cIdx: number, e: MouseEvent) {
101
+ if (isReadOnly) return;
102
+ e.preventDefault();
103
+ if (sheetState.editingCell) sheetState.commitEditing();
104
+ if (!sheetState.selectedRange || !rangeContains(sheetState.selectedRange, rIdx, cIdx)) {
105
+ sheetState.selectCell(rIdx, cIdx);
106
+ }
107
+ menu = { x: e.clientX, y: e.clientY, items: getSpreadsheetMenuItems(sheetState.controller, { row: rIdx, col: cIdx }) };
108
+ }
109
+
110
+ /** Sticky positioning for cells in frozen rows and/or columns. */
111
+ function frozenCellStyle(viewRow: Extract<SpreadsheetViewRow, { kind: 'row' }>, viewCol: SpreadsheetViewColumn): string {
112
+ if (!viewRow.frozen && !viewCol.frozen) return '';
113
+ const zIndex = viewRow.frozen && viewCol.frozen ? 14 : viewRow.frozen ? 12 : 11;
114
+ return (
115
+ 'position: sticky;' +
116
+ (viewRow.frozen ? ` top: ${COLUMN_HEADER_HEIGHT + viewRow.stickyTop}px;` : '') +
117
+ (viewCol.frozen ? ` left: ${ROW_HEADER_WIDTH + viewCol.stickyLeft}px;` : '') +
118
+ ` z-index: ${zIndex}; background: var(--background, #fff);`
119
+ );
120
+ }
121
+
41
122
 
42
123
 
43
124
  function handleCellClick(rIdx: number, cIdx: number, e: MouseEvent) {
@@ -112,10 +193,9 @@
112
193
  e.preventDefault();
113
194
  sheetState.redo();
114
195
  } else if ((e.metaKey || e.ctrlKey) && e.key.toLowerCase() === 'c') {
115
- // Copy the selection, not the whole sheet.
116
- e.preventDefault();
117
- const tsv = sheetState.exportToTsv(sheetState.selectedRange);
118
- navigator.clipboard?.writeText(tsv);
196
+ // Copy the selection, not the whole sheet. The browser's copy event
197
+ // writes TSV, HTML and the internal format; the fallback writes TSV.
198
+ copyShortcutFallback();
119
199
  } else if (!isReadOnly && e.key.length === 1 && !e.ctrlKey && !e.metaKey && !e.altKey) {
120
200
  // Direct typing into cell starts editing
121
201
  const col = sheetState.document.columns[sheetState.activeCell.col];
@@ -256,8 +336,8 @@
256
336
  if (gridContainer && typeof document !== 'undefined') {
257
337
  const table = gridContainer.querySelector('table');
258
338
  if (table) {
259
- const thElements = table.querySelectorAll('thead th');
260
- const th = thElements[colIndex + 1] as HTMLElement | undefined;
339
+ // Look cells up by column index: hidden columns and merges shift DOM positions.
340
+ const th = table.querySelector(`thead th[data-col="${colIndex}"]`) as HTMLElement | null;
261
341
  if (th) {
262
342
  const headerInner = (th.firstElementChild as HTMLElement) || th;
263
343
  const headerW = measureElementContentWidth(headerInner, gridContainer);
@@ -268,7 +348,7 @@
268
348
  const sampleLimit = Math.min(rows.length, 150);
269
349
  for (let r = 0; r < sampleLimit; r++) {
270
350
  const tr = rows[r];
271
- const td = tr.children[colIndex + 1] as HTMLElement | undefined;
351
+ const td = tr.querySelector(`td[data-col="${colIndex}"][colspan="1"]`) as HTMLElement | null;
272
352
  if (td) {
273
353
  const cellW = measureElementContentWidth(td, gridContainer);
274
354
  calculatedWidth = Math.max(calculatedWidth, cellW + 20);
@@ -345,15 +425,21 @@
345
425
  <th class="w-12 min-w-[3rem] sticky left-0 z-30 bg-muted/90 border-r border-border text-center text-[10px] text-muted-foreground font-medium">
346
426
  #
347
427
  </th>
348
- {#each sheetState.document.columns as col, cIdx (col.id)}
428
+ {#each sheetState.viewModel.columns as viewCol (viewCol.column.id)}
429
+ {@const col = viewCol.column}
430
+ {@const cIdx = viewCol.index}
349
431
  <th
432
+ data-col={cIdx}
350
433
  class="relative border-r border-border px-2 text-left font-medium text-muted-foreground hover:bg-muted/90 transition-colors group overflow-hidden max-w-0 {onColumnHeaderClick ? 'cursor-pointer' : 'cursor-default'}"
351
- style="width: {col.width || 130}px; min-width: {col.width || 130}px; max-width: {col.width || 130}px; overflow: hidden;"
434
+ style="width: {col.width || 130}px; min-width: {col.width || 130}px; max-width: {col.width || 130}px; overflow: hidden;{viewCol.frozen ? ` position: sticky; left: ${ROW_HEADER_WIDTH + viewCol.stickyLeft}px; z-index: 25; background: var(--muted, #f4f4f5);` : ''}"
352
435
  onclick={() => onColumnHeaderClick?.(col)}
353
436
  ondblclick={(e) => handleHeaderDblClick(col.id, cIdx, e)}
354
437
  >
355
438
  <div class="flex items-center justify-between gap-1 min-w-0 overflow-hidden truncate">
356
439
  <span class="truncate font-semibold text-foreground text-xs min-w-0">{col.title}</span>
440
+ {#if filteredColIds.has(col.id)}
441
+ <span class="text-[10px] text-primary shrink-0" title="Filtered" data-filtered="true">⏷</span>
442
+ {/if}
357
443
  <span class="text-[10px] text-muted-foreground/70 font-mono font-normal shrink-0">({col.key})</span>
358
444
  </div>
359
445
  <!-- Column Resize Handle -->
@@ -388,43 +474,98 @@
388
474
 
389
475
  <!-- Table Body Rows -->
390
476
  <tbody>
391
- {#each sheetState.document.rows as row, rIdx (row.id)}
392
- <tr class="border-b border-border hover:bg-muted/10 transition-colors" style="height: {row.height || 32}px;">
477
+ {#each sheetState.viewModel.rows as viewRow (viewRow.kind === 'group' ? `group:${viewRow.group.key}` : viewRow.row.id)}
478
+ {#if viewRow.kind === 'group'}
479
+ <SpreadsheetGroupRow
480
+ group={viewRow.group}
481
+ columns={sheetState.viewModel.columns}
482
+ groupColId={sheetState.document.table?.grouping?.colId ?? ''}
483
+ onToggle={sheetState.toggleGroupCollapsed}
484
+ trailingCell={!!onAddColumnClick}
485
+ />
486
+ {:else}
487
+ {@const displayRowIndex = viewRow.index}
488
+ <tr
489
+ data-row={displayRowIndex}
490
+ class="border-b border-border hover:bg-muted/10 transition-colors {viewRow.section !== 'body' ? 'bg-muted/30 font-semibold' : ''}"
491
+ style="height: {viewRow.row.height || 32}px;"
492
+ >
393
493
  <!-- Row Header Index -->
394
- <td class="w-12 min-w-[3rem] sticky left-0 z-10 bg-muted/50 border-r border-border text-center text-[10px] text-muted-foreground font-mono select-none">
395
- {rIdx + 1}
494
+ <td
495
+ class="w-12 min-w-[3rem] sticky left-0 z-10 bg-muted/50 border-r border-border text-center text-[10px] text-muted-foreground font-mono select-none"
496
+ style={viewRow.frozen ? `top: ${COLUMN_HEADER_HEIGHT + viewRow.stickyTop}px; z-index: 15;` : undefined}
497
+ >
498
+ {displayRowIndex + 1}
396
499
  </td>
397
500
 
398
- <!-- Cells -->
399
- {#each sheetState.document.columns as col, cIdx (col.id)}
501
+ <!-- Cells (a merged cell renders its top-left cell, spanning the merge) -->
502
+ {#each sheetState.viewModel.columns as viewCol (viewCol.column.id)}
503
+ {@const span = sheetState.viewModel.getCellSpan(displayRowIndex, viewCol.index)}
504
+ {#if span}
505
+ {@const rIdx = span.source.row}
506
+ {@const cIdx = span.source.col}
507
+ {@const row = sheetState.document.rows[rIdx]}
508
+ {@const col = sheetState.document.columns[cIdx]}
400
509
  {@const cell = sheetState.getCell(row.id, col.id)}
401
510
  {@const selected = isCellSelected(rIdx, cIdx)}
402
511
  {@const active = isActiveCell(rIdx, cIdx)}
403
512
  {@const isEditing = sheetState.editingCell?.row === rIdx && sheetState.editingCell?.col === cIdx}
404
513
 
514
+ {@const inFillPreview = !!fillDrag?.target && !selected && rangeContains(fillDrag.target, displayRowIndex, viewCol.index)}
515
+ {@const showFillHandle =
516
+ !isReadOnly &&
517
+ !isEditing &&
518
+ !!selection &&
519
+ (span.merge
520
+ ? rangeContains(span.merge, selection.endRow, selection.endCol)
521
+ : selection.endRow === rIdx && selection.endCol === cIdx)}
522
+
523
+ <!-- svelte-ignore a11y_mouse_events_have_key_events -->
405
524
  <td
406
- class="relative border-r border-border px-2 py-1 truncate max-w-0 text-foreground transition-colors {selected ? 'bg-primary/10' : ''} {active ? 'ring-2 ring-primary ring-inset z-10' : ''}"
407
- style="max-width: 0; overflow: hidden;"
525
+ data-row={rIdx}
526
+ data-col={cIdx}
527
+ rowspan={span.rowSpan}
528
+ colspan={span.colSpan}
529
+ class="relative border-r border-border px-2 py-1 truncate max-w-0 text-foreground transition-colors {selected ? 'bg-primary/10' : ''} {active ? 'ring-2 ring-primary ring-inset z-10' : ''} {viewCol.header ? 'font-semibold' : ''}"
530
+ style="max-width: 0; overflow: hidden; {frozenCellStyle(viewRow, viewCol)}{inFillPreview ? ' outline: 1px dashed var(--primary, #6366f1); outline-offset: -1px;' : ''}"
408
531
  onclick={(e) => handleCellClick(rIdx, cIdx, e)}
409
532
  ondblclick={() => handleCellDblClick(rIdx, cIdx)}
533
+ oncontextmenu={(e) => handleCellContextMenu(rIdx, cIdx, e)}
534
+ onmouseover={() => extendFill(displayRowIndex, viewCol.index)}
410
535
  >
411
536
  {#if isEditing}
412
537
  <input
413
538
  type="text"
414
539
  class="absolute inset-0 w-full h-full px-2 py-1 bg-background text-foreground outline-none font-sans text-xs border-2 border-primary z-20"
540
+ bind:this={cellInput}
415
541
  bind:value={sheetState.draftValue}
416
542
  onblur={() => sheetState.commitEditing()}
417
543
 
418
544
  />
545
+ <FormulaHints input={cellInput} value={sheetState.draftValue} onAccept={(next) => (sheetState.draftValue = next)} />
419
546
  {:else if customCellRenderer}
420
547
  {@const cellArg = Object.assign({ row, col, cell }, cell)}
421
548
  {@render (customCellRenderer as any)(cellArg, col, row)}
422
549
  {:else}
423
- <span class="truncate block max-w-full {cell.error ? 'text-destructive font-semibold' : ''}">
550
+ <span
551
+ class="truncate block max-w-full {cell.error ? 'text-destructive font-semibold' : ''}"
552
+ title={cell.error ? (cell.errorMessage ?? cell.error) : undefined}
553
+ >
424
554
  {cell.error || (cell.value ?? '')}
425
555
  </span>
426
556
  {/if}
557
+ {#if showFillHandle}
558
+ <!-- svelte-ignore a11y_no_static_element_interactions, a11y_click_events_have_key_events -->
559
+ <div
560
+ class="spreadsheet-fill-handle"
561
+ aria-label="Fill handle"
562
+ onmousedown={startFill}
563
+ onclick={(e) => e.stopPropagation()}
564
+ style="position: absolute; right: 0; bottom: 0; width: 7px; height: 7px; background: var(--primary, #6366f1); cursor: crosshair; z-index: 25;"
565
+ ></div>
566
+ {/if}
427
567
  </td>
568
+ {/if}
428
569
  {/each}
429
570
  {#if onAddColumnClick}
430
571
  <td class="w-10 min-w-[2.5rem] bg-muted/10 border-r border-border"></td>
@@ -432,10 +573,22 @@
432
573
  <!-- Spacer cell to absorb remaining table width -->
433
574
  <td class="border-b border-border bg-transparent p-0"></td>
434
575
  </tr>
576
+ {/if}
435
577
  {/each}
436
578
  </tbody>
437
579
  </table>
438
580
  </div>
581
+ {#if menu}
582
+ <SpreadsheetContextMenu
583
+ items={menu.items}
584
+ x={menu.x}
585
+ y={menu.y}
586
+ onClose={() => {
587
+ menu = null;
588
+ gridContainer?.focus();
589
+ }}
590
+ />
591
+ {/if}
439
592
  </div>
440
593
 
441
594
  <style>
@@ -1,5 +1,5 @@
1
1
  import type { Snippet } from 'svelte';
2
- import { type SpreadsheetColumn, type SpreadsheetRow, type CellData } from '@pixerate/editor';
2
+ import { type SpreadsheetColumn, type SpreadsheetRow, type CellData } from '@pixerate/editor/spreadsheet';
3
3
  import { createReactiveSpreadsheet } from './spreadsheet.svelte.js';
4
4
  interface Props {
5
5
  sheetState?: ReturnType<typeof createReactiveSpreadsheet>;
@@ -1 +1 @@
1
- {"version":3,"file":"SpreadsheetEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/spreadsheet/SpreadsheetEditor.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EAAkB,KAAK,iBAAiB,EAAE,KAAK,cAAc,EAAE,KAAK,QAAQ,EAAE,MAAM,kBAAkB,CAAC;AAE9G,OAAO,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAGlE,UAAU,KAAK;IACb,UAAU,CAAC,EAAE,UAAU,CAAC,OAAO,yBAAyB,CAAC,CAAC;IAC1D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kBAAkB,CAAC,EACf,OAAO,CAAC,CAAC,QAAQ,EAAE,iBAAiB,EAAE,cAAc,CAAC,CAAC,GACtD,OAAO,CAAC,CAAC;QAAE,GAAG,EAAE,cAAc,CAAC;QAAC,GAAG,EAAE,iBAAiB,CAAC;QAAC,IAAI,EAAE,QAAQ,CAAA;KAAE,CAAC,CAAC,CAAC;IAC/E,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAC;IAC9B,mBAAmB,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,CAAC;IACvD,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACzD;AA4YH,QAAA,MAAM,iBAAiB,2CAAwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}
1
+ {"version":3,"file":"SpreadsheetEditor.svelte.d.ts","sourceRoot":"","sources":["../../src/spreadsheet/SpreadsheetEditor.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,OAAO,EAAE,MAAM,QAAQ,CAAC;AACtC,OAAO,EAMH,KAAK,iBAAiB,EACtB,KAAK,cAAc,EACnB,KAAK,QAAQ,EAKd,MAAM,8BAA8B,CAAC;AAKxC,OAAO,EAAE,yBAAyB,EAAE,MAAM,yBAAyB,CAAC;AAIlE,UAAU,KAAK;IACb,UAAU,CAAC,EAAE,UAAU,CAAC,OAAO,yBAAyB,CAAC,CAAC;IAC1D,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,QAAQ,CAAC,EAAE,OAAO,CAAC;IACnB,cAAc,CAAC,EAAE,OAAO,CAAC;IACzB,KAAK,CAAC,EAAE,MAAM,CAAC;IACf,kBAAkB,CAAC,EACf,OAAO,CAAC,CAAC,QAAQ,EAAE,iBAAiB,EAAE,cAAc,CAAC,CAAC,GACtD,OAAO,CAAC,CAAC;QAAE,GAAG,EAAE,cAAc,CAAC;QAAC,GAAG,EAAE,iBAAiB,CAAC;QAAC,IAAI,EAAE,QAAQ,CAAA;KAAE,CAAC,CAAC,CAAC;IAC/E,gBAAgB,CAAC,EAAE,MAAM,IAAI,CAAC;IAC9B,mBAAmB,CAAC,EAAE,CAAC,GAAG,EAAE,iBAAiB,KAAK,IAAI,CAAC;IACvD,cAAc,CAAC,EAAE,CAAC,KAAK,EAAE,MAAM,EAAE,KAAK,EAAE,MAAM,KAAK,IAAI,CAAC;CACzD;AAqfH,QAAA,MAAM,iBAAiB,2CAAwC,CAAC;AAChE,KAAK,iBAAiB,GAAG,UAAU,CAAC,OAAO,iBAAiB,CAAC,CAAC;AAC9D,eAAe,iBAAiB,CAAC"}
@@ -0,0 +1,55 @@
1
+ <script lang="ts">
2
+ import type { SpreadsheetRowGroup, SpreadsheetViewColumn } from '@pixerate/editor/spreadsheet';
3
+
4
+ interface Props {
5
+ group: SpreadsheetRowGroup;
6
+ columns: SpreadsheetViewColumn[];
7
+ /** Column the rows are grouped by: shows the group label and toggle. */
8
+ groupColId: string;
9
+ onToggle: (key: string) => void;
10
+ /** Render a trailing cell for the "add column" column. */
11
+ trailingCell?: boolean;
12
+ }
13
+
14
+ let { group, columns, groupColId, onToggle, trailingCell = false }: Props = $props();
15
+
16
+ // The label goes in the grouping column, or the first column when that one is hidden.
17
+ let labelColId = $derived(columns.some((c) => c.column.id === groupColId) ? groupColId : columns[0]?.column.id);
18
+
19
+ const formatSummary = (value: number | null) => (value === null ? '–' : String(value));
20
+ </script>
21
+
22
+ <!-- A category header row: collapse toggle, label, row count and per-column summaries. -->
23
+ <tr class="border-b border-border bg-muted/40 font-semibold" data-group-key={group.key} style="height: 28px;">
24
+ <td class="w-12 min-w-[3rem] sticky left-0 z-10 bg-muted/60 border-r border-border text-center">
25
+ <button
26
+ type="button"
27
+ aria-label="{group.collapsed ? 'Expand' : 'Collapse'} group {group.label}"
28
+ aria-expanded={!group.collapsed}
29
+ class="h-5 w-5 rounded text-[10px] text-muted-foreground hover:bg-muted"
30
+ onclick={(e) => {
31
+ e.stopPropagation();
32
+ onToggle(group.key);
33
+ }}
34
+ >
35
+ {group.collapsed ? '▸' : '▾'}
36
+ </button>
37
+ </td>
38
+ {#each columns as { column } (column.id)}
39
+ {@const summary = group.summaries[column.id]}
40
+ <td class="border-r border-border px-2 py-1 truncate max-w-0 text-foreground" style="max-width: 0; overflow: hidden;">
41
+ {#if column.id === labelColId}
42
+ <span class="truncate">
43
+ {group.label} <span class="font-normal text-muted-foreground">({group.rowIndices.length})</span>
44
+ </span>
45
+ {:else if summary !== undefined}
46
+ <span class="truncate" data-summary={column.id}>{formatSummary(summary)}</span>
47
+ {/if}
48
+ </td>
49
+ {/each}
50
+ {#if trailingCell}
51
+ <td class="w-10 min-w-[2.5rem] bg-muted/10 border-r border-border"></td>
52
+ {/if}
53
+ <!-- Spacer cell to absorb remaining table width -->
54
+ <td class="border-b border-border bg-transparent p-0"></td>
55
+ </tr>
@@ -0,0 +1,14 @@
1
+ import type { SpreadsheetRowGroup, SpreadsheetViewColumn } from '@pixerate/editor/spreadsheet';
2
+ interface Props {
3
+ group: SpreadsheetRowGroup;
4
+ columns: SpreadsheetViewColumn[];
5
+ /** Column the rows are grouped by: shows the group label and toggle. */
6
+ groupColId: string;
7
+ onToggle: (key: string) => void;
8
+ /** Render a trailing cell for the "add column" column. */
9
+ trailingCell?: boolean;
10
+ }
11
+ declare const SpreadsheetGroupRow: import("svelte").Component<Props, {}, "">;
12
+ type SpreadsheetGroupRow = ReturnType<typeof SpreadsheetGroupRow>;
13
+ export default SpreadsheetGroupRow;
14
+ //# sourceMappingURL=SpreadsheetGroupRow.svelte.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"SpreadsheetGroupRow.svelte.d.ts","sourceRoot":"","sources":["../../src/spreadsheet/SpreadsheetGroupRow.svelte.ts"],"names":[],"mappings":"AAGA,OAAO,KAAK,EAAE,mBAAmB,EAAE,qBAAqB,EAAE,MAAM,8BAA8B,CAAC;AAG7F,UAAU,KAAK;IACb,KAAK,EAAE,mBAAmB,CAAC;IAC3B,OAAO,EAAE,qBAAqB,EAAE,CAAC;IACjC,wEAAwE;IACxE,UAAU,EAAE,MAAM,CAAC;IACnB,QAAQ,EAAE,CAAC,GAAG,EAAE,MAAM,KAAK,IAAI,CAAC;IAChC,0DAA0D;IAC1D,YAAY,CAAC,EAAE,OAAO,CAAC;CACxB;AA4CH,QAAA,MAAM,mBAAmB,2CAAwC,CAAC;AAClE,KAAK,mBAAmB,GAAG,UAAU,CAAC,OAAO,mBAAmB,CAAC,CAAC;AAClE,eAAe,mBAAmB,CAAC"}
@@ -1,9 +1,15 @@
1
1
  import type { ComponentProps } from "svelte";
2
2
  import type SpreadsheetEditorComponent from "./SpreadsheetEditor.svelte";
3
3
  import type FormulaBarComponent from "./FormulaBar.svelte";
4
+ import type FormulaHintsComponent from "./FormulaHints.svelte";
4
5
  export { default as SpreadsheetEditor } from './SpreadsheetEditor.svelte';
5
6
  export { default as FormulaBar } from './FormulaBar.svelte';
7
+ export { default as FormulaHints } from './FormulaHints.svelte';
6
8
  export * from './spreadsheet.svelte.js';
9
+ export { default as SpreadsheetContextMenu } from './SpreadsheetContextMenu.svelte';
10
+ export { default as SpreadsheetGroupRow } from './SpreadsheetGroupRow.svelte';
11
+ export * from './tableInteractions.js';
7
12
  export type SpreadsheetEditorProps = ComponentProps<typeof SpreadsheetEditorComponent>;
8
13
  export type FormulaBarProps = ComponentProps<typeof FormulaBarComponent>;
14
+ export type FormulaHintsProps = ComponentProps<typeof FormulaHintsComponent>;
9
15
  //# sourceMappingURL=index.d.ts.map
@@ -1 +1 @@
1
- {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/spreadsheet/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,KAAK,0BAA0B,MAAM,4BAA4B,CAAC;AACzE,OAAO,KAAK,mBAAmB,MAAM,qBAAqB,CAAC;AAE3D,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAC5D,cAAc,yBAAyB,CAAC;AAExC,MAAM,MAAM,sBAAsB,GAAG,cAAc,CAAC,OAAO,0BAA0B,CAAC,CAAC;AACvF,MAAM,MAAM,eAAe,GAAG,cAAc,CAAC,OAAO,mBAAmB,CAAC,CAAC"}
1
+ {"version":3,"file":"index.d.ts","sourceRoot":"","sources":["../../src/spreadsheet/index.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,cAAc,EAAE,MAAM,QAAQ,CAAC;AAC7C,OAAO,KAAK,0BAA0B,MAAM,4BAA4B,CAAC;AACzE,OAAO,KAAK,mBAAmB,MAAM,qBAAqB,CAAC;AAC3D,OAAO,KAAK,qBAAqB,MAAM,uBAAuB,CAAC;AAE/D,OAAO,EAAE,OAAO,IAAI,iBAAiB,EAAE,MAAM,4BAA4B,CAAC;AAC1E,OAAO,EAAE,OAAO,IAAI,UAAU,EAAE,MAAM,qBAAqB,CAAC;AAC5D,OAAO,EAAE,OAAO,IAAI,YAAY,EAAE,MAAM,uBAAuB,CAAC;AAChE,cAAc,yBAAyB,CAAC;AACxC,OAAO,EAAE,OAAO,IAAI,sBAAsB,EAAE,MAAM,iCAAiC,CAAC;AACpF,OAAO,EAAE,OAAO,IAAI,mBAAmB,EAAE,MAAM,8BAA8B,CAAC;AAC9E,cAAc,wBAAwB,CAAC;AAEvC,MAAM,MAAM,sBAAsB,GAAG,cAAc,CAAC,OAAO,0BAA0B,CAAC,CAAC;AACvF,MAAM,MAAM,eAAe,GAAG,cAAc,CAAC,OAAO,mBAAmB,CAAC,CAAC;AACzE,MAAM,MAAM,iBAAiB,GAAG,cAAc,CAAC,OAAO,qBAAqB,CAAC,CAAC"}