@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/README.md +11 -11
- package/dist/editor.js +7497 -7188
- package/package.json +1 -1
- package/src/build-processor.ts +44 -1
- package/src/dev-middleware.ts +47 -2
- package/src/editor/components/collections-browser.tsx +10 -6
- package/src/editor/components/fields.tsx +7 -7
- package/src/editor/components/frontmatter-fields.tsx +163 -2
- package/src/editor/components/outline.tsx +165 -6
- package/src/editor/components/seo-editor.tsx +2 -1
- package/src/editor/components/text-style-toolbar.tsx +1 -1
- package/src/editor/components/toast/toast.tsx +19 -2
- package/src/editor/editor.ts +24 -25
- package/src/editor/index.tsx +100 -5
- package/src/editor/types.ts +4 -2
- package/src/handlers/array-ops.ts +102 -0
- package/src/handlers/source-writer.ts +6 -2
- package/src/html-processor.ts +8 -4
- package/src/index.ts +1 -1
- package/src/source-finder/cross-file-tracker.ts +42 -0
- package/src/source-finder/element-finder.ts +18 -4
- package/src/source-finder/snippet-utils.ts +3 -0
- package/src/source-finder/types.ts +3 -0
- package/src/source-finder/variable-extraction.ts +2 -2
- package/src/tailwind-colors.ts +33 -0
- package/src/types.ts +3 -0
package/package.json
CHANGED
package/src/build-processor.ts
CHANGED
|
@@ -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 {
|
|
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
|
}
|
package/src/dev-middleware.ts
CHANGED
|
@@ -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
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
|
|
45
|
-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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-
|
|
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 =
|
|
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
|
|
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
|