@nuasite/cms 0.5.1 → 0.7.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.
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.5.1",
17
+ "version": "0.7.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -4,7 +4,8 @@ import fs from 'node:fs/promises'
4
4
  import path from 'node:path'
5
5
  import { fileURLToPath } from 'node:url'
6
6
  import { getProjectRoot } from './config'
7
- import { extractPropsFromSource, findComponentInvocationLine } from './handlers/component-ops'
7
+ import { detectArrayPattern, extractArrayElementProps } from './handlers/array-ops'
8
+ import { extractPropsFromSource, findComponentInvocationLine, findFrontmatterEnd } from './handlers/component-ops'
8
9
  import { extractComponentName, processHtml } from './html-processor'
9
10
  import type { ManifestWriter } from './manifest-writer'
10
11
  import { generateComponentPreviews } from './preview-generator'
@@ -402,6 +403,9 @@ async function processFile(
402
403
  entry.variableName = mdSource.variableName
403
404
  entry.collectionName = mdSource.collectionName
404
405
  entry.collectionSlug = mdSource.collectionSlug
406
+ if (mdSource.variableName) {
407
+ entry.allowStyling = false
408
+ }
405
409
  return
406
410
  }
407
411
  }
@@ -413,6 +417,9 @@ async function processFile(
413
417
  entry.sourceLine = sourceLocation.line
414
418
  entry.sourceSnippet = sourceLocation.snippet
415
419
  entry.variableName = sourceLocation.variableName
420
+ if (sourceLocation.variableName) {
421
+ entry.allowStyling = false
422
+ }
416
423
 
417
424
  // Update attribute and colorClasses source information if we have an opening tag
418
425
  if (sourceLocation.openingTagSnippet) {
@@ -640,6 +647,42 @@ async function processFile(
640
647
  comp.props = extractPropsFromSource(pageLines, invLine, comp.componentName)
641
648
  }
642
649
  }
650
+
651
+ // Resolve spread props for array-rendered components
652
+ const componentGroups = new Map<string, typeof result.components[string][]>()
653
+ for (const comp of Object.values(result.components)) {
654
+ const key = `${comp.componentName}::${comp.invocationSourcePath ?? ''}`
655
+ if (!componentGroups.has(key)) componentGroups.set(key, [])
656
+ componentGroups.get(key)!.push(comp)
657
+ }
658
+
659
+ for (const group of componentGroups.values()) {
660
+ if (group.length <= 1) continue
661
+ if (!group.some(c => Object.keys(c.props).length === 0)) continue
662
+
663
+ const firstComp = group[0]!
664
+ const invLine = findComponentInvocationLine(pageLines, firstComp.componentName, 0)
665
+ if (invLine < 0) continue
666
+
667
+ const pattern = detectArrayPattern(pageLines, invLine)
668
+ if (!pattern) continue
669
+
670
+ const fmEnd = findFrontmatterEnd(pageLines)
671
+ if (fmEnd === 0) continue
672
+
673
+ const frontmatterContent = pageLines.slice(1, fmEnd - 1).join('\n')
674
+
675
+ const sorted = [...group].sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
676
+ for (let i = 0; i < sorted.length; i++) {
677
+ const comp = sorted[i]!
678
+ if (Object.keys(comp.props).length > 0) continue
679
+
680
+ const arrayProps = extractArrayElementProps(frontmatterContent, pattern.arrayVarName, i)
681
+ if (arrayProps) {
682
+ comp.props = arrayProps
683
+ }
684
+ }
685
+ }
643
686
  } catch {
644
687
  // Could not read page source — leave props empty
645
688
  }
@@ -3,10 +3,11 @@ import fs from 'node:fs/promises'
3
3
  import type { IncomingMessage, ServerResponse } from 'node:http'
4
4
  import path from 'node:path'
5
5
  import { getProjectRoot } from './config'
