@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/dist/editor.js +9738 -9615
- 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/constants.ts +3 -0
- package/src/editor/dom.ts +40 -0
- package/src/editor/editor.ts +117 -5
- package/src/editor/index.tsx +9 -0
- package/src/field-types.ts +15 -1
- 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/constants.ts
CHANGED
|
@@ -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
|
+
}
|
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,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
|
-
|
|
181
|
-
|
|
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
|
-
|
|
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
|
}
|
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}
|
package/src/field-types.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|