@nuasite/cms 0.30.0 → 0.32.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.32.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
@@ -109,6 +109,9 @@ export const CSS = {
109
109
  HIGHLIGHT_ELEMENT: 'cms-highlight-overlay',
110
110
  /** Data attribute for background image elements */
111
111
  BG_IMAGE_ATTRIBUTE: 'data-cms-bg-img',
112
+ /** Data attribute set during edit mode on elements whose manifest entry has no source path;
113
+ * suppresses hover outlines and blocks interaction so the user doesn't type into a dead-end. */
114
+ LOCKED_ATTRIBUTE: 'data-cms-locked',
112
115
  } as const
113
116
 
114
117
  /**
package/src/editor/dom.ts CHANGED
@@ -76,6 +76,8 @@ export function getCmsElementAtPosition(
76
76
  if (!el.hasAttribute(CSS.ID_ATTRIBUTE)) continue
77
77
  // Skip component roots - they should be handled separately
78
78
  if (el.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)) continue
79
+ // Skip elements locked because their manifest entry has no source path
80
+ if (el.hasAttribute(CSS.LOCKED_ATTRIBUTE)) continue
79
81
 
80
82
  const cmsId = el.getAttribute(CSS.ID_ATTRIBUTE)
81
83
 
@@ -96,6 +98,7 @@ export function getCmsElementAtPosition(
96
98
  if (!(el instanceof HTMLElement)) continue
97
99
  if (!el.hasAttribute(CSS.ID_ATTRIBUTE)) continue
98
100
  if (el.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)) continue
101
+ if (el.hasAttribute(CSS.LOCKED_ATTRIBUTE)) continue
99
102
 
100
103
  const cmsId = el.getAttribute(CSS.ID_ATTRIBUTE)
101
104
 
@@ -420,3 +423,40 @@ function preventInteraction(event: Event): void {
420
423
  event.stopImmediatePropagation()
421
424
  }
422
425
  }
426
+
427
+ // Chromium exposes `caretRangeFromPoint`; Firefox exposes `caretPositionFromPoint`.
428
+ // Neither is universally supported, hence the fallback cascade.
429
+ export function getCaretRangeFromPoint(x: number, y: number): Range | null {
430
+ const doc = document as Document & {
431
+ caretRangeFromPoint?: (x: number, y: number) => Range | null
432
+ caretPositionFromPoint?: (x: number, y: number) => { offsetNode: Node; offset: number } | null
433
+ }
434
+ if (typeof doc.caretRangeFromPoint === 'function') {
435
+ return doc.caretRangeFromPoint(x, y)
436
+ }
437
+ if (typeof doc.caretPositionFromPoint === 'function') {
438
+ const pos = doc.caretPositionFromPoint(x, y)
439
+ if (pos) {
440
+ const range = document.createRange()
441
+ range.setStart(pos.offsetNode, pos.offset)
442
+ range.collapse(true)
443
+ return range
444
+ }
445
+ }
446
+ return null
447
+ }
448
+
449
+ export function positionFloatingChip(
450
+ rect: DOMRect,
451
+ opts: { width: number; height: number; padding?: number; gap?: number },
452
+ ): { left: number; top: number } {
453
+ const { width, height } = opts
454
+ const padding = opts.padding ?? 10
455
+ const gap = opts.gap ?? 8
456
+ let left = rect.left + rect.width / 2 - width / 2
457
+ let top = rect.top - height - gap
458
+ const maxLeft = window.innerWidth - width - padding
459
+ left = Math.max(padding, Math.min(left, maxLeft))
460
+ if (top < padding) top = rect.bottom + gap
461
+ return { left, top }
462
+ }
@@ -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,66 @@ 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
+ let lastLockedToastAt = 0
80
+
81
+ // Signals listener cleanup on stopEditMode. Aborting removes every listener
82
+ // attached with { signal } in the current edit session in one shot.
83
+ let editModeAbortController: AbortController | null = null
84
+
85
+ function notifyFormattingBlocked(): void {
86
+ const now = Date.now()
87
+ if (now - lastFormattingBlockedToastAt < FORMATTING_BLOCKED_TOAST_COOLDOWN_MS) {
88
+ return
89
+ }
90
+ lastFormattingBlockedToastAt = now
91
+ signals.showToast("Formatting isn't available — this text is used as a plain value", 'info')
92
+ }
93
+
94
+ export function notifyLockedElement(): void {
95
+ const now = Date.now()
96
+ if (now - lastLockedToastAt < FORMATTING_BLOCKED_TOAST_COOLDOWN_MS) {
97
+ return
98
+ }
99
+ lastLockedToastAt = now
100
+ signals.showToast("This text can't be edited here — no source file is linked to it", 'info')
101
+ }
102
+
103
+ /** Test-only: reset toast throttle state between test cases. */
104
+ export function _resetToastThrottles(): void {
105
+ lastFormattingBlockedToastAt = 0
106
+ lastLockedToastAt = 0
107
+ }
108
+
109
+ // Uses the Selection/Range API rather than the deprecated document.execCommand('insertText').
110
+ export function insertPlainTextAtRange(range: Range, text: string): boolean {
111
+ if (!text) return false
112
+ range.deleteContents()
113
+ const textNode = document.createTextNode(text)
114
+ range.insertNode(textNode)
115
+ range.setStartAfter(textNode)
116
+ range.setEndAfter(textNode)
117
+ const selection = window.getSelection()
118
+ if (selection) {
119
+ selection.removeAllRanges()
120
+ selection.addRange(range)
121
+ }
122
+ return true
123
+ }
124
+
125
+ function applyPlainTextInsert(el: HTMLElement, text: string, html: string, range: Range | null): void {
126
+ const inserted = range && text ? insertPlainTextAtRange(range, text) : false
127
+ // Dispatch even when only HTML was stripped (no plain text to insert) so downstream
128
+ // state resynchronizes with the intercepted event.
129
+ if (inserted || html) {
130
+ el.dispatchEvent(new Event('input', { bubbles: true }))
131
+ }
132
+ if (html) notifyFormattingBlocked()
133
+ }
134
+
74
135
  /**
75
136
  * Check if an element contains styled/formatted content (inline text styling).
76
137
  * This includes:
@@ -102,6 +163,10 @@ export async function startEditMode(
102
163
  initHighlightSystem()
103
164
  onStateChange?.()
104
165
 
166
+ editModeAbortController?.abort()
167
+ editModeAbortController = new AbortController()
168
+ const editModeSignal = editModeAbortController.signal
169
+
105
170
  try {
106
171
  const manifest = await fetchManifest()
107
172
  signals.setManifest(manifest)
@@ -175,10 +240,20 @@ export async function startEditMode(
175
240
  return
176
241
  }
177
242
 
243
+ // Without a source path, the writer has nowhere to persist text edits — lock
244
+ // the element so it can't be typed into and the user gets told why on click.
245
+ if (!manifestEntry?.sourcePath) {
246
+ logDebug(config.debug, 'Skipping element without source path:', cmsId)
247
+ makeElementNonEditable(el)
248
+ el.setAttribute(CSS.LOCKED_ATTRIBUTE, 'true')
249
+ el.addEventListener('click', notifyLockedElement, { signal: editModeSignal })
250
+ return
251
+ }
252
+
178
253
  makeElementEditable(el)
179
254
 
180
- // Suppress browser native contentEditable undo/redo (we handle it ourselves)
181
- // Also prevent Enter/Shift+Enter from inserting line breaks
255
+ const stylingAllowed = manifestEntry?.allowStyling !== false
256
+
182
257
  el.addEventListener('beforeinput', (e) => {
183
258
  if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
184
259
  e.preventDefault()
@@ -186,7 +261,39 @@ export async function startEditMode(
186
261
  if (e.inputType === 'insertParagraph' || e.inputType === 'insertLineBreak') {
187
262
  e.preventDefault()
188
263
  }
189
- })
264
+ if (!stylingAllowed && e.inputType?.startsWith('format')) {
265
+ e.preventDefault()
266
+ notifyFormattingBlocked()
267
+ }
268
+ }, { signal: editModeSignal })
269
+
270
+ if (!stylingAllowed) {
271
+ el.addEventListener('paste', (e) => {
272
+ const clipboard = (e as ClipboardEvent).clipboardData
273
+ if (!clipboard) return
274
+ const html = clipboard.getData('text/html')
275
+ const text = clipboard.getData('text/plain')
276
+ e.preventDefault()
277
+ const selection = window.getSelection()
278
+ const range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null
279
+ applyPlainTextInsert(el, text, html, range)
280
+ }, { signal: editModeSignal })
281
+
282
+ el.addEventListener('drop', (e) => {
283
+ const transfer = (e as DragEvent).dataTransfer
284
+ if (!transfer) return
285
+ const html = transfer.getData('text/html')
286
+ const text = transfer.getData('text/plain')
287
+ if (!text && !html) return
288
+ e.preventDefault()
289
+ let range = getCaretRangeFromPoint((e as DragEvent).clientX, (e as DragEvent).clientY)
290
+ if (!range) {
291
+ const selection = window.getSelection()
292
+ range = selection && selection.rangeCount > 0 ? selection.getRangeAt(0) : null
293
+ }
294
+ applyPlainTextInsert(el, text, html, range)
295
+ }, { signal: editModeSignal })
296
+ }
190
297
 
191
298
  // Setup color tracking for elements with colorClasses in manifest
192
299
  setupColorTracking(config, el, cmsId, savedColorEdits[cmsId])
@@ -338,6 +445,8 @@ export function stopEditMode(onStateChange?: () => void): void {
338
445
  if (!signals.isSelectMode.value) {
339
446
  enableAllInteractiveElements()
340
447
  }
448
+ editModeAbortController?.abort()
449
+ editModeAbortController = null
341
450
  cleanupHighlightSystem()
342
451
  onStateChange?.()
343
452
 
@@ -351,6 +460,7 @@ export function stopEditMode(onStateChange?: () => void): void {
351
460
 
352
461
  getAllCmsElements().forEach(el => {
353
462
  makeElementNonEditable(el)
463
+ el.removeAttribute(CSS.LOCKED_ATTRIBUTE)
354
464
  })
355
465
  }
356
466
 
@@ -639,8 +749,10 @@ export async function saveAllChanges(
639
749
  payload.childCmsIds = change.childCmsElements.map(c => c.id)
640
750
  }
641
751
 
642
- // Include HTML content when there are styled spans
643
- if (change.hasStyledContent) {
752
+ // Include HTML content when there are styled spans — but never for entries
753
+ // that disallow styling (string props, collection fields), since inline HTML
754
+ // would be written back into a string attribute and break the source.
755
+ if (change.hasStyledContent && entry?.allowStyling !== false) {
644
756
  payload.hasStyledContent = true
645
757
  payload.htmlValue = getEditableHtmlFromElement(change.element)
646
758
  }
@@ -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}
@@ -65,10 +65,24 @@ const toISODate = (v: unknown) => (v instanceof Date ? v.toISOString().slice(0,
65
65
  /** Normalize YAML Date objects to ISO datetime strings */
