@nuasite/cms 0.27.0 → 0.28.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 +103 -0
- package/dist/editor.js +11536 -11345
- package/package.json +1 -1
- package/src/collection-scanner.ts +152 -12
- package/src/editor/components/fields.tsx +8 -2
- package/src/editor/components/frontmatter-fields.tsx +13 -3
- package/src/editor/components/link-edit-popover.tsx +232 -0
- package/src/editor/components/markdown-inline-editor.tsx +25 -52
- package/src/editor/components/mdx-block-view.tsx +20 -17
- package/src/editor/hooks/useLinkPopover.ts +64 -0
- package/src/editor/milkdown-utils.ts +21 -0
- package/src/field-types.ts +109 -27
- package/src/index.ts +2 -0
- package/src/types.ts +18 -0
package/package.json
CHANGED
|
@@ -3,7 +3,7 @@ import path from 'node:path'
|
|
|
3
3
|
import { isMap, isPair, isScalar, parse as parseYaml, parseDocument } from 'yaml'
|
|
4
4
|
import { getProjectRoot } from './config'
|
|
5
5
|
import { slugifyHref } from './shared'
|
|
6
|
-
import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldType } from './types'
|
|
6
|
+
import type { CollectionDefinition, CollectionEntryInfo, FieldDefinition, FieldHints, FieldType } from './types'
|
|
7
7
|
|
|
8
8
|
/** Regex patterns for type inference */
|
|
9
9
|
const DATE_PATTERN = /^\d{4}-\d{2}-\d{2}/
|
|
@@ -31,7 +31,6 @@ const SIDEBAR_FIELD_NAMES = new Set([
|
|
|
31
31
|
'author',
|
|
32
32
|
])
|
|
33
33
|
|
|
34
|
-
/** Directive pattern: # @position <value> or # @group <value> */
|
|
35
34
|
/** Matches `@position <value>` or `@group <value>` in YAML comment text (# already stripped by parser) */
|
|
36
35
|
const DIRECTIVE_PATTERN = /^\s*@(position|group)\s+(.+)$/
|
|
37
36
|
|
|
@@ -400,10 +399,6 @@ async function parseContentConfigSchemaBlocks(): Promise<Array<{ collectionName:
|
|
|
400
399
|
const fullPath = path.join(projectRoot, configPath)
|
|
401
400
|
const content = await fs.readFile(fullPath, 'utf-8')
|
|
402
401
|
|
|
403
|
-
const collectionBlocks = content.matchAll(
|
|
404
|
-
/(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*z\.object\s*\(\s*\{([\s\S]*?)\}\s*\)/g,
|
|
405
|
-
)
|
|
406
|
-
|
|
407
402
|
// Map variable names to collection names from exports
|
|
408
403
|
const varToName = new Map<string, string>()
|
|
409
404
|
const exportMatch = content.match(/export\s+const\s+collections\s*=\s*\{([\s\S]*?)\}/)
|
|
@@ -414,13 +409,32 @@ async function parseContentConfigSchemaBlocks(): Promise<Array<{ collectionName:
|
|
|
414
409
|
}
|
|
415
410
|
}
|
|
416
411
|
|
|
412
|
+
// Find schema block starts via regex, then extract bodies with brace counting
|
|
413
|
+
// to correctly handle nested objects like n.number({ min: 1, max: 100 })
|
|
414
|
+
const schemaStart = /(?:const\s+(\w+)\s*=\s*)?defineCollection\s*\(\s*\{[\s\S]*?schema\s*:\s*(?:z|n)\.object\s*\(\s*\{/g
|
|
417
415
|
const blocks: Array<{ collectionName: string; schemaBody: string }> = []
|
|
418
|
-
|
|
419
|
-
|
|
420
|
-
|
|
416
|
+
|
|
417
|
+
let match
|
|
418
|
+
while ((match = schemaStart.exec(content)) !== null) {
|
|
419
|
+
const varName = match[1]
|
|
421
420
|
const collectionName = varName ? varToName.get(varName) : undefined
|
|
422
421
|
if (!collectionName) continue
|
|
423
|
-
|
|
422
|
+
|
|
423
|
+
// Brace-balanced extraction: the regex consumed the opening {,
|
|
424
|
+
// so start at depth 1 and scan forward for the matching }
|
|
425
|
+
const bodyStart = match.index + match[0].length
|
|
426
|
+
let depth = 1
|
|
427
|
+
let i = bodyStart
|
|
428
|
+
while (i < content.length && depth > 0) {
|
|
429
|
+
if (content[i] === '{') depth++
|
|
430
|
+
else if (content[i] === '}') depth--
|
|
431
|
+
i++
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
if (depth === 0) {
|
|
435
|
+
// i is one past the matching }, so body is [bodyStart, i-1)
|
|
436
|
+
blocks.push({ collectionName, schemaBody: content.slice(bodyStart, i - 1) })
|
|
437
|
+
}
|
|
424
438
|
}
|
|
425
439
|
|
|
426
440
|
if (blocks.length > 0) return blocks
|
|
@@ -455,7 +469,7 @@ function parseContentConfigReferences(
|
|
|
455
469
|
}
|
|
456
470
|
|
|
457
471
|
/** Valid field type names exported by `n` helper from @nuasite/cms */
|
|
458
|
-
const FIELD_HELPER_TYPES = new Set(['image', 'url', 'email', 'color', 'date', 'datetime', 'time', 'textarea'])
|
|
472
|
+
const FIELD_HELPER_TYPES = new Set(['text', 'number', 'image', 'url', 'email', 'color', 'date', 'datetime', 'time', 'textarea'])
|
|
459
473
|
|
|
460
474
|
/**
|
|
461
475
|
* Parse the content config file to extract explicit field type hints:
|
|
@@ -500,6 +514,54 @@ function parseContentConfigFieldTypes(
|
|
|
500
514
|
return result
|
|
501
515
|
}
|
|
502
516
|
|
|
517
|
+
/**
|
|
518
|
+
* Parse the content config to find `.orderBy('asc'|'desc')` markers on fields.
|
|
519
|
+
* Matches patterns like `fieldName: n.number().orderBy('asc')`.
|
|
520
|
+
* Returns a map: collectionName → { field, direction }.
|
|
521
|
+
*/
|
|
522
|
+
function parseContentConfigOrderBy(
|
|
523
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
524
|
+
): Map<string, { field: string; direction: 'asc' | 'desc' }> {
|
|
525
|
+
const result = new Map<string, { field: string; direction: 'asc' | 'desc' }>()
|
|
526
|
+
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
527
|
+
const match = schemaBody.match(/(\w+)\s*:.*\.orderBy\s*\(\s*(?:['"](\w+)['"])?\s*\)/)
|
|
528
|
+
if (match) {
|
|
529
|
+
const direction = match[2] === 'desc' ? 'desc' as const : 'asc' as const
|
|
530
|
+
result.set(collectionName, { field: match[1]!, direction })
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return result
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Apply orderBy configuration: set the field name and direction on the definition, then re-sort entries.
|
|
538
|
+
*/
|
|
539
|
+
function applyCollectionOrderBy(
|
|
540
|
+
collections: Record<string, CollectionDefinition>,
|
|
541
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
542
|
+
): void {
|
|
543
|
+
const orderByFields = parseContentConfigOrderBy(schemaBlocks)
|
|
544
|
+
for (const [collectionName, { field: fieldName, direction }] of orderByFields) {
|
|
545
|
+
const def = collections[collectionName]
|
|
546
|
+
if (!def) continue
|
|
547
|
+
def.orderBy = fieldName
|
|
548
|
+
def.orderDirection = direction
|
|
549
|
+
if (def.entries && def.entries.length > 1) {
|
|
550
|
+
const dir = direction === 'desc' ? -1 : 1
|
|
551
|
+
def.entries.sort((a, b) => {
|
|
552
|
+
const aVal = a.data?.[fieldName]
|
|
553
|
+
const bVal = b.data?.[fieldName]
|
|
554
|
+
if (aVal == null && bVal == null) return 0
|
|
555
|
+
if (aVal == null) return 1
|
|
556
|
+
if (bVal == null) return -1
|
|
557
|
+
if (typeof aVal === 'number' && typeof bVal === 'number') return (aVal - bVal) * dir
|
|
558
|
+
if (aVal instanceof Date && bVal instanceof Date) return (aVal.getTime() - bVal.getTime()) * dir
|
|
559
|
+
return String(aVal).localeCompare(String(bVal)) * dir
|
|
560
|
+
})
|
|
561
|
+
}
|
|
562
|
+
}
|
|
563
|
+
}
|
|
564
|
+
|
|
503
565
|
/**
|
|
504
566
|
* Extract all top-level field names from a schema body string.
|
|
505
567
|
* Matches `fieldName:` patterns at the start of lines within z.object({...}).
|
|
@@ -552,6 +614,82 @@ function applyConfigFieldTypes(
|
|
|
552
614
|
}
|
|
553
615
|
}
|
|
554
616
|
|
|
617
|
+
/** All recognized hint keys */
|
|
618
|
+
const VALID_HINT_KEYS = new Set(['min', 'max', 'step', 'placeholder', 'maxLength', 'minLength', 'rows', 'accept'])
|
|
619
|
+
/** Subset of hint keys that take numeric values */
|
|
620
|
+
const NUMERIC_HINT_KEYS = new Set(['min', 'max', 'step', 'maxLength', 'minLength', 'rows'])
|
|
621
|
+
|
|
622
|
+
/**
|
|
623
|
+
* Parse `n.type({ key: value, ... })` options objects from schema blocks.
|
|
624
|
+
* Returns a map: collectionName → fieldName → FieldHints.
|
|
625
|
+
*/
|
|
626
|
+
function parseContentConfigFieldHints(
|
|
627
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
628
|
+
): Map<string, Map<string, FieldHints>> {
|
|
629
|
+
const result = new Map<string, Map<string, FieldHints>>()
|
|
630
|
+
|
|
631
|
+
for (const { collectionName, schemaBody } of schemaBlocks) {
|
|
632
|
+
const fields = new Map<string, FieldHints>()
|
|
633
|
+
|
|
634
|
+
// Match: fieldName: n.helperName({ ...options })
|
|
635
|
+
const fieldMatches = schemaBody.matchAll(/(\w+)\s*:\s*n\.\w+\s*\(\s*\{([\s\S]*?)}\s*\)/g)
|
|
636
|
+
for (const m of fieldMatches) {
|
|
637
|
+
const fieldName = m[1]!
|
|
638
|
+
const optionsBody = m[2]!
|
|
639
|
+
const raw: Record<string, string | number> = {}
|
|
640
|
+
|
|
641
|
+
// Extract key-value pairs from the options body
|
|
642
|
+
const kvMatches = optionsBody.matchAll(/(\w+)\s*:\s*(?:"([^"]*)"|'([^']*)'|(-?[\d.]+))/g)
|
|
643
|
+
for (const kv of kvMatches) {
|
|
644
|
+
const key = kv[1]!
|
|
645
|
+
if (!VALID_HINT_KEYS.has(key)) continue
|
|
646
|
+
const strValue = kv[2] ?? kv[3]
|
|
647
|
+
const numValue = kv[4]
|
|
648
|
+
|
|
649
|
+
if (numValue != null && NUMERIC_HINT_KEYS.has(key)) {
|
|
650
|
+
raw[key] = Number(numValue)
|
|
651
|
+
} else if (strValue != null) {
|
|
652
|
+
if (NUMERIC_HINT_KEYS.has(key)) {
|
|
653
|
+
const parsed = Number(strValue)
|
|
654
|
+
raw[key] = Number.isNaN(parsed) ? strValue : parsed
|
|
655
|
+
} else {
|
|
656
|
+
raw[key] = strValue
|
|
657
|
+
}
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
const hints = raw as FieldHints
|
|
661
|
+
|
|
662
|
+
if (Object.keys(hints).length > 0) {
|
|
663
|
+
fields.set(fieldName, hints)
|
|
664
|
+
}
|
|
665
|
+
}
|
|
666
|
+
|
|
667
|
+
if (fields.size > 0) {
|
|
668
|
+
result.set(collectionName, fields)
|
|
669
|
+
}
|
|
670
|
+
}
|
|
671
|
+
return result
|
|
672
|
+
}
|
|
673
|
+
|
|
674
|
+
/**
|
|
675
|
+
* Apply field hints from content config parsing to scanned collections.
|
|
676
|
+
*/
|
|
677
|
+
function applyConfigFieldHints(
|
|
678
|
+
collections: Record<string, CollectionDefinition>,
|
|
679
|
+
schemaBlocks: Array<{ collectionName: string; schemaBody: string }>,
|
|
680
|
+
): void {
|
|
681
|
+
const configHints = parseContentConfigFieldHints(schemaBlocks)
|
|
682
|
+
for (const [collectionName, fieldHints] of configHints) {
|
|
683
|
+
const def = collections[collectionName]
|
|
684
|
+
if (!def) continue
|
|
685
|
+
for (const [fieldName, hints] of fieldHints) {
|
|
686
|
+
const field = def.fields.find(f => f.name === fieldName)
|
|
687
|
+
if (!field) continue
|
|
688
|
+
field.hints = hints
|
|
689
|
+
}
|
|
690
|
+
}
|
|
691
|
+
}
|
|
692
|
+
|
|
555
693
|
/**
|
|
556
694
|
* After all collections are scanned, detect reference fields.
|
|
557
695
|
* Prefers explicit reference() declarations from the content config file.
|
|
@@ -787,12 +925,14 @@ export async function scanCollections(contentDir: string = 'src/content'): Promi
|
|
|
787
925
|
// Content directory doesn't exist or isn't readable
|
|
788
926
|
}
|
|
789
927
|
|
|
790
|
-
// Post-scan: apply explicit type hints, detect references,
|
|
928
|
+
// Post-scan: apply explicit type hints, field hints, detect references, derived fields, and ordering
|
|
791
929
|
const schemaBlocks = await parseContentConfigSchemaBlocks()
|
|
792
930
|
filterFieldsBySchema(collections, schemaBlocks)
|
|
793
931
|
applyConfigFieldTypes(collections, schemaBlocks)
|
|
932
|
+
applyConfigFieldHints(collections, schemaBlocks)
|
|
794
933
|
await detectReferenceFields(collections, schemaBlocks)
|
|
795
934
|
detectDerivedHrefFields(collections)
|
|
935
|
+
applyCollectionOrderBy(collections, schemaBlocks)
|
|
796
936
|
|
|
797
937
|
return collections
|
|
798
938
|
}
|
|
@@ -39,13 +39,15 @@ export interface TextFieldProps {
|
|
|
39
39
|
label: string
|
|
40
40
|
value: string | undefined
|
|
41
41
|
placeholder?: string
|
|
42
|
+
maxLength?: number
|
|
43
|
+
minLength?: number
|
|
42
44
|
onChange: (value: string) => void
|
|
43
45
|
isDirty?: boolean
|
|
44
46
|
onReset?: () => void
|
|
45
47
|
inputType?: string
|
|
46
48
|
}
|
|
47
49
|
|
|
48
|
-
export function TextField({ label, value, placeholder, onChange, isDirty, onReset, inputType = 'text' }: TextFieldProps) {
|
|
50
|
+
export function TextField({ label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text' }: TextFieldProps) {
|
|
49
51
|
return (
|
|
50
52
|
<div class="space-y-1.5">
|
|
51
53
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
@@ -53,6 +55,8 @@ export function TextField({ label, value, placeholder, onChange, isDirty, onRese
|
|
|
53
55
|
type={inputType}
|
|
54
56
|
value={value ?? ''}
|
|
55
57
|
placeholder={placeholder}
|
|
58
|
+
maxLength={maxLength}
|
|
59
|
+
minLength={minLength}
|
|
56
60
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
57
61
|
class={cn(
|
|
58
62
|
'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',
|
|
@@ -270,12 +274,13 @@ export interface NumberFieldProps {
|
|
|
270
274
|
placeholder?: string
|
|
271
275
|
min?: number
|
|
272
276
|
max?: number
|
|
277
|
+
step?: number
|
|
273
278
|
onChange: (value: number | undefined) => void
|
|
274
279
|
isDirty?: boolean
|
|
275
280
|
onReset?: () => void
|
|
276
281
|
}
|
|
277
282
|
|
|
278
|
-
export function NumberField({ label, value, placeholder, min, max, onChange, isDirty, onReset }: NumberFieldProps) {
|
|
283
|
+
export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset }: NumberFieldProps) {
|
|
279
284
|
return (
|
|
280
285
|
<div class="space-y-1.5">
|
|
281
286
|
<FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
|
|
@@ -285,6 +290,7 @@ export function NumberField({ label, value, placeholder, min, max, onChange, isD
|
|
|
285
290
|
placeholder={placeholder}
|
|
286
291
|
min={min}
|
|
287
292
|
max={max}
|
|
293
|
+
step={step}
|
|
288
294
|
onInput={(e) => {
|
|
289
295
|
const val = (e.target as HTMLInputElement).value
|
|
290
296
|
onChange(val === '' ? undefined : Number(val))
|
|
@@ -395,6 +395,7 @@ export function SchemaFrontmatterField({
|
|
|
395
395
|
onChange,
|
|
396
396
|
}: SchemaFrontmatterFieldProps) {
|
|
397
397
|
const label = formatFieldLabel(field.name)
|
|
398
|
+
const hints = field.hints
|
|
398
399
|
|
|
399
400
|
switch (field.type) {
|
|
400
401
|
case 'text':
|
|
@@ -404,7 +405,9 @@ export function SchemaFrontmatterField({
|
|
|
404
405
|
<TextField
|
|
405
406
|
label={label}
|
|
406
407
|
value={(value as string) ?? ''}
|
|
407
|
-
placeholder={getPlaceholder(field)}
|
|
408
|
+
placeholder={hints?.placeholder ?? getPlaceholder(field)}
|
|
409
|
+
maxLength={hints?.maxLength as number | undefined}
|
|
410
|
+
minLength={hints?.minLength as number | undefined}
|
|
408
411
|
onChange={(v) => onChange(v)}
|
|
409
412
|
inputType={field.type === 'text' ? undefined : field.type}
|
|
410
413
|
/>
|
|
@@ -442,8 +445,9 @@ export function SchemaFrontmatterField({
|
|
|
442
445
|
<textarea
|
|
443
446
|
value={(value as string) ?? ''}
|
|
444
447
|
onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
|
|
445
|
-
placeholder={getPlaceholder(field)}
|
|
446
|
-
rows={3}
|
|
448
|
+
placeholder={hints?.placeholder ?? getPlaceholder(field)}
|
|
449
|
+
rows={hints?.rows ?? 3}
|
|
450
|
+
maxLength={hints?.maxLength as number | undefined}
|
|
447
451
|
class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white placeholder-white/30 focus:outline-none focus:border-white/40 resize-none"
|
|
448
452
|
data-cms-ui
|
|
449
453
|
/>
|
|
@@ -459,6 +463,8 @@ export function SchemaFrontmatterField({
|
|
|
459
463
|
<input
|
|
460
464
|
type={field.type === 'datetime' ? 'datetime-local' : field.type}
|
|
461
465
|
value={(value as string) ?? ''}
|
|
466
|
+
min={hints?.min != null ? String(hints.min) : undefined}
|
|
467
|
+
max={hints?.max != null ? String(hints.max) : undefined}
|
|
462
468
|
onInput={(e) => onChange((e.target as HTMLInputElement).value)}
|
|
463
469
|
class="px-3 py-2 text-sm bg-white/10 border border-white/20 rounded-cms-sm text-white focus:outline-none focus:border-white/40"
|
|
464
470
|
data-cms-ui
|
|
@@ -471,6 +477,10 @@ export function SchemaFrontmatterField({
|
|
|
471
477
|
<NumberField
|
|
472
478
|
label={label}
|
|
473
479
|
value={(value as number) ?? undefined}
|
|
480
|
+
placeholder={hints?.placeholder}
|
|
481
|
+
min={typeof hints?.min === 'number' ? hints.min : undefined}
|
|
482
|
+
max={typeof hints?.max === 'number' ? hints.max : undefined}
|
|
483
|
+
step={hints?.step}
|
|
474
484
|
onChange={(v) => onChange(v ?? 0)}
|
|
475
485
|
/>
|
|
476
486
|
)
|
|
@@ -0,0 +1,232 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useState } from 'preact/hooks'
|
|
2
|
+
import { HighlightMatch } from './fields'
|
|
3
|
+
|
|
4
|
+
export interface LinkSuggestion {
|
|
5
|
+
value: string
|
|
6
|
+
label: string
|
|
7
|
+
description?: string
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface LinkEditPopoverProps {
|
|
11
|
+
initialUrl: string
|
|
12
|
+
suggestions?: LinkSuggestion[]
|
|
13
|
+
onApply: (url: string) => void
|
|
14
|
+
onRemove?: () => void
|
|
15
|
+
onClose: () => void
|
|
16
|
+
/** Use static positioning instead of absolute (for inline contexts) */
|
|
17
|
+
inline?: boolean
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
export function LinkEditPopover({ initialUrl, suggestions, onApply, onRemove, onClose, inline }: LinkEditPopoverProps) {
|
|
21
|
+
const inputRef = useRef<HTMLInputElement>(null)
|
|
22
|
+
const rootRef = useRef<HTMLDivElement>(null)
|
|
23
|
+
const listRef = useRef<HTMLDivElement>(null)
|
|
24
|
+
const [query, setQuery] = useState(initialUrl)
|
|
25
|
+
const [showSuggestions, setShowSuggestions] = useState(false)
|
|
26
|
+
const [highlightedIndex, setHighlightedIndex] = useState(-1)
|
|
27
|
+
|
|
28
|
+
const filtered = useMemo(() => {
|
|
29
|
+
if (!suggestions?.length) return []
|
|
30
|
+
if (!query || query === 'https://') return suggestions
|
|
31
|
+
const q = query.toLowerCase()
|
|
32
|
+
return suggestions.filter(
|
|
33
|
+
o => o.value.toLowerCase().includes(q) || o.label.toLowerCase().includes(q),
|
|
34
|
+
)
|
|
35
|
+
}, [query, suggestions])
|
|
36
|
+
|
|
37
|
+
useEffect(() => {
|
|
38
|
+
const input = inputRef.current
|
|
39
|
+
if (input) {
|
|
40
|
+
input.focus()
|
|
41
|
+
input.select()
|
|
42
|
+
}
|
|
43
|
+
}, [])
|
|
44
|
+
|
|
45
|
+
// Close on click outside — uses `click` in bubble phase so form submit
|
|
46
|
+
// (which fires synchronously during the button's click) completes first
|
|
47
|
+
useEffect(() => {
|
|
48
|
+
const handler = (e: MouseEvent) => {
|
|
49
|
+
if (rootRef.current && !e.composedPath().includes(rootRef.current)) {
|
|
50
|
+
onClose()
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
document.addEventListener('click', handler)
|
|
54
|
+
return () => document.removeEventListener('click', handler)
|
|
55
|
+
}, [onClose])
|
|
56
|
+
|
|
57
|
+
// Scroll highlighted item into view
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
if (highlightedIndex >= 0 && listRef.current) {
|
|
60
|
+
const item = listRef.current.children[highlightedIndex] as HTMLElement | undefined
|
|
61
|
+
item?.scrollIntoView({ block: 'nearest' })
|
|
62
|
+
}
|
|
63
|
+
}, [highlightedIndex])
|
|
64
|
+
|
|
65
|
+
const handleSubmit = useCallback((e: Event) => {
|
|
66
|
+
e.preventDefault()
|
|
67
|
+
const url = inputRef.current?.value.trim()
|
|
68
|
+
if (url) {
|
|
69
|
+
onApply(url)
|
|
70
|
+
}
|
|
71
|
+
}, [onApply])
|
|
72
|
+
|
|
73
|
+
const selectOption = useCallback((value: string) => {
|
|
74
|
+
if (inputRef.current) inputRef.current.value = value
|
|
75
|
+
setQuery(value)
|
|
76
|
+
setShowSuggestions(false)
|
|
77
|
+
onApply(value)
|
|
78
|
+
}, [onApply])
|
|
79
|
+
|
|
80
|
+
const handleInput = useCallback((e: Event) => {
|
|
81
|
+
const v = (e.target as HTMLInputElement).value
|
|
82
|
+
setQuery(v)
|
|
83
|
+
setShowSuggestions(true)
|
|
84
|
+
setHighlightedIndex(-1)
|
|
85
|
+
}, [])
|
|
86
|
+
|
|
87
|
+
const handleKeyDown = useCallback((e: KeyboardEvent) => {
|
|
88
|
+
if (e.key === 'Escape') {
|
|
89
|
+
if (showSuggestions && filtered.length > 0) {
|
|
90
|
+
e.preventDefault()
|
|
91
|
+
e.stopPropagation()
|
|
92
|
+
setShowSuggestions(false)
|
|
93
|
+
return
|
|
94
|
+
}
|
|
95
|
+
e.preventDefault()
|
|
96
|
+
e.stopPropagation()
|
|
97
|
+
onClose()
|
|
98
|
+
return
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
if (!showSuggestions || filtered.length === 0) return
|
|
102
|
+
|
|
103
|
+
if (e.key === 'ArrowDown') {
|
|
104
|
+
e.preventDefault()
|
|
105
|
+
setHighlightedIndex(i => Math.min(i + 1, filtered.length - 1))
|
|
106
|
+
} else if (e.key === 'ArrowUp') {
|
|
107
|
+
e.preventDefault()
|
|
108
|
+
setHighlightedIndex(i => Math.max(i - 1, 0))
|
|
109
|
+
} else if (e.key === 'Enter' && highlightedIndex >= 0) {
|
|
110
|
+
e.preventDefault()
|
|
111
|
+
selectOption(filtered[highlightedIndex]!.value)
|
|
112
|
+
}
|
|
113
|
+
}, [showSuggestions, filtered, highlightedIndex, selectOption, onClose])
|
|
114
|
+
|
|
115
|
+
const handleFocus = useCallback(() => {
|
|
116
|
+
setShowSuggestions(true)
|
|
117
|
+
}, [])
|
|
118
|
+
|
|
119
|
+
const handleBlur = useCallback(() => {
|
|
120
|
+
setTimeout(() => setShowSuggestions(false), 150)
|
|
121
|
+
}, [])
|
|
122
|
+
|
|
123
|
+
const showDropdown = showSuggestions && filtered.length > 0
|
|
124
|
+
|
|
125
|
+
return (
|
|
126
|
+
<div
|
|
127
|
+
ref={rootRef}
|
|
128
|
+
class={inline ? 'slide-in' : 'relative z-[9999] slide-in shrink-0'}
|
|
129
|
+
data-cms-ui
|
|
130
|
+
>
|
|
131
|
+
<form
|
|
132
|
+
onSubmit={handleSubmit}
|
|
133
|
+
class={`flex items-center gap-2 ${inline ? 'py-1.5' : 'px-4 py-2.5 bg-cms-dark border-b border-white/10'}`}
|
|
134
|
+
>
|
|
135
|
+
<svg class="w-4 h-4 text-white/40 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
136
|
+
<path
|
|
137
|
+
stroke-linecap="round"
|
|
138
|
+
stroke-linejoin="round"
|
|
139
|
+
d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1"
|
|
140
|
+
/>
|
|
141
|
+
</svg>
|
|
142
|
+
|
|
143
|
+
<div class="flex-1 min-w-0 relative">
|
|
144
|
+
<input
|
|
145
|
+
ref={inputRef}
|
|
146
|
+
type="text"
|
|
147
|
+
defaultValue={initialUrl}
|
|
148
|
+
placeholder="https://example.com or /page"
|
|
149
|
+
onInput={handleInput}
|
|
150
|
+
onFocus={handleFocus}
|
|
151
|
+
onBlur={handleBlur}
|
|
152
|
+
onKeyDown={handleKeyDown}
|
|
153
|
+
autocomplete="off"
|
|
154
|
+
class="w-full bg-white/5 border border-white/10 rounded-cms-sm px-2.5 py-1.5 text-[13px] text-white placeholder:text-white/30 outline-none focus:border-cms-primary/50 transition-colors"
|
|
155
|
+
data-cms-ui
|
|
156
|
+
/>
|
|
157
|
+
{showDropdown && (
|
|
158
|
+
<div
|
|
159
|
+
ref={listRef}
|
|
160
|
+
class="absolute z-50 left-0 right-0 mt-1 max-h-48 overflow-y-auto bg-cms-dark border border-white/15 rounded-cms-sm shadow-lg"
|
|
161
|
+
data-cms-ui
|
|
162
|
+
>
|
|
163
|
+
{filtered.map((opt, i) => (
|
|
164
|
+
<button
|
|
165
|
+
key={opt.value}
|
|
166
|
+
type="button"
|
|
167
|
+
onMouseDown={(e) => {
|
|
168
|
+
e.preventDefault()
|
|
169
|
+
selectOption(opt.value)
|
|
170
|
+
}}
|
|
171
|
+
class={`w-full text-left px-3 py-2 text-xs transition-colors cursor-pointer ${
|
|
172
|
+
i === highlightedIndex
|
|
173
|
+
? 'bg-white/15 text-white'
|
|
174
|
+
: 'text-white/70 hover:bg-white/10 hover:text-white'
|
|
175
|
+
}`}
|
|
176
|
+
data-cms-ui
|
|
177
|
+
>
|
|
178
|
+
<span class="block truncate font-medium">
|
|
179
|
+
<HighlightMatch text={opt.label} query={query === 'https://' ? '' : query} />
|
|
180
|
+
</span>
|
|
181
|
+
{opt.description && (
|
|
182
|
+
<span class="block truncate text-white/40">
|
|
183
|
+
<HighlightMatch text={opt.description} query={query === 'https://' ? '' : query} />
|
|
184
|
+
</span>
|
|
185
|
+
)}
|
|
186
|
+
</button>
|
|
187
|
+
))}
|
|
188
|
+
</div>
|
|
189
|
+
)}
|
|
190
|
+
</div>
|
|
191
|
+
|
|
192
|
+
<button
|
|
193
|
+
type="submit"
|
|
194
|
+
class="px-3 py-1.5 bg-cms-primary text-cms-primary-text text-[12px] font-medium rounded-cms-sm hover:bg-cms-primary-hover transition-colors shrink-0"
|
|
195
|
+
data-cms-ui
|
|
196
|
+
>
|
|
197
|
+
Apply
|
|
198
|
+
</button>
|
|
199
|
+
|
|
200
|
+
{onRemove && (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
onClick={onRemove}
|
|
204
|
+
class="p-1.5 text-white/40 hover:text-red-400 hover:bg-red-500/10 rounded-cms-sm transition-colors shrink-0"
|
|
205
|
+
title="Remove link"
|
|
206
|
+
data-cms-ui
|
|
207
|
+
>
|
|
208
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
209
|
+
<path
|
|
210
|
+
stroke-linecap="round"
|
|
211
|
+
stroke-linejoin="round"
|
|
212
|
+
d="M18.364 18.364A9 9 0 005.636 5.636m12.728 12.728A9 9 0 015.636 5.636m12.728 12.728L5.636 5.636"
|
|
213
|
+
/>
|
|
214
|
+
</svg>
|
|
215
|
+
</button>
|
|
216
|
+
)}
|
|
217
|
+
|
|
218
|
+
<button
|
|
219
|
+
type="button"
|
|
220
|
+
onClick={onClose}
|
|
221
|
+
class="p-1.5 text-white/40 hover:text-white hover:bg-white/10 rounded-cms-sm transition-colors shrink-0"
|
|
222
|
+
title="Cancel"
|
|
223
|
+
data-cms-ui
|
|
224
|
+
>
|
|
225
|
+
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" stroke-width="2">
|
|
226
|
+
<path stroke-linecap="round" stroke-linejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
227
|
+
</svg>
|
|
228
|
+
</button>
|
|
229
|
+
</form>
|
|
230
|
+
</div>
|
|
231
|
+
)
|
|
232
|
+
}
|
|
@@ -4,7 +4,6 @@ import {
|
|
|
4
4
|
commonmark,
|
|
5
5
|
liftListItemCommand,
|
|
6
6
|
toggleEmphasisCommand,
|
|
7
|
-
toggleLinkCommand,
|
|
8
7
|
toggleStrongCommand,
|
|
9
8
|
wrapInBlockquoteCommand,
|
|
10
9
|
wrapInBulletListCommand,
|
|
@@ -13,10 +12,12 @@ import {
|
|
|
13
12
|
import { gfm, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
|
|
14
13
|
import { callCommand, insert, replaceAll } from '@milkdown/utils'
|
|
15
14
|
import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
|
|
15
|
+
import { useLinkPopover } from '../hooks/useLinkPopover'
|
|
16
16
|
import { uploadMedia } from '../markdown-api'
|
|
17
17
|
import { insertMdxComponentCommand, mdxComponentPlugin } from '../milkdown-mdx-plugin'
|
|
18
18
|
import { type ActiveFormats, defaultActiveFormats, isInListType, setupFormatTracking, toggleHeading } from '../milkdown-utils'
|
|
19
19
|
import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMarkdownEditorState, showToast, updateMarkdownContent } from '../signals'
|
|
20
|
+
import { LinkEditPopover } from './link-edit-popover'
|
|
20
21
|
import { MdxComponentIcon } from './mdx-block-view'
|
|
21
22
|
import { MdxComponentPicker } from './mdx-component-picker'
|
|
22
23
|
|
|
@@ -46,6 +47,15 @@ export function MarkdownInlineEditor({
|
|
|
46
47
|
|
|
47
48
|
// Track active formatting for toolbar highlighting
|
|
48
49
|
const [activeFormats, setActiveFormats] = useState<ActiveFormats>(defaultActiveFormats)
|
|
50
|
+
const {
|
|
51
|
+
linkPopoverState,
|
|
52
|
+
linkPopoverOpen,
|
|
53
|
+
closeLinkPopover,
|
|
54
|
+
toggleLinkPopover,
|
|
55
|
+
applyLink,
|
|
56
|
+
removeLink,
|
|
57
|
+
pageSuggestions,
|
|
58
|
+
} = useLinkPopover(editorInstanceRef, activeFormats)
|
|
49
59
|
|
|
50
60
|
// Store initial content in ref to avoid stale closure issues
|
|
51
61
|
const initialContentRef = useRef(initialContent)
|
|
@@ -202,54 +212,6 @@ export function MarkdownInlineEditor({
|
|
|
202
212
|
}
|
|
203
213
|
}, [runCommand, checkInList])
|
|
204
214
|
|
|
205
|
-
const handleInsertLink = useCallback(() => {
|
|
206
|
-
if (!editorInstanceRef.current) return
|
|
207
|
-
|
|
208
|
-
// If already in a link, remove it
|
|
209
|
-
if (activeFormats.link) {
|
|
210
|
-
try {
|
|
211
|
-
// Use toggleLinkCommand with empty href to remove link
|
|
212
|
-
editorInstanceRef.current.action(
|
|
213
|
-
callCommand(toggleLinkCommand.key, { href: '' }),
|
|
214
|
-
)
|
|
215
|
-
return
|
|
216
|
-
} catch (error) {
|
|
217
|
-
console.error('Failed to remove link:', error)
|
|
218
|
-
}
|
|
219
|
-
}
|
|
220
|
-
|
|
221
|
-
// Get selected text from editor
|
|
222
|
-
let selectedText = ''
|
|
223
|
-
try {
|
|
224
|
-
const view = editorInstanceRef.current.ctx.get(editorViewCtx)
|
|
225
|
-
const { state } = view
|
|
226
|
-
const { from, to } = state.selection
|
|
227
|
-
if (from !== to) {
|
|
228
|
-
selectedText = state.doc.textBetween(from, to, ' ')
|
|
229
|
-
}
|
|
230
|
-
} catch {
|
|
231
|
-
// Ignore errors
|
|
232
|
-
}
|
|
233
|
-
|
|
234
|
-
// Prompt for URL (pre-fill with existing URL if editing)
|
|
235
|
-
const defaultUrl = activeFormats.linkHref || ''
|
|
236
|
-
const url = prompt('Enter URL:', defaultUrl)
|
|
237
|
-
if (url) {
|
|
238
|
-
try {
|
|
239
|
-
// Use toggleLinkCommand to add/update link
|
|
240
|
-
editorInstanceRef.current.action(
|
|
241
|
-
callCommand(toggleLinkCommand.key, { href: url }),
|
|
242
|
-
)
|
|
243
|
-
} catch (error) {
|
|
244
|
-
console.error('Failed to add link:', error)
|
|
245
|
-
// Fallback: use markdown insertion
|
|
246
|
-
const linkText = selectedText || prompt('Enter link text:', 'Link') || 'Link'
|
|
247
|
-
const linkMarkdown = `[${linkText}](${url})`
|
|
248
|
-
editorInstanceRef.current.action(insert(linkMarkdown))
|
|
249
|
-
}
|
|
250
|
-
}
|
|
251
|
-
}, [activeFormats.link, activeFormats.linkHref])
|
|
252
|
-
|
|
253
215
|
const handleInsertHeading = useCallback((level: number) => {
|
|
254
216
|
if (!editorInstanceRef.current) return
|
|
255
217
|
try {
|
|
@@ -575,9 +537,9 @@ export function MarkdownInlineEditor({
|
|
|
575
537
|
{/* Links & Images */}
|
|
576
538
|
<div class="flex items-center gap-0.5">
|
|
577
539
|
<ToolbarButton
|
|
578
|
-
onClick={
|
|
579
|
-
title={activeFormats.link ? '
|
|
580
|
-
active={activeFormats.link}
|
|
540
|
+
onClick={toggleLinkPopover}
|
|
541
|
+
title={activeFormats.link ? 'Edit Link' : 'Insert Link'}
|
|
542
|
+
active={activeFormats.link || linkPopoverOpen}
|
|
581
543
|
>
|
|
582
544
|
<svg
|
|
583
545
|
class="w-4 h-4"
|
|
@@ -620,6 +582,17 @@ export function MarkdownInlineEditor({
|
|
|
620
582
|
</div>
|
|
621
583
|
</div>
|
|
622
584
|
|
|
585
|
+
{/* Link edit popover — rendered outside the toolbar stacking context so it layers above the sidebar */}
|
|
586
|
+
{linkPopoverState && (
|
|
587
|
+
<LinkEditPopover
|
|
588
|
+
initialUrl={linkPopoverState.href}
|
|
589
|
+
suggestions={pageSuggestions}
|
|
590
|
+
onApply={applyLink}
|
|
591
|
+
onRemove={linkPopoverState.isEdit ? removeLink : undefined}
|
|
592
|
+
onClose={closeLinkPopover}
|
|
593
|
+
/>
|
|
594
|
+
)}
|
|
595
|
+
|
|
623
596
|
{/* Editor */}
|
|
624
597
|
<div
|
|
625
598
|
class={`flex-1 min-h-0 overflow-auto relative transition-colors ${isDragging ? 'bg-cms-primary/10' : ''}`}
|