astro-tractstack 2.0.9 → 2.0.10

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 (40) hide show
  1. package/dist/index.js +4 -6
  2. package/package.json +1 -1
  3. package/templates/css/custom.css +0 -6
  4. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +1 -1
  5. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +2 -1
  6. package/templates/src/components/codehooks/ProductGridSetup.tsx +4 -4
  7. package/templates/src/components/compositor/Compositor.tsx +335 -16
  8. package/templates/src/components/compositor/Node.tsx +86 -6
  9. package/templates/src/components/compositor/nodes/RenderChildren.tsx +3 -6
  10. package/templates/src/components/compositor/nodes/tagElements/NodeA.tsx +2 -1
  11. package/templates/src/components/compositor/nodes/tagElements/NodeAnchorComponent.tsx +11 -19
  12. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag.tsx +70 -17
  13. package/templates/src/components/compositor/nodes/tagElements/NodeButton.tsx +1 -1
  14. package/templates/src/components/compositor/nodes/tagElements/NodeText.tsx +78 -8
  15. package/templates/src/components/edit/SettingsPanel.tsx +1 -1
  16. package/templates/src/components/edit/ToolMode.tsx +93 -22
  17. package/templates/src/components/edit/pane/AddPanePanel_break.tsx +2 -1
  18. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +2 -1
  19. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +1 -1
  20. package/templates/src/components/edit/pane/PageGen_preview.tsx +2 -1
  21. package/templates/src/components/edit/panels/StyleElementPanel_update.tsx +9 -5
  22. package/templates/src/components/edit/state/SaveModal.tsx +84 -14
  23. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +2 -2
  24. package/templates/src/components/search/SearchModal.tsx +2 -1
  25. package/templates/src/components/search/SearchResults.tsx +2 -1
  26. package/templates/src/components/search/SearchWrapper.tsx +1 -1
  27. package/templates/src/components/storykeep/Dashboard_Analytics.tsx +1 -1
  28. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +3 -5
  29. package/templates/src/components/storykeep/controls/content/BeliefTable.tsx +1 -1
  30. package/templates/src/components/storykeep/controls/content/MenuTable.tsx +1 -1
  31. package/templates/src/components/storykeep/controls/content/StoryFragmentTable.tsx +1 -1
  32. package/templates/src/components/widgets/ImpressionWrapper.tsx +1 -1
  33. package/templates/src/hooks/useFormState.ts +3 -4
  34. package/templates/src/stores/nodes.ts +627 -21
  35. package/templates/src/stores/selection.ts +41 -0
  36. package/templates/src/types/compositorTypes.ts +1 -0
  37. package/templates/src/types/nodeProps.ts +12 -0
  38. package/templates/src/utils/compositor/nodesHelper.ts +2 -2
  39. package/utils/inject-files.ts +4 -6
  40. package/templates/src/components/compositor/elements/PlayButton.tsx +0 -19
package/dist/index.js CHANGED
@@ -381,12 +381,6 @@ async function w(t, e, c) {
381
381
  src: t("../templates/src/components/compositor/elements/Svg.tsx"),
382
382
  dest: "src/components/compositor/elements/Svg.tsx"
383
383
  },
384
- {
385
- src: t(
386
- "../templates/src/components/compositor/elements/PlayButton.tsx"
387
- ),
388
- dest: "src/components/compositor/elements/PlayButton.tsx"
389
- },
390
384
  // Compositor panels