66
66
  const toISODatetime = (v: unknown) => (v instanceof Date ? v.toISOString() : v)
67
67
 
68
- /** Add a chainable `.orderBy()` method to a Zod schema. The scanner detects it from source code. */
68
+ const WRAPPING_METHODS = ['default', 'optional', 'nullable', 'nullish'] as const
69
+
70
+ /**
71
+ * Add a chainable `.orderBy()` method to a Zod schema. The scanner detects it from source code.
72
+ *
73
+ * Also wraps the Zod methods that produce a new schema instance (`.default()`, `.optional()`,
74
+ * `.nullable()`, `.nullish()`) so the marker survives those wrappers — chain order doesn't matter.
75
+ */
69
76
  function withOrderBy<T extends z.ZodTypeAny>(schema: T): WithOrderBy<T> {
70
77
  const s = schema as WithOrderBy<T>
71
78
  s.orderBy = (_direction?: OrderByDirection) => schema
79
+ for (const method of WRAPPING_METHODS) {
80
+ const original = (schema as unknown as Record<string, unknown>)[method]
81
+ if (typeof original !== 'function') continue
82
+ ;(s as unknown as Record<string, unknown>)[method] = function(this: unknown, ...args: unknown[]) {
83
+ return withOrderBy((original as (...a: unknown[]) => z.ZodTypeAny).apply(this, args))
84
+ }
85
+ }
72
86
  return s
73
87
  }
74
88