@bendyline/squisq-editor-react 2.0.0 → 2.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 (55) hide show
  1. package/dist/index.d.ts +75 -33
  2. package/dist/index.js +1717 -935
  3. package/dist/index.js.map +1 -1
  4. package/dist/styles/index.css +145 -5
  5. package/package.json +4 -4
  6. package/src/DocumentSettingsDialog.tsx +32 -18
  7. package/src/EditorShell.tsx +1 -1
  8. package/src/PreviewControls.tsx +57 -24
  9. package/src/RecorderEntry.tsx +2 -0
  10. package/src/Toolbar.tsx +164 -19
  11. package/src/__tests__/codeContextSectionView.test.tsx +8 -6
  12. package/src/__tests__/documentSettingsDialog.test.tsx +22 -0
  13. package/src/__tests__/mediaAttachmentFlow.test.ts +2 -2
  14. package/src/__tests__/previewControls.test.tsx +164 -0
  15. package/src/__tests__/recorderTheme.test.tsx +42 -0
  16. package/src/__tests__/selectionConversions.test.ts +80 -0
  17. package/src/__tests__/tiptapBridge.test.ts +48 -7
  18. package/src/__tests__/tiptapImageRoundTrip.test.ts +1 -1
  19. package/src/__tests__/toolbarSelectionConversion.test.tsx +164 -0
  20. package/src/asciiDiagram/AsciiDiagramWidget.tsx +34 -4
  21. package/src/asciiDiagram/__tests__/asciiDiagramCommands.test.ts +58 -1
  22. package/src/asciiDiagram/asciiDiagramCommands.ts +36 -10
  23. package/src/asciiDiagram/asciiDiagramData.ts +33 -0
  24. package/src/asciiDiagram/asciiDiagramOps.ts +19 -0
  25. package/src/codeContext/types.ts +1 -1
  26. package/src/customTemplates/__tests__/useMemoryLayerAdapter.test.ts +13 -0
  27. package/src/customTemplates/useMemoryLayerAdapter.ts +7 -1
  28. package/src/diagram/DiagramCanvas.tsx +16 -2
  29. package/src/frontmatterSettings.ts +23 -0
  30. package/src/index.ts +7 -1
  31. package/src/recorder/RecorderButton.tsx +9 -1
  32. package/src/recorder/RecorderModal.tsx +84 -41
  33. package/src/recorder/RecorderPanel.tsx +9 -1
  34. package/src/scene/__tests__/sceneIsolation.test.tsx +27 -1
  35. package/src/scene/adapters/DrawingAdapter.ts +6 -1
  36. package/src/scene/adapters/LayoutAdapter.ts +3 -0
  37. package/src/scene/commands/SceneCommand.ts +13 -2
  38. package/src/scene/tools/SelectTool.ts +5 -5
  39. package/src/selectionConversions.ts +155 -0
  40. package/src/styles/ascii-timeline.css +101 -4
  41. package/src/styles/editor.css +30 -0
  42. package/src/styles/tree-view.css +51 -1
  43. package/src/timeline/TimelineEditorWidget.tsx +200 -41
  44. package/src/timeline/__tests__/TimelineEditorWidget.test.tsx +61 -2
  45. package/src/timeline/__tests__/timelineCommands.test.ts +32 -0
  46. package/src/timeline/__tests__/timelineOps.test.ts +55 -0
  47. package/src/timeline/timelineCommands.ts +54 -0
  48. package/src/timeline/timelineOps.ts +107 -3
  49. package/src/tiptapBridge.ts +23 -5
  50. package/src/treeview/TreeOutlineWidget.tsx +153 -3
  51. package/src/treeview/__tests__/TreeOutlineWidget.test.tsx +156 -0
  52. package/src/treeview/__tests__/treeOps.test.ts +52 -0
  53. package/src/treeview/__tests__/treeViewCommands.test.ts +16 -0
  54. package/src/treeview/treeOps.ts +59 -0
  55. package/src/treeview/treeViewCommands.ts +5 -0
