astro-tractstack 2.0.26 → 2.0.28

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.
package/dist/index.js CHANGED
@@ -1394,6 +1394,12 @@ async function w(t, e, c) {
1394
1394
  src: t("../templates/src/components/edit/state/StylesMemory.tsx"),
1395
1395
  dest: "src/components/edit/state/StylesMemory.tsx"
1396
1396
  },
1397
+ {
1398
+ src: t(
1399
+ "../templates/src/components/edit/panels/StyleWordCarouselPanel.tsx"
1400
+ ),
1401
+ dest: "src/components/edit/panels/StyleWordCarouselPanel.tsx"
1402
+ },
1397
1403
  {
1398
1404
  src: t(
1399
1405
  "../templates/src/components/edit/panels/StyleBreakPanel.tsx"
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "astro-tractstack",
3
- "version": "2.0.26",
3
+ "version": "2.0.28",
4
4
  "description": "Astro integration for TractStack - redeeming the web from boring experiences",
5
5
  "type": "module",
6
6
  "main": "dist/index.js",
@@ -26,7 +26,7 @@ import {
26
26
  resetSelectionStore,
27
27
  type SelectionStoreState,
28
28
  } from '@/stores/selection';
29
- import type { LoadData } from '@/types/compositorTypes';
29
+ import type { LoadData, FlatNode } from '@/types/compositorTypes';
30
30
  import type {
31
31
  Theme,
32
32
  BrandConfig,
@@ -410,6 +410,38 @@ export const Compositor = (props: CompositorProps) => {
410
410
  });
411
411
  }
412
412
  resetSelectionStore();
413
+ } else if ($selection.pendingAction === 'carousel') {
414
+ if (VERBOSE) console.log(LOG_PREFIX + 'useEffect acting on: carousel');
415
+ const newSpanNodeId = await ctx.wrapRangeInSpan(
416
+ range as SelectionStoreState,
417
+ 'span'
418
+ );
419
+
420
+ if (newSpanNodeId) {
421
+ const node = ctx.allNodes.get().get(newSpanNodeId);
422
+ const childIds = ctx.getChildNodeIDs(newSpanNodeId);
423
+ let initialText = '';
424
+ childIds.forEach((childId) => {
425
+ const child = ctx.allNodes.get().get(childId) as FlatNode;
426
+ if (child && child.copy) initialText += child.copy;
427
+ });
428
+ const words = initialText ? [initialText] : [];
429
+ if (node) {
430
+ ctx.modifyNodes([
431
+ {
432
+ ...node,
433
+ wordCarouselPayload: { words, speed: 2 },
434
+ isChanged: true,
435
+ } as FlatNode,
436
+ ]);
437
+ settingsPanelStore.set({
438
+ action: 'style-word-carousel',
439
+ nodeId: newSpanNodeId,
440
+ expanded: true,
441
+ });
442
+ }
443
+ }
444
+ resetSelectionStore();
413
445
  }
414
446
  ctx.notifyNode('root');
415
447
  };
