@nuasite/cms 0.27.0 → 0.29.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.
@@ -39,13 +39,18 @@ 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
48
+ required?: boolean
46
49
  }
47
50
 
48
- export function TextField({ label, value, placeholder, onChange, isDirty, onReset, inputType = 'text' }: TextFieldProps) {
51
+ export function TextField(
52
+ { label, value, placeholder, maxLength, minLength, onChange, isDirty, onReset, inputType = 'text', required }: TextFieldProps,
53
+ ) {
49
54
  return (
50
55
  <div class="space-y-1.5">
51
56
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
@@ -53,6 +58,9 @@ export function TextField({ label, value, placeholder, onChange, isDirty, onRese
53
58
  type={inputType}
54
59
  value={value ?? ''}
55
60
  placeholder={placeholder}
61
+ maxLength={maxLength}
62
+ minLength={minLength}
63
+ required={required}
56
64
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
57
65
  class={cn(
58
66
  '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',
@@ -78,9 +86,10 @@ export interface ImageFieldProps {
78
86
  onBrowse: () => void
79
87
  isDirty?: boolean
80
88
  onReset?: () => void
89
+ required?: boolean
81
90
  }
82
91
 
83
- export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset }: ImageFieldProps) {
92
+ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDirty, onReset, required }: ImageFieldProps) {
84
93
  const hasImage = !!value && value.length > 0
85
94
 
86
95
  return (
@@ -110,6 +119,7 @@ export function ImageField({ label, value, placeholder, onChange, onBrowse, isDi
110
119
  type="text"
111
120
  value={value ?? ''}
112
121
  placeholder={placeholder}
122
+ required={required}
113
123
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
114
124
  class={cn(
115
125
  'flex-1 min-w-0 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',
@@ -143,9 +153,10 @@ export interface ColorFieldProps {
143
153
  onChange: (value: string) => void
144
154
  isDirty?: boolean
145
155
  onReset?: () => void
156
+ required?: boolean
146
157
  }
147
158
 
148
- export function ColorField({ label, value, placeholder, onChange, isDirty, onReset }: ColorFieldProps) {
159
+ export function ColorField({ label, value, placeholder, onChange, isDirty, onReset, required }: ColorFieldProps) {
149
160
  const colorValue = value || '#000000'
150
161
  // Validate hex for the native picker (must be #rrggbb)
151
162
  const isValidHex = /^#[0-9a-fA-F]{6}$/.test(colorValue)
@@ -166,6 +177,7 @@ export function ColorField({ label, value, placeholder, onChange, isDirty, onRes
166
177
  type="text"
167
178
  value={value ?? ''}
168
179
  placeholder={placeholder ?? '#000000'}
180
+ required={required}
169
181
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
170
182
  class={cn(
171
183
  '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',
@@ -270,12 +282,14 @@ export interface NumberFieldProps {
270
282
  placeholder?: string
271
283
  min?: number
272
284
  max?: number
285
+ step?: number
273
286
  onChange: (value: number | undefined) => void
274
287
  isDirty?: boolean
275
288
  onReset?: () => void
289
+ required?: boolean
276
290
  }
277
291
 
278
- export function NumberField({ label, value, placeholder, min, max, onChange, isDirty, onReset }: NumberFieldProps) {
292
+ export function NumberField({ label, value, placeholder, min, max, step, onChange, isDirty, onReset, required }: NumberFieldProps) {
279
293
  return (
280
294
  <div class="space-y-1.5">
281
295
  <FieldLabel label={label} isDirty={isDirty} onReset={onReset} />
@@ -285,6 +299,8 @@ export function NumberField({ label, value, placeholder, min, max, onChange, isD
285
299
  placeholder={placeholder}
286
300
  min={min}
287
301
  max={max}
302
+ step={step}
303
+ required={required}
288
304
  onInput={(e) => {
289
305
  const val = (e.target as HTMLInputElement).value
290
306
  onChange(val === '' ? undefined : Number(val))
@@ -330,9 +346,10 @@ export interface ComboBoxFieldProps {
330
346
  onChange: (value: string) => void
331
347
  isDirty?: boolean
332
348
  onReset?: () => void
349
+ required?: boolean
333
350
  }
334
351
 
335
- export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset }: ComboBoxFieldProps) {
352
+ export function ComboBoxField({ label, value, placeholder, options, onChange, isDirty, onReset, required }: ComboBoxFieldProps) {
336
353
  const [query, setQuery] = useState('')
337
354
  const [isOpen, setIsOpen] = useState(false)
338
355
  const [highlightedIndex, setHighlightedIndex] = useState(-1)
@@ -372,6 +389,13 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
372
389
  }, [onChange])
373
390
 
374
391
  const handleKeyDown = useCallback((e: KeyboardEvent) => {
392
+ if (e.key === 'Enter') {
393
+ e.preventDefault()
394
+ if (isOpen && highlightedIndex >= 0 && filtered[highlightedIndex]) {
395
+ selectOption(filtered[highlightedIndex]!.value)
396
+ }
397
+ return
398
+ }
375
399
  if (!isOpen || filtered.length === 0) return
376
400
  if (e.key === 'ArrowDown') {
377
401
  e.preventDefault()
@@ -379,9 +403,6 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
379
403
  } else if (e.key === 'ArrowUp') {
380
404
  e.preventDefault()
381
405
  setHighlightedIndex(i => Math.max(i - 1, 0))
382
- } else if (e.key === 'Enter' && highlightedIndex >= 0) {
383
- e.preventDefault()
384
- selectOption(filtered[highlightedIndex]!.value)
385
406
  } else if (e.key === 'Escape') {
386
407
  setIsOpen(false)
387
408
  }
@@ -405,6 +426,7 @@ export function ComboBoxField({ label, value, placeholder, options, onChange, is
405
426
  type="text"
406
427
  value={value ?? ''}
407
428
  placeholder={placeholder}
429
+ required={required}
408
430
  onInput={handleInput}
409
431
  onFocus={handleFocus}
410
432
  onBlur={handleBlur}
@@ -394,7 +394,8 @@ export function SchemaFrontmatterField({
394
394
  value,
395
395
  onChange,
396
396
  }: SchemaFrontmatterFieldProps) {
397
- const label = formatFieldLabel(field.name)
397
+ const label = field.required ? `${formatFieldLabel(field.name)} *` : formatFieldLabel(field.name)
398
+ const hints = field.hints
398
399
 
399
400
  switch (field.type) {
400
401
  case 'text':
@@ -404,9 +405,12 @@ 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}
413
+ required={field.required}
410
414
  />
411
415
  )
412
416
 
@@ -422,6 +426,7 @@ export function SchemaFrontmatterField({
422
426
  onChange(url)
423
427
  })
424
428
  }}
429
+ required={field.required}
425
430
  />
426
431
  )
427
432
 
@@ -432,6 +437,7 @@ export function SchemaFrontmatterField({
432
437
  value={(value as string) ?? ''}
433
438
  placeholder={getPlaceholder(field)}
434
439
  onChange={(v) => onChange(v)}
440
+ required={field.required}
435
441
  />
436
442
  )
437
443
 
@@ -442,8 +448,10 @@ export function SchemaFrontmatterField({
442
448
  <textarea
443
449
  value={(value as string) ?? ''}
444
450
  onInput={(e) => onChange((e.target as HTMLTextAreaElement).value)}
445
- placeholder={getPlaceholder(field)}
446
- rows={3}
451
+ placeholder={hints?.placeholder ?? getPlaceholder(field)}
452
+ rows={hints?.rows ?? 3}
453
+ maxLength={hints?.maxLength as number | undefined}
454
+ required={field.required}
447
455
  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
456
  data-cms-ui
449
457
  />
@@ -459,6 +467,9 @@ export function SchemaFrontmatterField({
459
467
  <input
460
468
  type={field.type === 'datetime' ? 'datetime-local' : field.type}
461
469
  value={(value as string) ?? ''}
470
+ min={hints?.min != null ? String(hints.min) : undefined}
471
+ max={hints?.max != null ? String(hints.max) : undefined}
472
+ required={field.required}
462
473
  onInput={(e) => onChange((e.target as HTMLInputElement).value)}
463
474
  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
475
  data-cms-ui
@@ -471,7 +482,12 @@ export function SchemaFrontmatterField({
471
482
  <NumberField
472
483
  label={label}
473
484
  value={(value as number) ?? undefined}
485
+ placeholder={hints?.placeholder}
486
+ min={typeof hints?.min === 'number' ? hints.min : undefined}
487
+ max={typeof hints?.max === 'number' ? hints.max : undefined}
488
+ step={hints?.step}
474
489
  onChange={(v) => onChange(v ?? 0)}
490
+ required={field.required}
475
491
  />
476
492
  )
477
493
 
@@ -495,6 +511,7 @@ export function SchemaFrontmatterField({
495
511
  label: opt,
496
512
  }))}
497
513
  onChange={(v) => onChange(v)}
514
+ required={field.required}
498
515
  />
499
516
  )
500
517
 
@@ -507,6 +524,7 @@ export function SchemaFrontmatterField({
507
524
  placeholder={`Select ${label.toLowerCase()}...`}
508
525
  options={refOptions}
509
526
  onChange={(v) => onChange(v)}
527
+ required={field.required}
510
528
  />
511
529
  )
512
530
  }
@@ -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
+ }
@@ -375,11 +375,22 @@ export function MarkdownEditorOverlay() {
375
375
  onMouseDown={stopPropagation}
376
376
  onClick={stopPropagation}
377
377
  >
378
- <div
378
+ <form
379
379
  class={`bg-cms-dark rounded-cms-xl shadow-[0_8px_32px_rgba(0,0,0,0.4)] border border-white/10 w-full max-h-[90vh] flex flex-col ${
380
380
  hasSidebar ? 'max-w-6xl' : 'max-w-4xl'
381
381
  }`}
382
382
  data-cms-ui
383
+ onSubmit={(e) => {
384
+ e.preventDefault()
385
+ if (isCreateMode) {
386
+ handleCreate()
387
+ } else {
388
+ const currentContent = currentMarkdownPage.value?.content
389
+ if (currentContent !== undefined) {
390
+ handleSave(currentContent)
391
+ }
392
+ }
393
+ }}
383
394
  >
384
395
  {/* Header */}
385
396
  <div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
@@ -495,9 +506,8 @@ export function MarkdownEditorOverlay() {
495
506
  {isCreateMode
496
507
  ? (
497
508
  <button
498
- type="button"
499
- onClick={handleCreate}
500
- disabled={isSaving || !(page.frontmatter.title as string)?.trim()}
509
+ type="submit"
510
+ disabled={isSaving}
501
511
  class="px-4 py-2 text-sm bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-pill transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
502
512
  data-cms-ui
503
513
  >
@@ -507,13 +517,7 @@ export function MarkdownEditorOverlay() {
507
517
  )
508
518
  : (
509
519
  <button
510
- type="button"
511
- onClick={() => {
512
- const currentContent = currentMarkdownPage.value?.content
513
- if (currentContent !== undefined) {
514
- handleSave(currentContent)
515
- }
516
- }}
520
+ type="submit"
517
521
  disabled={isSaving}
518
522
  class="px-4 py-2 text-sm bg-cms-primary text-cms-primary-text hover:bg-cms-primary-hover rounded-cms-pill transition-colors font-medium disabled:opacity-50 disabled:cursor-not-allowed flex items-center gap-2"
519
523
  data-cms-ui
@@ -577,7 +581,7 @@ export function MarkdownEditorOverlay() {
577
581
  />
578
582
  )}
579
583
  </div>
580
- </div>
584
+ </form>
581
585
  </div>
582
586
  )
583
587
  }
@@ -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={handleInsertLink}
579
- title={activeFormats.link ? 'Remove Link' : 'Insert 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' : ''}`}