@nuasite/cms 0.30.0 → 0.31.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/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.30.0",
17
+ "version": "0.31.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -325,12 +325,41 @@ function buildCollectionDefinition(
325
325
  */
326
326
  async function scanCollection(collectionPath: string, collectionName: string, contentDir: string): Promise<CollectionDefinition | null> {
327
327
  try {
328
- const entries = await fs.readdir(collectionPath, { withFileTypes: true })
329
- const markdownFiles = entries.filter(e => e.isFile() && (e.name.endsWith('.md') || e.name.endsWith('.mdx')))
328
+ const dirEntries = await fs.readdir(collectionPath, { withFileTypes: true })
330
329
 
331
- if (markdownFiles.length === 0) return null
330
+ const sources: Array<{ slug: string; relPath: string }> = []
331
+ const takenSlugs = new Set<string>()
332
332
 
333
- const hasMd = markdownFiles.some(f => f.name.endsWith('.md'))
333
+ for (const entry of dirEntries) {
334
+ if (!entry.isFile()) continue
335
+ if (!entry.name.endsWith('.md') && !entry.name.endsWith('.mdx')) continue
336
+ const slug = entry.name.replace(/\.(md|mdx)$/, '')
337
+ sources.push({ slug, relPath: entry.name })
338
+ takenSlugs.add(slug)
339
+ }
340
+
341
+ // Hugo-style layout: <slug>/index.md(x). Flat files win on slug conflict.
342
+ const subdirs = dirEntries.filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
343
+ const indexLookups = await Promise.all(subdirs.map(async dir => {
344
+ if (takenSlugs.has(dir.name)) return null
345
+ for (const ext of ['md', 'mdx'] as const) {
346
+ const relPath = path.join(dir.name, `index.${ext}`)
347
+ try {
348
+ await fs.access(path.join(collectionPath, relPath))
349
+ return { slug: dir.name, relPath }
350
+ } catch {
351
+ // try next extension
352
+ }
353
+ }
354
+ return null
355
+ }))
356
+ for (const entry of indexLookups) {
357
+ if (entry) sources.push(entry)
358
+ }
359
+
360
+ if (sources.length === 0) return null
361
+
362
+ const hasMd = sources.some(s => s.relPath.endsWith('.md'))
334
363
  const fileExtension: 'md' | 'mdx' = hasMd ? 'md' : 'mdx'
335
364
 
336
365
  const fieldMap = new Map<string, FieldObservation>()
@@ -339,11 +368,11 @@ async function scanCollection(collectionPath: string, collectionName: string, co
339
368
  let hasDraft = false
340
369
 
341
370
  const fileContents = await Promise.all(
342
- markdownFiles.map(file => fs.readFile(path.join(collectionPath, file.name), 'utf-8')),
371
+ sources.map(s => fs.readFile(path.join(collectionPath, s.relPath), 'utf-8')),
343
372
  )
344
373
 
345
- for (let i = 0; i < markdownFiles.length; i++) {
346
- const file = markdownFiles[i]!
374
+ for (let i = 0; i < sources.length; i++) {
375
+ const source = sources[i]!
347
376
  const content = fileContents[i]!
348
377
  const frontmatter = parseFrontmatter(content)
349
378
 
@@ -354,10 +383,9 @@ async function scanCollection(collectionPath: string, collectionName: string, co
354
383
  }
355
384
  }
356
385
 
357
- const slug = file.name.replace(/\.(md|mdx)$/, '')
358
386
  const entryInfo: CollectionEntryInfo = {
359
- slug,
360
- sourcePath: path.join(contentDir, collectionName, file.name),
387
+ slug: source.slug,
388
+ sourcePath: path.join(contentDir, collectionName, source.relPath),
361
389
  }
362
390
  if (frontmatter) {
363
391
  if (typeof frontmatter.title === 'string') {
@@ -373,10 +401,10 @@ async function scanCollection(collectionPath: string, collectionName: string, co
373
401
  if (!frontmatter) continue
374
402
 
375
403
  if (frontmatter.draft === true) hasDraft = true
376
- collectFieldObservations(fieldMap, frontmatter, markdownFiles.length)
404
+ collectFieldObservations(fieldMap, frontmatter, sources.length)
377
405
  }
378
406
 
379
- const def = buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, markdownFiles.length, {
407
+ const def = buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
380
408
  supportsDraft: hasDraft,
381
409
  fileExtension,
382
410
  })
@@ -886,42 +914,76 @@ function detectDerivedHrefFields(collections: Record<string, CollectionDefinitio
886
914
  */
887
915
  async function scanDataCollection(collectionPath: string, collectionName: string, contentDir: string): Promise<CollectionDefinition | null> {
888
916
  try {
889
- const entries = await fs.readdir(collectionPath, { withFileTypes: true })
890
- const dataFiles = entries.filter(e => e.isFile() && (e.name.endsWith('.json') || e.name.endsWith('.yaml') || e.name.endsWith('.yml')))
891
- if (dataFiles.length === 0) return null
917
+ const dirEntries = await fs.readdir(collectionPath, { withFileTypes: true })
918
+
919
+ const sources: Array<{ slug: string; relPath: string }> = []
920
+ const takenSlugs = new Set<string>()
921
+
922
+ for (const entry of dirEntries) {
923
+ if (!entry.isFile()) continue
924
+ if (!entry.name.endsWith('.json') && !entry.name.endsWith('.yaml') && !entry.name.endsWith('.yml')) continue
925
+ const slug = entry.name.replace(/\.(json|ya?ml)$/, '')
926
+ sources.push({ slug, relPath: entry.name })
927
+ takenSlugs.add(slug)
928
+ }
929
+
930
+ // Hugo-style layout: <slug>/index.{json,yaml,yml}. Flat files win on slug conflict.
931
+ const subdirs = dirEntries.filter(e => e.isDirectory() && !e.name.startsWith('_') && !e.name.startsWith('.'))
932
+ const indexLookups = await Promise.all(subdirs.map(async dir => {
933
+ if (takenSlugs.has(dir.name)) return null
934
+ for (const indexExt of ['json', 'yaml', 'yml'] as const) {
935
+ const relPath = path.join(dir.name, `index.${indexExt}`)
936
+ try {
937
+ await fs.access(path.join(collectionPath, relPath))
938
+ return { slug: dir.name, relPath }
939
+ } catch {
940
+ // try next extension
941
+ }
942
+ }
943
+ return null
944
+ }))
945
+ for (const entry of indexLookups) {
946
+ if (entry) sources.push(entry)
947
+ }
948
+
949
+ if (sources.length === 0) return null
892
950
 
893
951
  const fieldMap = new Map<string, FieldObservation>()
894
952
  const entryInfos: CollectionEntryInfo[] = []
895
- const ext = dataFiles.some(file => file.name.endsWith('.json'))
953
+ const ext = sources.some(s => s.relPath.endsWith('.json'))
896
954
  ? 'json' as const
897
- : dataFiles.some(file => file.name.endsWith('.yaml'))
955
+ : sources.some(s => s.relPath.endsWith('.yaml'))
898
956
  ? 'yaml' as const
899
957
  : 'yml' as const
900
958
 
901
959
  const fileContents = await Promise.all(
902
- dataFiles.map(file => fs.readFile(path.join(collectionPath, file.name), 'utf-8').catch(() => null)),
960
+ sources.map(s => fs.readFile(path.join(collectionPath, s.relPath), 'utf-8').catch(() => null)),
903
961
  )
904
962
 
905
- for (let i = 0; i < dataFiles.length; i++) {
906
- const file = dataFiles[i]!
963
+ for (let i = 0; i < sources.length; i++) {
964
+ const source = sources[i]!
907
965
  const raw = fileContents[i]!
908
966
  if (raw === null) continue
909
967
  let data: Record<string, unknown> | null = null
910
968
  try {
911
- data = file.name.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) as Record<string, unknown>
969
+ data = source.relPath.endsWith('.json') ? JSON.parse(raw) : parseYaml(raw) as Record<string, unknown>
912
970
  } catch {
913
971
  continue
914
972
  }
915
973
  if (!data || typeof data !== 'object') continue
916
974
 
917
- const slug = file.name.replace(/\.(json|ya?ml)$/, '')
918
975
  const title = typeof data.name === 'string' ? data.name : typeof data.title === 'string' ? data.title : undefined
919
- entryInfos.push({ slug, title, sourcePath: path.join(contentDir, collectionName, file.name), data })
976
+ entryInfos.push({
977
+ slug: source.slug,
978
+ title,
979
+ sourcePath: path.join(contentDir, collectionName, source.relPath),
980
+ data,
981
+ })
920
982
 
921
- collectFieldObservations(fieldMap, data, dataFiles.length)
983
+ collectFieldObservations(fieldMap, data, sources.length)
922
984
  }
923
985
 
924
- return buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, dataFiles.length, {
986
+ return buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos, sources.length, {
925
987
  type: 'data',
926
988
  fileExtension: ext,
927
989
  })
@@ -1,5 +1,6 @@
1
1
  import { useEffect, useRef } from 'preact/hooks'
2
2
  import { Z_INDEX } from '../constants'
3
+ import { getCaretRangeFromPoint } from '../dom'
3
4
  import * as signals from '../signals'
4
5
 
5
6
  export interface ImageOverlayProps {
@@ -122,24 +123,12 @@ export function ImageOverlay({ visible, rect, element, cmsId }: ImageOverlayProp
122
123
  if (el instanceof HTMLElement && el.contentEditable === 'true') {
123
124
  e.preventDefault()
124
125
  e.stopPropagation()
125
- // Focus the element and place cursor at click position
126
126
  el.focus()
127
- // Try to place cursor at the click position using caretPositionFromPoint
128
- const caretPos = document.caretPositionFromPoint?.(e.clientX, e.clientY)
129
- ?? (document as { caretRangeFromPoint?: (x: number, y: number) => Range | null }).caretRangeFromPoint?.(e.clientX, e.clientY)
130
- if (caretPos) {
127
+ const range = getCaretRangeFromPoint(e.clientX, e.clientY)
128
+ if (range) {
131
129
  const selection = window.getSelection()
132
130
  if (selection) {
133
131
  selection.removeAllRanges()
134
- const range = document.createRange()
135
- if ('offsetNode' in caretPos) {
136
- // caretPositionFromPoint result
137
- range.setStart(caretPos.offsetNode, caretPos.offset)
138
- } else {
139
- // caretRangeFromPoint result (Range)
140
- range.setStart(caretPos.startContainer, caretPos.startOffset)
141
- }
142
- range.collapse(true)
143
132
  selection.addRange(range)
144
133
  }
145
134
  }
@@ -0,0 +1,14 @@
1
+ import type { ManifestEntry } from '../../types'
2
+
3
+ export function describeSource(entry: ManifestEntry | undefined): string {
4
+ if (!entry) return 'no formatting'
5
+ if (entry.collectionName) {
6
+ return `${entry.collectionName} collection field`
7
+ }
8
+ if (entry.variableName) {
9
+ return `${entry.variableName} prop`
10
+ }
11
+ // Entry marked non-styleable but missing both collection and variable context —
12
+ // visible fallback so the edge case surfaces instead of masquerading as a missing entry.
13
+ return 'unknown source'
14
+ }
@@ -0,0 +1,61 @@
1
+ import type { ManifestEntry } from '../../types'
2
+ import { Z_INDEX } from '../constants'
3
+ import { positionFloatingChip } from '../dom'
4
+ import { describeSource } from './plain-text-chip-utils'
5
+
6
+ export interface PlainTextChipProps {
7
+ visible: boolean
8
+ rect: DOMRect | null
9
+ entry: ManifestEntry | undefined
10
+ }
11
+
12
+ export function PlainTextChip({ visible, rect, entry }: PlainTextChipProps) {
13
+ if (!visible || !rect) {
14
+ return null
15
+ }
16
+
17
+ const maxChipWidth = 280
18
+ const { left, top } = positionFloatingChip(rect, { width: maxChipWidth, height: 28 })
19
+
20
+ const source = describeSource(entry)
21
+
22
+ return (
23
+ <div
24
+ data-cms-ui
25
+ onMouseDown={(e) => {
26
+ e.preventDefault()
27
+ e.stopPropagation()
28
+ }}
29
+ onClick={(e) => e.stopPropagation()}
30
+ style={{
31
+ position: 'fixed',
32
+ left: `${left}px`,
33
+ top: `${top}px`,
34
+ zIndex: Z_INDEX.MODAL,
35
+ fontFamily: 'system-ui, -apple-system, BlinkMacSystemFont, sans-serif',
36
+ fontSize: '11px',
37
+ maxWidth: `${maxChipWidth}px`,
38
+ }}
39
+ >
40
+ <div class="flex items-center gap-2 px-3 py-1.5 bg-cms-dark border border-white/10 shadow-[0_8px_32px_rgba(0,0,0,0.3)] rounded-cms-xl text-white/80 min-w-0">
41
+ <svg
42
+ class="shrink-0"
43
+ width="12"
44
+ height="12"
45
+ viewBox="0 0 24 24"
46
+ fill="none"
47
+ stroke="currentColor"
48
+ stroke-width="2"
49
+ stroke-linecap="round"
50
+ stroke-linejoin="round"
51
+ aria-hidden="true"
52
+ >
53
+ <path d="M4 7V4h16v3" />
54
+ <path d="M9 20h6" />
55
+ <path d="M12 4v16" />
56
+ </svg>
57
+ <span class="truncate" title={`Plain text · ${source}`}>Plain text · {source}</span>
58
+ </div>
59
+ </div>
60
+ )
61
+ }
@@ -1,5 +1,6 @@
1
1
  import { useCallback, useEffect, useState } from 'preact/hooks'
2
2
  import { CSS, Z_INDEX } from '../constants'
3
+ import { positionFloatingChip } from '../dom'
3
4
  import { cn } from '../lib/cn'
4
5
  import * as signals from '../signals'
5
6
  import {
@@ -177,21 +178,7 @@ export function TextStyleToolbar({ visible, rect, element, onStyleChange }: Text
177
178
  return null
178
179
  }
179
180
 
180
- // Position toolbar above the selection
181
- const toolbarHeight = 44
182
- const toolbarWidth = 320
183
- let left = rect.left + rect.width / 2 - toolbarWidth / 2
184
- let top = rect.top - toolbarHeight - 8
185
-
186
- const padding = 10
187
- const maxLeft = window.innerWidth - toolbarWidth - padding
188
- const minLeft = padding
189
-
190
- left = Math.max(minLeft, Math.min(left, maxLeft))
191
-
192
- if (top < padding) {
193
- top = rect.bottom + 8
194
- }
181
+ const { left, top } = positionFloatingChip(rect, { width: 320, height: 44 })
195
182
 
196
183
  return (
197
184
  <div
package/src/editor/dom.ts CHANGED
@@ -420,3 +420,40 @@ function preventInteraction(event: Event): void {
420
420
  event.stopImmediatePropagation()
421
421
  }
422
422
  }
423
+
424
+ // Chromium exposes `caretRangeFromPoint`; Firefox exposes `caretPositionFromPoint`.
425
+ // Neither is universally supported, hence the fallback cascade.
426
+ export function getCaretRangeFromPoint(x: number, y: number): Range | null {
427
+ const doc = document as Document & {
428
+ caretRangeFromPoint?: (x: number, y: number) => Range | null
429
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null
430
+ }
431
+ if (typeof doc.caretRangeFromPoint === 'function') {
432
+ return doc.caretRangeFromPoint(x, y)
433
+ }
434
+ if (typeof doc.caretPositionFromPoint === 'function') {
435
+ const pos = doc.caretPositionFromPoint(x, y)
436
+ if (pos) {
437
+ const range = document.createRange()
438
+ range.setStart(pos.offsetNode, pos.offset)
439
+ range.collapse(true)
440
+ return range
441
+ }
442
+ }
443
+ return null
444
+ }
445
+
446
+ export function positionFloatingChip(
447
+ rect: DOMRect,
448
+ opts: { width: number; height: number; padding?: number; gap?: number },
449
+ ): { left: number; top: number } {
450
+ const { width, height } = opts
451
+ const padding = opts.padding ?? 10
452
+ const gap = opts.gap ?? 8
453
+ let left = rect.left + rect.width / 2 - width / 2
454
+ let top = rect.top - height - gap
455
+ const maxLeft = window.innerWidth - width - padding
456
+ left = Math.max(padding, Math.min(left, maxLeft))
457
+ if (top < padding) top = rect.bottom + gap
458
+ return { left, top }
459
+ }
@@ -6,6 +6,7 @@ import {
6
6
  enableAllInteractiveElements,
7
7
  findInnermostCmsElement,
8
8
  getAllCmsElements,
9
+ getCaretRangeFromPoint,
9
10
  getChildCmsElements,
10
11
  getEditableHtmlFromElement,
11
12
  getEditableTextFromElement,
@@ -71,6 +72,50 @@ const INLINE_STYLE_ELEMENTS = [
71
72
  'q',
72
73
  ]
73
74
 
75
+ // Collapse burst spam (repeated shortcut / paste) into a single toast; long enough to
76
+ // merge a burst, short enough that the next deliberate action still explains itself.
77
+ const FORMATTING_BLOCKED_TOAST_COOLDOWN_MS = 3000
78
+ let lastFormattingBlockedToastAt = 0
79
+
80
+ // Signals listener cleanup on stopEditMode. Aborting removes every listener
81
+ // attached with { signal } in the current edit session in one shot.
82
+ let editModeAbortController: AbortController | null = null
83
+
84
+ function notifyFormattingBlocked(): void {
85
+ const now = Date.now()
86
+ if (now - lastFormattingBlockedToastAt < FORMATTING_BLOCKED_TOAST_COOLDOWN_MS) {
87
+ return
88
+ }
89
+ lastFormattingBlockedToastAt = now
90
+ signals.showToast("Formatting isn't available — this text is used as a plain value", 'info')
91
+ }
92
+
93
+ // Uses the Selection/Range API rather than the deprecated document.execCommand('insertText').
94
+ export function insertPlainTextAtRange(range: Range, text: string): boolean {
95
+ if (!text) return false
96
+ range.deleteContents()
97
+ const textNode = document.createTextNode(text)
98
+ range.insertNode(textNode)
99
+ range.setStartAfter(textNode)
100
+ range.setEndAfter(textNode)
101
+ const selection = window.getSelection()
102
+ if (selection) {
103
+ selection.removeAllRanges()
104
+ selection.addRange(range)
105
+ }
106
+ return true
107
+ }
108
+
109
+ function applyPlainTextInsert(el: HTMLElement, text: string, html: string, range: Range | null): void {
110
+ const inserted = range && text ? insertPlainTextAtRange(range, text) : false
111
+ // Dispatch even when only HTML was stripped (no plain text to insert) so downstream
112
+ // state resynchronizes with the intercepted event.
113
+ if (inserted || html) {
114
+ el.dispatchEvent(new Event('input', { bubbles: true }))
115
+ }
116
+ if (html) notifyFormattingBlocked()
117
+ }
118
+
74
119
  /**
75
120
  * Check if an element contains styled/formatted content (inline text styling).
76
121
  * This includes:
@@ -102,6 +147,10 @@ export async function startEditMode(
102
147
  initHighlightSystem()
103
148
  onStateChange?.()
104
149
 
150
+ editModeAbortController?.abort()
151
+ editModeAbortController = new AbortController()
152
+ const editModeSignal = editModeAbortController.signal
153
+
105
154
  try {
106
155
  const manifest = await fetchManifest()
107
156
  signals.setManifest(manifest)
@@ -177,8 +226,8 @@ export async function startEditMode(
177
226
 
178
227
  makeElementEditable(el)
179
228
 
180
- // Suppress browser native contentEditable undo/redo (we handle it ourselves)
181
- // Also prevent Enter/Shift+Enter from inserting line breaks
229
+ const stylingAllowed = manifestEntry?.allowStyling !== false
230
+
182
231
  el.addEventListener('beforeinput', (e) => {
183
232
  if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
184
233
  e.preventDefault()
@@ -186,7 +235,39 @@ export async function startEditMode(
186
235
  if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') {
187
236
  e.preventDefault()
188
237
  }
189
- })
238
+ if (!stylingAllowed && e.inputType?.startsWith('format')) {
239
+ e.preventDefault()
240
+ notifyFormattingBlocked()
241
+ }
242
+ }, { signal: editModeSignal })
243
+
244
+ if (!stylingAllowed) {
245
+ el.addEventListener('paste', (e) => {
246
+ const clipboard = (e as ClipboardEvent).clipboardData
247
+ if (!clipboard) return
248
+ const html = clipboard.getData('text/html')
249
+ const text = clipboard.getData('text/plain')
250
+ e.preventDefault()
251
+ const selection = window.getSelection()
252
+ const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null
253
+ applyPlainTextInsert(el, text, html, range)
254
+ }, { signal: editModeSignal })
255
+
256
+ el.addEventListener('drop', (e) => {
257
+ const transfer = (e as DragEvent).dataTransfer
258
+ if (!transfer) return
259
+ const html = transfer.getData('text/html')
260
+ const text = transfer.getData('text/plain')
261
+ if (!text && !html) return
262
+ e.preventDefault()
263
+ let range = getCaretRangeFromPoint((e as DragEvent).clientX, (e as DragEvent).clientY)
264
+ if (!range) {
265
+ const selection = window.getSelection()
266
+ range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null
267
+ }
268
+ applyPlainTextInsert(el, text, html, range)
269
+ }, { signal: editModeSignal })
270
+ }
190
271
 
191
272
  // Setup color tracking for elements with colorClasses in manifest
192
273
  setupColorTracking(config, el, cmsId, savedColorEdits[cmsId])
@@ -338,6 +419,8 @@ export function stopEditMode(onStateChange?: () => void): void {
338
419
  if (!signals.isSelectMode.value) {
339
420
  enableAllInteractiveElements()
340
421
  }
422
+ editModeAbortController?.abort()
423
+ editModeAbortController = null
341
424
  cleanupHighlightSystem()
342
425
  onStateChange?.()
343
426
 
@@ -639,8 +722,10 @@ export async function saveAllChanges(
639
722
  payload.childCmsIds = change.childCmsElements.map(c => c.id)
640
723
  }
641
724
 
642
- // Include HTML content when there are styled spans
643
- if (change.hasStyledContent) {
725
+ // Include HTML content when there are styled spans — but never for entries
726
+ // that disallow styling (string props, collection fields), since inline HTML
727
+ // would be written back into a string attribute and break the source.
728
+ if (change.hasStyledContent && entry?.allowStyling !== false) {
644
729
  payload.hasStyledContent = true
645
730
  payload.htmlValue = getEditableHtmlFromElement(change.element)
646
731
  }
@@ -16,6 +16,7 @@ import { ImageOverlay } from './components/image-overlay'
16
16
  import { MarkdownEditorOverlay } from './components/markdown-editor-overlay'
17
17
  import { MediaLibrary } from './components/media-library'
18
18
  import { Outline } from './components/outline'
19
+ import { PlainTextChip } from './components/plain-text-chip'
19
20
  import { RedirectCountdown } from './components/redirect-countdown'
20
21
  import { RedirectsManager } from './components/redirects-manager'
21
22
  import { ReferencePicker } from './components/reference-picker'
@@ -562,6 +563,14 @@ const CmsUI = () => {
562
563
  />
563
564
  </ErrorBoundary>
564
565
 
566
+ <ErrorBoundary componentName="Plain Text Chip">
567
+ <PlainTextChip
568
+ visible={textSelectionState.hasSelection && isEditing && !isTextStylingAllowed}
569
+ rect={textSelectionState.rect}
570
+ entry={selectedEntry}
571
+ />
572
+ </ErrorBoundary>
573
+
565
574
  <ErrorBoundary componentName="Color Toolbar">
566
575
  <ColorToolbar
567
576
  visible={colorEditorState.isOpen && isEditing}