@@ -12,6 +12,7 @@ import {
12
12
  import { useStore } from '@nanostores/react';
13
13
  import XMarkIcon from '@heroicons/react/24/outline/XMarkIcon';
14
14
  import PaintBrushIcon from '@heroicons/react/24/outline/PaintBrushIcon';
15
+ import ChatBubbleBottomCenterTextIcon from '@heroicons/react/24/outline/ChatBubbleBottomCenterTextIcon';
15
16
  import { getCtx } from '@/stores/nodes';
16
17
  import {
17
18
  viewportKeyStore,
@@ -24,7 +25,7 @@ import {
24
25
  processRichTextToNodes,
25
26
  getTemplateNode,
26
27
  } from '@/utils/compositor/nodesHelper';
27
- import { cloneDeep } from '@/utils/helpers';
28
+ import { cloneDeep, classNames } from '@/utils/helpers';
28
29
  import { PatchOp } from '@/stores/nodesHistory';
29
30
  import type { FlatNode, PaneNode } from '@/types/compositorTypes';
30
31
  import type { NodeProps } from '@/types/nodeProps';
@@ -273,6 +274,15 @@ export const NodeBasicTag = (props: NodeTagProps) => {
273
274
  e.stopPropagation();
274
275
  ctx.unwrapNode(nodeId);
275
276
  };
277
+ const handleWordCarouselClick = (e: MouseEvent<HTMLButtonElement>) => {
278
+ e.preventDefault();
279
+ e.stopPropagation();
280
+ settingsPanelStore.set({
281
+ action: 'style-word-carousel',
282
+ nodeId: nodeId,
283
+ expanded: true,
284
+ });
285
+ };
276
286
 
277
287
  let baseClasses = ctx.getNodeClasses(nodeId, viewportKeyStore.get().value);
278
288
  baseClasses += ' outline outline-1 outline-dotted outline-black';
@@ -300,7 +310,7 @@ export const NodeBasicTag = (props: NodeTagProps) => {
300
310
  <RenderChildren key="children" children={children} nodeProps={props} />,
301
311
  isEditorEnabled && (
302
312
  <span
303
- key="chip"
313
+ key={`toolbar-${nodeId}`}
304
314
  className="absolute z-10 flex select-none gap-x-1"
305
315
  data-attr="exclude"
306
316
  style={{ top: '-0.9rem', left: '0' }}
@@ -309,17 +319,34 @@ export const NodeBasicTag = (props: NodeTagProps) => {
309
319
  <button
310
320
  type="button"
311
321
  onClick={handleStyleClick}
312
- className="flex h-4 w-4 items-center justify-center rounded-full bg-blue-100/90 text-blue-700 shadow-sm hover:bg-blue-300/50 focus:outline-none"
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"
313
323
  aria-label="Style selection"
314
324
  data-attr="exclude"
315
325
  >
316
326
  <PaintBrushIcon className="h-3 w-3" data-attr="exclude" />
317
327
  </button>
318
328
  )}
329
+ <button
330
+ type="button"
331
+ onClick={handleWordCarouselClick}
332
+ className={classNames(
333
+ 'flex h-4 w-4 items-center justify-center rounded-full bg-opacity-50 shadow-sm focus:outline-none',
334
+ node.wordCarouselPayload
335
+ ? 'bg-green-100 text-green-700 hover:bg-green-300'
336
+ : 'bg-gray-100 bg-opacity-90 text-gray-700 hover:bg-gray-300'
337
+ )}
338
+ aria-label="Edit Carousel"
339
+ data-attr="exclude"
340
+ >
341
+ <ChatBubbleBottomCenterTextIcon
342
+ className="h-3 w-3"
343
+ data-attr="exclude"
344
+ />
345
+ </button>
319
346
  <button
320
347
  type="button"
321
348
  onClick={handleUnwrapClick}
322
- className="flex h-4 w-4 items-center justify-center rounded-full bg-gray-100/90 text-gray-700 shadow-sm hover:bg-gray-300/50 focus:outline-none"
349
+ 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"
323
350
  aria-label="Remove formatting"
324
351
  data-attr="exclude"
325
352
  >
@@ -34,6 +34,7 @@ import StyleLiElementPanel from './panels/StyleLiElementPanel';
34
34
  import StyleLiElementAddPanel from './panels/StyleLiElementPanel_add';
35
35
  import StyleLiElementUpdatePanel from './panels/StyleLiElementPanel_update';
36
36
  import StyleLiElementRemovePanel from './panels/StyleLiElementPanel_remove';
37
+ import StyleWordCarouselPanel from './panels/StyleWordCarouselPanel';
37
38
  import StyleCodeHookPanel from './panels/StyleCodeHookPanel';
38
39
  import { getSettingsPanelTitle } from '@/utils/helpers';
39
40
  import type {
@@ -270,6 +271,10 @@ const PanelSwitch = ({
270
271
  );
271
272
  break;
272
273
 
274
+ case 'style-word-carousel':
275
+ if (clickedNode) return <StyleWordCarouselPanel node={clickedNode} />;
276
+ break;
277
+
273
278
  case 'style-image': {
274
279
  let imageNode: FlatNode | undefined;
275
280
  let containerNode: FlatNode | undefined;
@@ -8,6 +8,7 @@ import ArrowsUpDownIcon from '@heroicons/react/24/outline/ArrowsUpDownIcon';
8
8
  import PlusIcon from '@heroicons/react/24/outline/PlusIcon';
9
9
  import BugAntIcon from '@heroicons/react/24/outline/BugAntIcon';
10
10
  import LinkIcon from '@heroicons/react/24/solid/LinkIcon';
11
+ import ChatBubbleBottomCenterTextIcon from '@heroicons/react/24/outline/ChatBubbleBottomCenterTextIcon';
11
12
  import XMarkIcon from '@heroicons/react/24/solid/XMarkIcon';
12
13
  import { settingsPanelStore } from '@/stores/storykeep';
13
14
  import { getCtx } from '@/stores/nodes';
@@ -97,6 +98,10 @@ const StoryKeepToolMode = ({ isContext }: StoryKeepToolModeProps) => {
97
98
  selectionStore.setKey('pendingAction', 'link');
98
99
  };
99
100
 
101
+ const handleCarouselClick = () => {
102
+ selectionStore.setKey('pendingAction', 'carousel');
103
+ };
104
+
100
105
  const handleCancelClick = () => {
101
106
  resetSelectionStore();
102
107
  };
@@ -184,6 +189,15 @@ const StoryKeepToolMode = ({ isContext }: StoryKeepToolModeProps) => {
184
189
  >
185
190
  <LinkIcon className="h-5 w-5" />
186
191
  </button>
192
+ <button
193
+ type="button"
194
+ onClick={handleCarouselClick}
195
+ className="flex h-8 w-8 items-center justify-center rounded-lg bg-blue-100 text-blue-700 shadow-sm hover:bg-blue-200"
196
+ aria-label="Create Word Carousel"
197
+ title="Word Carousel"
198
+ >
199
+ <ChatBubbleBottomCenterTextIcon className="h-5 w-5" />
200
+ </button>
187
201
  <button
188
202
  type="button"
189
203
  onClick={handleCancelClick}
@@ -1,14 +1,19 @@
1
- import { useMemo, useEffect } from 'react';
1
+ import { useMemo, useEffect, useState } from 'react';
2
+ import Cog6ToothIcon from '@heroicons/react/24/outline/Cog6ToothIcon';
2
3
  import {
3
4
  styleElementInfoStore,
4
5
  resetStyleElementInfo,
5
6
  settingsPanelStore,
6
7
  } from '@/stores/storykeep';
8
+ import { getCtx } from '@/stores/nodes';
7
9
  import { StylesMemory } from '@/components/edit/state/StylesMemory';
8
10
  import {
9
11
  isMarkdownPaneFragmentNode,
10
12
  isGridLayoutNode,
11
13
  } from '@/utils/compositor/typeGuards';
14
+ import { getNodeText } from '@/utils/compositor/nodesHelper';
15
+ import { cloneDeep } from '@/utils/helpers';
16
+ import { processClassesForViewports } from '@/utils/compositor/reduceNodesClassNames';
12
17
  import SelectedTailwindClass from '@/components/fields/SelectedTailwindClass';
13
18
  import { tagTitles } from '@/types/compositorTypes';
14
19
  import type {
@@ -18,6 +23,78 @@ import type {
18
23
  GridLayoutNode,
19
24
  } from '@/types/compositorTypes';
20
25
 
26
+ type SpanOverride = {
27
+ mobile?: Record<string, string>;
28
+ tablet?: Record<string, string>;
29
+ desktop?: Record<string, string>;
30
+ };
31
+
32
+ const spanStyleClasses: SpanOverride[] = [
33
+ {
34
+ mobile: {
35
+ bgCLIP: 'text',
36
+ bgGradientDIRECTION: 'r',
37
+ gradientFrom: 'blue-600',
38
+ gradientTo: 'teal-500',
39
+ textCOLOR: 'transparent',
40
+ },
41
+ },
42
+ {
43
+ mobile: {
44
+ textCOLOR: 'blue-600',
45
+ },
46
+ },
47
+ {
48
+ mobile: {
49
+ bgCOLOR: 'yellow-300',
50
+ textCOLOR: 'slate-900',
51
+ px: '1',
52
+ rounded: 'sm',
53
+ },
54
+ },
55
+ {
56
+ mobile: {
57
+ display: 'inline-block',
58
+ bgCOLOR: 'indigo-100',
59
+ textCOLOR: 'indigo-700',
60
+ textSIZE: 'xs',
61
+ fontWEIGHT: 'bold',
62
+ px: '2.5',
63
+ py: '0.5',
64
+ rounded: 'full',
65
+ },
66
+ },
67
+ {
68
+ mobile: {
69
+ bgCLIP: 'text',
70
+ textCOLOR: 'transparent',
71
+ bgGradientDIRECTION: 'r',
72
+ gradientFrom: 'orange-400',
73
+ gradientVia: 'pink-500',
74
+ gradientTo: 'purple-600',
75
+ fontWEIGHT: 'bold',
76
+ },
77
+ },
78
+ {
79
+ mobile: {
80
+ textDECORATION: 'underline',
81
+ textDECORATIONSTYLE: 'wavy',
82
+ textDECORATIONCOLOR: 'teal-400',
83
+ textDECORATIONTHICKNESS: '4',
84
+ textUNDERLINEOFFSET: '4',
85
+ },
86
+ },
87
+ {
88
+ mobile: {
89
+ display: 'inline-block',
90
+ bgCOLOR: 'rose-500',
91
+ textCOLOR: 'white',
92
+ px: '2',
93
+ skew: '-3',
94
+ },
95
+ },
96
+ ];
97
+
21
98
  export interface StyleElementPanelProps {
22
99
  node: FlatNode;
23
100
  parentNode: MarkdownPaneFragmentNode | GridLayoutNode;
@@ -29,6 +106,8 @@ const StyleElementPanel = ({
29
106
  parentNode,
30
107
  onTitleChange,
31
108
  }: StyleElementPanelProps) => {
109
+ const [showPresets, setShowPresets] = useState(true);
110
+
32
111
  if (
33
112
  !node?.tagName ||
34
113
  (!isMarkdownPaneFragmentNode(parentNode) && !isGridLayoutNode(parentNode))
@@ -39,6 +118,18 @@ const StyleElementPanel = ({
39
118
  const defaultClasses = parentNode.defaultClasses?.[node.tagName];
40
119
  const overrideClasses = node.overrideClasses;
41
120
 
121
+ const hasOverrides = useMemo(() => {
122
+ return (
123
+ overrideClasses &&
124
+ ((overrideClasses.mobile &&
125
+ Object.keys(overrideClasses.mobile).length > 0) ||
126
+ (overrideClasses.tablet &&
127
+ Object.keys(overrideClasses.tablet).length > 0) ||
128
+ (overrideClasses.desktop &&
129
+ Object.keys(overrideClasses.desktop).length > 0))
130
+ );
131
+ }, [overrideClasses]);
132
+
42
133
  const mergedClasses = useMemo(() => {
43
134
  const result: {
44
135
  [key: string]: {
@@ -48,7 +139,6 @@ const StyleElementPanel = ({
48
139
  };
49
140
  } = {};
50
141
 
51
- // First add all default classes
52
142
  if (defaultClasses) {
53
143
  Object.keys(defaultClasses.mobile).forEach((className) => {
54
144
  result[className] = {
@@ -63,7 +153,6 @@ const StyleElementPanel = ({
63
153
  });
64
154
  }
65
155
 
66
- // Then overlay any override classes
67
156
  if (overrideClasses) {
68
157
  ['mobile', 'tablet', 'desktop'].forEach((viewport) => {
69
158
  const viewportOverrides =
@@ -113,6 +202,24 @@ const StyleElementPanel = ({
113
202
  });
114
203
  };
115
204
 
205
+ const applySpanPreset = (styleIndex: number) => {
206
+ const ctx = getCtx();
207
+ const allNodes = ctx.allNodes.get();
208
+ const targetNode = cloneDeep(allNodes.get(node.id)) as FlatNode;
209
+ if (!targetNode) return;
210
+
211
+ const preset = spanStyleClasses[styleIndex];
212
+
213
+ targetNode.overrideClasses = {
214
+ ...targetNode.overrideClasses,
215
+ ...preset,
216
+ };
217
+
218
+ ctx.modifyNodes([{ ...targetNode, isChanged: true }]);
219
+
220
+ setShowPresets(false);
221
+ };
222
+
116
223
  useEffect(() => {
117
224
  if (
118
225
  styleElementInfoStore.get().markdownParentId !== parentNode.id ||
@@ -139,44 +246,119 @@ const StyleElementPanel = ({
139
246
  }
140
247
  }, [node?.tagName, onTitleChange]);
141
248
 
249
+ const shouldShowQuickStyles =
250
+ node.tagName === 'span' && !hasOverrides && showPresets;
251
+ const nodeText = shouldShowQuickStyles ? getNodeText(node) : '';
252
+
142
253
  return (
143
254
  <div className="space-y-4">
144
- {Object.keys(mergedClasses).length > 0 ? (
145
- <div className="flex flex-wrap gap-2">
146
- {Object.entries(mergedClasses).map(([className, values]) => (
147
- <SelectedTailwindClass
148
- key={className}
149
- name={className}
150
- values={values}
151
- onRemove={handleRemove}
152
- onUpdate={handleUpdate}
153
- />
154
- ))}
155
- </div>
156
- ) : (
157
- <div className="space-y-4">
158
- <em>No styles.</em>
255
+ {node.wordCarouselPayload && (
256
+ <div className="pb-2">
257
+ <div className="text-myblack hover:bg-mygreen/20 w-fit rounded border border-slate-200 p-2 text-sm">
258
+ <div
259
+ title="Configure Word Carousel"
260
+ className="flex items-center gap-2 font-bold"
261
+ >
262
+ <Cog6ToothIcon className="h-4 w-4" />
263
+ <button
264
+ onClick={() =>
265
+ settingsPanelStore.set({
266
+ nodeId: node.id,
267
+ action: 'style-word-carousel',
268
+ expanded: true,
269
+ })
270
+ }
271
+ >
272
+ Configure Word Carousel
273
+ </button>
274
+ </div>
275
+ </div>
159
276
  </div>
160
277
  )}
161
278
 
162
- <div className="space-y-4">
163
- <ul className="text-mydarkgrey flex flex-wrap gap-x-4 gap-y-1">
164
- <li>
165
- <em>Actions:</em>
166
- </li>
167
- <li>
279
+ {shouldShowQuickStyles ? (
280
+ <div className="space-y-6">
281
+ <div className="space-y-2">
282
+ <h3 className="text-mydarkgrey text-sm font-bold">
283
+ Quick Style Selection
284
+ </h3>
285
+ <p className="text-xs text-gray-500">
286
+ Select a preset style for your text selection.
287
+ </p>
288
+ </div>
289
+
290
+ <div className="flex flex-col gap-4">
291
+ {spanStyleClasses.map((style, index) => {
292
+ const [classesPayload] = processClassesForViewports(
293
+ style as any,
294
+ {},
295
+ 1
296
+ );
297
+ const combinedClasses = classesPayload[0] || '';
298
+
299
+ return (
300
+ <button
301
+ key={index}
302
+ onClick={() => applySpanPreset(index)}
303
+ className="group w-full text-left text-xl transition-colors hover:outline-dotted hover:outline-2 hover:outline-black"
304
+ >
305
+ <span className={combinedClasses}>
306
+ {nodeText || 'Sample Text'}
307
+ </span>
308
+ </button>
309
+ );
310
+ })}
311
+ </div>
312
+
313
+ <div className="border-t border-gray-100 pt-4">
168
314
  <button
169
- onClick={() => handleClickAdd()}
170
- className="text-myblue font-bold underline hover:text-black"
315
+ onClick={() => setShowPresets(false)}
316
+ className="text-myblue w-full text-center text-sm underline hover:text-black"
171
317
  >
172
- Add Style
318
+ Apply your own styles manually
173
319
  </button>
174
- </li>
175
- <li>
176
- <StylesMemory node={node} parentNode={parentNode} />
177
- </li>
178
- </ul>
179
- </div>
320
+ </div>
321
+ </div>
322
+ ) : (
323
+ <>
324
+ {Object.keys(mergedClasses).length > 0 ? (
325
+ <div className="flex flex-wrap gap-2">
326
+ {Object.entries(mergedClasses).map(([className, values]) => (
327
+ <SelectedTailwindClass
328
+ key={className}
329
+ name={className}
330
+ values={values}
331
+ onRemove={handleRemove}
332
+ onUpdate={handleUpdate}
333
+ />
334
+ ))}
335
+ </div>
336
+ ) : (
337
+ <div className="space-y-4">
338
+ <em>No styles.</em>
339
+ </div>
340
+ )}
341
+
342
+ <div className="space-y-4">
343
+ <ul className="text-mydarkgrey flex flex-wrap gap-x-4 gap-y-1">
344
+ <li>
345
+ <em>Actions:</em>
346
+ </li>
347
+ <li>
348
+ <button
349
+ onClick={() => handleClickAdd()}
350
+ className="text-myblue font-bold underline hover:text-black"
351
+ >
352
+ Add Style
353
+ </button>
354
+ </li>
355
+ <li>
356
+ <StylesMemory node={node} parentNode={parentNode} />
357
+ </li>
358
+ </ul>
359
+ </div>
360
+ </>
361
+ )}
180
362
  </div>
181
363
  );
182
364
  };
@@ -32,10 +32,13 @@ type ButtonStylePair = [ButtonStyle, ButtonStyle];
32
32
 
33
33
  const buttonStyleOptions = [
34
34
  'Plain text inline',
35
- 'Fancy text inline',
36
- 'Fancy button',
35
+ 'Primary',
36
+ 'Primary inverse',
37
+ 'Dark',
38
+ 'Dark inverse',
37
39
  ];
38
40
  const buttonStyleClasses: ButtonStylePair[] = [
41
+ // Plain text inline
39
42
  [
40
43
  {
41
44
  fontWEIGHT: ['bold'],
@@ -48,32 +51,93 @@ const buttonStyleClasses: ButtonStylePair[] = [
48
51
  textCOLOR: ['brand-1'],
49
52
  },
50
53
  ],
54
+ // Primary Solid Button
51
55
  [
52
56
  {
53
- bgCOLOR: ['brand-4'],
54
- fontWEIGHT: ['bold'],
55
- px: ['3.5'],
56
- py: ['1.5'],
57
- rounded: ['lg'],
58
- textCOLOR: ['brand-1'],
57
+ alignITEMS: ['center'],
58
+ bgCOLOR: ['blue-600'],
59
+ borderCOLOR: ['transparent'],
60
+ display: ['inline-flex'],
61
+ justifyCONTENT: ['center'],
62
+ px: ['6'],
63
+ py: ['3'],
64
+ rounded: ['md'],
65
+ textCOLOR: ['white'],
66
+ textSIZE: ['base'],
67
+ transition: ['colors'],
68
+ transitionDURATION: ['200'],
69
+ w: ['full'],
59
70
  },
60
71
  {
61
- bgCOLOR: ['brand-3'],
72
+ bgCOLOR: ['blue-700'],
62
73
  },
63
74
  ],
75
+ // Secondary Outline Button
64
76
  [
65
77
  {
66
- bgCOLOR: ['brand-4'],
67
- display: ['inline-block'],
68
- fontWEIGHT: ['bold'],
69
- px: ['3.5'],
70
- py: ['2.5'],
78
+ alignITEMS: ['center'],
79
+ bgCOLOR: ['transparent'],
80
+ borderCOLOR: ['blue-600'],
81
+ borderWIDTH: ['2'],
82
+ display: ['inline-flex'],
83
+ justifyCONTENT: ['center'],
84
+ px: ['6'],
85
+ py: ['3'],
71
86
  rounded: ['md'],
72
- textCOLOR: ['brand-1'],
87
+ textCOLOR: ['blue-600'],
88
+ textSIZE: ['base'],
89
+ transition: ['colors'],
90
+ transitionDURATION: ['200'],
91
+ w: ['full'],
92
+ },
93
+ {
94
+ bgCOLOR: ['blue-50'],
95
+ },
96
+ ],
97
+ [
98
+ {
99
+ alignITEMS: ['center'],
100
+ bgCOLOR: ['black'],
101
+ borderCOLOR: ['transparent'],
102
+ borderWIDTH: ['2'],
103
+ display: ['inline-flex'],
104
+ justifyCONTENT: ['center'],
105
+ px: ['6'],
106
+ py: ['3'],
107
+ rounded: ['md'],
108
+ textCOLOR: ['white'],
109
+ textSIZE: ['base'],
110
+ transition: ['colors'],
111
+ transitionDURATION: ['200'],
112
+ w: ['full'],
113
+ },
114
+ {
115
+ bgCOLOR: ['white'],
116
+ textCOLOR: ['black'],
117
+ borderCOLOR: ['black'],
118
+ borderWIDTH: ['2'],
119
+ },
120
+ ],
121
+ [
122
+ {
123
+ alignITEMS: ['center'],
124
+ bgCOLOR: ['transparent'],
125
+ borderCOLOR: ['black'],
126
+ borderWIDTH: ['2'],
127
+ display: ['inline-flex'],
128
+ justifyCONTENT: ['center'],
129
+ px: ['6'],
130
+ py: ['3'],
131
+ rounded: ['md'],
132
+ textCOLOR: ['black'],
133
+ textSIZE: ['base'],
134
+ transition: ['colors'],
135
+ transitionDURATION: ['200'],
136
+ w: ['full'],
73
137
  },
74
138
  {
75
- bgCOLOR: ['brand-3'],
76
- rotate: ['2'],
139
+ textCOLOR: ['myblack'],
140
+ bgCOLOR: ['slate-100'],
77
141
  },
78
142
  ],
79
143
  ];
@@ -0,0 +1,181 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { getCtx } from '@/stores/nodes';
3
+ import { settingsPanelStore } from '@/stores/storykeep';
4
+ import { processRichTextToNodes } from '@/utils/compositor/nodesHelper';
5
+ import type { FlatNode } from '@/types/compositorTypes';
6
+
7
+ interface StyleWordCarouselPanelProps {
8
+ node: FlatNode;
9
+ }
10
+
11
+ const StyleWordCarouselPanel = ({ node }: StyleWordCarouselPanelProps) => {
12
+ const ctx = getCtx();
13
+ const [words, setWords] = useState<string>('');
14
+ const [speed, setSpeed] = useState<number>(2);
15
+
16
+ useEffect(() => {
17
+ if (node.wordCarouselPayload) {
18
+ setWords(node.wordCarouselPayload.words.join('\n'));
19
+ setSpeed(node.wordCarouselPayload.speed);
20
+ } else {
21
+ // Initialize from current node text content if payload doesn't exist
22
+ const childIds = ctx.getChildNodeIDs(node.id);
23
+ const allNodes = ctx.allNodes.get();
24
+ const currentText = childIds
25
+ .map((id) => {
26
+ const child = allNodes.get(id) as FlatNode | undefined;
27
+ return child?.text || child?.copy || '';
28
+ })
29
+ .join('');
30
+
31
+ setWords(currentText || node.copy || '');
32
+ setSpeed(2);
33
+ }
34
+ }, [node.id, node.wordCarouselPayload]);
35
+
36
+ const saveChanges = (currentWords: string, currentSpeed: number) => {
37
+ const wordsArray = currentWords.split('\n').filter((w) => w.trim() !== '');
38
+
39
+ // Sync the first word to the node's text content
40
+ if (wordsArray.length > 0) {
41
+ const firstWord = wordsArray[0];
42
+ const childIds = ctx.getChildNodeIDs(node.id);
43
+ const allNodes = ctx.allNodes.get();
44
+ const currentText = childIds
45
+ .map((id) => {
46
+ const child = allNodes.get(id) as FlatNode | undefined;
47
+ return child?.text || child?.copy || '';
48
+ })
49
+ .join('');
50
+
51
+ if (currentText !== firstWord) {
52
+ ctx.deleteChildren(node.id);
53
+ // Use processRichTextToNodes to generate proper text nodes compatible with the compositor
54
+ const newNodes = processRichTextToNodes(
55
+ firstWord,
56
+ node.id,
57
+ [],
58
+ () => {}
59
+ );
60
+ if (newNodes.length > 0) {
61
+ ctx.addNodes(newNodes);
62
+ }
63
+ }
64
+ }
65
+
66
+ ctx.modifyNodes([
67
+ {
68
+ ...node,
69
+ wordCarouselPayload: {
70
+ words: wordsArray,
71
+ speed: currentSpeed,
72
+ },
73
+ isChanged: true,
74
+ } as FlatNode,
75
+ ]);
76
+ };
77
+
78
+ const handleWordsChange = (val: string) => {
79
+ setWords(val);
80
+ };
81
+
82
+ const handleWordsBlur = () => {
83
+ saveChanges(words, speed);
84
+ };
85
+
86
+ const handleSpeedChange = (val: number) => {
87
+ setSpeed(val);
88
+ saveChanges(words, val);
89
+ };
90
+
91
+ const handleRemove = () => {
92
+ // Fetch the latest node state to ensure we check current classes/properties
93
+ const currentNode = ctx.allNodes.get().get(node.id) as FlatNode;
94
+ if (!currentNode) return;
95
+
96
+ // Logic: Only fully unwrap (delete node) if it is a "naked" span
97
+ // If it has custom classes, overrides, or is a semantic tag (em, strong), we just strip the payload.
98
+
99
+ const isSpan = currentNode.tagName === 'span';
100
+
101
+ const hasCustomClasses =
102
+ !!currentNode.elementCss && currentNode.elementCss.trim().length > 0;
103
+
104
+ const hasOverrides =
105
+ currentNode.overrideClasses &&
106
+ (Object.keys(currentNode.overrideClasses.mobile || {}).length > 0 ||
107
+ Object.keys(currentNode.overrideClasses.tablet || {}).length > 0 ||
108
+ Object.keys(currentNode.overrideClasses.desktop || {}).length > 0);
109
+
110
+ // Condition: It is a span, and it has NO custom styling or overrides.
111
+ if (isSpan && !hasCustomClasses && !hasOverrides) {
112
+ // "Remove outright"
113
+ ctx.unwrapNode(node.id);
114
+ } else {
115
+ // Keep the tag/styles, but remove the carousel functionality
116
+ ctx.modifyNodes([
117
+ {
118
+ ...currentNode,
119
+ wordCarouselPayload: undefined,
120
+ isChanged: true,
121
+ } as FlatNode,
122
+ ]);
123
+ }
124
+
125
+ settingsPanelStore.set(null);
126
+ };
127
+
128
+ return (
129
+ <div className="flex flex-col gap-4">
130
+ <div>
131
+ <label className="mb-1 block text-xs font-bold text-gray-500">
132
+ Words (one per line)
133
+ </label>
134
+ <textarea
135
+ className="focus:border-myblue focus:ring-myblue w-full rounded border border-gray-300 p-2 text-sm focus:ring-1"
136
+ rows={5}
137
+ value={words}
138
+ onChange={(e) => handleWordsChange(e.target.value)}
139
+ onBlur={handleWordsBlur}
140
+ placeholder="Enter words..."
141
+ />
142
+ </div>
143
+ <div>
144
+ <label className="mb-1 block text-xs font-bold text-gray-500">
145
+ Speed (seconds)
146
+ </label>
147
+ <div className="flex gap-2">
148
+ {[1, 2, 3].map((s) => (
149
+ <button
150
+ key={s}
151
+ onClick={() => handleSpeedChange(s)}
152
+ className={`rounded px-3 py-1 text-sm font-bold transition-colors ${
153
+ speed === s
154
+ ? 'bg-myblue text-white'
155
+ : 'bg-gray-100 text-gray-600 hover:bg-gray-200'
156
+ }`}
157
+ >
158
+ {s}s
159
+ </button>
160
+ ))}
161
+ </div>
162
+ </div>
163
+ <div className="mt-2 flex items-center justify-between border-t border-gray-100 pt-2">
164
+ <button
165
+ onClick={() => settingsPanelStore.set(null)}
166
+ className="text-xs text-gray-400 underline hover:text-gray-600"
167
+ >
168
+ Done
169
+ </button>
170
+ <button
171
+ onClick={handleRemove}
172
+ className="text-xs text-red-400 underline hover:text-red-600"
173
+ >
174
+ Remove
175
+ </button>
176
+ </div>
177
+ </div>
178
+ );
179
+ };
180
+
181
+ export default StyleWordCarouselPanel;
@@ -1228,27 +1228,6 @@ export class NodesContext {
1228
1228
  }
1229
1229
  }
1230
1230
 
1231
- //getStyleByViewport(
1232
- // defaultClasses:
1233
- // | {
1234
- // mobile?: Record<string, string> | undefined;
1235
- // tablet?: Record<string, string> | undefined;
1236
- // desktop?: Record<string, string> | undefined;
1237
- // }
1238
- // | undefined,
1239
- // viewport: ViewportKey
1240
- //): Record<string, string> {
1241
- // switch (viewport) {
1242
- // case "desktop":
1243
- // return defaultClasses?.desktop || {};
1244
- // case "tablet":
1245
- // return defaultClasses?.tablet || {};
1246
- // default:
1247
- // case "mobile":
1248
- // return defaultClasses?.mobile || {};
1249
- // }
1250
- //}
1251
-
1252
1231
  getNodeSlug(nodeId: string): string {
1253
1232
  const node = this.allNodes.get().get(nodeId);
1254
1233
  if (!node || !(`slug` in node) || typeof node.slug !== `string`) return '';
@@ -18,7 +18,7 @@ export interface SelectionStoreState extends SelectionRange {
18
18
  isDragging: boolean;
19
19
  isActive: boolean;
20
20
  selectionBox: SelectionBox | null;
21
- pendingAction: 'style' | 'link' | null;
21
+ pendingAction: 'style' | 'link' | 'carousel' | null;
22
22
  isRestyleModalOpen: boolean;
23
23
  paneToRestyleId: string | null;
24
24
  }
@@ -399,6 +399,10 @@ export interface FlatNode extends BaseNode {
399
399
  };
400
400
  elementCss?: string;
401
401
  buttonPayload?: ButtonPayload;
402
+ wordCarouselPayload?: {
403
+ words: string[];
404
+ speed: number;
405
+ };
402
406
  }
403
407
  export type ButtonPayload = {
404
408
  buttonClasses: Record<string, string[]>;
@@ -371,8 +371,10 @@ export function processRichTextToNodes(
371
371
  } else if (node.tagName === 'span') {
372
372
  node.elementCss = matchingOriginalNode.elementCss;
373
373
  node.overrideClasses = matchingOriginalNode.overrideClasses;
374
+ if (matchingOriginalNode.wordCarouselPayload) {
375
+ node.wordCarouselPayload = matchingOriginalNode.wordCarouselPayload;
376
+ }
374
377
  }
375
- console.log(node);
376
378
  } else if (onInsertSignal) {
377
379
  // New interactive element detected, trigger insert signal
378
380
  onInsertSignal(node.tagName, node.id);
@@ -83,6 +83,7 @@ export function transformToOptionsPayload(
83
83
  codeHookParams: flatNode.codeHookParams,
84
84
  buttonPayload: flatNode.buttonPayload,
85
85
  overrideClasses: flatNode.overrideClasses,
86
+ wordCarouselPayload: flatNode.wordCarouselPayload,
86
87
  };
87
88
 
88
89
  if (VERBOSE)
@@ -1420,6 +1420,12 @@ export async function injectTemplateFiles(
1420
1420
  src: resolve('../templates/src/components/edit/state/StylesMemory.tsx'),
1421
1421
  dest: 'src/components/edit/state/StylesMemory.tsx',
1422
1422
  },
1423
+ {
1424
+ src: resolve(
1425
+ '../templates/src/components/edit/panels/StyleWordCarouselPanel.tsx'
1426
+ ),
1427
+ dest: 'src/components/edit/panels/StyleWordCarouselPanel.tsx',
1428
+ },
1423
1429
  {
1424
1430
  src: resolve(
1425
1431
  '../templates/src/components/edit/panels/StyleBreakPanel.tsx'