@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/dist/editor.js +9166 -9054
- package/package.json +1 -1
- package/src/collection-scanner.ts +87 -25
- package/src/editor/components/image-overlay.tsx +3 -14
- package/src/editor/components/plain-text-chip-utils.ts +14 -0
- package/src/editor/components/plain-text-chip.tsx +61 -0
- package/src/editor/components/text-style-toolbar.tsx +2 -15
- package/src/editor/dom.ts +37 -0
- package/src/editor/editor.ts +90 -5
- package/src/editor/index.tsx +9 -0
- package/src/handlers/source-writer.ts +75 -21
- package/src/source-finder/ast-extractors.ts +37 -0
- package/src/source-finder/cache.ts +23 -0
- package/src/source-finder/search-index.ts +304 -13
- package/src/source-finder/snippet-utils.ts +179 -2
- package/src/source-finder/types.ts +3 -0
- package/src/source-finder/variable-extraction.ts +8 -1
package/package.json
CHANGED
|
@@ -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
|
|
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
|
-
|
|
330
|
+
const sources: Array<{ slug: string; relPath: string }> = []
|
|
331
|
+
const takenSlugs = new Set<string>()
|
|
332
332
|
|
|
333
|
-
const
|
|
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
|
-
|
|
371
|
+
sources.map(s => fs.readFile(path.join(collectionPath, s.relPath), 'utf-8')),
|
|
343
372
|
)
|
|
344
373
|
|
|
345
|
-
for (let i = 0; i <
|
|
346
|
-
const
|
|
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,
|
|
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,
|
|
404
|
+
collectFieldObservations(fieldMap, frontmatter, sources.length)
|
|
377
405
|
}
|
|
378
406
|
|
|
379
|
-
const def = buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos,
|
|
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
|
|
890
|
-
|
|
891
|
-
|
|
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 =
|
|
953
|
+
const ext = sources.some(s => s.relPath.endsWith('.json'))
|
|
896
954
|
? 'json' as const
|
|
897
|
-
:
|
|
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
|
-
|
|
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 <
|
|
906
|
-
const
|
|
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 =
|
|
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({
|
|
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,
|
|
983
|
+
collectFieldObservations(fieldMap, data, sources.length)
|
|
922
984
|
}
|
|
923
985
|
|
|
924
|
-
return buildCollectionDefinition(collectionName, contentDir, fieldMap, entryInfos,
|
|
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
|
-
|
|
128
|
-
|
|
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
|
-
|
|
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
|
+
}
|
package/src/editor/editor.ts
CHANGED
|
@@ -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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
}
|
package/src/editor/index.tsx
CHANGED
|
@@ -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}
|