@nuasite/cms-mdx-editor 0.43.4 → 0.44.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/cms-mdx-editor"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.43.4",
17
+ "version": "0.44.2",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -31,7 +31,7 @@
31
31
  "@milkdown/preset-commonmark": "^7.20.0",
32
32
  "@milkdown/preset-gfm": "^7.20.0",
33
33
  "@milkdown/utils": "^7.20.0",
34
- "@nuasite/cms-types": "0.43.4",
34
+ "@nuasite/cms-types": "0.44.2",
35
35
  "remark-mdx": "^3.1.0",
36
36
  "remark-parse": "^11.0.0",
37
37
  "unified": "^11.0.5"
@@ -16,13 +16,17 @@ import {
16
16
  wrapInBulletListCommand,
17
17
  wrapInOrderedListCommand,
18
18
  } from '@milkdown/preset-commonmark'
19
- import { toggleStrikethroughCommand } from '@milkdown/preset-gfm'
19
+ import { insertTableCommand, toggleStrikethroughCommand } from '@milkdown/preset-gfm'
20
20
  import { callCommand } from '@milkdown/utils'
21
+ import type { CmsListStyle } from '@nuasite/cms-types'
21
22
  import { useEffect, useState } from 'react'
23
+ import { ImagePopover } from './image-popover'
22
24
  import { LinkPopover } from './link-popover'
23
25
  import { MediaLibrary } from './media-library'
24
26
  import type { MediaContext, MediaSource } from './media-source'
25
27
  import { type ActiveFormats, defaultActiveFormats, isInListType, removeLinkMark, setupFormatTracking, toggleHeading } from './milkdown-utils'
28
+ import { setListStyleCommand } from './styled-list-plugin'
29
+ import { YoutubePopover } from './youtube-popover'
26
30
 
27
31
  /** Track active formats on the editor, re-attaching when the instance changes. */
28
32
  export function useFormatTracking(editor: Editor | null): ActiveFormats {
@@ -51,18 +55,38 @@ function toggleList(editor: Editor, type: 'bullet' | 'ordered') {
51
55
  }
52
56
  }
53
57
 
