astro-tractstack 2.1.3 → 2.2.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 (128) hide show
  1. package/README.md +54 -266
  2. package/bin/create-tractstack.js +9 -6
  3. package/dist/index.js +109 -71
  4. package/package.json +4 -2
  5. package/templates/css/custom.css +5 -0
  6. package/templates/icons/code.svg +18 -0
  7. package/templates/icons/li.svg +4 -0
  8. package/templates/icons/link.svg +22 -0
  9. package/templates/icons/p.svg +3 -0
  10. package/templates/src/client/app.js +80 -1
  11. package/templates/src/components/Footer.astro +1 -1
  12. package/templates/src/components/codehooks/BunnyVideoSetup.tsx +6 -6
  13. package/templates/src/components/codehooks/EpinetDurationSelector.tsx +3 -3
  14. package/templates/src/components/codehooks/FeaturedArticleSetup.tsx +1 -1
  15. package/templates/src/components/codehooks/ListContentSetup.tsx +2 -2
  16. package/templates/src/components/codehooks/ProductCardSetup.tsx +1 -1
  17. package/templates/src/components/codehooks/ProductGridSetup.tsx +2 -2
  18. package/templates/src/components/codehooks/SandboxRegisterForm.tsx +3 -3
  19. package/templates/src/components/compositor/Compositor.tsx +25 -9
  20. package/templates/src/components/compositor/Node.tsx +168 -496
  21. package/templates/src/components/compositor/PanelVisibilityWrapper.tsx +1 -0
  22. package/templates/src/components/compositor/elements/SignUp.tsx +1 -1
  23. package/templates/src/components/compositor/elements/YouTubeWrapper.tsx +2 -0
  24. package/templates/src/components/compositor/nodes/CreativePane.tsx +262 -0
  25. package/templates/src/components/compositor/nodes/GhostInsertBlock.tsx +4 -6
  26. package/templates/src/components/compositor/nodes/GridLayout.tsx +4 -2
  27. package/templates/src/components/compositor/nodes/Markdown.tsx +18 -3
  28. package/templates/src/components/compositor/nodes/Pane.tsx +11 -5
  29. package/templates/src/components/compositor/nodes/RenderChildren.tsx +1 -1
  30. package/templates/src/components/compositor/nodes/tagElements/NodeAnchorComponent.tsx +5 -5
  31. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag.tsx +90 -42
  32. package/templates/src/components/compositor/nodes/tagElements/NodeImg.tsx +2 -0
  33. package/templates/src/components/compositor/nodes/tagElements/NodeText.tsx +27 -1
  34. package/templates/src/components/compositor/preview/PaneSnapshotGenerator.tsx +10 -8
  35. package/templates/src/components/compositor/tools/NodeOverlay.tsx +224 -0
  36. package/templates/src/components/compositor/tools/PaneOverlay.tsx +122 -0
  37. package/templates/src/components/edit/Header.tsx +68 -9
  38. package/templates/src/components/edit/PanelSwitch.tsx +42 -4
  39. package/templates/src/components/edit/SettingsPanel.tsx +2 -3
  40. package/templates/src/components/edit/ToolMode.tsx +1 -31
  41. package/templates/src/components/edit/pane/AddPanePanel_break.tsx +2 -2
  42. package/templates/src/components/edit/pane/AddPanePanel_codehook.tsx +1 -1
  43. package/templates/src/components/edit/pane/AddPanePanel_new.tsx +193 -659
  44. package/templates/src/components/edit/pane/AddPanePanel_reuse.tsx +15 -82
  45. package/templates/src/components/edit/pane/AiRestylePaneModal.tsx +95 -45
  46. package/templates/src/components/edit/pane/ConfigPanePanel.tsx +137 -49
  47. package/templates/src/components/edit/pane/RestylePaneModal.tsx +1 -1
  48. package/templates/src/components/edit/pane/steps/AiCreativeDesignStep.tsx +375 -0
  49. package/templates/src/components/edit/pane/steps/AiDesignStep.tsx +1 -23
  50. package/templates/src/components/edit/pane/steps/AiLibraryCopyStep.tsx +327 -0
  51. package/templates/src/components/edit/pane/steps/AiRefineDesignStep.tsx +267 -0
  52. package/templates/src/components/edit/pane/steps/AiStandardDesignStep.tsx +371 -0
  53. package/templates/src/components/edit/pane/steps/CopyInputStep.tsx +201 -76
  54. package/templates/src/components/edit/pane/steps/CreativeInjectStep.tsx +141 -0
  55. package/templates/src/components/edit/panels/CreativeImagePanel.tsx +435 -0
  56. package/templates/src/components/edit/panels/CreativeLinkPanel.tsx +110 -0
  57. package/templates/src/components/edit/panels/StyleCodeHookPanel.tsx +1 -1
  58. package/templates/src/components/edit/panels/StyleParentPanel.tsx +118 -126
  59. package/templates/src/components/edit/panels/StyleParentPanel_add.tsx +3 -2
  60. package/templates/src/components/edit/panels/StyleParentPanel_deleteLayer.tsx +1 -0
  61. package/templates/src/components/edit/panels/StyleParentPanel_remove.tsx +3 -1
  62. package/templates/src/components/edit/panels/StyleParentPanel_update.tsx +3 -1
  63. package/templates/src/components/edit/panels/StyleWidgetPanel.tsx +1 -1
  64. package/templates/src/components/edit/state/SaveModal.tsx +19 -787
  65. package/templates/src/components/edit/state/SaveToLibraryModal.tsx +2 -2
  66. package/templates/src/components/edit/storyfragment/StoryFragmentPanel_menu.tsx +1 -1
  67. package/templates/src/components/edit/widgets/BunnyWidget.tsx +5 -5
  68. package/templates/src/components/edit/widgets/InteractiveDisclosureWidget.tsx +1 -1
  69. package/templates/src/components/edit/widgets/SignupWidget.tsx +1 -1
  70. package/templates/src/components/fields/ActionBuilderTimeSelector.tsx +1 -1
  71. package/templates/src/components/fields/ArtpackImage.tsx +11 -3
  72. package/templates/src/components/fields/BackgroundImage.tsx +8 -0
  73. package/templates/src/components/fields/BackgroundImageWrapper.tsx +15 -9
  74. package/templates/src/components/fields/ImageUpload.tsx +6 -0
  75. package/templates/src/components/form/ActionBuilderField.tsx +15 -5
  76. package/templates/src/components/form/ActionBuilderSlugSelector.tsx +1 -1
  77. package/templates/src/components/form/ColorPicker.tsx +1 -1
  78. package/templates/src/components/form/EnumSelect.tsx +1 -1
  79. package/templates/src/components/form/NumberInput.tsx +1 -1
  80. package/templates/src/components/form/StringArrayInput.tsx +1 -1
  81. package/templates/src/components/form/StringInput.tsx +1 -1
  82. package/templates/src/components/form/UnsavedChangesBar.tsx +1 -1
  83. package/templates/src/components/form/advanced/APIConfigSection.tsx +2 -2
  84. package/templates/src/components/form/advanced/AuthConfigSection.tsx +2 -2
  85. package/templates/src/components/profile/ProfileCreate.tsx +1 -1
  86. package/templates/src/components/profile/ProfileEdit.tsx +1 -1
  87. package/templates/src/components/storykeep/Dashboard_Advanced.tsx +2 -2
  88. package/templates/src/components/storykeep/controls/content/BeliefForm.tsx +1 -1
  89. package/templates/src/components/storykeep/controls/content/ContentSummary.tsx +2 -2
  90. package/templates/src/components/storykeep/controls/content/KnownResourceTable.tsx +1 -1
  91. package/templates/src/components/storykeep/controls/content/ManageContent.tsx +6 -6
  92. package/templates/src/components/storykeep/controls/content/MenuForm.tsx +1 -1
  93. package/templates/src/components/storykeep/controls/content/PaneTable.tsx +358 -0
  94. package/templates/src/components/storykeep/controls/content/ResourceTable.tsx +1 -1
  95. package/templates/src/constants/prompts.json +18 -10
  96. package/templates/src/constants.ts +3 -0
  97. package/templates/src/hooks/usePaneFragments.ts +60 -0
  98. package/templates/src/lib/session.ts +71 -16
  99. package/templates/src/pages/[...slug].astro +4 -46
  100. package/templates/src/pages/api/css.ts +149 -0
  101. package/templates/src/pages/maint.astro +1 -1
  102. package/templates/src/pages/storykeep/login.astro +2 -2
  103. package/templates/src/stores/nodes.ts +162 -49
  104. package/templates/src/stores/orphanAnalysis.ts +6 -30
  105. package/templates/src/stores/previews.ts +7 -0
  106. package/templates/src/stores/storykeep.ts +0 -8
  107. package/templates/src/types/compositorTypes.ts +53 -10
  108. package/templates/src/utils/compositor/aiGeneration.ts +93 -0
  109. package/templates/src/utils/compositor/allowInsert.ts +2 -0
  110. package/templates/src/utils/compositor/htmlAst.ts +704 -0
  111. package/templates/src/utils/compositor/nodesHelper.ts +281 -102
  112. package/templates/src/utils/compositor/savePipeline.ts +893 -0
  113. package/templates/src/utils/etl/index.ts +3 -0
  114. package/templates/src/utils/etl/transformer.ts +10 -0
  115. package/templates/src/utils/helpers.ts +101 -0
  116. package/utils/inject-files.ts +100 -62
  117. package/templates/icons/text.svg +0 -6
  118. package/templates/src/components/compositor/NodeWithGuid.tsx +0 -69
  119. package/templates/src/components/compositor/nodes/GridLayout_eraser.tsx +0 -33
  120. package/templates/src/components/compositor/nodes/Markdown_eraser.tsx +0 -56
  121. package/templates/src/components/compositor/nodes/Pane_DesignLibrary.tsx +0 -269
  122. package/templates/src/components/compositor/nodes/Pane_eraser.tsx +0 -186
  123. package/templates/src/components/compositor/nodes/Pane_layout.tsx +0 -79
  124. package/templates/src/components/compositor/nodes/tagElements/NodeA_eraser.tsx +0 -26
  125. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_eraser.tsx +0 -61
  126. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_insert.tsx +0 -120
  127. package/templates/src/components/compositor/nodes/tagElements/NodeBasicTag_settings.tsx +0 -62
  128. package/templates/src/components/compositor/nodes/tagElements/NodeButton_eraser.tsx +0 -26