@@ -0,0 +1,23 @@
1
+ /** Canonical and legacy keys for frontmatter settings managed by the editor. */
2
+ export const FRONTMATTER_SETTING_KEYS = {
3
+ theme: { canonical: 'squisq-theme', legacy: ['themeId', 'theme'] as const },
4
+ transform: { canonical: 'squisq-transform', legacy: 'transform-style' as const },
5
+ captions: { canonical: 'squisq-captions', legacy: 'caption-style' as const },
6
+ coverSlide: { canonical: 'squisq-cover-slide', legacy: 'cover-slide' as const },
7
+ } as const;
8
+
9
+ /** Runtime defaults whose equivalent frontmatter entries can be omitted. */
10
+ export const FRONTMATTER_SETTING_DEFAULTS = {
11
+ theme: 'standard',
12
+ transform: '',
13
+ captions: 'standard',
14
+ coverSlide: true,
15
+ } as const;
16
+
17
+ /** Return `null` when a setting matches its runtime default so writers remove it. */
18
+ export function omitFrontmatterDefault<T extends string | number | boolean>(
19
+ value: T,
20
+ defaultValue: T,
21
+ ): T | null {
22
+ return value === defaultValue ? null : value;
23
+ }
package/src/index.ts CHANGED
@@ -263,6 +263,7 @@ export {
263
263
  applyRepairCommand,
264
264
  replaceAsciiFenceText,
265
265
  } from './asciiDiagram/asciiDiagramCommands.js';
266
+ export type { ApplyAsciiDiagramCommandOptions } from './asciiDiagram/asciiDiagramCommands.js';
266
267
  // RepairableDiagramExtension mounts an inline "Repair as diagram" button on
267
268
  // code fences holding BROKEN box art — art too misaligned for clean detection
268
269
  // (so it renders as a faithful code block). One click reconstructs it into
@@ -288,6 +289,7 @@ export {
288
289
  renameNodeOp,
289
290
  resizeNodeOp,
290
291
  sanitizeAsciiLabel,
292
+ translateDiagramOp,
291
293
  } from './asciiDiagram/asciiDiagramOps.js';
292
294
  export { shouldPasteAsAsciiFence } from './asciiDiagram/asciiPaste.js';
293
295
 
@@ -367,7 +369,11 @@ export type { JsonEditorProps } from './jsonEditor/index.js';
367
369
  // `MediaProvider`. Previously published as `@bendyline/squisq-recorder-react`;
368
370
  // folded into editor-react so it ships with the editor it's wired into.
369
371
  export { RecorderModal } from './recorder/RecorderModal.js';
370
- export type { RecorderModalProps, RecorderSaveResult } from './recorder/RecorderModal.js';
372
+ export type {
373
+ RecorderColorScheme,
374
+ RecorderModalProps,
375
+ RecorderSaveResult,
376
+ } from './recorder/RecorderModal.js';
371
377
  export { RecorderButton } from './recorder/RecorderButton.js';
372
378
  export type { RecorderButtonProps } from './recorder/RecorderButton.js';
373
379
  export { RecorderPanel } from './recorder/RecorderPanel.js';
@@ -11,7 +11,11 @@ import { useCallback, useState, type CSSProperties } from 'react';
11
11
  import { createPortal } from 'react-dom';
12
12
  import type { MediaProvider } from '@bendyline/squisq/schemas';
13
13
  import type { ContentContainer } from '@bendyline/squisq/storage';
14
- import { RecorderModal, type RecorderSaveResult } from './RecorderModal.js';
14
+ import {
15
+ RecorderModal,
16
+ type RecorderColorScheme,
17
+ type RecorderSaveResult,
18
+ } from './RecorderModal.js';
15
19
  import type { RecorderSource } from './hooks/useMediaRecorder.js';
16
20
 