54
- function insertImage(editor: Editor, src: string, alt: string) {
58
+ function applyListStyle(editor: Editor, listStyle: string | null) {
59
+ const view = editor.ctx.get(editorViewCtx)
60
+ view.focus()
61
+ editor.action(callCommand(setListStyleCommand.key, listStyle))
62
+ }
63
+
64
+ function insertImage(editor: Editor, src: string, alt: string, title: string) {
55
65
  editor.action((ctx) => {
56
66
  const view = ctx.get(editorViewCtx)
57
67
  const imageType = view.state.schema.nodes.image
58
68
  if (!imageType) return
59
69
  view.focus()
60
- view.dispatch(view.state.tr.replaceSelectionWith(imageType.create({ src, alt })).scrollIntoView())
70
+ view.dispatch(view.state.tr.replaceSelectionWith(imageType.create({ src, alt, title })).scrollIntoView())
71
+ })
72
+ }
73
+
74
+ function insertYoutubeDirective(editor: Editor, id: string) {
75
+ editor.action((ctx) => {
76
+ const view = ctx.get(editorViewCtx)
77
+ const paragraphType = view.state.schema.nodes.paragraph
78
+ if (!paragraphType) return
79
+
80
+ // Directive format: `:::youtube{<id>}` where id is the bare 11-char video id, with no surrounding spaces.
81
+ const paragraph = paragraphType.create(null, view.state.schema.text(`:::youtube{${id}}`))
82
+ view.focus()
83
+ view.dispatch(view.state.tr.replaceSelectionWith(paragraph).scrollIntoView())
61
84
  })
62
85
  }
63
86
 
64
87
  export interface FormatToolbarProps {
65
88
  editor: Editor | null
89
+ listStyles?: CmsListStyle[]
66
90
  media?: MediaSource
67
91
  mediaContext?: MediaContext
68
92
  /** Upload field the image is filed under (e.g. 'body'). */
@@ -92,6 +116,17 @@ const baseBtn: React.CSSProperties = {
92
116
  color: '#52525b',
93
117
  }
94
118
  const activeBtn: React.CSSProperties = { ...baseBtn, background: '#2563eb', borderColor: '#2563eb', color: '#fff' }
119
+ const selectStyle: React.CSSProperties = {
120
+ border: '1px solid #d4d4d8',
121
+ borderRadius: 4,
122
+ background: '#fff',
123
+ color: '#3f3f46',
124
+ font: 'inherit',
125
+ fontSize: 12,
126
+ lineHeight: 1.4,
127
+ padding: '2px 6px',
128
+ maxWidth: 150,
129
+ }
95
130
 
96
131
  function Btn({ active, title, onClick, style, children }: {
97
132
  active?: boolean
@@ -114,11 +149,16 @@ function Btn({ active, title, onClick, style, children }: {
114
149
  )
115
150
  }
116
151
 
117
- export function FormatToolbar({ editor, media, mediaContext, field, onInsertComponent }: FormatToolbarProps) {
152
+ export function FormatToolbar({ editor, listStyles, media, mediaContext, field, onInsertComponent }: FormatToolbarProps) {
118
153
  const formats = useFormatTracking(editor)
119
154
  const [linkOpen, setLinkOpen] = useState(false)
120
155
  const [mediaOpen, setMediaOpen] = useState(false)
156
+ const [youtubeOpen, setYoutubeOpen] = useState(false)
157
+ const [pendingImage, setPendingImage] = useState<{ url: string; alt: string } | null>(null)
121
158
  const disabled = editor === null
159
+ const hasListStyles = (listStyles?.length ?? 0) > 0
160
+ const inList = formats.bulletList || formats.orderedList
161
+ const currentListStyle = formats.listStyle && listStyles?.some(style => style.class === formats.listStyle) ? formats.listStyle : ''
122
162
 
123
163
  const applyLink = (url: string) => {
124
164
  setLinkOpen(false)
@@ -163,7 +203,31 @@ export function FormatToolbar({ editor, media, mediaContext, field, onInsertComp
163
203
  <span style={sep} />
164
204
  <Btn active={formats.bulletList} title="Bullet list" onClick={() => editor && toggleList(editor, 'bullet')}>• List</Btn>
165
205
  <Btn active={formats.orderedList} title="Numbered list" onClick={() => editor && toggleList(editor, 'ordered')}>1. List</Btn>
206
+ {hasListStyles
207
+ ? (
208
+ <select
209
+ title="List style"
210
+ aria-label="List style"
211
+ disabled={disabled || !inList}
212
+ value={currentListStyle}
213
+ onChange={(event) => {
214
+ if (!editor) return
215
+ applyListStyle(editor, event.currentTarget.value || null)
216
+ }}
217
+ style={{
218
+ ...selectStyle,
219
+ opacity: disabled || !inList ? 0.55 : 1,
220
+ cursor: disabled || !inList ? 'not-allowed' : 'default',
221
+ }}
222
+ >
223
+ <option value="">Default</option>
224
+ {listStyles?.map(style => <option key={style.class} value={style.class}>{style.label}</option>)}
225
+ </select>
226
+ )
227
+ : null}
166
228
  <Btn active={formats.blockquote} title="Quote" onClick={() => editor?.action(callCommand(wrapInBlockquoteCommand.key))}>❝</Btn>
229
+ <Btn title="Insert table" onClick={() => editor?.action(callCommand(insertTableCommand.key, { row: 3, col: 3 }))}>▦ Table</Btn>
230
+ <Btn active={youtubeOpen} title="Insert YouTube" onClick={() => !disabled && setYoutubeOpen(v => !v)}>YouTube</Btn>
167
231
  <span style={sep} />
168
232
  <Btn active={formats.link || linkOpen} title="Link" onClick={() => !disabled && setLinkOpen(v => !v)}>🔗 Link</Btn>
169
233
  {media ? <Btn title="Insert image" onClick={() => !disabled && setMediaOpen(true)}>🖼 Image</Btn> : null}
@@ -193,6 +257,36 @@ export function FormatToolbar({ editor, media, mediaContext, field, onInsertComp
193
257
  )
194
258
  : null}
195
259
 
260
+ {youtubeOpen
261
+ ? (
262
+ <div style={{ padding: '0 6px' }}>
263
+ <YoutubePopover
264
+ onApply={(id) => {
265
+ setYoutubeOpen(false)
266
+ if (editor) insertYoutubeDirective(editor, id)
267
+ }}
268
+ onClose={() => setYoutubeOpen(false)}
269
+ />
270
+ </div>
271
+ )
272
+ : null}
273
+
274
+ {pendingImage
275
+ ? (
276
+ <div style={{ padding: '0 6px' }}>
277
+ <ImagePopover
278
+ initialAlt={pendingImage.alt}
279
+ onApply={(alt, caption) => {
280
+ const { url } = pendingImage
281
+ setPendingImage(null)
282
+ if (editor) insertImage(editor, url, alt, caption)
283
+ }}
284
+ onClose={() => setPendingImage(null)}
285
+ />
286
+ </div>
287
+ )
288
+ : null}
289
+
196
290
  {mediaOpen && media
197
291
  ? (
198
292
  <MediaLibrary
@@ -202,7 +296,7 @@ export function FormatToolbar({ editor, media, mediaContext, field, onInsertComp
202
296
  accept="image/*"
203
297
  onSelect={(url, alt) => {
204
298
  setMediaOpen(false)
205
- if (editor) insertImage(editor, url, alt ?? '')
299
+ if (editor) setPendingImage({ url, alt: alt ?? '' })
206
300
  }}
207
301
  onClose={() => setMediaOpen(false)}
208
302
  />
@@ -0,0 +1,62 @@
1
+ /**
2
+ * Inline popover shown after an image is picked from the media library: confirm the
3
+ * alt text (prefilled from the library) and optionally add a caption/source. Apply
4
+ * inserts the image; Cancel aborts insertion entirely. Mirrors the LinkPopover style.
5
+ */
6
+ import { useState } from 'react'
7
+ import { popoverBtn, popoverInput, popoverWrap } from './link-popover'
8
+
9
+ export interface ImagePopoverProps {
10
+ initialAlt: string
11
+ onApply: (alt: string, caption: string) => void
12
+ onClose: () => void
13
+ }
14
+
15
+ const wrap: React.CSSProperties = { ...popoverWrap, flexWrap: 'wrap' }
16
+ const label: React.CSSProperties = { fontSize: 11, color: '#71717a', whiteSpace: 'nowrap' }
17
+
18
+ export function ImagePopover({ initialAlt, onApply, onClose }: ImagePopoverProps) {
19
+ const [alt, setAlt] = useState(initialAlt)
20
+ const [caption, setCaption] = useState('')
21
+
22
+ const apply = () => onApply(alt.trim(), caption.trim())
23
+
24
+ const onKeyDown = (e: React.KeyboardEvent) => {
25
+ if (e.key === 'Enter') {
26
+ e.preventDefault()
27
+ apply()
28
+ }
29
+ if (e.key === 'Escape') onClose()
30
+ }
31
+
32
+ return (
33
+ <div style={wrap} data-mdx-action="image" onMouseDown={e => e.stopPropagation()}>
34
+ <span style={label}>Alt</span>
35
+ <input
36
+ style={popoverInput}
37
+ autoFocus
38
+ placeholder="Alt text for screen readers"
39
+ value={alt}
40
+ onChange={e => setAlt(e.currentTarget.value)}
41
+ onKeyDown={onKeyDown}
42
+ />
43
+ <span style={label}>Caption</span>
44
+ <input
45
+ style={popoverInput}
46
+ placeholder="Caption / source (optional)"
47
+ value={caption}
48
+ onChange={e => setCaption(e.currentTarget.value)}
49
+ onKeyDown={onKeyDown}
50
+ />
51
+ <button
52
+ type="button"
53
+ style={{ ...popoverBtn, background: '#2563eb', borderColor: '#2563eb', color: '#fff' }}
54
+ onMouseDown={e => e.preventDefault()}
55
+ onClick={apply}
56
+ >
57
+ Insert
58
+ </button>
59
+ <button type="button" style={popoverBtn} onMouseDown={e => e.preventDefault()} onClick={onClose}>Cancel</button>
60
+ </div>
61
+ )
62
+ }
@@ -14,7 +14,8 @@ export interface LinkPopoverProps {
14
14
  onClose: () => void
15
15
  }
16
16
 
17
- const wrap: React.CSSProperties = {
17
+ /** Shared popover styles, reused by the image and YouTube insert popovers. */
18
+ export const popoverWrap: React.CSSProperties = {
18
19
  display: 'flex',
19
20
  gap: 6,
20
21
  alignItems: 'center',
@@ -25,7 +26,7 @@ const wrap: React.CSSProperties = {
25
26
  boxShadow: '0 4px 16px rgba(0,0,0,0.12)',
26
27
  marginTop: 6,
27
28
  }
28
- const input: React.CSSProperties = {
29
+ export const popoverInput: React.CSSProperties = {
29
30
  flex: 1,
30
31
  border: '1px solid #d4d4d8',
31
32
  borderRadius: 4,
@@ -34,7 +35,7 @@ const input: React.CSSProperties = {
34
35
  outline: 'none',
35
36
  minWidth: 0,
36
37
  }
37
- const btn: React.CSSProperties = {
38
+ export const popoverBtn: React.CSSProperties = {
38
39
  border: '1px solid #d4d4d8',
39
40
  background: '#fff',
40
41
  borderRadius: 4,
@@ -45,6 +46,9 @@ const btn: React.CSSProperties = {
45
46
  color: '#3f3f46',
46
47
  whiteSpace: 'nowrap',
47
48
  }
49
+ const wrap = popoverWrap
50
+ const input = popoverInput
51
+ const btn = popoverBtn
48
52
 
49
53
  export function LinkPopover({ initialUrl, isEdit, onApply, onRemove, onClose }: LinkPopoverProps) {
50
54
  const [url, setUrl] = useState(initialUrl)
@@ -13,19 +13,22 @@ import { listener, listenerCtx } from '@milkdown/plugin-listener'
13
13
  import { commonmark } from '@milkdown/preset-commonmark'
14
14
  import { gfm } from '@milkdown/preset-gfm'
15
15
  import { callCommand, replaceAll } from '@milkdown/utils'
16
- import type { ComponentDefinition } from '@nuasite/cms-types'
16
+ import type { CmsListStyle, ComponentDefinition } from '@nuasite/cms-types'
17
17
  import { useEffect, useRef, useState } from 'react'
18
18
  import { ComponentPicker } from './component-picker'
19
19
  import { FormatToolbar } from './format-toolbar'
20
20
  import { insertMdxComponentCommand, mdxComponentNode, mdxEsmNode, remarkMdxPlugin } from './mdx-plugin'
21
21
  import { type ComponentResolver, createMdxComponentView } from './mdx-view'
22
22
  import type { MediaContext, MediaSource } from './media-source'
23
+ import { styledListPlugin } from './styled-list-plugin'
23
24
 
24
25
  export interface MdxBodyEditorProps {
25
26
  value: string
26
27
  onChange: (markdown: string) => void
27
28
  /** Project component definitions — drives the insert picker and prop labels. */
28
29
  components?: ComponentDefinition[]
30
+ /** Project-defined list styles shown in the toolbar. */
31
+ listStyles?: CmsListStyle[]
29
32
  /**
30
33
  * Media source (the host's `CmsClient` satisfies it) — enables the toolbar's
31
34
  * image insert and image-prop browse/upload. Absent → those affordances hide.
@@ -71,6 +74,15 @@ const EDITOR_CSS = `
71
74
  .nua-mdx-editor .ProseMirror pre code { background: none; padding: 0; }
72
75
  .nua-mdx-editor .ProseMirror img { max-width: 100%; height: auto; border-radius: 4px; }
73
76
  .nua-mdx-editor .ProseMirror hr { border: none; border-top: 1px solid #e4e4e7; margin: 1em 0; }
77
+ /* tables (GFM) — PM/prosemirror-tables ship no usable styles; without these the
78
+ cells collapse to 0 width and the table is invisible & uneditable */
79
+ .nua-mdx-editor .ProseMirror .tableWrapper { overflow-x: auto; margin: 0.8em 0; }
80
+ .nua-mdx-editor .ProseMirror table { border-collapse: collapse; table-layout: fixed; width: 100%; margin: 0.8em 0; overflow: hidden; }
81
+ .nua-mdx-editor .ProseMirror th, .nua-mdx-editor .ProseMirror td { border: 1px solid #d4d4d8; padding: 0.4em 0.6em; vertical-align: top; box-sizing: border-box; position: relative; min-width: 5em; }
82
+ .nua-mdx-editor .ProseMirror th { background: #f4f4f5; font-weight: 600; text-align: left; }
83
+ .nua-mdx-editor .ProseMirror th > p, .nua-mdx-editor .ProseMirror td > p { margin: 0; }
84
+ .nua-mdx-editor .ProseMirror .column-resize-handle { position: absolute; right: -2px; top: 0; bottom: 0; width: 4px; z-index: 20; background-color: #93c5fd; pointer-events: none; }
85
+ .nua-mdx-editor .ProseMirror .selectedCell:after { content: ''; position: absolute; inset: 0; z-index: 2; background: rgba(200,200,255,0.4); pointer-events: none; }
74
86
  `
75
87
 
76
88
  function useEditorStyles() {
@@ -83,7 +95,7 @@ function useEditorStyles() {
83
95
  }, [])
84
96
  }
85
97
 
86
- export function MdxBodyEditor({ value, onChange, components, media, mediaContext, allowComponents = true }: MdxBodyEditorProps) {
98
+ export function MdxBodyEditor({ value, onChange, components, listStyles, media, mediaContext, allowComponents = true }: MdxBodyEditorProps) {
87
99
  useEditorStyles()
88
100
  const hostRef = useRef<HTMLDivElement>(null)
89
101
  const editorRef = useRef<Editor | null>(null)
@@ -130,6 +142,7 @@ export function MdxBodyEditor({ value, onChange, components, media, mediaContext
130
142
  })
131
143
  })
132
144
  .use(commonmark)
145
+ .use(styledListPlugin)
133
146
  .use(gfm)
134
147
  .use(listener)
135
148
  .use(remarkMdxPlugin)
@@ -182,6 +195,7 @@ export function MdxBodyEditor({ value, onChange, components, media, mediaContext
182
195
  <div className="nua-mdx-editor" style={wrapper}>
183
196
  <FormatToolbar
184
197
  editor={editorInstance}
198
+ listStyles={listStyles}
185
199
  media={media}
186
200
  mediaContext={mediaContext}
187
201
  field="body"
@@ -15,6 +15,7 @@ export interface ActiveFormats {
15
15
  linkHref: string | null
16
16
  bulletList: boolean
17
17
  orderedList: boolean
18
+ listStyle: string | null
18
19
  blockquote: boolean
19
20
  heading: number | null
20
21
  }
@@ -27,6 +28,7 @@ export const defaultActiveFormats: ActiveFormats = {
27
28
  linkHref: null,
28
29
  bulletList: false,
29
30
  orderedList: false,
31
+ listStyle: null,
30
32
  blockquote: false,
31
33
  heading: null,
32
34
  }
@@ -69,13 +71,17 @@ export function getActiveFormats(view: EditorView): ActiveFormats {
69
71
 
70
72
  let bulletList = false
71
73
  let orderedList = false
74
+ let listStyle: string | null = null
72
75
  let blockquote = false
73
76
  let heading: number | null = null
74
77
 
75
78
  for (let depth = $from.depth; depth > 0; depth--) {
76
79
  const node = $from.node(depth)
77
- if (node.type.name === 'bullet_list') bulletList = true
78
- if (node.type.name === 'ordered_list') orderedList = true
80
+ if (node.type.name === 'bullet_list' || node.type.name === 'ordered_list') {
81
+ if (node.type.name === 'bullet_list') bulletList = true
82
+ if (node.type.name === 'ordered_list') orderedList = true
83
+ if (listStyle === null) listStyle = typeof node.attrs.listStyle === 'string' ? node.attrs.listStyle : null
84
+ }
79
85
  if (node.type.name === 'blockquote') blockquote = true
80
86
  }
81
87
 
@@ -84,7 +90,7 @@ export function getActiveFormats(view: EditorView): ActiveFormats {
84
90
  heading = typeof level === 'number' ? level : null
85
91
  }
86
92
 
87
- return { bold, italic, strikethrough, link, linkHref, bulletList, orderedList, blockquote, heading }
93
+ return { bold, italic, strikethrough, link, linkHref, bulletList, orderedList, listStyle, blockquote, heading }
88
94
  }
89
95
 
90
96
  /** Whether the current selection is inside a list of the given node-type name. */
@@ -143,6 +149,7 @@ function formatsEqual(a: ActiveFormats, b: ActiveFormats): boolean {
143
149
  && a.linkHref === b.linkHref
144
150
  && a.bulletList === b.bulletList
145
151
  && a.orderedList === b.orderedList
152
+ && a.listStyle === b.listStyle
146
153
  && a.blockquote === b.blockquote
147
154
  && a.heading === b.heading
148
155
  }