@@ -24,13 +24,19 @@ import { RenderChildren } from '../RenderChildren';
24
24
  import {
25
25
  processRichTextToNodes,
26
26
  getTemplateNode,
27
+ isAddressableNode,
28
+ canEditText,
29
+ getNodeDisplayMode,
27
30
  } from '@/utils/compositor/nodesHelper';
28
31
  import { cloneDeep, classNames } from '@/utils/helpers';
29
32
  import { PatchOp } from '@/stores/nodesHistory';
30
33
  import type { FlatNode, PaneNode } from '@/types/compositorTypes';
31
34
  import type { NodeProps } from '@/types/nodeProps';
32
35
 
33
- export type NodeTagProps = NodeProps & { tagName: keyof JSX.IntrinsicElements };
36
+ export type NodeTagProps = NodeProps & {
37
+ tagName: keyof JSX.IntrinsicElements;
38
+ style?: any;
39
+ };
34
40
 
35
41
  type EditState = 'viewing' | 'editing';
36
42
  const VERBOSE = false;
@@ -39,9 +45,9 @@ export const NodeBasicTag = (props: NodeTagProps) => {
39
45
  const nodeId = props.nodeId;
40
46
  const ctx = getCtx(props);
41
47
  const settingsPanel = useStore(settingsPanelStore);
42
- const toolMode = useStore(ctx.toolModeValStore).value;
43
- const Tag =
44
- ctx.toolModeValStore.get().value === `debug` ? `div` : props.tagName;
48
+ const Tag = ['em', 'strong', 'a', 'button', 'img'].includes(props.tagName)
49
+ ? props.tagName
50
+ : 'div';
45
51
 
46
52
  if (props.tagName === 'span') {
47
53
  const node = ctx.allNodes.get().get(props.nodeId);
@@ -66,14 +72,16 @@ export const NodeBasicTag = (props: NodeTagProps) => {
66
72
  const cursorPositionRef = useRef<{ node: Node; offset: number } | null>(null);
67
73
 
68
74
  const { value: toolModeVal } = useStore(ctx.toolModeValStore);
75
+ const toolAddMode = useStore(ctx.toolAddModeStore).value;
69
76
 
70
77
  // Get node data
71
78
  const node = ctx.allNodes.get().get(nodeId) as FlatNode;
72
79
  const children = ctx.getChildNodeIDs(nodeId);
73
- const isEditableMode = ctx.toolModeValStore.get().value === 'text';
74
- const supportsEditing = !['ol', 'ul'].includes(props.tagName);
80
+ const isEditableMode = toolModeVal === 'text';
81
+ const supportsEditing = canEditText(node, ctx);
75
82
  const isPlaceholder = node?.isPlaceholder === true;
76
83
  const isEmpty = elementRef.current?.textContent?.trim() === '';
84
+ const isInline = getNodeDisplayMode(node, viewportKeyStore.get().value, ctx);
77
85
 
78
86
  // Auto-enter edit mode for new placeholder nodes
79
87
  useEffect(() => {
@@ -258,8 +266,6 @@ export const NodeBasicTag = (props: NodeTagProps) => {
258
266
 
259
267
  // For formatting nodes <em> and <strong> and <span>
260
268
  if (['em', 'strong', 'span'].includes(props.tagName)) {
261
- const isEditorActive = ['styles', 'text'].includes(toolModeVal);
262
- const isEditorEnabled = toolModeVal === 'styles';
263
269
  const handleStyleClick = (e: MouseEvent<HTMLButtonElement>) => {
264
270
  e.preventDefault();
265
271
  e.stopPropagation();
@@ -300,32 +306,35 @@ export const NodeBasicTag = (props: NodeTagProps) => {
300
306
  }
301
307
  },
302
308
  'data-node-id': nodeId,
309
+ 'data-tag': props.tagName,
303
310
  tabIndex: isEditableMode ? -1 : undefined,
304
311
  style: {
305
- position: isEditorActive ? 'relative' : undefined,
312
+ position: 'relative',
306
313
  outlineOffset: '1px',
314
+ display: isInline ? 'inline-block' : undefined,
307
315
  },
308
316
  },
309
317
  [
310
318
  <RenderChildren key="children" children={children} nodeProps={props} />,
311
- isEditorEnabled && (
312
- <span
313
- key={`toolbar-${nodeId}`}
314
- className="absolute z-10 flex select-none gap-x-1"
315
- data-attr="exclude"
316
- style={{ top: '-0.9rem', left: '0' }}
317
- >
318
- {props.tagName === 'span' && (
319
- <button
320
- type="button"
321
- onClick={handleStyleClick}
322
- className="flex h-4 w-4 items-center justify-center rounded-full bg-blue-100 bg-opacity-90 text-blue-700 shadow-sm hover:bg-blue-300 focus:outline-none"
323
- aria-label="Style selection"
324
- data-attr="exclude"
325
- >
326
- <PaintBrushIcon className="h-3 w-3" data-attr="exclude" />
327
- </button>
328
- )}
319
+ <span
320
+ key={`toolbar-${nodeId}`}
321
+ className="absolute z-10 flex select-none gap-x-1"
322
+ data-attr="exclude"
323
+ style={{ top: '-0.9rem', left: '0' }}
324
+ >
325
+ {props.tagName === 'span' && toolModeVal === `text` && (
326
+ <button
327
+ type="button"
328
+ onClick={handleStyleClick}
329
+ className="flex h-4 w-4 items-center justify-center rounded-full bg-blue-100 bg-opacity-90 text-blue-700 shadow-sm hover:bg-blue-300 focus:outline-none"
330
+ aria-label="Style this"
331
+ title="Style this"
332
+ data-attr="exclude"
333
+ >
334
+ <PaintBrushIcon className="h-3 w-3" data-attr="exclude" />
335
+ </button>
336
+ )}
337
+ {props.tagName === 'span' && toolModeVal === `insert` && (
329
338
  <button
330
339
  type="button"
331
340
  onClick={handleWordCarouselClick}
@@ -335,7 +344,8 @@ export const NodeBasicTag = (props: NodeTagProps) => {
335
344
  ? 'bg-green-100 text-green-700 hover:bg-green-300'
336
345
  : 'bg-gray-100 bg-opacity-90 text-gray-700 hover:bg-gray-300'
337
346
  )}
338
- aria-label="Edit Carousel"
347
+ aria-label="Word Carousel"
348
+ title="Word Carousel"
339
349
  data-attr="exclude"
340
350
  >
341
351
  <ChatBubbleBottomCenterTextIcon
@@ -343,17 +353,20 @@ export const NodeBasicTag = (props: NodeTagProps) => {
343
353
  data-attr="exclude"
344
354
  />
345
355
  </button>
356
+ )}
357
+ {props.tagName === 'span' && toolModeVal === `text` && (
346
358
  <button
347
359
  type="button"
348
360
  onClick={handleUnwrapClick}
349
361
  className="flex h-4 w-4 items-center justify-center rounded-full bg-gray-100 bg-opacity-90 text-gray-700 shadow-sm hover:bg-gray-300 focus:outline-none"
350
362
  aria-label="Remove formatting"
363
+ title="Remove formatting"
351
364
  data-attr="exclude"
352
365
  >
353
366
  <XMarkIcon className="h-3.5 w-3.5" data-attr="exclude" />
354
367
  </button>
355
- </span>
356
- ),
368
+ )}
369
+ </span>,
357
370
  ]
358
371
  );
359
372
  }
@@ -373,6 +386,7 @@ export const NodeBasicTag = (props: NodeTagProps) => {
373
386
  }
374
387
  },
375
388
  'data-node-id': nodeId,
389
+ 'data-tag': props.tagName,
376
390
  tabIndex: isEditableMode ? -1 : undefined,
377
391
  },
378
392
  <RenderChildren children={children} nodeProps={props} />
@@ -380,7 +394,13 @@ export const NodeBasicTag = (props: NodeTagProps) => {
380
394
  }
381
395
 
382
396
  const startEditing = () => {
383
- if (!isEditableMode || !supportsEditing || editState === 'editing') return;
397
+ if (
398
+ !isEditableMode ||
399
+ !supportsEditing ||
400
+ editState === 'editing' ||
401
+ !canEditText(node, ctx)
402
+ )
403
+ return;
384
404
 
385
405
  originalContentRef.current = elementRef.current?.innerHTML || '';
386
406
  setEditState('editing');
@@ -395,13 +415,35 @@ export const NodeBasicTag = (props: NodeTagProps) => {
395
415
  };
396
416
 
397
417
  const saveAndExit = () => {
418
+ if (VERBOSE)
419
+ console.log(`[DEBUG] saveAndExit triggered for nodeId: ${nodeId}`, {
420
+ editState,
421
+ toolMode: toolModeVal,
422
+ hasSettingsPanel: !!settingsPanel,
423
+ activeSignal: settingsPanel?.action,
424
+ });
398
425
  if (editState !== 'editing') return;
399
-
400
- // Use an in-memory element to safely parse and strip UI chrome from the content.
401
426
  const tempDiv = document.createElement('div');
427
+ if (VERBOSE)
428
+ console.log(`[DEBUG] Raw HTML before sanitization:`, tempDiv.innerHTML);
402
429
  tempDiv.innerHTML = elementRef.current?.innerHTML || '';
403
- tempDiv.querySelectorAll('[data-attr="exclude"]');
430
+ const chromeElements = tempDiv.querySelectorAll(
431
+ '.compositor-chrome, [data-attr="exclude"]'
432
+ );
433
+ chromeElements.forEach((el) => el.remove());
434
+ const wrappers = tempDiv.querySelectorAll(
435
+ '.compositor-wrapper, [data-node-overlay]'
436
+ );
437
+ wrappers.forEach((el) => {
438
+ while (el.firstChild) {
439
+ el.parentNode?.insertBefore(el.firstChild, el);
440
+ }
441
+ el.remove();
442
+ });
443
+
404
444
  const currentContent = tempDiv.innerHTML;
445
+ if (VERBOSE)
446
+ console.log(`[DEBUG] Sanitized HTML sent to Parser:`, currentContent);
405
447
 
406
448
  if (currentContent !== originalContentRef.current) {
407
449
  try {
@@ -636,9 +678,14 @@ export const NodeBasicTag = (props: NodeTagProps) => {
636
678
  const relatedTarget = e.relatedTarget as HTMLElement | null;
637
679
  if (VERBOSE)
638
680
  console.log(`[NodeBasicTag] Blur event, relatedTarget:`, e.relatedTarget);
681
+
682
+ // Check if the focus moved to the Settings Panel or Toolbar
683
+ const isSettingsControl = relatedTarget?.closest('#settingsControls');
684
+
639
685
  if (
640
686
  relatedTarget?.hasAttribute('data-tab-indicator') ||
641
- (relatedTarget && elementRef.current?.contains(relatedTarget))
687
+ (relatedTarget && elementRef.current?.contains(relatedTarget)) ||
688
+ isSettingsControl
642
689
  ) {
643
690
  return;
644
691
  }
@@ -688,8 +735,7 @@ export const NodeBasicTag = (props: NodeTagProps) => {
688
735
  const clickTimeoutRef = useRef<NodeJS.Timeout | null>(null);
689
736
 
690
737
  const handleClick = (e: MouseEvent) => {
691
- if (toolModeVal === 'styles') {
692
- console.log(`skipping handleClick on purpose`);
738
+ if (!canEditText(node, ctx)) {
693
739
  return;
694
740
  }
695
741
  if (
@@ -709,6 +755,7 @@ export const NodeBasicTag = (props: NodeTagProps) => {
709
755
 
710
756
  // Delay single-click behavior to see if double-click follows
711
757
  clickTimeoutRef.current = setTimeout(() => {
758
+ if (!isAddressableNode(node, ctx)) return;
712
759
  if (isEditableMode && supportsEditing && editState === 'viewing') {
713
760
  const selection = window.getSelection();
714
761
  if (selection && selection.rangeCount > 0) {
@@ -788,11 +835,6 @@ export const NodeBasicTag = (props: NodeTagProps) => {
788
835
  if (settingsPanel?.nodeId === nodeId) {
789
836
  outlineClasses +=
790
837
  ' outline-4 outline-dotted outline-orange-400 outline-offset-2';
791
- } else if (toolMode === 'styles') {
792
- outlineClasses += ' hover:outline hover:outline-2 hover:outline-black';
793
- if (['span', 'strong', 'em'].includes(props.tagName)) {
794
- outlineClasses += ' outline outline-1 outline-dotted outline-black';
795
- }
796
838
  }
797
839
  const editingClasses =
798
840
  editState === 'editing'
@@ -816,11 +858,17 @@ export const NodeBasicTag = (props: NodeTagProps) => {
816
858
  onClick: handleClick,
817
859
  onDoubleClick: handleDoubleClick,
818
860
  style: {
861
+ userSelect:
862
+ toolModeVal === 'insert' && toolAddMode === 'span'
863
+ ? 'none'
864
+ : undefined,
819
865
  cursor: isEditableMode && supportsEditing ? 'text' : 'default',
820
866
  minHeight: isPlaceholder ? '1.5em' : undefined,
867
+ display: isInline ? 'inline-block' : undefined,
821
868
  },
822
869
  'data-node-id': nodeId,
823
870
  'data-placeholder': isPlaceholder,
871
+ 'data-tag': props.tagName,
824
872
  },
825
873
  <RenderChildren
826
874
  children={children}
@@ -15,6 +15,8 @@ export const NodeImg = (props: NodeProps) => {
15
15
  viewportKeyStore.get().value
16
16
  )}
17
17
  alt={node.alt}
18
+ data-node-id={props.nodeId}
19
+ data-tag="img"
18
20
  onClick={(e) => {
19
21
  getCtx(props).setClickedNodeId(props.nodeId);
20
22
  e.stopPropagation();
@@ -3,28 +3,53 @@ import type { FlatNode } from '@/types/compositorTypes';
3
3
  import type { NodeProps } from '@/types/nodeProps';
4
4
  import { useStore } from '@nanostores/react';
5
5
  import { selectionStore } from '@/stores/selection';
6
+ import type { MouseEvent } from 'react';
6
7
 
7
8
  export const NodeText = (props: NodeProps) => {
8
9
  const ctx = getCtx(props);
9
10
  const node = ctx.allNodes.get().get(props.nodeId) as FlatNode;
10
11
  const { value: toolModeVal } = useStore(ctx.toolModeValStore);
12
+ const toolAddMode = useStore(ctx.toolAddModeStore).value;
11
13
  const selection = useStore(selectionStore);
12
14
 
13
15
  if (!node) return <>ERROR MISSING NODE</>;
14
16
 
15
17
  const text = node.copy || '';
16
18
 
17
- if (toolModeVal === 'styles' && props.isSelectableText) {
19
+ if (
20
+ toolModeVal === 'insert' &&
21
+ toolAddMode === `span` &&
22
+ props.isSelectableText
23
+ ) {
18
24
  let charOffset = 0;
19
25
  const wordSpans = text.split(/(\s+)/).map((segment, index) => {
20
26
  const startOffset = charOffset;
21
27
  const endOffset = charOffset + segment.length;
22
28
  charOffset = endOffset;
23
29
 
30
+ const handleMouseDown = (e: MouseEvent<HTMLElement>) => {
31
+ e.preventDefault();
32
+ e.stopPropagation();
33
+ if (props.onDragStart) {
34
+ props.onDragStart(
35
+ {
36
+ blockNodeId: node.parentId!,
37
+ lcaNodeId: node.parentId!,
38
+ startNodeId: props.nodeId,
39
+ startCharOffset: startOffset,
40
+ endNodeId: props.nodeId,
41
+ endCharOffset: startOffset,
42
+ },
43
+ e
44
+ );
45
+ }
46
+ };
47
+
24
48
  if (segment.trim() === '') {
25
49
  return (
26
50
  <span
27
51
  key={index}
52
+ onMouseDown={handleMouseDown}
28
53
  data-parent-text-node-id={props.nodeId}
29
54
  data-start-char-offset={startOffset}
30
55
  data-end-char-offset={endOffset}
@@ -69,6 +94,7 @@ export const NodeText = (props: NodeProps) => {
69
94
  className={
70
95
  isInSelection ? 'outline-dotted outline-2 outline-blue-600' : ''
71
96
  }
97
+ onMouseDown={handleMouseDown} // <--- ADDED THIS
72
98
  data-parent-text-node-id={props.nodeId}
73
99
  data-start-char-offset={startOffset}
74
100
  data-end-char-offset={endOffset}
@@ -22,7 +22,7 @@ function hashString(str: string): string {
22
22
  for (let i = 0; i < str.length; i++) {
23
23
  const char = str.charCodeAt(i);
24
24
  hash = (hash << 5) - hash + char;
25
- hash = hash & hash; // Convert to 32bit integer
25
+ hash = hash & hash;
26
26
  }
27
27
  return hash.toString(36);
28
28
  }
@@ -37,7 +37,7 @@ export const PaneSnapshotGenerator = ({
37
37
  const [isGenerating, setIsGenerating] = useState(false);
38
38
 
39
39
  useEffect(() => {
40
- if (!htmlString || isGenerating) return;
40
+ if (!htmlString) return;
41
41
 
42
42
  const cacheKey = `${id}-${hashString(htmlString)}-${outputWidth}`;
43
43
  if (snapshotCache.has(cacheKey)) {
@@ -81,7 +81,6 @@ export const PaneSnapshotGenerator = ({
81
81
  const brandColors =
82
82
  brandConfigStore.get()?.BRAND_COLOURS?.split(',') || [];
83
83
 
84
- // Get all existing CSS links from current document
85
84
  const existingCssLinks = Array.from(
86
85
  document.querySelectorAll('link[rel="stylesheet"]')
87
86
  )
@@ -131,7 +130,6 @@ export const PaneSnapshotGenerator = ({
131
130
  (iframeDoc as any).write(fullHtml);
132
131
  iframeDoc.close();
133
132
 
134
- // Wait for CSS to load
135
133
  await new Promise((resolve) => {
136
134
  if (iframeDoc.readyState === 'complete') {
137
135
  resolve(void 0);
@@ -140,7 +138,6 @@ export const PaneSnapshotGenerator = ({
140
138
  }
141
139
  });
142
140
 
143
- // Additional wait for rendering
144
141
  await new Promise((resolve) => setTimeout(resolve, 1000));
145
142
 
146
143
  const iframeBody = iframeDoc.body;
@@ -195,16 +192,21 @@ export const PaneSnapshotGenerator = ({
195
192
  onComplete(id, snapshotData);
196
193
  } catch (error) {
197
194
  console.error(`Snapshot generation failed for ${id}:`, error);
198
- onError?.(id, error instanceof Error ? error.message : 'Unknown error');
195
+
196
+ const fallbackData: SnapshotData = {
197
+ imageData: '/static.jpg',
198
+ height: 300,
199
+ };
200
+ snapshotCache.set(cacheKey, fallbackData);
201
+ onComplete(id, fallbackData);
199
202
  } finally {
200
203
  setIsGenerating(false);
201
204
  }
202
205
  };
203
206
 
204
207
  generateSnapshot();
205
- }, [id, htmlString, isGenerating, onComplete, onError, outputWidth]);
208
+ }, [id, htmlString, onComplete, onError, outputWidth]);
206
209
 
207
- // Show spinner while generating
208
210
  if (isGenerating) {
209
211
  return (
210
212
  <div className="flex h-24 items-center justify-center">
@@ -0,0 +1,224 @@
1
+ import {
2
+ useState,
3
+ useRef,
4
+ useEffect,
5
+ type MouseEvent,
6
+ type ReactNode,
7
+ } from 'react';
8
+ import { useStore } from '@nanostores/react';
9
+ import PencilSquareIcon from '@heroicons/react/24/outline/PencilSquareIcon';
10
+ import TrashIcon from '@heroicons/react/24/outline/TrashIcon';
11
+ import PlusIcon from '@heroicons/react/24/outline/PlusIcon';
12
+ import { getCtx } from '@/stores/nodes';
13
+ import { settingsPanelStore } from '@/stores/storykeep';
14
+ import { handleClickEventDefault } from '@/utils/compositor/handleClickEvent';
15
+ import { getTemplateNode } from '@/utils/compositor/nodesHelper';
16
+ import { classNames } from '@/utils/helpers';
17
+ import type { NodeProps } from '@/types/nodeProps';
18
+ import type { FlatNode } from '@/types/compositorTypes';
19
+
20
+ interface NodeOverlayProps extends NodeProps {
21
+ children: ReactNode;
22
+ isTopLevel: boolean;
23
+ isInline?: boolean;
24
+ }
25
+
26
+ const getIconForTag = (tagName?: string): string | null => {
27
+ const t = tagName?.toLowerCase() || '';
28
+ if (['h2', 'h3', 'h4', 'h5', 'h6', 'p', 'li', 'code'].includes(t)) {
29
+ return `/icons/${t}.svg`;
30
+ }
31
+ if (t === 'img' || t === 'image') return '/icons/image.svg';
32
+ if (t === 'a' || t === 'button') return '/icons/link.svg';
33
+ return null;
34
+ };
35
+
36
+ export const NodeOverlay = ({
37
+ nodeId,
38
+ children,
39
+ isTopLevel,
40
+ isInline,
41
+ ...props
42
+ }: NodeOverlayProps) => {
43
+ const ctx = getCtx({ nodeId, ...props });
44
+ const toolMode = useStore(ctx.toolModeValStore).value;
45
+ const toolAddMode = useStore(ctx.toolAddModeStore).value;
46
+ const settingsPanel = useStore(settingsPanelStore);
47
+ const [hoverZone, setHoverZone] = useState<'before' | 'after' | null>(null);
48
+
49
+ // put a contentEditable={false} component inside a tree that inherits contentEditable={true}.
50
+ const chromeRef = useRef<HTMLDivElement>(null);
51
+
52
+ useEffect(() => {
53
+ if (chromeRef.current) {
54
+ chromeRef.current.contentEditable = 'false';
55
+ }
56
+ });
57
+
58
+ const node = ctx.allNodes.get().get(nodeId) as FlatNode;
59
+ if (!node) return <>{children}</>;
60
+
61
+ const zIndexClass = isTopLevel ? 'z-101 hover:z-103' : 'z-101 hover:z-104';
62
+
63
+ const isSelected = settingsPanel?.nodeId === nodeId;
64
+
65
+ const outlineClass = isSelected
66
+ ? 'outline outline-4 outline-dotted outline-orange-400 outline-offset-2'
67
+ : 'hover:outline hover:outline-2 hover:outline-dotted hover:outline-cyan-500 hover:outline-offset-2';
68
+
69
+ const handleEditClick = (e: MouseEvent) => {
70
+ e.stopPropagation();
71
+ handleClickEventDefault(node, true);
72
+ };
73
+
74
+ const handleDeleteClick = (e: MouseEvent) => {
75
+ e.stopPropagation();
76
+ ctx.deleteNode(nodeId);
77
+ };
78
+
79
+ const handleCopyIdClick = (e: MouseEvent) => {
80
+ e.stopPropagation();
81
+ navigator.clipboard.writeText(nodeId);
82
+ };
83
+
84
+ const handleInsert = (location: 'before' | 'after', e: MouseEvent) => {
85
+ e.stopPropagation();
86
+
87
+ const tagName = toolAddMode || 'p';
88
+ const templateNode = getTemplateNode(tagName);
89
+
90
+ if (templateNode) {
91
+ ctx.addTemplateNode(nodeId, templateNode, nodeId, location);
92
+ }
93
+ };
94
+
95
+ const canInsert =
96
+ toolMode === 'insert'
97
+ ? ctx.allowInsert(nodeId, toolAddMode || 'p')
98
+ : { allowInsertBefore: false, allowInsertAfter: false };
99
+
100
+ const iconSrc = getIconForTag(node.tagName);
101
+
102
+ return (
103
+ <div
104
+ className={classNames(
105
+ 'compositor-wrapper group relative transition-all duration-200',
106
+ zIndexClass,
107
+ toolMode === 'text' ? outlineClass : ''
108
+ )}
109
+ style={isInline ? { display: 'inline-block' } : {}}
110
+ data-node-overlay={nodeId}
111
+ >
112
+ {children}
113
+
114
+ {/* Text Mode: Tool Cart */}
115
+ {toolMode === 'text' && (
116
+ <div
117
+ ref={chromeRef}
118
+ className="node-overlay compositor-chrome absolute flex gap-1 opacity-10 transition-opacity duration-200 group-hover:opacity-100"
119
+ style={{
120
+ top: '-24px',
121
+ right: 0,
122
+ zIndex: 10,
123
+ }}
124
+ onClick={(e) => e.stopPropagation()}
125
+ data-attr="exclude"
126
+ >
127
+ <button
128
+ onClick={handleCopyIdClick}
129
+ className="flex h-6 w-auto min-w-px cursor-help items-center justify-center px-1"
130
+ title={`${node.tagName}: ${nodeId}`}
131
+ >
132
+ {iconSrc && (
133
+ <img
134
+ src={iconSrc}
135
+ alt={node.tagName || 'node'}
136
+ className="h-3.5 w-auto"
137
+ />
138
+ )}
139
+ </button>
140
+
141
+ <button
142
+ onClick={handleEditClick}
143
+ className="flex h-6 w-6 items-center justify-center rounded-full bg-cyan-500 text-white shadow-md hover:scale-110 hover:bg-cyan-600"
144
+ title="Edit Styles"
145
+ >
146
+ <PencilSquareIcon className="h-3.5 w-3.5" />
147
+ </button>
148
+
149
+ <button
150
+ onClick={handleDeleteClick}
151
+ className="flex h-6 w-6 items-center justify-center rounded-full bg-red-500 text-white shadow-md hover:scale-110 hover:bg-red-600"
152
+ title="Delete Element"
153
+ >
154
+ <TrashIcon className="h-3.5 w-3.5" />
155
+ </button>
156
+ </div>
157
+ )}
158
+
159
+ {/* Insert Mode: Split Drop Zones */}
160
+ {toolMode === 'insert' && toolAddMode !== `span` && (
161
+ <div
162
+ className="compositor-chrome absolute inset-0 z-50 flex flex-col"
163
+ data-attr="exclude"
164
+ >
165
+ {/* Top / Before Zone */}
166
+ <div
167
+ className={classNames(
168
+ 'flex-1 transition-colors duration-200',
169
+ canInsert.allowInsertBefore
170
+ ? 'cursor-pointer hover:bg-blue-500/10'
171
+ : 'cursor-not-allowed opacity-0'
172
+ )}
173
+ onMouseEnter={() => setHoverZone('before')}
174
+ onMouseLeave={() => setHoverZone(null)}
175
+ onClick={(e) =>
176
+ canInsert.allowInsertBefore && handleInsert('before', e)
177
+ }
178
+ >
179
+ {canInsert.allowInsertBefore && (
180
+ <div
181
+ className={classNames(
182
+ 'absolute left-1/2 top-0 -translate-x-1/2 -translate-y-1/2 transform transition-opacity duration-200',
183
+ hoverZone === 'before' ? 'opacity-100' : 'opacity-40'
184
+ )}
185
+ >
186
+ <div className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-white shadow-sm">
187
+ <PlusIcon className="h-3.5 w-3.5" />
188
+ </div>
189
+ </div>
190
+ )}
191
+ </div>
192
+
193
+ {/* Bottom / After Zone */}
194
+ <div
195
+ className={classNames(
196
+ 'flex-1 transition-colors duration-200',
197
+ canInsert.allowInsertAfter
198
+ ? 'cursor-pointer hover:bg-blue-500/10'
199
+ : 'cursor-not-allowed opacity-0'
200
+ )}
201
+ onMouseEnter={() => setHoverZone('after')}
202
+ onMouseLeave={() => setHoverZone(null)}
203
+ onClick={(e) =>
204
+ canInsert.allowInsertAfter && handleInsert('after', e)
205
+ }
206
+ >
207
+ {canInsert.allowInsertAfter && (
208
+ <div
209
+ className={classNames(
210
+ 'absolute bottom-0 left-1/2 -translate-x-1/2 translate-y-1/2 transform transition-opacity duration-200',
211
+ hoverZone === 'after' ? 'opacity-100' : 'opacity-40'
212
+ )}
213
+ >
214
+ <div className="flex h-5 w-5 items-center justify-center rounded-full bg-blue-600 text-white shadow-sm">
215
+ <PlusIcon className="h-3.5 w-3.5" />
216
+ </div>
217
+ </div>
218
+ )}
219
+ </div>
220
+ </div>
221
+ )}
222
+ </div>
223
+ );
224
+ };