17
21
  export interface RecorderButtonProps {
@@ -21,6 +25,8 @@ export interface RecorderButtonProps {
21
25
  container?: ContentContainer | null;
22
26
  /** Initial capture source. Defaults to `'mic'`. */
23
27
  initialMode?: RecorderSource;
28
+ /** Light/dark chrome scheme copied onto the portaled modal. */
29
+ colorScheme?: RecorderColorScheme;
24
30
  /** Fired after a successful save. */
25
31
  onSave?: (result: RecorderSaveResult) => void;
26
32
  /** Button label. Defaults to `'Record'`. */
@@ -35,6 +41,7 @@ export function RecorderButton({
35
41
  mediaProvider,
36
42
  container = null,
37
43
  initialMode = 'mic',
44
+ colorScheme = 'light',
38
45
  onSave,
39
46
  label = 'Record',
40
47
  style,
@@ -62,6 +69,7 @@ export function RecorderButton({
62
69
  mediaProvider={mediaProvider}
63
70
  container={container}
64
71
  initialMode={initialMode}
72
+ colorScheme={colorScheme}
65
73
  onClose={handleClose}
66
74
  onSave={handleSave}
67
75
  />,
@@ -12,7 +12,8 @@
12
12
  * doc parse.
13
13
  *
14
14
  * Visual conventions match `VideoExportModal` from `@bendyline/squisq-
15
- * video-react` (cream / gold palette, inline styles, no external CSS).
15
+ * video-react`, with inline theme tokens so the body-level portal follows
16
+ * the editor's light/dark chrome scheme.
16
17
  */
17
18
 
18
19
  import { useCallback, useEffect, useRef, useState, type CSSProperties } from 'react';
@@ -25,6 +26,8 @@ import { buildTimingJson, encodeTimingJson, timingPathFor } from './timingJson.j
25
26
 
26
27
  // ── Types ──────────────────────────────────────────────────────────
27
28
 
29
+ export type RecorderColorScheme = 'light' | 'dark';
30
+
28
31
  export interface RecorderModalProps {
29
32
  /** Required — recordings are written here. */
30
33
  mediaProvider: MediaProvider;
@@ -37,6 +40,8 @@ export interface RecorderModalProps {
37
40
  container?: ContentContainer | null;
38
41
  /** Initial capture source. Defaults to `'mic'` (narration). */
39
42
  initialMode?: RecorderSource;
43
+ /** Light/dark chrome scheme. Defaults to `'light'`. */
44
+ colorScheme?: RecorderColorScheme;
40
45
  /** Called after the modal is dismissed (save or cancel). */
41
46
  onClose: () => void;
42
47
  /**
@@ -77,9 +82,34 @@ const overlayStyle: CSSProperties = {
77
82
  zIndex: 10000,
78
83
  };
79
84
 
85
+ /**
86
+ * The recorder is normally portaled to `document.body`, outside the editor
87
+ * shell's CSS-variable scope. Copy the scheme onto a fresh scope and provide
88
+ * inline fallbacks so the standalone RecorderModal/RecorderButton exports are
89
+ * themed even when a host has not loaded the editor stylesheet.
90
+ */
91
+ function recorderThemeStyle(colorScheme: RecorderColorScheme): CSSProperties {
92
+ const dark = colorScheme === 'dark';
93
+ return {
94
+ colorScheme,
95
+ '--squisq-recorder-surface': `var(--squisq-bg, ${dark ? '#1f2937' : '#fffdf7'})`,
96
+ '--squisq-recorder-input': `var(--squisq-input-bg, ${dark ? '#374151' : '#fff'})`,
97
+ '--squisq-recorder-border': `var(--squisq-border, ${dark ? '#4b5563' : '#c9b98a'})`,
98
+ '--squisq-recorder-text': `var(--squisq-text, ${dark ? '#e5e7eb' : '#4a3c1f'})`,
99
+ '--squisq-recorder-muted': `var(--squisq-text-muted, ${dark ? '#9ca3af' : '#5a4a2a'})`,
100
+ '--squisq-recorder-accent': 'var(--squisq-accent, #8b6914)',
101
+ '--squisq-recorder-accent-text': '#fff',
102
+ '--squisq-recorder-danger': dark ? '#dc4c4c' : '#b33a3a',
103
+ '--squisq-recorder-danger-border': dark ? '#ef6a6a' : '#902929',
104
+ '--squisq-recorder-error-bg': dark ? '#3f151b' : '#fceeee',
105
+ '--squisq-recorder-error-border': dark ? '#7f1d1d' : '#d88a8a',
106
+ '--squisq-recorder-error-text': dark ? '#fecdd3' : '#8c2a2a',
107
+ } as CSSProperties;
108
+ }
109
+
80
110
  const modalStyle: CSSProperties = {
81
- background: '#FFFDF7',
82
- border: '1px solid #c9b98a',
111
+ background: 'var(--squisq-recorder-surface)',
112
+ border: '1px solid var(--squisq-recorder-border)',
83
113
  borderRadius: 0,
84
114
  padding: '24px 28px',
85
115
  width: 'min(560px, calc(100vw - 48px))',
@@ -87,14 +117,14 @@ const modalStyle: CSSProperties = {
87
117
  overflowY: 'auto',
88
118
  boxShadow: '0 8px 32px rgba(0,0,0,0.18)',
89
119
  fontFamily: 'system-ui, -apple-system, sans-serif',
90
- color: '#4a3c1f',
120
+ color: 'var(--squisq-recorder-text)',
91
121
  };
92
122
 
93
123
  const titleStyle: CSSProperties = {
94
124
  margin: '0 0 16px 0',
95
125
  fontSize: 18,
96
126
  fontWeight: 600,
97
- color: '#2d2310',
127
+ color: 'var(--squisq-recorder-text)',
98
128
  };
99
129
 
100
130
  const labelStyle: CSSProperties = {
@@ -102,7 +132,7 @@ const labelStyle: CSSProperties = {
102
132
  fontSize: 13,
103
133
  fontWeight: 500,
104
134
  marginBottom: 4,
105
- color: '#5a4a2a',
135
+ color: 'var(--squisq-recorder-text)',
106
136
  };
107
137
 
108
138
  const inputStyle: CSSProperties = {
@@ -110,10 +140,10 @@ const inputStyle: CSSProperties = {
110
140
  padding: '6px 8px',
111
141
  fontSize: 13,
112
142
  fontFamily: 'inherit',
113
- border: '1px solid #c9b98a',
143
+ border: '1px solid var(--squisq-recorder-border)',
114
144
  borderRadius: 0,
115
- background: '#fff',
116
- color: '#4a3c1f',
145
+ background: 'var(--squisq-recorder-input)',
146
+ color: 'var(--squisq-recorder-text)',
117
147
  marginBottom: 12,
118
148
  boxSizing: 'border-box',
119
149
  };
@@ -130,9 +160,9 @@ const btnPrimary: CSSProperties = {
130
160
  fontFamily: 'inherit',
131
161
  fontWeight: 500,
132
162
  cursor: 'pointer',
133
- background: '#8B6914',
134
- color: '#fff',
135
- border: '1px solid #7a5c10',
163
+ background: 'var(--squisq-recorder-accent)',
164
+ color: 'var(--squisq-recorder-accent-text)',
165
+ border: '1px solid var(--squisq-recorder-accent)',
136
166
  borderRadius: 0,
137
167
  };
138
168
 
@@ -142,16 +172,16 @@ const btnSecondary: CSSProperties = {
142
172
  fontFamily: 'inherit',
143
173
  fontWeight: 500,
144
174
  cursor: 'pointer',
145
- background: '#E8DFC6',
146
- color: '#4a3c1f',
147
- border: '1px solid #c9b98a',
175
+ background: 'var(--squisq-recorder-input)',
176
+ color: 'var(--squisq-recorder-text)',
177
+ border: '1px solid var(--squisq-recorder-border)',
148
178
  borderRadius: 0,
149
179
  };
150
180
 
151
181
  const btnDanger: CSSProperties = {
152
182
  ...btnPrimary,
153
- background: '#B33A3A',
154
- borderColor: '#902929',
183
+ background: 'var(--squisq-recorder-danger)',
184
+ borderColor: 'var(--squisq-recorder-danger-border)',
155
185
  };
156
186
 
157
187
  const toggleRowStyle: CSSProperties = {
@@ -166,17 +196,17 @@ const toggleBase: CSSProperties = {
166
196
  fontFamily: 'inherit',
167
197
  cursor: 'pointer',
168
198
  background: 'transparent',
169
- color: '#5a4a2a',
170
- border: '1px solid #c9b98a',
199
+ color: 'var(--squisq-recorder-text)',
200
+ border: '1px solid var(--squisq-recorder-border)',
171
201
  borderRadius: 999,
172
202
  };
173
203
 
174
204
  const toggleActive: CSSProperties = {
175
205
  ...toggleBase,
176
- color: '#fffdf5',
206
+ color: 'var(--squisq-recorder-accent-text)',
177
207
  fontWeight: 600,
178
- background: '#8B6914',
179
- borderColor: '#8B6914',
208
+ background: 'var(--squisq-recorder-accent)',
209
+ borderColor: 'var(--squisq-recorder-accent)',
180
210
  };
181
211
 
182
212
  const previewBoxStyle: CSSProperties = {
@@ -196,21 +226,21 @@ const previewBoxStyle: CSSProperties = {
196
226
  const audioMeterStyle: CSSProperties = {
197
227
  width: '100%',
198
228
  height: 56,
199
- background: '#F2EBD9',
200
- border: '1px solid #c9b98a',
229
+ background: 'var(--squisq-recorder-input)',
230
+ border: '1px solid var(--squisq-recorder-border)',
201
231
  marginBottom: 12,
202
232
  display: 'flex',
203
233
  alignItems: 'center',
204
234
  justifyContent: 'center',
205
- color: '#5a4a2a',
235
+ color: 'var(--squisq-recorder-muted)',
206
236
  fontSize: 13,
207
237
  fontVariantNumeric: 'tabular-nums',
208
238
  };
209
239
 
210
240
  const errorStyle: CSSProperties = {
211
- background: '#FCEEEE',
212
- border: '1px solid #D88A8A',
213
- color: '#8C2A2A',
241
+ background: 'var(--squisq-recorder-error-bg)',
242
+ border: '1px solid var(--squisq-recorder-error-border)',
243
+ color: 'var(--squisq-recorder-error-text)',
214
244
  padding: '8px 10px',
215
245
  fontSize: 13,
216
246
  marginBottom: 12,
@@ -223,6 +253,20 @@ const buttonRowStyle: CSSProperties = {
223
253
  marginTop: 8,
224
254
  };
225
255
 
256
+ const summaryStyle: CSSProperties = {
257
+ margin: '0 0 12px 0',
258
+ fontSize: 12,
259
+ color: 'var(--squisq-recorder-muted)',
260
+ };
261
+
262
+ const recordingStatusStyle: CSSProperties = {
263
+ fontSize: 13,
264
+ fontVariantNumeric: 'tabular-nums',
265
+ marginBottom: 12,
266
+ color: 'var(--squisq-recorder-accent)',
267
+ fontWeight: 600,
268
+ };
269
+
226
270
  // ── Helpers ────────────────────────────────────────────────────────
227
271
 
228
272
  function formatDurationMs(ms: number): string {
@@ -293,6 +337,7 @@ export function RecorderModal({
293
337
  mediaProvider,
294
338
  container = null,
295
339
  initialMode = 'mic',
340
+ colorScheme = 'light',
296
341
  onClose,
297
342
  onSave,
298
343
  }: RecorderModalProps) {
@@ -471,7 +516,14 @@ export function RecorderModal({
471
516
  };
472
517
 
473
518
  return (
474
- <div style={overlayStyle} role="dialog" aria-modal="true" aria-label="Record media">
519
+ <div
520
+ className="squisq-editor-shell squisq-recorder-overlay"
521
+ data-theme={colorScheme}
522
+ style={{ ...overlayStyle, ...recorderThemeStyle(colorScheme) }}
523
+ role="dialog"
524
+ aria-modal="true"
525
+ aria-label="Record media"
526
+ >
475
527
  <div style={modalStyle} onClick={(e) => e.stopPropagation()}>
476
528
  <h2 style={titleStyle}>Record media</h2>
477
529
 
@@ -493,9 +545,7 @@ export function RecorderModal({
493
545
  })}
494
546
  </div>
495
547
 
496
- <p style={{ margin: '0 0 12px 0', fontSize: 12, color: '#5a4a2a' }}>
497
- {captureSummary(micOn, video)}
498
- </p>
548
+ <p style={summaryStyle}>{captureSummary(micOn, video)}</p>
499
549
 
500
550
  {recorder.error && <div style={errorStyle}>{recorder.error.message}</div>}
501
551
  {saveError && <div style={errorStyle}>{saveError}</div>}
@@ -579,6 +629,7 @@ export function RecorderModal({
579
629
  >
580
630
  <input
581
631
  type="checkbox"
632
+ style={{ accentColor: 'var(--squisq-recorder-accent)' }}
582
633
  checked={includeSystemAudio}
583
634
  onChange={(e) => setIncludeSystemAudio(e.target.checked)}
584
635
  disabled={recorder.state === 'recording' || recorder.state === 'requesting'}
@@ -602,15 +653,7 @@ export function RecorderModal({
602
653
 
603
654
  {/* Live duration during recording */}
604
655
  {recorder.state === 'recording' && !isAudioOnly && (
605
- <div
606
- style={{
607
- fontSize: 13,
608
- fontVariantNumeric: 'tabular-nums',
609
- marginBottom: 12,
610
- color: '#8B6914',
611
- fontWeight: 600,
612
- }}
613
- >
656
+ <div style={recordingStatusStyle}>
614
657
  ● Recording {formatDurationMs(recorder.durationMs)}
615
658
  </div>
616
659
  )}
@@ -12,7 +12,11 @@ import { useCallback, useState } from 'react';
12
12
  import { createPortal } from 'react-dom';
13
13
  import type { MediaProvider } from '@bendyline/squisq/schemas';
14
14
  import type { ContentContainer } from '@bendyline/squisq/storage';
15
- import { RecorderModal, type RecorderSaveResult } from './RecorderModal.js';
15
+ import {
16
+ RecorderModal,
17
+ type RecorderColorScheme,
18
+ type RecorderSaveResult,
19
+ } from './RecorderModal.js';
16
20
  import { Icon } from '../Icon';
17
21
  import type { RecorderSource } from './hooks/useMediaRecorder.js';
18
22
 
@@ -20,6 +24,8 @@ export interface RecorderPanelProps {
20
24
  mediaProvider: MediaProvider;
21
25
  container?: ContentContainer | null;
22
26
  initialMode?: RecorderSource;
27
+ /** Light/dark chrome scheme copied onto the portaled modal. */
28
+ colorScheme?: RecorderColorScheme;
23
29
  onSave?: (result: RecorderSaveResult) => void;
24
30
  /** ARIA / tooltip label. Defaults to `'Record media'`. */
25
31
  tooltip?: string;
@@ -31,6 +37,7 @@ export function RecorderPanel({
31
37
  mediaProvider,
32
38
  container = null,
33
39
  initialMode = 'mic',
40
+ colorScheme = 'light',
34
41
  onSave,
35
42
  tooltip = 'Record media',
36
43
  className,
@@ -57,6 +64,7 @@ export function RecorderPanel({
57
64
  mediaProvider={mediaProvider}
58
65
  container={container}
59
66
  initialMode={initialMode}
67
+ colorScheme={colorScheme}
60
68
  onClose={handleClose}
61
69
  onSave={(result) => {
62
70
  onSave?.(result);
@@ -2,7 +2,7 @@
2
2
  import { render } from '@testing-library/react';
3
3
  import { describe, expect, it, vi } from 'vitest';
4
4
  import type { SceneToolContext } from '../tools/SceneTool';
5
- import { SelectTool, getActiveMoveOffset } from '../tools/SelectTool';
5
+ import { SelectTool, beginHandleDrag, getActiveMoveOffset } from '../tools/SelectTool';
6
6
  import { ConnectTool } from '../tools/ConnectTool';
7
7
  import { createSceneTextChannel } from '../text/sceneTextChannel';
8
8
  import { SceneViewport } from '../SceneViewport';
@@ -61,6 +61,32 @@ describe('Scene instance isolation', () => {
61
61
  expect(second.interaction.connect).toBeUndefined();
62
62
  });
63
63
 
64
+ it('commits a resize as one command containing origin and size', () => {
65
+ const ctx = context('node-card-a');
66
+ beginHandleDrag(
67
+ {
68
+ layerId: 'node-card-a',
69
+ corner: 'nw',
70
+ startV: { x: 10, y: 20 },
71
+ startBounds: { x: 10, y: 20, width: 100, height: 50 },
72
+ },
73
+ ctx.interaction,
74
+ );
75
+
76
+ SelectTool.onPointerMove!(pointer(0, 0), ctx);
77
+ SelectTool.onPointerUp!(pointer(0, 0), ctx);
78
+
79
+ expect(ctx.dispatch).toHaveBeenCalledTimes(1);
80
+ expect(ctx.dispatch).toHaveBeenCalledWith({
81
+ kind: 'resizeLayer',
82
+ id: 'node-card-a',
83
+ x: 0,
84
+ y: 0,
85
+ width: 110,
86
+ height: 70,
87
+ });
88
+ });
89
+
64
90
  it('creates independent text-toolbar channels', () => {
65
91
  const first = createSceneTextChannel();
66
92
  const second = createSceneTextChannel();
@@ -238,7 +238,12 @@ export function useDrawingAdapter(
238
238
  case 'resizeLayer': {
239
239
  if (!isPrimaryShapeLayer(cmd.id)) return;
240
240
  const id = shapeIdFromLayerId(cmd.id);
241
- if (id) resizeShape(editor, headingPos, id, cmd.width, cmd.height);
241
+ if (id) {
242
+ if (cmd.x !== undefined && cmd.y !== undefined) {
243
+ moveShape(editor, headingPos, id, cmd.x, cmd.y);
244
+ }
245
+ resizeShape(editor, headingPos, id, cmd.width, cmd.height);
246
+ }
242
247
  return;
243
248
  }
244
249
  case 'addLayer': {
@@ -130,6 +130,9 @@ export function useLayoutAdapter(
130
130
  moveLayoutLayer(editor, headingPos, cmd.id, cmd.x, cmd.y);
131
131
  return;
132
132
  case 'resizeLayer':
133
+ if (cmd.x !== undefined && cmd.y !== undefined) {
134
+ moveLayoutLayer(editor, headingPos, cmd.id, cmd.x, cmd.y);
135
+ }
133
136
  resizeLayoutLayer(editor, headingPos, cmd.id, cmd.width, cmd.height);
134
137
  return;
135
138
  case 'addLayer': {
@@ -16,8 +16,19 @@ import type { DiagramEdgeAnchor, Layer } from '@bendyline/squisq/schemas';
16
16
  export type SceneCommand =
17
17
  /** Commit a layer's new top-left position (in viewport units). */
18
18
  | { kind: 'moveLayer'; id: string; x: number; y: number }
19
- /** Commit a layer's new size (in viewport units). */
20
- | { kind: 'resizeLayer'; id: string; width: number; height: number }
19
+ /**
20
+ * Commit a layer's new size (in viewport units). `x`/`y` carry the final
21
+ * top-left for west/north handle drags so hosts can persist the whole
22
+ * resize atomically instead of reparsing between a move and a size write.
23
+ */
24
+ | {
25
+ kind: 'resizeLayer';
26
+ id: string;
27
+ width: number;
28
+ height: number;
29
+ x?: number;
30
+ y?: number;
31
+ }
21
32
  /** Append a new layer to the scene. */
22
33
  | { kind: 'addLayer'; layer: Layer }
23
34
  /** Remove a layer by id. */
@@ -212,11 +212,11 @@ export const SelectTool: SceneTool = {
212
212
  }
213
213
  } else if (dragState.kind === 'resize') {
214
214
  const { x, y, width, height } = dragState.currentBounds;
215
- // Resize translates into a move (if origin shifted) + a size change.
216
- // We dispatch both so a single grab on a corner can change all four
217
- // fields atomically from the host's point of view.
218
- ctx.dispatch({ kind: 'moveLayer', id: dragState.layerId, x, y });
219
- ctx.dispatch({ kind: 'resizeLayer', id: dragState.layerId, width, height });
215
+ // Keep the final origin + size in one command. In particular, the ASCII
216
+ // diagram adapter must render/reparse only once: committing a move and
217
+ // then a resize used to normalize the art between the two halves of one
218
+ // pointer gesture and could snap the node to a different grid position.
219
+ ctx.dispatch({ kind: 'resizeLayer', id: dragState.layerId, x, y, width, height });
220
220
  }
221
221
  (e.currentTarget as Element).releasePointerCapture?.(e.pointerId);
222
222
  setDragState(ctx.interaction, null);
@@ -0,0 +1,155 @@
1
+ /**
2
+ * Plain-text selection conversions used by the toolbar's Convert menu.
3
+ * Keeping delimiter detection separate from the editor integrations makes
4
+ * the behavior identical in Monaco and Tiptap.
5
+ */
6
+
7
+ export type SelectionTableDelimiter = 'pipe' | 'comma' | 'tab' | 'multispace' | null;
8
+
9
+ export interface SelectionTable {
10
+ delimiter: SelectionTableDelimiter;
11
+ rows: string[][];
12
+ }
13
+
14
+ export interface SelectionTaskItem {
15
+ checked: boolean;
16
+ text: string;
17
+ }
18
+
19
+ type CandidateDelimiter = Exclude<SelectionTableDelimiter, null>;
20
+
21
+ /** Non-empty selected lines, trimmed without changing their order. */
22
+ export function selectionLines(text: string): string[] {
23
+ return text
24
+ .replace(/\r\n?/g, '\n')
25
+ .split('\n')
26
+ .map((line) => line.trim())
27
+ .filter((line) => line.length > 0);
28
+ }
29
+
30
+ function splitPipeLine(line: string): string[] {
31
+ let value = line.trim();
32
+ if (value.startsWith('|')) value = value.slice(1);
33
+ if (value.endsWith('|') && !value.endsWith('\\|')) value = value.slice(0, -1);
34
+
35
+ const cells: string[] = [];
36
+ let cell = '';
37
+ for (let i = 0; i < value.length; i++) {
38
+ const char = value[i];
39
+ if (char === '\\' && value[i + 1] === '|') {
40
+ cell += '|';
41
+ i++;
42
+ } else if (char === '|') {
43
+ cells.push(cell.trim());
44
+ cell = '';
45
+ } else {
46
+ cell += char;
47
+ }
48
+ }
49
+ cells.push(cell.trim());
50
+ return cells;
51
+ }
52
+
53
+ /** A small CSV-row parser so quoted commas remain inside their cell. */
54
+ function splitCommaLine(line: string): string[] {
55
+ const cells: string[] = [];
56
+ let cell = '';
57
+ let quoted = false;
58
+
59
+ for (let i = 0; i < line.length; i++) {
60
+ const char = line[i];
61
+ if (char === '"' && !quoted && cell.trim().length === 0) {
62
+ // A quote only opens CSV quoting at the start of a field. Literal
63
+ // quotes elsewhere (for example 5" screen) remain part of the value.
64
+ quoted = true;
65
+ cell = '';
66
+ } else if (char === '"' && quoted) {
67
+ if (line[i + 1] === '"') {
68
+ cell += '"';
69
+ i++;
70
+ } else {
71
+ quoted = false;
72
+ }
73
+ } else if (char === ',' && !quoted) {
74
+ cells.push(cell.trim());
75
+ cell = '';
76
+ } else {
77
+ cell += char;
78
+ }
79
+ }
80
+ if (quoted) return line.split(',').map((value) => value.trim());
81
+ cells.push(cell.trim());
82
+ return cells;
83
+ }
84
+
85
+ function splitLine(line: string, delimiter: CandidateDelimiter): string[] {
86
+ switch (delimiter) {
87
+ case 'pipe':
88
+ return splitPipeLine(line);
89
+ case 'comma':
90
+ return splitCommaLine(line);
91
+ case 'tab':
92
+ return line
93
+ .trim()
94
+ .split('\t')
95
+ .map((cell) => cell.trim());
96
+ case 'multispace':
97
+ return line
98
+ .trim()
99
+ .split(/[ \u00a0]{2,}/)
100
+ .map((cell) => cell.trim());
101
+ }
102
+ }
103
+
104
+ /**
105
+ * Detect a delimiter only when at least two lines use it and every line
106
+ * produces the same number of columns. Inconsistent prose therefore falls
107
+ * back to a useful one-column table instead of losing text.
108
+ */
109
+ export function selectionToTable(text: string): SelectionTable {
110
+ const lines = selectionLines(text);
111
+ const candidates: CandidateDelimiter[] = ['pipe', 'comma', 'tab', 'multispace'];
112
+
113
+ if (lines.length >= 2) {
114
+ for (const delimiter of candidates) {
115
+ const rows = lines.map((line) => splitLine(line, delimiter));
116
+ const columnCount = rows[0]?.length ?? 0;
117
+ if (columnCount >= 2 && rows.every((row) => row.length === columnCount)) {
118
+ return { delimiter, rows };
119
+ }
120
+ }
121
+ }
122
+
123
+ return { delimiter: null, rows: lines.map((line) => [line]) };
124
+ }
125
+
126
+ function escapeTableCell(cell: string): string {
127
+ return cell.replace(/\|/g, '\\|');
128
+ }
129
+
130
+ /** Render a selection as a GFM table, using its first row as the header. */
131
+ export function selectionToTableMarkdown(text: string): string {
132
+ const { rows } = selectionToTable(text);
133
+ if (rows.length === 0) return '';
134
+
135
+ const formatRow = (row: string[]) => `| ${row.map(escapeTableCell).join(' | ')} |`;
136
+ const separator = rows[0].map(() => '---');
137
+ return [formatRow(rows[0]), formatRow(separator), ...rows.slice(1).map(formatRow)].join('\n');
138
+ }
139
+
140
+ /** Convert selected lines to task items, normalizing existing list markers. */
141
+ export function selectionToTaskItems(text: string): SelectionTaskItem[] {
142
+ return selectionLines(text).map((line) => {
143
+ const task = /^[-*+]\s+\[([ xX])\]\s*(.*)$/.exec(line);
144
+ if (task) return { checked: task[1].toLowerCase() === 'x', text: task[2].trim() };
145
+
146
+ const withoutListMarker = line.replace(/^(?:[-*+]\s+|\d+[.)]\s+)/, '');
147
+ return { checked: false, text: withoutListMarker.trim() };
148
+ });
149
+ }
150
+
151
+ export function selectionToTaskListMarkdown(text: string): string {
152
+ return selectionToTaskItems(text)
153
+ .map((item) => `- [${item.checked ? 'x' : ' '}] ${item.text}`)
154
+ .join('\n');
155
+ }