6
- import { handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
6
+ import { detectArrayPattern, extractArrayElementProps, handleAddArrayItem, handleRemoveArrayItem } from './handlers/array-ops'
7
7
  import {
8
8
  extractPropsFromSource,
9
9
  findComponentInvocationLine,
10
+ findFrontmatterEnd,
10
11
  getPageFileCandidates,
11
12
  handleInsertComponent,
12
13
  handleRemoveComponent,
@@ -429,7 +430,7 @@ async function handleCmsApiRoute(
429
430
 
430
431
  // GET /_nua/cms/deployment/status
431
432
  if (route === 'deployment/status' && req.method === 'GET') {
432
- sendJson(res, { currentDeployment: null, pendingCount: 0 })
433
+ sendJson(res, { currentDeployment: null, pendingCount: 0, deploymentEnabled: false })
433
434
  return
434
435
  }
435
436
 
@@ -536,6 +537,50 @@ async function processHtmlForDev(
536
537
  }
537
538
  }
538
539
 
540
+ // Resolve spread props for array-rendered components.
541
+ // Group components by (name, invocationSourcePath) to detect array patterns.
542
+ const componentGroups = new Map<string, typeof result.components[string][]>()
543
+ for (const comp of Object.values(result.components)) {
544
+ const key = `${comp.componentName}::${comp.invocationSourcePath ?? ''}`
545
+ if (!componentGroups.has(key)) componentGroups.set(key, [])
546
+ componentGroups.get(key)!.push(comp)
547
+ }
548
+
549
+ for (const group of componentGroups.values()) {
550
+ if (group.length <= 1) continue
551
+ // Only process groups where at least one component has empty props (spread case)
552
+ if (!group.some(c => Object.keys(c.props).length === 0)) continue
553
+
554
+ const firstComp = group[0]!
555
+ const filePath = normalizeFilePath(firstComp.invocationSourcePath ?? firstComp.sourcePath)
556
+ const lines = await readLines(path.resolve(projectRoot, filePath))
557
+ if (!lines) continue
558
+
559
+ // Find the invocation line (occurrence 0, since .map() has a single <Component> tag)
560
+ const invLine = findComponentInvocationLine(lines, firstComp.componentName, 0)
561
+ if (invLine < 0) continue
562
+
563
+ const pattern = detectArrayPattern(lines, invLine)
564
+ if (!pattern) continue
565
+
566
+ const fmEnd = findFrontmatterEnd(lines)
567
+ if (fmEnd === 0) continue
568
+
569
+ const frontmatterContent = lines.slice(1, fmEnd - 1).join('\n')
570
+
571
+ // Sort group by invocationIndex to match array element order
572
+ const sorted = [...group].sort((a, b) => (a.invocationIndex ?? 0) - (b.invocationIndex ?? 0))
573
+ for (let i = 0; i < sorted.length; i++) {
574
+ const comp = sorted[i]!
575
+ if (Object.keys(comp.props).length > 0) continue
576
+
577
+ const arrayProps = extractArrayElementProps(frontmatterContent, pattern.arrayVarName, i)
578
+ if (arrayProps) {
579
+ comp.props = arrayProps
580
+ }
581
+ }
582
+ }
583
+
539
584
  // Build collection entry if this is a collection page
540
585
  let collectionEntry: CollectionEntry | undefined
541
586
  if (collectionInfo && mdContent) {
@@ -3,6 +3,7 @@ import {
3
3
  closeCollectionsBrowser,
4
4
  isCollectionsBrowserOpen,
5
5
  manifest,
6
+ openMarkdownEditorForEntry,
6
7
  openMarkdownEditorForNewPage,
7
8
  selectBrowserCollection,
8
9
  selectedBrowserCollection,
@@ -16,7 +17,7 @@ export function CollectionsBrowser() {
16
17
  const collectionDefinitions = manifest.value.collectionDefinitions ?? {}
17
18
 
18
19
  const collections = useMemo(() => {
19
- return Object.values(collectionDefinitions)
20
+ return Object.values(collectionDefinitions).sort((a, b) => a.label.localeCompare(b.label))
20
21
  }, [collectionDefinitions])
21
22
 
22
23
  if (!visible) return null
@@ -38,11 +39,14 @@ export function CollectionsBrowser() {
38
39
 
39
40
  const handleEntryClick = (slug: string, sourcePath: string, pathname?: string) => {
40
41
  closeCollectionsBrowser()
41
- // Navigate to the collection detail page to edit inline.
42
- // Use known pathname or construct one from collection/slug.
43
- const targetPath = pathname || `/${selected}/${slug}`
44
- savePendingEntryNavigation({ collectionName: selected, slug, sourcePath, pathname: targetPath })
45
- window.location.href = targetPath
42
+ if (pathname) {
43
+ // Navigate to the collection detail page to edit inline.
44
+ savePendingEntryNavigation({ collectionName: selected, slug, sourcePath, pathname })
45
+ window.location.href = pathname
46
+ } else {
47
+ // No detail page exists for this entry — open the markdown editor inline.
48
+ openMarkdownEditorForEntry(selected, slug, sourcePath, def)
49
+ }
46
50
  }
47
51
 
48
52
  const handleAddNew = () => {
@@ -54,7 +54,7 @@ export function TextField({ label, value, placeholder, onChange, isDirty, onRese
54
54
  placeholder={placeholder}
55
55
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
56
56
  class={cn(
57
- 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
57
+ 'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
58
58
  isDirty
59
59
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
60
60
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -90,7 +90,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
90
90
  placeholder={placeholder}
91
91
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
92
92
  class={cn(
93
- 'flex-1 px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
93
+ 'flex-1 px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
94
94
  isDirty
95
95
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
96
96
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -100,7 +100,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
100
100
  <button
101
101
  type="button"
102
102
  onClick={onBrowse}
103
- class="px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-md text-sm text-white transition-colors cursor-pointer"
103
+ class="px-3 py-2 bg-white/10 hover:bg-white/20 border border-white/20 rounded-cms-sm text-sm text-white transition-colors cursor-pointer"
104
104
  data-cms-ui
105
105
  >
106
106
  Browse
@@ -132,7 +132,7 @@ export function SelectField({ label, value, options, onChange, isDirty, onReset,
132
132
  value={value ?? ''}
133
133
  onChange={(e) => onChange((e.target as HTMLSelectElement).value)}
134
134
  class={cn(
135
- 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer',
135
+ 'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white focus:outline-none focus:ring-1 transition-colors cursor-pointer',
136
136
  isDirty
137
137
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
138
138
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -220,7 +220,7 @@ export function NumberField({ label, value, placeholder, min, max, onChange, isD
220
220
  onChange(val === '' ? undefined : Number(val))
221
221
  }}
222
222
  class={cn(
223
- 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
223
+ 'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
224
224
  isDirty
225
225
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
226
226
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -341,7 +341,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
341
341
  onKeyDown={handleKeyDown}
342
342
  autocomplete="off"
343
343
  class={cn(
344
- 'w-full px-3 py-2 bg-white/10 border rounded-cms-md text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
344
+ 'w-full px-3 py-2 bg-white/10 border rounded-cms-sm text-sm text-white placeholder:text-white/40 focus:outline-none focus:ring-1 transition-colors',
345
345
  isDirty
346
346
  ? 'border-cms-primary focus:border-cms-primary focus:ring-cms-primary/30'
347
347
  : 'border-white/20 focus:border-white/40 focus:ring-white/10',
@@ -351,7 +351,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
351
351
  {showDropdown && (
352
352
  <div
353
353
  ref={listRef}
354
- class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-md shadow-lg"
354
+ class="absolute z-50 left-0 right-0 mt-1 max-h-40 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-sm shadow-lg"
355
355
  data-cms-ui
356
356
  >
357
357
  {filtered.map((opt, i) => (
@@ -1,3 +1,4 @@
1
+ import { useState } from 'preact/hooks'
1
2
  import { markdownEditorState, openMediaLibraryWithCallback, updateMarkdownFrontmatter } from '../signals'
2
3
  import type { CollectionDefinition, FieldDefinition, MarkdownPageEntry } from '../types'
3
4
  import { ComboBoxField, ImageField, NumberField, TextField, ToggleField } from './fields'
@@ -87,6 +88,17 @@ export function FrontmatterField({
87
88
  )
88
89
  }
89
90
 
91
+ // Object field - render nested fields with add/remove
92
+ if (value !== null && typeof value === 'object' && !Array.isArray(value)) {
93
+ return (
94
+ <ObjectFields
95
+ label={label}
96
+ value={value as Record<string, unknown>}
97
+ onChange={onChange}
98
+ />
99
+ )
100
+ }
101
+
90
102
  // String field (default) - check if it's a long text (excerpt, etc.)
91
103
  const isLongText = fieldKey.toLowerCase().includes('excerpt')
92
104
  || fieldKey.toLowerCase().includes('description')
@@ -158,7 +170,7 @@ export function CreateModeFrontmatter({
158
170
  }
159
171
  }}
160
172
  placeholder="url-friendly-slug"
161
- class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
173
+ class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white placeholder-white/40 focus:outline-none focus:border-white/40 focus:ring-1 focus:ring-white/10"
162
174
  data-cms-ui
163
175
  />
164
176
  <p class="mt-1 text-xs text-white/40">
@@ -214,7 +226,7 @@ export function EditModeFrontmatter({
214
226
  <input
215
227
  type="text"
216
228
  value={page.slug}
217
- class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-md text-sm text-white/50 focus:outline-none cursor-not-allowed"
229
+ class="w-full px-3 py-2 bg-white/10 border border-white/20 rounded-cms-sm text-sm text-white/50 focus:outline-none cursor-not-allowed"
218
230
  disabled
219
231
  data-cms-ui
220
232
  />
@@ -415,6 +427,32 @@ export function SchemaFrontmatterField({
415
427
  )
416
428
  }
417
429
 
430
+ case 'object': {
431
+ const obj = (value ?? {}) as Record<string, unknown>
432
+ const nestedFields = field.fields ?? []
433
+ if (nestedFields.length > 0) {
434
+ // Schema-defined nested fields + any extra keys from the actual value
435
+ const schemaNames = new Set(nestedFields.map((f) => f.name))
436
+ const extraKeys = Object.keys(obj).filter((k) => !schemaNames.has(k))
437
+ return (
438
+ <ObjectFields
439
+ label={label}
440
+ value={obj}
441
+ onChange={onChange}
442
+ schemaFields={nestedFields}
443
+ extraKeys={extraKeys}
444
+ />
445
+ )
446
+ }
447
+ return (
448
+ <ObjectFields
449
+ label={label}
450
+ value={obj}
451
+ onChange={onChange}
452
+ />
453
+ )
454
+ }
455
+
418
456
  default:
419
457
  return (
420
458
  <div class="flex flex-col gap-1" data-cms-ui>
@@ -431,6 +469,129 @@ export function SchemaFrontmatterField({
431
469
  }
432
470
  }
433
471
 
472
+ // ============================================================================
473
+ // Object Fields — renders nested fields with add/remove key support
474
+ // ============================================================================
475
+
476
+ interface ObjectFieldsProps {
477
+ label: string
478
+ value: Record<string, unknown>
479
+ onChange: (value: unknown) => void
480
+ schemaFields?: FieldDefinition[]
481
+ extraKeys?: string[]
482
+ }
483
+
484
+ function ObjectFields({ label, value, onChange, schemaFields, extraKeys }: ObjectFieldsProps) {
485
+ const [newKey, setNewKey] = useState('')
486
+ const obj = value ?? {}
487
+
488
+ const handleRemoveKey = (key: string) => {
489
+ const { [key]: _, ...rest } = obj
490
+ onChange(rest)
491
+ }
492
+
493
+ const handleAddKey = () => {
494
+ const trimmed = newKey.trim()
495
+ if (!trimmed || trimmed in obj) return
496
+ onChange({ ...obj, [trimmed]: '' })
497
+ setNewKey('')
498
+ }
499
+
500
+ return (
501
+ <div class="flex flex-col gap-2 col-span-2" data-cms-ui>
502
+ <label class="text-xs text-white/60 font-medium">{label}</label>
503
+ <div class="space-y-2 pl-3 border-l-2 border-white/10">
504
+ {schemaFields
505
+ ? (
506
+ <>
507
+ {schemaFields.map((subField) => (
508
+ <div key={subField.name} class="flex items-end gap-2">
509
+ <div class="flex-1 min-w-0">
510
+ <SchemaFrontmatterField
511
+ field={subField}
512
+ value={obj[subField.name]}
513
+ onChange={(newValue) => onChange({ ...obj, [subField.name]: newValue })}
514
+ />
515
+ </div>
516
+ </div>
517
+ ))}
518
+ {(extraKeys ?? []).map((key) => (
519
+ <div key={key} class="flex items-end gap-2">
520
+ <div class="flex-1 min-w-0">
521
+ <FrontmatterField
522
+ fieldKey={key}
523
+ value={obj[key]}
524
+ onChange={(newValue) => onChange({ ...obj, [key]: newValue })}
525
+ />
526
+ </div>
527
+ <button
528
+ type="button"
529
+ onClick={() => handleRemoveKey(key)}
530
+ class="p-1 mb-1 text-white/30 hover:text-red-400 transition-colors shrink-0"
531
+ title={`Remove ${key}`}
532
+ data-cms-ui
533
+ >
534
+ <RemoveIcon />
535
+ </button>
536
+ </div>
537
+ ))}
538
+ </>
539
+ )
540
+ : Object.entries(obj).map(([key, subValue]) => (
541
+ <div key={key} class="flex items-end gap-2">
542
+ <div class="flex-1 min-w-0">
543
+ <FrontmatterField
544
+ fieldKey={key}
545
+ value={subValue}
546
+ onChange={(newValue) => onChange({ ...obj, [key]: newValue })}
547
+ />
548
+ </div>
549
+ <button
550
+ type="button"
551
+ onClick={() => handleRemoveKey(key)}
552
+ class="p-1 mb-1 text-white/30 hover:text-red-400 transition-colors shrink-0"
553
+ title={`Remove ${key}`}
554
+ data-cms-ui
555
+ >
556
+ <RemoveIcon />
557
+ </button>
558
+ </div>
559
+ ))
560
+ }
561
+ {/* Add new key */}
562
+ <div class="flex items-center gap-2 pt-1">
563
+ <input
564
+ type="text"
565
+ value={newKey}
566
+ onInput={(e) => setNewKey((e.target as HTMLInputElement).value)}
567
+ onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); handleAddKey() } }}
568
+ placeholder="New field name..."
569
+ class="flex-1 px-2 py-1 text-xs bg-white/5 border border-white/10 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/30"
570
+ data-cms-ui
571
+ />
572
+ <button
573
+ type="button"
574
+ onClick={handleAddKey}
575
+ disabled={!newKey.trim() || newKey.trim() in obj}
576
+ class="px-2 py-1 text-xs font-medium text-white/60 hover:text-white bg-white/5 hover:bg-white/10 border border-white/10 rounded-cms-sm transition-colors disabled:opacity-30 disabled:cursor-not-allowed"
577
+ data-cms-ui
578
+ >
579
+ + Add
580
+ </button>
581
+ </div>
582
+ </div>
583
+ </div>
584
+ )
585
+ }
586
+
587
+ function RemoveIcon() {
588
+ return (
589
+ <svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
590
+ <path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
591
+ </svg>
592
+ )
593
+ }
594
+
434
595
  // ============================================================================
435
596
  // Helper Functions
436
597
  // ============================================================================
@@ -3,6 +3,7 @@ import { getColorPreview, parseColorClass } from '../color-utils'
3
3
  import { Z_INDEX } from '../constants'
4
4
  import { isPageDark } from '../dom'
5
5
  import * as signals from '../signals'
6
+ import type { Attribute } from '../../types'
6
7
 
7
8
  export interface OutlineProps {
8
9
  visible: boolean
@@ -14,10 +15,14 @@ export interface OutlineProps {
14
15
  element?: HTMLElement | null
15
16
  /** CMS ID of the hovered element */
16
17
  cmsId?: string | null
18
+ /** Current text style classes from pending changes (reactive) */
19
+ textStyleClasses?: Record<string, Attribute>
17
20
  /** Callback when a color swatch is clicked */
18
21
  onColorClick?: (cmsId: string, rect: DOMRect) => void
19
22
  /** Callback when an attribute indicator is clicked */
20
23
  onAttributeClick?: (cmsId: string, rect: DOMRect) => void
24
+ /** Callback when a text style toggle is clicked */
25
+ onTextStyleChange?: (cmsId: string, styleType: string, oldClass: string, newClass: string) => void
21
26
  }
22
27
 
23
28
  // Minimum space needed to show label outside the element
@@ -30,7 +35,7 @@ const STICKY_PADDING = 8
30
35
  * Uses a custom element with Shadow DOM to avoid style conflicts.
31
36
  */
32
37
  export function Outline(
33
- { visible, rect, isComponent = false, componentName, tagName, element, cmsId, onColorClick, onAttributeClick }: OutlineProps,
38
+ { visible, rect, isComponent = false, componentName, tagName, element, cmsId, textStyleClasses, onColorClick, onAttributeClick, onTextStyleChange }: OutlineProps,
34
39
  ) {
35
40
  const containerRef = useRef<HTMLDivElement>(null)
36
41
  const shadowRootRef = useRef<ShadowRoot | null>(null)
@@ -182,6 +187,60 @@ export function Outline(
182
187
  .attr-button:hover svg {
183
188
  color: #DFFF40;
184
189
  }
190
+
191
+ .text-style-btn {
192
+ width: 28px;
193
+ height: 28px;
194
+ display: flex;
195
+ align-items: center;
196
+ justify-content: center;
197
+ background: transparent;
198
+ border: 1px solid transparent;
199
+ border-radius: 6px;
200
+ cursor: pointer;
201
+ transition: all 150ms ease;
202
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
203
+ font-size: 13px;
204
+ color: rgba(255,255,255,0.7);
205
+ padding: 0;
206
+ line-height: 1;
207
+ }
208
+
209
+ .text-style-btn:hover {
210
+ background: rgba(255,255,255,0.1);
211
+ color: #DFFF40;
212
+ }
213
+
214
+ .text-style-btn.active {
215
+ background: rgba(223, 255, 64, 0.15);
216
+ border-color: rgba(223, 255, 64, 0.4);
217
+ color: #DFFF40;
218
+ }
219
+
220
+ .text-size-select {
221
+ height: 28px;
222
+ background: transparent;
223
+ border: 1px solid rgba(255,255,255,0.15);
224
+ border-radius: 6px;
225
+ color: rgba(255,255,255,0.7);
226
+ font-family: system-ui, -apple-system, BlinkMacSystemFont, sans-serif;
227
+ font-size: 11px;
228
+ padding: 0 4px;
229
+ cursor: pointer;
230
+ transition: all 150ms ease;
231
+ -webkit-appearance: none;
232
+ appearance: none;
233
+ }
234
+
235
+ .text-size-select:hover {
236
+ border-color: rgba(223, 255, 64, 0.4);
237
+ color: #DFFF40;
238
+ }
239
+
240
+ .text-size-select:focus {
241
+ outline: none;
242
+ border-color: #DFFF40;
243
+ }
185
244
  `
186
245
 
187
246
  overlayRef.current = document.createElement('div')
@@ -305,15 +364,15 @@ export function Outline(
305
364
 
306
365
  // Check for color swatches and attribute button
307
366
  const manifest = signals.manifest.value
308
- const pendingColorChange = cmsId ? signals.pendingColorChanges.value.get(cmsId) : null
309
367
  const entry = cmsId ? manifest.entries[cmsId] : null
310
- const colorClasses = pendingColorChange?.newClasses ?? entry?.colorClasses
368
+ const colorClasses = textStyleClasses ?? entry?.colorClasses
311
369
 
312
370
  const hasColorSwatches = colorClasses && (colorClasses.bg?.value || colorClasses.text?.value) && onColorClick
313
371
  const hasEditableAttributes = entry?.attributes && Object.keys(entry.attributes).length > 0
372
+ const needsElementLevelStyling = entry?.allowStyling === false && onTextStyleChange
314
373
 
315
- // Show unified toolbar if there are swatches or attribute button
316
- if ((hasColorSwatches || hasEditableAttributes) && (onColorClick || onAttributeClick)) {
374
+ // Show unified toolbar if there are swatches, attribute button, or element-level text styling
375
+ if ((hasColorSwatches || hasEditableAttributes || needsElementLevelStyling) && (onColorClick || onAttributeClick || onTextStyleChange)) {
317
376
  toolbarRef.current.className = 'element-toolbar'
318
377
  toolbarRef.current.innerHTML = ''
319
378
 
@@ -390,11 +449,111 @@ export function Outline(
390
449
  }
391
450
  toolbarRef.current.appendChild(attrButton)
392
451
  }
452
+
453
+ // Add text style buttons for elements where inline styling is unavailable
454
+ if (needsElementLevelStyling && cmsId) {
455
+ // Add divider if there are other toolbar items before
456
+ if (hasColorSwatches || hasEditableAttributes) {
457
+ const divider = document.createElement('div')
458
+ divider.className = 'toolbar-divider'
459
+ toolbarRef.current.appendChild(divider)
460
+ }
461
+
462
+ const currentClasses = textStyleClasses ?? colorClasses ?? {}
463
+
464
+ // Bold toggle
465
+ const boldBtn = document.createElement('button')
466
+ const isBold = currentClasses.fontWeight?.value === 'font-bold'
467
+ boldBtn.className = `text-style-btn${isBold ? ' active' : ''}`
468
+ boldBtn.innerHTML = '<strong>B</strong>'
469
+ boldBtn.title = isBold ? 'Remove bold' : 'Bold'
470
+ boldBtn.onclick = (e) => {
471
+ e.stopPropagation()
472
+ const oldClass = currentClasses.fontWeight?.value || ''
473
+ const newClass = isBold ? 'font-normal' : 'font-bold'
474
+ onTextStyleChange!(cmsId!, 'fontWeight', oldClass, newClass)
475
+ }
476
+ toolbarRef.current.appendChild(boldBtn)
477
+
478
+ // Italic toggle
479
+ const italicBtn = document.createElement('button')
480
+ const isItalic = currentClasses.fontStyle?.value === 'italic'
481
+ italicBtn.className = `text-style-btn${isItalic ? ' active' : ''}`
482
+ italicBtn.innerHTML = '<em>I</em>'
483
+ italicBtn.title = isItalic ? 'Remove italic' : 'Italic'
484
+ italicBtn.onclick = (e) => {
485
+ e.stopPropagation()
486
+ const oldClass = currentClasses.fontStyle?.value || ''
487
+ const newClass = isItalic ? 'not-italic' : 'italic'
488
+ onTextStyleChange!(cmsId!, 'fontStyle', oldClass, newClass)
489
+ }
490
+ toolbarRef.current.appendChild(italicBtn)
491
+
492
+ // Underline toggle
493
+ const underlineBtn = document.createElement('button')
494
+ const isUnderline = currentClasses.textDecoration?.value === 'underline'
495
+ underlineBtn.className = `text-style-btn${isUnderline ? ' active' : ''}`
496
+ underlineBtn.innerHTML = '<span style="text-decoration:underline">U</span>'
497
+ underlineBtn.title = isUnderline ? 'Remove underline' : 'Underline'
498
+ underlineBtn.onclick = (e) => {
499
+ e.stopPropagation()
500
+ const oldClass = currentClasses.textDecoration?.value || ''
501
+ const newClass = isUnderline ? 'no-underline' : 'underline'
502
+ onTextStyleChange!(cmsId!, 'textDecoration', oldClass, newClass)
503
+ }
504
+ toolbarRef.current.appendChild(underlineBtn)
505
+
506
+ // Strikethrough toggle
507
+ const strikeBtn = document.createElement('button')
508
+ const isStrike = currentClasses.textDecoration?.value === 'line-through'
509
+ strikeBtn.className = `text-style-btn${isStrike ? ' active' : ''}`
510
+ strikeBtn.innerHTML = '<span style="text-decoration:line-through">S</span>'
511
+ strikeBtn.title = isStrike ? 'Remove strikethrough' : 'Strikethrough'
512
+ strikeBtn.onclick = (e) => {
513
+ e.stopPropagation()
514
+ const oldClass = currentClasses.textDecoration?.value || ''
515
+ const newClass = isStrike ? 'no-underline' : 'line-through'
516
+ onTextStyleChange!(cmsId!, 'textDecoration', oldClass, newClass)
517
+ }
518
+ toolbarRef.current.appendChild(strikeBtn)
519
+
520
+ // Font size dropdown
521
+ const sizeSelect = document.createElement('select')
522
+ sizeSelect.className = 'text-size-select'
523
+ sizeSelect.title = 'Font size'
524
+ const sizeOptions = [
525
+ { value: '', label: 'Size' },
526
+ { value: 'text-xs', label: 'XS' },
527
+ { value: 'text-sm', label: 'SM' },
528
+ { value: 'text-base', label: 'Base' },
529
+ { value: 'text-lg', label: 'LG' },
530
+ { value: 'text-xl', label: 'XL' },
531
+ { value: 'text-2xl', label: '2XL' },
532
+ { value: 'text-3xl', label: '3XL' },
533
+ ]
534
+ const currentSize = currentClasses.fontSize?.value || ''
535
+ for (const opt of sizeOptions) {
536
+ const option = document.createElement('option')
537
+ option.value = opt.value
538
+ option.textContent = opt.label
539
+ if (opt.value === currentSize) option.selected = true
540
+ sizeSelect.appendChild(option)
541
+ }
542
+ sizeSelect.onchange = (e) => {
543
+ e.stopPropagation()
544
+ const newClass = (e.target as HTMLSelectElement).value
545
+ if (newClass) {
546
+ const oldClass = currentClasses.fontSize?.value || ''
547
+ onTextStyleChange!(cmsId!, 'fontSize', oldClass, newClass)
548
+ }
549
+ }
550
+ toolbarRef.current.appendChild(sizeSelect)
551
+ }
393
552
  } else {
394
553
  toolbarRef.current.className = 'element-toolbar hidden'
395
554
  }
396
555
  }
397
- }, [visible, rect, isComponent, componentName, tagName, cmsId, onColorClick, onAttributeClick])
556
+ }, [visible, rect, isComponent, componentName, tagName, cmsId, textStyleClasses, onColorClick, onAttributeClick, onTextStyleChange])
398
557
 
399
558
  return (
400
559
  <div