391
385
  {
392
386
  src: t(
@@ -581,6 +575,10 @@ async function w(t, e, c) {
581
575
  src: t("../templates/src/stores/nodesHistory.ts"),
582
576
  dest: "src/stores/nodesHistory.ts"
583
577
  },
578
+ {
579
+ src: t("../templates/src/stores/selection.ts"),
580
+ dest: "src/stores/selection.ts"
581
+ },
584
582
  // AAI utils
585
583
  {
586
584
  src: t("../templates/src/utils/aai/getTitleSlug.ts"),
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.9",
3
+ "version": "2.0.10",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -43,9 +43,3 @@ body {
43
43
  @media (prefers-reduced-motion) {
44
44
  scroll-behavior: auto;
45
45
  }
46
-
47
- [contenteditable]:empty:before {
48
- content: attr(data-placeholder);
49
- color: #888;
50
- font-style: italic;
51
- }
@@ -512,7 +512,7 @@ const EpinetDurationSelector = ({
512
512
  onBeliefFilterChange(filter.beliefSlug, value)
513
513
  }
514
514
  type="button"
515
- className={`flex items-center gap-x-1.5 rounded-full px-3 py-1 text-sm font-medium transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 ${
515
+ className={`flex items-center gap-x-1.5 rounded-full px-3 py-1 text-sm font-bold transition-all duration-150 focus:outline-none focus:ring-2 focus:ring-cyan-500 focus:ring-offset-2 ${
516
516
  selectedValue === value
517
517
  ? 'bg-cyan-600 text-white shadow-sm'
518
518
  : 'bg-gray-100 text-gray-700 hover:bg-gray-200'
@@ -2,7 +2,8 @@ import { useState, useEffect, useMemo, useRef } from 'react';
2
2
  import { useStore } from '@nanostores/react';
3
3
  import { Combobox } from '@ark-ui/react';
4
4
  import { createListCollection } from '@ark-ui/react/collection';
5
- import { ChevronUpDownIcon, CheckIcon } from '@heroicons/react/20/solid';
5
+ import CheckIcon from '@heroicons/react/20/solid/CheckIcon';
6
+ import ChevronUpDownIcon from '@heroicons/react/20/solid/ChevronUpDownIcon';
6
7
  import { fullContentMapStore, viewportKeyStore } from '@/stores/storykeep';
7
8
  import { getCtx } from '@/stores/nodes';
8
9
  import { cloneDeep } from '@/utils/helpers';
@@ -190,7 +190,7 @@ export const ProductGridSetup = (props: ProductGridSetupProps) => {
190
190
  <div className="space-y-2 rounded-md border p-3">
191
191
  <label
192
192
  htmlFor="productType"
193
- className="text-sm font-medium text-gray-700"
193
+ className="text-sm font-bold text-gray-700"
194
194
  >
195
195
  Product Type
196
196
  </label>
@@ -209,7 +209,7 @@ export const ProductGridSetup = (props: ProductGridSetupProps) => {
209
209
  <div className="rounded-md border bg-gray-50 p-3">
210
210
  <div className="flex items-center justify-between">
211
211
  <div>
212
- <p className="text-sm font-medium text-gray-600">
212
+ <p className="text-sm font-bold text-gray-600">
213
213
  Selected Products
214
214
  </p>
215
215
  <p className="font-bold text-gray-900">
@@ -219,7 +219,7 @@ export const ProductGridSetup = (props: ProductGridSetupProps) => {
219
219
  <button
220
220
  type="button"
221
221
  onClick={() => setShowSelector(!showSelector)}
222
- className="rounded bg-white px-3 py-1 text-sm font-semibold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
222
+ className="rounded bg-white px-3 py-1 text-sm font-bold text-gray-900 shadow-sm ring-1 ring-inset ring-gray-300 hover:bg-gray-50"
223
223
  >
224
224
  {showSelector ? 'Close' : 'Change Selection'}
225
225
  </button>
@@ -235,7 +235,7 @@ export const ProductGridSetup = (props: ProductGridSetupProps) => {
235
235
  lazyMount
236
236
  unmountOnExit
237
237
  >
238
- <Combobox.Label className="text-sm font-medium text-gray-700">
238
+ <Combobox.Label className="text-sm font-bold text-gray-700">
239
239
  Find products to include
240
240
  </Combobox.Label>
241
241
  <Combobox.Control>
@@ -1,4 +1,9 @@
1
- import { useEffect, useState } from 'react';
1
+ import {
2
+ useEffect,
3
+ useState,
4
+ useRef,
5
+ type MouseEvent as ReactMouseEvent, // Alias React's MouseEvent
6
+ } from 'react';
2
7
  import { useStore } from '@nanostores/react';
3
8
  import {
4
9
  viewportKeyStore,
@@ -10,17 +15,31 @@ import {
10
15
  codehookMapStore,
11
16
  brandColourStore,
12
17
  hasArtpacksStore,
18
+ settingsPanelStore,
13
19
  } from '@/stores/storykeep';
14
20
  import { getCtx, ROOT_NODE_NAME, type NodesContext } from '@/stores/nodes';
15
21
  import { stopLoadingAnimation } from '@/utils/helpers';
16
22
  import Node from './Node';
17
23
  import { ARTPACKS } from '@/constants/brandThemes';
24
+ import {
25
+ selectionStore,
26
+ resetSelectionStore,
27
+ type SelectionStoreState,
28
+ } from '@/stores/selection';
18
29
  import type { LoadData } from '@/types/compositorTypes';
19
30
  import type {
20
31
  Theme,
21
32
  BrandConfig,
22
33
  FullContentMapItem,
23
34
  } from '@/types/tractstack';
35
+ import type { SelectionOrigin } from '@/types/nodeProps';
36
+
37
+ type SelectionRect = {
38
+ top: number;
39
+ left: number;
40
+ width: number;
41
+ height: number;
42
+ };
24
43
 
25
44
  export type CompositorProps = {
26
45
  nodes: LoadData | null;
@@ -33,10 +52,235 @@ export type CompositorProps = {
33
52
  fullCanonicalURL: string;
34
53
  };
35
54
 
55
+ const VERBOSE = false;
56
+ const LOG_PREFIX = '[Compositor] ';
57
+
36
58
  export const Compositor = (props: CompositorProps) => {
37
59
  const [initialized, setInitialized] = useState(false);
38
60
  const [updateCounter, setUpdateCounter] = useState(0);
39
- const [isLoading, setIsLoading] = useState(true); // Start with loading true
61
+ const [isLoading, setIsLoading] = useState(true);
62
+
63
+ const [selectionRect, setSelectionRect] = useState<SelectionRect | null>(
64
+ null
65
+ );
66
+ const isDragging = useRef(false);
67
+ const selectionOrigin = useRef<SelectionOrigin | null>(null);
68
+ const dragStartCoords = useRef<{ x: number; y: number } | null>(null);
69
+
70
+ const $viewportKey = useStore(viewportKeyStore);
71
+ const $selection = useStore(selectionStore);
72
+ const viewportMaxWidth =
73
+ $viewportKey.value === `mobile`
74
+ ? 600
75
+ : $viewportKey.value === `tablet`
76
+ ? 1000
77
+ : 1500;
78
+ const viewportMinWidth =
79
+ $viewportKey.value === `mobile`
80
+ ? null
81
+ : $viewportKey.value === `tablet`
82
+ ? 801
83
+ : 1368;
84
+
85
+ const handleDragStart = (
86
+ origin: SelectionOrigin,
87
+ e: ReactMouseEvent<HTMLElement> // Use aliased React MouseEvent
88
+ ) => {
89
+ if (VERBOSE)
90
+ console.log(LOG_PREFIX + 'handleDragStart FIRED', { origin, event: e });
91
+ if (isDragging.current) {
92
+ if (VERBOSE)
93
+ console.log(LOG_PREFIX + 'handleDragStart aborted: already dragging.');
94
+ return;
95
+ }
96
+ isDragging.current = true;
97
+ selectionOrigin.current = origin;
98
+ dragStartCoords.current = { x: e.clientX, y: e.clientY };
99
+
100
+ resetSelectionStore();
101
+ if (VERBOSE) console.log(LOG_PREFIX + 'Selection store reset.');
102
+ selectionStore.setKey('isDragging', true);
103
+ selectionStore.setKey('blockNodeId', origin.blockNodeId);
104
+ selectionStore.setKey('lcaNodeId', origin.lcaNodeId);
105
+ selectionStore.setKey('startNodeId', origin.startNodeId);
106
+ selectionStore.setKey('startCharOffset', origin.startCharOffset);
107
+ selectionStore.setKey('endNodeId', origin.endNodeId);
108
+ selectionStore.setKey('endCharOffset', origin.endCharOffset);
109
+ if (VERBOSE)
110
+ console.log(
111
+ LOG_PREFIX + 'Selection store updated with origin:',
112
+ selectionStore.get()
113
+ );
114
+
115
+ const initialRect = {
116
+ left: e.clientX,
117
+ top: e.clientY,
118
+ width: 0,
119
+ height: 0,
120
+ };
121
+ setSelectionRect(initialRect);
122
+ if (VERBOSE)
123
+ console.log(LOG_PREFIX + 'Initial selectionRect set:', initialRect);
124
+
125
+ if (VERBOSE) console.log(LOG_PREFIX + 'Adding window event listeners...');
126
+ try {
127
+ window.addEventListener('mousemove', handleDragMove);
128
+ window.addEventListener('mouseup', handleMouseUp);
129
+ if (VERBOSE)
130
+ console.log(LOG_PREFIX + 'Window event listeners successfully added.');
131
+ } catch (error) {
132
+ console.error(LOG_PREFIX + 'Error adding window event listeners:', error);
133
+ }
134
+ };
135
+
136
+ const handleDragMove = (e: globalThis.MouseEvent) => {
137
+ if (
138
+ !isDragging.current ||
139
+ !selectionOrigin.current ||
140
+ !dragStartCoords.current
141
+ ) {
142
+ return;
143
+ }
144
+
145
+ const startX = dragStartCoords.current.x;
146
+ const startY = dragStartCoords.current.y;
147
+ const currentX = e.clientX;
148
+ const currentY = e.clientY;
149
+
150
+ const newRect = {
151
+ left: Math.min(startX, currentX),
152
+ top: Math.min(startY, currentY),
153
+ width: Math.abs(currentX - startX),
154
+ height: Math.abs(currentY - startY),
155
+ };
156
+ setSelectionRect(newRect);
157
+
158
+ const elementAtPoint = document.elementFromPoint(currentX, currentY);
159
+ if (!elementAtPoint) return;
160
+
161
+ const textNodeElement = elementAtPoint.closest(
162
+ '[data-parent-text-node-id]'
163
+ );
164
+
165
+ if (textNodeElement) {
166
+ const parentBlockNodeId =
167
+ textNodeElement
168
+ .closest('[data-node-id]')
169
+ ?.getAttribute('data-node-id') || null;
170
+
171
+ if (parentBlockNodeId !== selectionOrigin.current.blockNodeId) {
172
+ return;
173
+ }
174
+
175
+ const endNodeId = textNodeElement.getAttribute(
176
+ 'data-parent-text-node-id'
177
+ );
178
+ const endCharOffset = parseInt(
179
+ textNodeElement.getAttribute('data-end-char-offset') || '0',
180
+ 10
181
+ );
182
+
183
+ if (endNodeId) {
184
+ selectionStore.setKey('endNodeId', endNodeId);
185
+ selectionStore.setKey('endCharOffset', endCharOffset);
186
+ }
187
+ }
188
+ };
189
+
190
+ const handleMouseUp = async (e: globalThis.MouseEvent) => {
191
+ if (VERBOSE) console.log(LOG_PREFIX + 'handleMouseUp FIRED', { event: e });
192
+ if (!isDragging.current) {
193
+ if (VERBOSE)
194
+ console.log(LOG_PREFIX + 'handleMouseUp aborted: was not dragging.');
195
+ return;
196
+ }
197
+
198
+ if (VERBOSE) console.log(LOG_PREFIX + 'Removing window event listeners...');
199
+ try {
200
+ window.removeEventListener('mousemove', handleDragMove);
201
+ window.removeEventListener('mouseup', handleMouseUp);
202
+ if (VERBOSE)
203
+ console.log(
204
+ LOG_PREFIX + 'Window event listeners successfully removed.'
205
+ );
206
+ } catch (error) {
207
+ console.error(
208
+ LOG_PREFIX + 'Error removing window event listeners:',
209
+ error
210
+ );
211
+ }
212
+
213
+ isDragging.current = false;
214
+ dragStartCoords.current = null;
215
+ setSelectionRect(null);
216
+ if (VERBOSE)
217
+ console.log(LOG_PREFIX + 'Drag state reset, selectionRect cleared.');
218
+
219
+ const selectionRange = selectionStore.get();
220
+ if (VERBOSE)
221
+ console.log(
222
+ LOG_PREFIX + 'Final selection range from store:',
223
+ selectionRange
224
+ );
225
+
226
+ if (
227
+ !selectionRange.startNodeId ||
228
+ !selectionRange.endNodeId ||
229
+ (selectionRange.startNodeId === selectionRange.endNodeId &&
230
+ selectionRange.startCharOffset === selectionRange.endCharOffset)
231
+ ) {
232
+ if (VERBOSE)
233
+ console.log(
234
+ LOG_PREFIX +
235
+ 'handleMouseUp aborted: invalid or zero-length selection.'
236
+ );
237
+ resetSelectionStore();
238
+ selectionOrigin.current = null;
239
+ return;
240
+ }
241
+
242
+ if (VERBOSE)
243
+ console.log(LOG_PREFIX + 'Calculating selection bounding box.');
244
+
245
+ const startElement = document.querySelector(
246
+ `[data-parent-text-node-id="${selectionRange.startNodeId}"][data-start-char-offset="${selectionRange.startCharOffset}"]`
247
+ );
248
+ const endElement = document.querySelector(
249
+ `[data-parent-text-node-id="${selectionRange.endNodeId}"]`
250
+ );
251
+
252
+ let selectionBox = null;
253
+ if (startElement && endElement) {
254
+ const startRect = startElement.getBoundingClientRect();
255
+ const endRect = endElement.getBoundingClientRect();
256
+ const contentRect = document
257
+ .getElementById('content')
258
+ ?.getBoundingClientRect();
259
+
260
+ if (contentRect) {
261
+ selectionBox = {
262
+ top: Math.min(startRect.top, endRect.top) - contentRect.top,
263
+ left: Math.min(startRect.left, endRect.left) - contentRect.left,
264
+ };
265
+ }
266
+ }
267
+
268
+ if (selectionBox) {
269
+ if (VERBOSE)
270
+ console.log(LOG_PREFIX + 'Selection complete, setting isActive: true.');
271
+ selectionStore.setKey('selectionBox', selectionBox);
272
+ selectionStore.setKey('isActive', true);
273
+ } else {
274
+ if (VERBOSE)
275
+ console.log(
276
+ LOG_PREFIX + 'Could not calculate bounding box, resetting selection.'
277
+ );
278
+ resetSelectionStore();
279
+ }
280
+
281
+ selectionStore.setKey('isDragging', false);
282
+ selectionOrigin.current = null;
283
+ };
40
284
 
41
285
  useEffect(() => {
42
286
  fullContentMapStore.set(props.fullContentMap);
@@ -56,35 +300,28 @@ export const Compositor = (props: CompositorProps) => {
56
300
  props.availableCodeHooks,
57
301
  ]);
58
302
 
59
- const $viewportKey = useStore(viewportKeyStore);
60
- const viewportMaxWidth =
61
- $viewportKey.value === `mobile`
62
- ? 600
63
- : $viewportKey.value === `tablet`
64
- ? 1000
65
- : 1500;
66
- const viewportMinWidth =
67
- $viewportKey.value === `mobile`
68
- ? null
69
- : $viewportKey.value === `tablet`
70
- ? 801
71
- : 1368;
72
-
73
303
  // Initialize nodes tree and set up subscriptions
74
304
  useEffect(() => {
305
+ if (VERBOSE) console.log(LOG_PREFIX + 'Compositor initializing...');
75
306
  getCtx(props).buildNodesTreeFromRowDataMadeNodes(props.nodes);
76
307
  hasArtpacksStore.set(ARTPACKS);
77
308
  setInitialized(true);
309
+ if (VERBOSE)
310
+ console.log(LOG_PREFIX + 'Nodes tree built, initialized set to true.');
78
311
 
79
312
  // Stop initial loading after initialization
80
313
  setTimeout(() => {
81
314
  setIsLoading(false);
82
315
  stopLoadingAnimation();
316
+ if (VERBOSE)
317
+ console.log(LOG_PREFIX + 'Initial loading animation stopped.');
83
318
  }, 300);
84
319
 
85
320
  const unsubscribe = getCtx(props).notifications.subscribe(
86
321
  ROOT_NODE_NAME,
87
322
  () => {
323
+ if (VERBOSE)
324
+ console.log(LOG_PREFIX + 'Received root notification, updating...');
88
325
  // Start loading state
89
326
  setIsLoading(true);
90
327
 
@@ -95,16 +332,83 @@ export const Compositor = (props: CompositorProps) => {
95
332
  setTimeout(() => {
96
333
  setIsLoading(false);
97
334
  stopLoadingAnimation();
335
+ if (VERBOSE)
336
+ console.log(LOG_PREFIX + 'Update loading animation stopped.');
98
337
  }, 300);
99
338
  }
100
339
  );
101
340
 
341
+ const unsubscribeToolMode = getCtx(props).toolModeValStore.subscribe(
342
+ (mode) => {
343
+ if (VERBOSE) console.log(LOG_PREFIX + 'Tool mode changed:', mode.value);
344
+ if (mode.value !== 'styles') {
345
+ if (VERBOSE)
346
+ console.log(
347
+ LOG_PREFIX + 'Exited styles mode, resetting selection store.'
348
+ );
349
+ resetSelectionStore();
350
+ // Ensure drag state is also reset if mode changes mid-drag
351
+ if (isDragging.current) {
352
+ if (VERBOSE)
353
+ console.log(
354
+ LOG_PREFIX + 'Mode changed mid-drag, cleaning up listeners.'
355
+ );
356
+ window.removeEventListener('mousemove', handleDragMove);
357
+ window.removeEventListener('mouseup', handleMouseUp);
358
+ isDragging.current = false;
359
+ dragStartCoords.current = null;
360
+ setSelectionRect(null);
361
+ selectionOrigin.current = null;
362
+ }
363
+ }
364
+ }
365
+ );
366
+
367
+ // Cleanup function
102
368
  return () => {
369
+ if (VERBOSE)
370
+ console.log(LOG_PREFIX + 'Compositor unmounting, cleaning up...');
103
371
  unsubscribe();
372
+ unsubscribeToolMode();
104
373
  stopLoadingAnimation();
374
+ // Ensure listeners are removed on unmount
375
+ window.removeEventListener('mousemove', handleDragMove);
376
+ window.removeEventListener('mouseup', handleMouseUp);
377
+ if (VERBOSE) console.log(LOG_PREFIX + 'Cleanup complete.');
105
378
  };
106
379
  }, []);
107
380
 
381
+ useEffect(() => {
382
+ const handleAction = async () => {
383
+ if (!$selection.isActive || !$selection.pendingAction) {
384
+ return;
385
+ }
386
+
387
+ const ctx = getCtx(props);
388
+ const range = $selection;
389
+
390
+ if ($selection.pendingAction === 'style') {
391
+ if (VERBOSE) console.log(LOG_PREFIX + 'useEffect acting on: style');
392
+ await ctx.wrapRangeInSpan(range as SelectionStoreState, 'span');
393
+ resetSelectionStore();
394
+ }
395
+
396
+ if ($selection.pendingAction === 'link') {
397
+ if (VERBOSE) console.log(LOG_PREFIX + 'useEffect acting on: link');
398
+ const newAnchorNodeId = await ctx.wrapRangeInAnchor(
399
+ range as SelectionStoreState
400
+ );
401
+ if (newAnchorNodeId) {
402
+ ctx.handleInsertSignal('a', newAnchorNodeId);
403
+ }
404
+ resetSelectionStore();
405
+ }
406
+ ctx.notifyNode('root');
407
+ };
408
+
409
+ handleAction();
410
+ }, [$selection.pendingAction, $selection.isActive]);
411
+
108
412
  return (
109
413
  <div
110
414
  id="content" // This ID is used by startLoadingAnimation
@@ -130,6 +434,20 @@ export const Compositor = (props: CompositorProps) => {
130
434
  </div>
131
435
  )}
132
436
 
437
+ {/* Selection drag box */}
438
+ {selectionRect && (
439
+ <div
440
+ className="bg-mygreen/20 fixed z-50 border border-blue-600"
441
+ style={{
442
+ left: `${selectionRect.left}px`,
443
+ top: `${selectionRect.top}px`,
444
+ width: `${selectionRect.width}px`,
445
+ height: `${selectionRect.height}px`,
446
+ pointerEvents: 'none',
447
+ }}
448
+ />
449
+ )}
450
+
133
451
  {/* Main content */}
134
452
  {initialized && (
135
453
  <Node
@@ -137,6 +455,7 @@ export const Compositor = (props: CompositorProps) => {
137
455
  key={`${props.id}-${updateCounter}`}
138
456
  ctx={props.ctx}
139
457
  config={props.config}
458
+ onDragStart={handleDragStart}
140
459
  />
141
460
  )}
142
461
  </div>
@@ -1,7 +1,14 @@
1
- import { memo, type ReactElement, useEffect, useState } from 'react';
1
+ import {
2
+ memo,
3
+ type ReactElement,
4
+ useEffect,
5
+ useState,
6
+ createElement,
7
+ type MouseEvent,
8
+ } from 'react';
2
9
  import { useStore } from '@nanostores/react';
3
10
  import { getCtx } from '@/stores/nodes';
4
- import { styleElementInfoStore } from '@/stores/storykeep';
11
+ import { styleElementInfoStore, viewportKeyStore } from '@/stores/storykeep';
5
12
  import { getType } from '@/utils/compositor/typeGuards';
6
13
  import { NodeWithGuid } from './NodeWithGuid';
7
14
  import PanelVisibilityWrapper from './PanelVisibilityWrapper';
@@ -31,13 +38,16 @@ import StoryFragmentTitlePanel from '@/components/edit/storyfragment/StoryFragme
31
38
  import ContextPanePanel from '@/components/edit/context/ContextPaneConfig';
32
39
  import ContextPaneTitlePanel from '@/components/edit/context/ContextPaneConfig_title';
33
40
  import { regexpHook } from '@/constants';
41
+ import { RenderChildren } from './nodes/RenderChildren';
34
42
  import type {
35
43
  StoryFragmentNode,
36
44
  PaneNode,
37
45
  BaseNode,
38
46
  FlatNode,
39
47
  } from '@/types/compositorTypes';
40
- import type { NodeProps } from '@/types/nodeProps';
48
+ import type { NodeProps, SelectionOrigin } from '@/types/nodeProps';
49
+
50
+ const VERBOSE = false;
41
51
 
42
52
  function parseCodeHook(node: BaseNode | FlatNode) {
43
53
  if ('codeHookParams' in node && Array.isArray(node.codeHookParams)) {
@@ -84,7 +94,7 @@ const getElement = (
84
94
  const isPreview = getCtx(props).rootNodeId.get() === `tmp`;
85
95
  const hasPanes = useStore(getCtx(props).hasPanes);
86
96
  const isTemplate = useStore(getCtx(props).isTemplate);
87
- const sharedProps = { nodeId: node.id, ctx: props.ctx, config: props.config };
97
+ const sharedProps = { ...props, nodeId: node.id };
88
98
  const type = getType(node);
89
99
 
90
100
  switch (type) {
@@ -274,6 +284,75 @@ const getElement = (
274
284
  case 'aside':
275
285
  case 'p': {
276
286
  const toolModeVal = getCtx(props).toolModeValStore.get().value;
287
+
288
+ if (toolModeVal === 'styles') {
289
+ const className = getCtx(props).getNodeClasses(
290
+ node.id,
291
+ viewportKeyStore.get().value
292
+ );
293
+
294
+ const handleMouseDown = (e: MouseEvent<HTMLElement>) => {
295
+ if (VERBOSE)
296
+ console.log('[Node.tsx] handleMouseDown FIRED', { event: e });
297
+ if (!props.onDragStart) {
298
+ if (VERBOSE)
299
+ console.log(
300
+ '[Node.tsx] handleMouseDown ABORTED: no onDragStart prop'
301
+ );
302
+ return;
303
+ }
304
+ e.preventDefault();
305
+ if (VERBOSE) console.log('[Node.tsx] preventDefault called');
306
+
307
+ const target = e.target as HTMLElement;
308
+ if (VERBOSE) console.log('[Node.tsx] mousedown target:', target);
309
+ const textNodeElement = target.closest('[data-parent-text-node-id]');
310
+
311
+ if (textNodeElement) {
312
+ const parentTextNodeId = textNodeElement.getAttribute(
313
+ 'data-parent-text-node-id'
314
+ );
315
+ const startCharOffset = parseInt(
316
+ textNodeElement.getAttribute('data-start-char-offset') || '0',
317
+ 10
318
+ );
319
+
320
+ if (parentTextNodeId) {
321
+ const origin: SelectionOrigin = {
322
+ blockNodeId: node.id,
323
+ lcaNodeId: node.id,
324
+ startNodeId: parentTextNodeId,
325
+ startCharOffset: startCharOffset,
326
+ endNodeId: parentTextNodeId,
327
+ endCharOffset: startCharOffset,
328
+ };
329
+ props.onDragStart(origin, e);
330
+ }
331
+ }
332
+ };
333
+
334
+ const children = getCtx(props).getChildNodeIDs(node.id);
335
+
336
+ // Propagate props to children, but explicitly enable text selection
337
+ // and remove the onDragStart handler to prevent it from firing on child elements.
338
+ const childProps: NodeProps = {
339
+ ...props,
340
+ onDragStart: undefined,
341
+ isSelectableText: true,
342
+ };
343
+
344
+ return createElement(
345
+ type,
346
+ {
347
+ className: className,
348
+ onMouseDown: handleMouseDown,
349
+ 'data-node-id': node.id,
350
+ style: { userSelect: 'none' },
351
+ },
352
+ <RenderChildren children={children} nodeProps={childProps} />
353
+ );
354
+ }
355
+
277
356
  if (toolModeVal === `insert`)
278
357
  return <NodeBasicTagInsert {...sharedProps} tagName={type} />;
279
358
  else if (toolModeVal === `eraser`)
@@ -283,6 +362,7 @@ const getElement = (
283
362
  return <NodeBasicTag {...sharedProps} tagName={type} />;
284
363
  }
285
364
 
365
+ case 'span':
286
366
  case 'strong':
287
367
  case 'em':
288
368
  return <NodeBasicTag {...sharedProps} tagName={type} />;
@@ -293,12 +373,12 @@ const getElement = (
293
373
  const toolModeVal = getCtx(props).toolModeValStore.get().value;
294
374
  if (toolModeVal === `eraser`)
295
375
  return <NodeButtonEraser {...sharedProps} />;
296
- return <NodeButton {...sharedProps} />;
376
+ return <NodeButton {...sharedProps} isSelectableText={false} />;
297
377
  }
298
378
  case 'a': {
299
379
  const toolModeVal = getCtx(props).toolModeValStore.get().value;
300
380
  if (toolModeVal === `eraser`) return <NodeAEraser {...sharedProps} />;
301
- return <NodeA {...sharedProps} />;
381
+ return <NodeA {...sharedProps} isSelectableText={false} />;
302
382
  }
303
383
  case 'img':
304
384
  return <NodeImg {...sharedProps} />;
@@ -1,3 +1,5 @@
1
+ // templates/src/components/compositor/nodes/RenderChildren.tsx
2
+
1
3
  import Node from '../Node';
2
4
  import type { NodeProps } from '@/types/nodeProps';
3
5
  import type { CompositorProps } from '../Compositor';
@@ -12,12 +14,7 @@ export const RenderChildren = (props: RenderChildrenProps) => {
12
14
  return (
13
15
  <>
14
16
  {children.map((id: string) => (
15
- <Node
16
- nodeId={id}
17
- key={id}
18
- ctx={nodeProps.ctx}
19
- config={nodeProps.config}
20
- />
17
+ <Node {...nodeProps} nodeId={id} key={id} />
21
18
  ))}
22
19
  </>
23
20
  );