@nuasite/cms 0.19.1 → 0.20.1

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.
@@ -50,6 +50,7 @@ export function MarkdownEditorOverlay() {
50
50
  // Preview mode state
51
51
  const [isPreview, setIsPreview] = useState(false)
52
52
  const originalHTMLRef = useRef<string | null>(null)
53
+ const previewTargetRef = useRef<HTMLElement | null>(null)
53
54
  const editorInstanceRef = useRef<Editor | null>(null)
54
55
 
55
56
  useEffect(() => {
@@ -58,6 +59,19 @@ export function MarkdownEditorOverlay() {
58
59
  }
59
60
  }, [isCreateMode, isDataCollection])
60
61
 
62
+ // Auto-generate slug from title in create mode (unless user manually edited the slug)
63
+ useEffect(() => {
64
+ if (!isCreateMode || slugManuallyEdited) return
65
+ const title = (page?.frontmatter.title as string) || (page?.frontmatter.name as string) || ''
66
+ if (!title) return
67
+ markdownEditorState.value = {
68
+ ...markdownEditorState.value,
69
+ currentPage: markdownEditorState.value.currentPage
70
+ ? { ...markdownEditorState.value.currentPage, slug: slugify(title), isDirty: true }
71
+ : null,
72
+ }
73
+ }, [isCreateMode, slugManuallyEdited, page?.frontmatter.title, page?.frontmatter.name])
74
+
61
75
  const handleDeploymentComplete = useCallback(
62
76
  (status: 'completed' | 'failed' | 'timeout') => {
63
77
  if (status === 'failed') {
@@ -67,14 +81,21 @@ export function MarkdownEditorOverlay() {
67
81
  [],
68
82
  )
69
83
 
84
+ /** Find the [data-cms-markdown] wrapper element on the actual page (not CMS UI). */
85
+ const findMarkdownWrapper = useCallback((): HTMLElement | null => {
86
+ const SKIP_TAGS = new Set(['BODY', 'HTML', 'BUTTON', 'SPAN', 'A'])
87
+ const candidates = document.querySelectorAll('[data-cms-markdown]:not([data-cms-ui])')
88
+ for (const c of candidates) {
89
+ if (!SKIP_TAGS.has(c.tagName)) return c as HTMLElement
90
+ }
91
+ return null
92
+ }, [])
93
+
70
94
  const restoreOriginalHTML = useCallback(() => {
71
- const activeId = markdownEditorState.value.activeElementId
72
- if (originalHTMLRef.current !== null && activeId) {
73
- const el = document.querySelector(`[data-cms-id="${activeId}"]`)
74
- if (el) {
75
- el.innerHTML = originalHTMLRef.current
76
- }
95
+ if (originalHTMLRef.current !== null && previewTargetRef.current) {
96
+ previewTargetRef.current.innerHTML = originalHTMLRef.current
77
97
  originalHTMLRef.current = null
98
+ previewTargetRef.current = null
78
99
  }
79
100
  }, [])
80
101
 
@@ -95,11 +116,8 @@ export function MarkdownEditorOverlay() {
95
116
  if (result.success) {
96
117
  // Keep the preview HTML in place so user sees changes immediately
97
118
  // If not in preview mode, inject editor HTML into the page element
98
- const activeId = markdownEditorState.value.activeElementId
99
- if (activeId && editorInstanceRef.current && !isPreview) {
100
- const el = document.querySelector(
101
- `[data-cms-id="${activeId}"]`,
102
- )
119
+ if (editorInstanceRef.current && !isPreview) {
120
+ const el = findMarkdownWrapper()
103
121
  if (el) {
104
122
  try {
105
123
  const view = editorInstanceRef.current.ctx.get(editorViewCtx)
@@ -133,7 +151,7 @@ export function MarkdownEditorOverlay() {
133
151
  setIsSaving(false)
134
152
  }
135
153
  },
136
- [isSaving, isPreview, handleDeploymentComplete],
154
+ [isSaving, isPreview, handleDeploymentComplete, findMarkdownWrapper],
137
155
  )
138
156
 
139
157
  const handleCreate = useCallback(async () => {
@@ -215,19 +233,21 @@ export function MarkdownEditorOverlay() {
215
233
  if (!editorInstanceRef.current || !activeId) return
216
234
 
217
235
  if (!isPreview) {
218
- // Enter preview
219
- const el = document.querySelector(`[data-cms-id="${activeId}"]`)
236
+ // Enter preview — inject editor HTML into the markdown wrapper element.
237
+ const el = findMarkdownWrapper()
220
238
  if (!el) {
221
239
  showToast('Could not find page element to preview', 'error')
222
240
  return
223
241
  }
224
242
  originalHTMLRef.current = el.innerHTML
243
+ previewTargetRef.current = el
225
244
  try {
226
245
  const view = editorInstanceRef.current.ctx.get(editorViewCtx)
227
246
  el.innerHTML = view.dom.innerHTML
228
247
  } catch (error) {
229
248
  console.error('Failed to get editor HTML for preview:', error)
230
249
  originalHTMLRef.current = null
250
+ previewTargetRef.current = null
231
251
  showToast('Failed to generate preview', 'error')
232
252
  return
233
253
  }
@@ -239,7 +259,7 @@ export function MarkdownEditorOverlay() {
239
259
  setIsPreview(false)
240
260
  isMarkdownPreview.value = false
241
261
  }
242
- }, [isPreview, restoreOriginalHTML])
262
+ }, [isPreview, restoreOriginalHTML, findMarkdownWrapper])
243
263
 
244
264
  const handleCancel = useCallback(() => {
245
265
  restoreOriginalHTML()
@@ -326,8 +346,8 @@ export function MarkdownEditorOverlay() {
326
346
  >
327
347
  {/* Header */}
328
348
  <div class="flex items-center justify-between px-5 py-4 border-b border-white/10">
329
- <div class="flex items-center gap-3">
330
- <div class="flex items-center text-white">
349
+ <div class="flex items-center gap-3 flex-1 min-w-0">
350
+ <div class="flex items-center text-white shrink-0">
331
351
  <svg
332
352
  width="20"
333
353
  height="20"
@@ -345,36 +365,11 @@ export function MarkdownEditorOverlay() {
345
365
  <line x1="10" y1="9" x2="8" y2="9" />
346
366
  </svg>
347
367
  </div>
348
- <div>
349
- <input
350
- type="text"
351
- value={(page.frontmatter.title as string) || (page.frontmatter.name as string) || ''}
352
- placeholder={isDataCollection ? 'Entry name...' : 'Page title...'}
353
- onInput={(e) => {
354
- const title = (e.target as HTMLInputElement).value
355
- // Data collections may use 'name' instead of 'title'
356
- const titleField = isDataCollection && !('title' in page.frontmatter) && 'name' in page.frontmatter ? 'name' : 'title'
357
- updateMarkdownFrontmatter({ [titleField]: title })
358
- // Auto-generate slug in create mode if not manually edited
359
- if (isCreateMode && !slugManuallyEdited) {
360
- markdownEditorState.value = {
361
- ...markdownEditorState.value,
362
- currentPage: markdownEditorState.value.currentPage
363
- ? {
364
- ...markdownEditorState.value.currentPage,
365
- slug: slugify(title),
366
- isDirty: true,
367
- }
368
- : null,
369
- }
370
- }
371
- }}
372
- class="text-base font-semibold text-white m-0 bg-transparent border-none outline-none placeholder-white/40 w-64"
373
- data-cms-ui
374
- />
375
- </div>
368
+ <span class="text-base font-semibold text-white truncate">
369
+ {(page.frontmatter.title as string) || (page.frontmatter.name as string) || (isDataCollection ? 'Entry name' : 'Page title')}
370
+ </span>
376
371
  </div>
377
- <div class="flex items-center gap-2">
372
+ <div class="flex items-center gap-2 shrink-0">
378
373
  {!isDataCollection && (
379
374
  <button
380
375
  type="button"
@@ -1,4 +1,4 @@
1
- import { commandsCtx, defaultValueCtx, Editor, editorViewCtx, rootCtx } from '@milkdown/core'
1
+ import { defaultValueCtx, Editor, editorViewCtx, rootCtx } from '@milkdown/core'
2
2
  import { listener, listenerCtx } from '@milkdown/plugin-listener'
3
3
  import {
4
4
  commonmark,
@@ -15,10 +15,10 @@ import { callCommand, insert, replaceAll } from '@milkdown/utils'
15
15
  import { useCallback, useEffect, useRef, useState } from 'preact/hooks'
16
16
  import { uploadMedia } from '../markdown-api'
17
17
  import { insertMdxComponentCommand, mdxComponentPlugin } from '../milkdown-mdx-plugin'
18
+ import { type ActiveFormats, defaultActiveFormats, isInListType, setupFormatTracking, toggleHeading } from '../milkdown-utils'
18
19
  import { config, mdxComponentPickerOpen, openMediaLibraryWithCallback, resetMarkdownEditorState, showToast, updateMarkdownContent } from '../signals'
19
20
  import { MdxComponentIcon } from './mdx-block-view'
20
21
  import { MdxComponentPicker } from './mdx-component-picker'
21
- import { MdxPropsEditor } from './mdx-props-editor'
22
22
 
23
23
  export interface MarkdownInlineEditorProps {
24
24
  elementId: string
@@ -45,27 +45,7 @@ export function MarkdownInlineEditor({
45
45
  const [uploadProgress, setUploadProgress] = useState<number | null>(null)
46
46
 
47
47
  // Track active formatting for toolbar highlighting
48
- const [activeFormats, setActiveFormats] = useState<{
49
- bold: boolean
50
- italic: boolean
51
- strikethrough: boolean
52
- link: boolean
53
- linkHref: string | null
54
- bulletList: boolean
55
- orderedList: boolean
56
- blockquote: boolean
57
- heading: number | null
58
- }>({
59
- bold: false,
60
- italic: false,
61
- strikethrough: false,
62
- link: false,
63
- linkHref: null,
64
- bulletList: false,
65
- orderedList: false,
66
- blockquote: false,
67
- heading: null,
68
- })
48
+ const [activeFormats, setActiveFormats] = useState<ActiveFormats>(defaultActiveFormats)
69
49
 
70
50
  // Store initial content in ref to avoid stale closure issues
71
51
  const initialContentRef = useRef(initialContent)
@@ -79,91 +59,11 @@ export function MarkdownInlineEditor({
79
59
  const isMdxRef = useRef(isMdx ?? false)
80
60
  isMdxRef.current = isMdx ?? false
81
61
 
82
- // Check active formatting at current selection
83
- const updateActiveFormats = useCallback(() => {
84
- if (!editorInstanceRef.current) return
85
-
86
- try {
87
- const view = editorInstanceRef.current.ctx.get(editorViewCtx)
88
- const { state } = view
89
- const { $from, from, to } = state.selection
90
-
91
- // Check marks (inline formatting)
92
- let bold = false
93
- let italic = false
94
- let strikethrough = false
95
- let link = false
96
- let linkHref: string | null = null
97
-
98
- // Check if marks are active in the selection
99
- const marks = state.storedMarks || $from.marks()
100
- for (const mark of marks) {
101
- if (mark.type.name === 'strong') bold = true
102
- if (mark.type.name === 'emphasis') italic = true
103
- if (mark.type.name === 'strikethrough') strikethrough = true
104
- if (mark.type.name === 'link') {
105
- link = true
106
- linkHref = mark.attrs.href as string
107
- }
108
- }
109
-
110
- // Also check marks in the selection range
111
- if (from !== to) {
112
- state.doc.nodesBetween(from, to, (node) => {
113
- if (node.marks) {
114
- for (const mark of node.marks) {
115
- if (mark.type.name === 'strong') bold = true
116
- if (mark.type.name === 'emphasis') italic = true
117
- if (mark.type.name === 'strikethrough') strikethrough = true
118
- if (mark.type.name === 'link') {
119
- link = true
120
- linkHref = mark.attrs.href as string
121
- }
122
- }
123
- }
124
- })
125
- }
126
-
127
- // Check block types (lists, blockquote, heading)
128
- let bulletList = false
129
- let orderedList = false
130
- let blockquote = false
131
- let heading: number | null = null
132
-
133
- for (let depth = $from.depth; depth > 0; depth--) {
134
- const node = $from.node(depth)
135
- if (node.type.name === 'bullet_list') bulletList = true
136
- if (node.type.name === 'ordered_list') orderedList = true
137
- if (node.type.name === 'blockquote') blockquote = true
138
- }
139
-
140
- // Check heading at current position
141
- const parentNode = $from.parent
142
- if (parentNode.type.name === 'heading') {
143
- heading = parentNode.attrs.level as number
144
- }
145
-
146
- setActiveFormats({
147
- bold,
148
- italic,
149
- strikethrough,
150
- link,
151
- linkHref,
152
- bulletList,
153
- orderedList,
154
- blockquote,
155
- heading,
156
- })
157
- } catch {
158
- // Ignore errors during format checking
159
- }
160
- }, [])
161
-
162
62
  // Initialize Milkdown editor
163
63
  useEffect(() => {
164
64
  if (!editorRef.current) return
165
65
 
166
- let formatRaf = 0
66
+ let cleanupTracking: (() => void) | undefined
167
67
 
168
68
  const initEditor = async () => {
169
69
  try {
@@ -193,20 +93,8 @@ export function MarkdownInlineEditor({
193
93
  setIsReady(true)
194
94
  onEditorReadyRef.current?.(editor)
195
95
 
196
- // Set up selection change listener debounce via rAF to avoid
197
- // redundant mark-scanning on every keystroke
198
- const view = editor.ctx.get(editorViewCtx)
199
- const originalDispatch = view.dispatch.bind(view)
200
- view.dispatch = (tr) => {
201
- originalDispatch(tr)
202
- if (tr.selectionSet || tr.docChanged) {
203
- cancelAnimationFrame(formatRaf)
204
- formatRaf = requestAnimationFrame(updateActiveFormats)
205
- }
206
- }
207
-
208
- // Initial format check
209
- updateActiveFormats()
96
+ // Set up selection change listener with shallow equality check
97
+ cleanupTracking = setupFormatTracking(editor, setActiveFormats)
210
98
  } catch (error) {
211
99
  console.error('Milkdown editor initialization failed:', error)
212
100
  showToast('Failed to initialize markdown editor', 'error')
@@ -216,11 +104,11 @@ export function MarkdownInlineEditor({
216
104
  initEditor()
217
105
 
218
106
  return () => {
219
- cancelAnimationFrame(formatRaf)
107
+ cleanupTracking?.()
220
108
  editorInstanceRef.current?.destroy()
221
109
  editorInstanceRef.current = null
222
110
  }
223
- }, [updateActiveFormats])
111
+ }, [])
224
112
 
225
113
  const handleSave = useCallback(() => {
226
114
  onSave(content)
@@ -283,18 +171,12 @@ export function MarkdownInlineEditor({
283
171
  )
284
172
 
285
173
  // Check if selection is inside a list of given type
286
- const isInList = useCallback(
174
+ const checkInList = useCallback(
287
175
  (listType: 'bullet_list' | 'ordered_list'): boolean => {
288
176
  if (!editorInstanceRef.current) return false
289
177
  try {
290
178
  const view = editorInstanceRef.current.ctx.get(editorViewCtx)
291
- const { state } = view
292
- const { $from } = state.selection
293
- for (let depth = $from.depth; depth > 0; depth--) {
294
- const node = $from.node(depth)
295
- if (node.type.name === listType) return true
296
- }
297
- return false
179
+ return isInListType(view, listType)
298
180
  } catch {
299
181
  return false
300
182
  }
@@ -304,21 +186,21 @@ export function MarkdownInlineEditor({
304
186
 
305
187
  // Toggle bullet list - if in bullet list, remove it; otherwise add it
306
188
  const handleBulletList = useCallback(() => {
307
- if (isInList('bullet_list')) {
189
+ if (checkInList('bullet_list')) {
308
190
  runCommand(liftListItemCommand.key)
309
191
  } else {
310
192
  runCommand(wrapInBulletListCommand.key)
311
193
  }
312
- }, [runCommand, isInList])
194
+ }, [runCommand, checkInList])
313
195
 
314
196
  // Toggle ordered list - if in ordered list, remove it; otherwise add it
315
197
  const handleOrderedList = useCallback(() => {
316
- if (isInList('ordered_list')) {
198
+ if (checkInList('ordered_list')) {
317
199
  runCommand(liftListItemCommand.key)
318
200
  } else {
319
201
  runCommand(wrapInOrderedListCommand.key)
320
202
  }
321
- }, [runCommand, isInList])
203
+ }, [runCommand, checkInList])
322
204
 
323
205
  const handleInsertLink = useCallback(() => {
324
206
  if (!editorInstanceRef.current) return
@@ -369,24 +251,20 @@ export function MarkdownInlineEditor({
369
251
  }, [activeFormats.link, activeFormats.linkHref])
370
252
 
371
253
  const handleInsertHeading = useCallback((level: number) => {
372
- const prefix = '#'.repeat(level) + ' '
373
- const headingMarkdown = `\n\n${prefix}Heading\n\n`
374
-
375
- // Insert at cursor position
376
- if (editorInstanceRef.current) {
377
- try {
378
- editorInstanceRef.current.action(insert(headingMarkdown))
379
- } catch (error) {
380
- console.error('Failed to insert heading:', error)
381
- }
254
+ if (!editorInstanceRef.current) return
255
+ try {
256
+ const view = editorInstanceRef.current.ctx.get(editorViewCtx)
257
+ toggleHeading(view, level)
258
+ } catch (error) {
259
+ console.error('Failed to toggle heading:', error)
382
260
  }
383
261
  }, [])
384
262
 
385
263
  // MDX component insertion
386
- const handleInsertMdxComponent = useCallback((componentName: string, props: Record<string, string>) => {
264
+ const handleInsertMdxComponent = useCallback((componentName: string, props: Record<string, string>, children?: string) => {
387
265
  if (editorInstanceRef.current) {
388
266
  try {
389
- editorInstanceRef.current.action(callCommand(insertMdxComponentCommand.key, { componentName, props }))
267
+ editorInstanceRef.current.action(callCommand(insertMdxComponentCommand.key, { componentName, props, children }))
390
268
  } catch (error) {
391
269
  console.error('Failed to insert MDX component:', error)
392
270
  }
@@ -397,42 +275,36 @@ export function MarkdownInlineEditor({
397
275
  mdxComponentPickerOpen.value = true
398
276
  }, [])
399
277
 
400
- const handleUpdateMdxProps = useCallback((nodePos: number, props: Record<string, string>) => {
401
- if (!editorInstanceRef.current) return
402
- try {
403
- const view = editorInstanceRef.current.ctx.get(editorViewCtx)
404
- const node = view.state.doc.nodeAt(nodePos)
405
- if (node && node.type.name === 'mdx_component') {
406
- const tr = view.state.tr.setNodeMarkup(nodePos, undefined, {
407
- ...node.attrs,
408
- props: JSON.stringify(props),
409
- })
410
- view.dispatch(tr)
411
- }
412
- } catch (error) {
413
- console.error('Failed to update MDX component props:', error)
414
- }
415
- }, [])
416
-
417
278
  // Drag and drop handlers for direct image upload
279
+ // Only intercept external file drags — let ProseMirror handle internal drags (node reorder)
280
+ const hasFiles = (e: DragEvent) => e.dataTransfer?.types?.includes('Files') ?? false
281
+
418
282
  const handleDragOver = useCallback((e: DragEvent) => {
283
+ if (!hasFiles(e)) return
419
284
  e.preventDefault()
420
285
  e.stopPropagation()
421
286
  setIsDragging(true)
422
287
  }, [])
423
288
 
424
289
  const handleDragLeave = useCallback((e: DragEvent) => {
290
+ if (!hasFiles(e)) return
425
291
  e.preventDefault()
426
292
  e.stopPropagation()
427
293
  setIsDragging(false)
428
294
  }, [])
429
295
 
430
296
  const handleDrop = useCallback(async (e: DragEvent) => {
297
+ // Only handle external file drops — let ProseMirror handle internal drags (e.g. node reorder)
298
+ if (!hasFiles(e)) return
299
+
431
300
  e.preventDefault()
432
301
  e.stopPropagation()
433
302
  setIsDragging(false)
434
303
 
435
- const file = e.dataTransfer?.files[0]
304
+ const files = e.dataTransfer?.files
305
+ if (!files || files.length === 0) return
306
+
307
+ const file = files[0]
436
308
  if (!file || !file.type.startsWith('image/')) {
437
309
  showToast('Please drop an image file', 'error')
438
310
  return
@@ -811,9 +683,6 @@ export function MarkdownInlineEditor({
811
683
 
812
684
  {/* MDX Component Picker */}
813
685
  {isMdx && <MdxComponentPicker onInsert={handleInsertMdxComponent} />}
814
-
815
- {/* MDX Props Editor */}
816
- {isMdx && <MdxPropsEditor onUpdateProps={handleUpdateMdxProps} />}
817
686
  </div>
818
687
  )
819
688
  }