@nuasite/cms 0.1.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/README.md +237 -0
- package/dist/src/build-processor.d.ts +20 -0
- package/dist/src/build-processor.d.ts.map +1 -0
- package/dist/src/collection-scanner.d.ts +6 -0
- package/dist/src/collection-scanner.d.ts.map +1 -0
- package/dist/src/component-registry.d.ts +63 -0
- package/dist/src/component-registry.d.ts.map +1 -0
- package/dist/src/config.d.ts +24 -0
- package/dist/src/config.d.ts.map +1 -0
- package/dist/src/dev-middleware.d.ts +20 -0
- package/dist/src/dev-middleware.d.ts.map +1 -0
- package/dist/src/editor/ai.d.ts +60 -0
- package/dist/src/editor/ai.d.ts.map +1 -0
- package/dist/src/editor/api.d.ts +140 -0
- package/dist/src/editor/api.d.ts.map +1 -0
- package/dist/src/editor/color-utils.d.ts +106 -0
- package/dist/src/editor/color-utils.d.ts.map +1 -0
- package/dist/src/editor/components/ai-chat.d.ts +11 -0
- package/dist/src/editor/components/ai-chat.d.ts.map +1 -0
- package/dist/src/editor/components/ai-tooltip.d.ts +12 -0
- package/dist/src/editor/components/ai-tooltip.d.ts.map +1 -0
- package/dist/src/editor/components/attribute-editor.d.ts +5 -0
- package/dist/src/editor/components/attribute-editor.d.ts.map +1 -0
- package/dist/src/editor/components/block-editor.d.ts +12 -0
- package/dist/src/editor/components/block-editor.d.ts.map +1 -0
- package/dist/src/editor/components/collections-browser.d.ts +2 -0
- package/dist/src/editor/components/collections-browser.d.ts.map +1 -0
- package/dist/src/editor/components/color-toolbar.d.ts +12 -0
- package/dist/src/editor/components/color-toolbar.d.ts.map +1 -0
- package/dist/src/editor/components/confirm-dialog.d.ts +2 -0
- package/dist/src/editor/components/confirm-dialog.d.ts.map +1 -0
- package/dist/src/editor/components/create-page-modal.d.ts +2 -0
- package/dist/src/editor/components/create-page-modal.d.ts.map +1 -0
- package/dist/src/editor/components/editable-highlights.d.ts +9 -0
- package/dist/src/editor/components/editable-highlights.d.ts.map +1 -0
- package/dist/src/editor/components/error-boundary.d.ts +32 -0
- package/dist/src/editor/components/error-boundary.d.ts.map +1 -0
- package/dist/src/editor/components/fields.d.ts +75 -0
- package/dist/src/editor/components/fields.d.ts.map +1 -0
- package/dist/src/editor/components/frontmatter-fields.d.ts +29 -0
- package/dist/src/editor/components/frontmatter-fields.d.ts.map +1 -0
- package/dist/src/editor/components/highlight-overlay.d.ts +64 -0
- package/dist/src/editor/components/highlight-overlay.d.ts.map +1 -0
- package/dist/src/editor/components/image-overlay.d.ts +12 -0
- package/dist/src/editor/components/image-overlay.d.ts.map +1 -0
- package/dist/src/editor/components/markdown-editor-overlay.d.ts +6 -0
- package/dist/src/editor/components/markdown-editor-overlay.d.ts.map +1 -0
- package/dist/src/editor/components/markdown-inline-editor.d.ts +10 -0
- package/dist/src/editor/components/markdown-inline-editor.d.ts.map +1 -0
- package/dist/src/editor/components/media-library.d.ts +2 -0
- package/dist/src/editor/components/media-library.d.ts.map +1 -0
- package/dist/src/editor/components/outline.d.ts +21 -0
- package/dist/src/editor/components/outline.d.ts.map +1 -0
- package/dist/src/editor/components/redirect-countdown.d.ts +2 -0
- package/dist/src/editor/components/redirect-countdown.d.ts.map +1 -0
- package/dist/src/editor/components/seo-editor.d.ts +2 -0
- package/dist/src/editor/components/seo-editor.d.ts.map +1 -0
- package/dist/src/editor/components/text-style-toolbar.d.ts +8 -0
- package/dist/src/editor/components/text-style-toolbar.d.ts.map +1 -0
- package/dist/src/editor/components/toast/toast-container.d.ts +7 -0
- package/dist/src/editor/components/toast/toast-container.d.ts.map +1 -0
- package/dist/src/editor/components/toast/toast.d.ts +7 -0
- package/dist/src/editor/components/toast/toast.d.ts.map +1 -0
- package/dist/src/editor/components/toast/types.d.ts +7 -0
- package/dist/src/editor/components/toast/types.d.ts.map +1 -0
- package/dist/src/editor/components/toolbar.d.ts +21 -0
- package/dist/src/editor/components/toolbar.d.ts.map +1 -0
- package/dist/src/editor/config.d.ts +4 -0
- package/dist/src/editor/config.d.ts.map +1 -0
- package/dist/src/editor/constants.d.ts +101 -0
- package/dist/src/editor/constants.d.ts.map +1 -0
- package/dist/src/editor/context.d.ts +14 -0
- package/dist/src/editor/context.d.ts.map +1 -0
- package/dist/src/editor/dom.d.ts +77 -0
- package/dist/src/editor/dom.d.ts.map +1 -0
- package/dist/src/editor/editor.d.ts +64 -0
- package/dist/src/editor/editor.d.ts.map +1 -0
- package/dist/src/editor/history.d.ts +20 -0
- package/dist/src/editor/history.d.ts.map +1 -0
- package/dist/src/editor/hooks/index.d.ts +14 -0
- package/dist/src/editor/hooks/index.d.ts.map +1 -0
- package/dist/src/editor/hooks/useAIHandlers.d.ts +22 -0
- package/dist/src/editor/hooks/useAIHandlers.d.ts.map +1 -0
- package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts +18 -0
- package/dist/src/editor/hooks/useBlockEditorHandlers.d.ts.map +1 -0
- package/dist/src/editor/hooks/useElementDetection.d.ts +26 -0
- package/dist/src/editor/hooks/useElementDetection.d.ts.map +1 -0
- package/dist/src/editor/hooks/useImageHoverDetection.d.ts +12 -0
- package/dist/src/editor/hooks/useImageHoverDetection.d.ts.map +1 -0
- package/dist/src/editor/hooks/useTextSelection.d.ts +23 -0
- package/dist/src/editor/hooks/useTextSelection.d.ts.map +1 -0
- package/dist/src/editor/hooks/useTooltipState.d.ts +19 -0
- package/dist/src/editor/hooks/useTooltipState.d.ts.map +1 -0
- package/dist/src/editor/hooks/utils.d.ts +32 -0
- package/dist/src/editor/hooks/utils.d.ts.map +1 -0
- package/dist/src/editor/index.d.ts +12 -0
- package/dist/src/editor/index.d.ts.map +1 -0
- package/dist/src/editor/lib/cn.d.ts +3 -0
- package/dist/src/editor/lib/cn.d.ts.map +1 -0
- package/dist/src/editor/manifest.d.ts +19 -0
- package/dist/src/editor/manifest.d.ts.map +1 -0
- package/dist/src/editor/markdown-api.d.ts +36 -0
- package/dist/src/editor/markdown-api.d.ts.map +1 -0
- package/dist/src/editor/signals.d.ts +242 -0
- package/dist/src/editor/signals.d.ts.map +1 -0
- package/dist/src/editor/storage.d.ts +27 -0
- package/dist/src/editor/storage.d.ts.map +1 -0
- package/dist/src/editor/text-styling.d.ts +350 -0
- package/dist/src/editor/text-styling.d.ts.map +1 -0
- package/dist/src/editor/themes.d.ts +38 -0
- package/dist/src/editor/themes.d.ts.map +1 -0
- package/dist/src/editor/types.d.ts +454 -0
- package/dist/src/editor/types.d.ts.map +1 -0
- package/dist/src/error-collector.d.ts +56 -0
- package/dist/src/error-collector.d.ts.map +1 -0
- package/dist/src/handlers/component-ops.d.ts +34 -0
- package/dist/src/handlers/component-ops.d.ts.map +1 -0
- package/dist/src/handlers/markdown-ops.d.ts +41 -0
- package/dist/src/handlers/markdown-ops.d.ts.map +1 -0
- package/dist/src/handlers/request-utils.d.ts +20 -0
- package/dist/src/handlers/request-utils.d.ts.map +1 -0
- package/dist/src/handlers/source-writer.d.ts +51 -0
- package/dist/src/handlers/source-writer.d.ts.map +1 -0
- package/dist/src/html-processor.d.ts +63 -0
- package/dist/src/html-processor.d.ts.map +1 -0
- package/dist/src/index.d.ts +41 -0
- package/dist/src/index.d.ts.map +1 -0
- package/dist/src/manifest-writer.d.ts +111 -0
- package/dist/src/manifest-writer.d.ts.map +1 -0
- package/dist/src/media/contember.d.ts +15 -0
- package/dist/src/media/contember.d.ts.map +1 -0
- package/dist/src/media/local.d.ts +9 -0
- package/dist/src/media/local.d.ts.map +1 -0
- package/dist/src/media/s3.d.ts +12 -0
- package/dist/src/media/s3.d.ts.map +1 -0
- package/dist/src/media/types.d.ts +40 -0
- package/dist/src/media/types.d.ts.map +1 -0
- package/dist/src/preview-generator.d.ts +19 -0
- package/dist/src/preview-generator.d.ts.map +1 -0
- package/dist/src/seo-processor.d.ts +23 -0
- package/dist/src/seo-processor.d.ts.map +1 -0
- package/dist/src/source-finder/ast-extractors.d.ts +35 -0
- package/dist/src/source-finder/ast-extractors.d.ts.map +1 -0
- package/dist/src/source-finder/ast-parser.d.ts +16 -0
- package/dist/src/source-finder/ast-parser.d.ts.map +1 -0
- package/dist/src/source-finder/cache.d.ts +18 -0
- package/dist/src/source-finder/cache.d.ts.map +1 -0
- package/dist/src/source-finder/collection-finder.d.ts +29 -0
- package/dist/src/source-finder/collection-finder.d.ts.map +1 -0
- package/dist/src/source-finder/cross-file-tracker.d.ts +39 -0
- package/dist/src/source-finder/cross-file-tracker.d.ts.map +1 -0
- package/dist/src/source-finder/element-finder.d.ts +42 -0
- package/dist/src/source-finder/element-finder.d.ts.map +1 -0
- package/dist/src/source-finder/image-finder.d.ts +24 -0
- package/dist/src/source-finder/image-finder.d.ts.map +1 -0
- package/dist/src/source-finder/index.d.ts +9 -0
- package/dist/src/source-finder/index.d.ts.map +1 -0
- package/dist/src/source-finder/search-index.d.ts +27 -0
- package/dist/src/source-finder/search-index.d.ts.map +1 -0
- package/dist/src/source-finder/snippet-utils.d.ts +90 -0
- package/dist/src/source-finder/snippet-utils.d.ts.map +1 -0
- package/dist/src/source-finder/source-lookup.d.ts +16 -0
- package/dist/src/source-finder/source-lookup.d.ts.map +1 -0
- package/dist/src/source-finder/types.d.ts +167 -0
- package/dist/src/source-finder/types.d.ts.map +1 -0
- package/dist/src/source-finder/variable-extraction.d.ts +37 -0
- package/dist/src/source-finder/variable-extraction.d.ts.map +1 -0
- package/dist/src/tailwind-colors.d.ts +54 -0
- package/dist/src/tailwind-colors.d.ts.map +1 -0
- package/dist/src/tsconfig.tsbuildinfo +1 -0
- package/dist/src/types.d.ts +367 -0
- package/dist/src/types.d.ts.map +1 -0
- package/dist/src/utils.d.ts +61 -0
- package/dist/src/utils.d.ts.map +1 -0
- package/dist/src/vite-plugin.d.ts +14 -0
- package/dist/src/vite-plugin.d.ts.map +1 -0
- package/dist/types/tsconfig.tsbuildinfo +1 -0
- package/package.json +80 -0
- package/src/build-processor.ts +784 -0
- package/src/collection-scanner.ts +304 -0
- package/src/component-registry.ts +393 -0
- package/src/config.ts +74 -0
- package/src/dev-middleware.ts +525 -0
- package/src/dist/src/tsconfig.tsbuildinfo +1 -0
- package/src/editor/ai.ts +185 -0
- package/src/editor/api.ts +513 -0
- package/src/editor/color-utils.ts +556 -0
- package/src/editor/components/ai-chat.tsx +632 -0
- package/src/editor/components/ai-tooltip.tsx +179 -0
- package/src/editor/components/attribute-editor.tsx +596 -0
- package/src/editor/components/block-editor.tsx +546 -0
- package/src/editor/components/collections-browser.tsx +248 -0
- package/src/editor/components/color-toolbar.tsx +314 -0
- package/src/editor/components/confirm-dialog.tsx +69 -0
- package/src/editor/components/create-page-modal.tsx +163 -0
- package/src/editor/components/editable-highlights.tsx +260 -0
- package/src/editor/components/error-boundary.tsx +87 -0
- package/src/editor/components/fields.tsx +387 -0
- package/src/editor/components/frontmatter-fields.tsx +469 -0
- package/src/editor/components/highlight-overlay.ts +229 -0
- package/src/editor/components/image-overlay.tsx +230 -0
- package/src/editor/components/markdown-editor-overlay.tsx +505 -0
- package/src/editor/components/markdown-inline-editor.tsx +780 -0
- package/src/editor/components/media-library.tsx +297 -0
- package/src/editor/components/outline.tsx +402 -0
- package/src/editor/components/redirect-countdown.tsx +45 -0
- package/src/editor/components/seo-editor.tsx +498 -0
- package/src/editor/components/text-style-toolbar.tsx +362 -0
- package/src/editor/components/toast/toast-container.tsx +15 -0
- package/src/editor/components/toast/toast.tsx +49 -0
- package/src/editor/components/toast/types.ts +7 -0
- package/src/editor/components/toolbar.tsx +366 -0
- package/src/editor/config.ts +12 -0
- package/src/editor/constants.ts +106 -0
- package/src/editor/context.tsx +38 -0
- package/src/editor/dom.ts +357 -0
- package/src/editor/editor.ts +1510 -0
- package/src/editor/env.d.ts +4 -0
- package/src/editor/history.ts +355 -0
- package/src/editor/hooks/index.ts +19 -0
- package/src/editor/hooks/useAIHandlers.ts +345 -0
- package/src/editor/hooks/useBlockEditorHandlers.ts +206 -0
- package/src/editor/hooks/useElementDetection.ts +284 -0
- package/src/editor/hooks/useImageHoverDetection.ts +102 -0
- package/src/editor/hooks/useTextSelection.ts +187 -0
- package/src/editor/hooks/useTooltipState.ts +126 -0
- package/src/editor/hooks/utils.ts +101 -0
- package/src/editor/index.tsx +481 -0
- package/src/editor/lib/cn.ts +4 -0
- package/src/editor/manifest.ts +25 -0
- package/src/editor/markdown-api.ts +209 -0
- package/src/editor/signals.ts +1351 -0
- package/src/editor/storage.ts +266 -0
- package/src/editor/styles.css +465 -0
- package/src/editor/text-styling.ts +773 -0
- package/src/editor/themes.ts +210 -0
- package/src/editor/types.ts +591 -0
- package/src/error-collector.ts +106 -0
- package/src/handlers/component-ops.ts +463 -0
- package/src/handlers/markdown-ops.ts +202 -0
- package/src/handlers/request-utils.ts +151 -0
- package/src/handlers/source-writer.ts +649 -0
- package/src/html-processor.ts +1108 -0
- package/src/index.ts +284 -0
- package/src/manifest-writer.ts +371 -0
- package/src/media/contember.ts +84 -0
- package/src/media/local.ts +114 -0
- package/src/media/s3.ts +133 -0
- package/src/media/types.ts +33 -0
- package/src/preview-generator.ts +293 -0
- package/src/seo-processor.ts +567 -0
- package/src/source-finder/ast-extractors.ts +185 -0
- package/src/source-finder/ast-parser.ts +150 -0
- package/src/source-finder/cache.ts +76 -0
- package/src/source-finder/collection-finder.ts +335 -0
- package/src/source-finder/cross-file-tracker.ts +741 -0
- package/src/source-finder/element-finder.ts +387 -0
- package/src/source-finder/image-finder.ts +283 -0
- package/src/source-finder/index.ts +37 -0
- package/src/source-finder/search-index.ts +525 -0
- package/src/source-finder/snippet-utils.ts +668 -0
- package/src/source-finder/source-lookup.ts +200 -0
- package/src/source-finder/types.ts +210 -0
- package/src/source-finder/variable-extraction.ts +406 -0
- package/src/tailwind-colors.ts +874 -0
- package/src/tsconfig.json +25 -0
- package/src/types.ts +406 -0
- package/src/utils.ts +186 -0
- package/src/vite-plugin.ts +42 -0
|
@@ -0,0 +1,1510 @@
|
|
|
1
|
+
import type { Attribute } from './types'
|
|
2
|
+
import { fetchManifest, getDeploymentStatus, getMarkdownContent, saveBatchChanges } from './api'
|
|
3
|
+
import { CSS, TIMING } from './constants'
|
|
4
|
+
import { clearHistory, isApplyingUndoRedo, recordChange, recordTextChange } from './history'
|
|
5
|
+
import {
|
|
6
|
+
cleanupHighlightSystem,
|
|
7
|
+
disableAllInteractiveElements,
|
|
8
|
+
enableAllInteractiveElements,
|
|
9
|
+
findInnermostCmsElement,
|
|
10
|
+
getAllCmsElements,
|
|
11
|
+
getChildCmsElements,
|
|
12
|
+
getEditableHtmlFromElement,
|
|
13
|
+
getEditableTextFromElement,
|
|
14
|
+
initHighlightSystem,
|
|
15
|
+
logDebug,
|
|
16
|
+
makeElementEditable,
|
|
17
|
+
makeElementNonEditable,
|
|
18
|
+
} from './dom'
|
|
19
|
+
import { getManifestEntryCount, hasManifestEntry } from './manifest'
|
|
20
|
+
import * as signals from './signals'
|
|
21
|
+
import {
|
|
22
|
+
clearAllEditsFromStorage,
|
|
23
|
+
loadAttributeEditsFromStorage,
|
|
24
|
+
loadColorEditsFromStorage,
|
|
25
|
+
loadEditsFromStorage,
|
|
26
|
+
loadImageEditsFromStorage,
|
|
27
|
+
loadPendingEntryNavigation,
|
|
28
|
+
saveAttributeEditsToStorage,
|
|
29
|
+
saveColorEditsToStorage,
|
|
30
|
+
saveEditsToStorage,
|
|
31
|
+
saveImageEditsToStorage,
|
|
32
|
+
} from './storage'
|
|
33
|
+
import type { AttributeChangePayload, ChangePayload, CmsConfig, DeploymentStatusResponse, ManifestEntry, SavedAttributeEdit } from './types'
|
|
34
|
+
|
|
35
|
+
// CSS attribute for markdown content elements
|
|
36
|
+
const MARKDOWN_ATTRIBUTE = 'data-cms-markdown'
|
|
37
|
+
// CSS attribute for image elements
|
|
38
|
+
const IMAGE_ATTRIBUTE = 'data-cms-img'
|
|
39
|
+
|
|
40
|
+
/**
|
|
41
|
+
* Inline HTML elements that indicate styled/formatted content.
|
|
42
|
+
* When an element contains these, we need to preserve the HTML structure.
|
|
43
|
+
*/
|
|
44
|
+
const INLINE_STYLE_ELEMENTS = [
|
|
45
|
+
'strong',
|
|
46
|
+
'b',
|
|
47
|
+
'em',
|
|
48
|
+
'i',
|
|
49
|
+
'u',
|
|
50
|
+
's',
|
|
51
|
+
'strike',
|
|
52
|
+
'del',
|
|
53
|
+
'ins',
|
|
54
|
+
'mark',
|
|
55
|
+
'small',
|
|
56
|
+
'sub',
|
|
57
|
+
'sup',
|
|
58
|
+
'abbr',
|
|
59
|
+
'cite',
|
|
60
|
+
'code',
|
|
61
|
+
'kbd',
|
|
62
|
+
'samp',
|
|
63
|
+
'var',
|
|
64
|
+
'time',
|
|
65
|
+
'dfn',
|
|
66
|
+
'q',
|
|
67
|
+
]
|
|
68
|
+
|
|
69
|
+
/**
|
|
70
|
+
* Check if an element contains styled/formatted content (inline text styling).
|
|
71
|
+
* This includes:
|
|
72
|
+
* - Spans with data-cms-styled attribute (Tailwind styled)
|
|
73
|
+
* - Inline HTML elements (strong, b, em, i, etc.)
|
|
74
|
+
*/
|
|
75
|
+
function hasStyledContent(el: HTMLElement): boolean {
|
|
76
|
+
// Check for spans with explicit styling attribute
|
|
77
|
+
if (el.querySelector('[data-cms-styled]') !== null) {
|
|
78
|
+
return true
|
|
79
|
+
}
|
|
80
|
+
|
|
81
|
+
// Check for inline HTML style elements
|
|
82
|
+
const selector = INLINE_STYLE_ELEMENTS.join(', ')
|
|
83
|
+
return el.querySelector(selector) !== null
|
|
84
|
+
}
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Start edit mode - enables inline editing on all CMS elements.
|
|
88
|
+
* Uses signals for state management.
|
|
89
|
+
*/
|
|
90
|
+
export async function startEditMode(
|
|
91
|
+
config: CmsConfig,
|
|
92
|
+
onStateChange?: () => void,
|
|
93
|
+
): Promise<void> {
|
|
94
|
+
signals.setEditing(true)
|
|
95
|
+
disableAllInteractiveElements()
|
|
96
|
+
initHighlightSystem()
|
|
97
|
+
onStateChange?.()
|
|
98
|
+
|
|
99
|
+
try {
|
|
100
|
+
const manifest = await fetchManifest()
|
|
101
|
+
signals.setManifest(manifest)
|
|
102
|
+
const entryCount = getManifestEntryCount(manifest)
|
|
103
|
+
logDebug(config.debug, 'Loaded manifest with', entryCount, 'entries')
|
|
104
|
+
} catch (err) {
|
|
105
|
+
console.error('[CMS] Failed to load manifest:', err)
|
|
106
|
+
return
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
const savedEdits = loadEditsFromStorage()
|
|
110
|
+
const savedImageEdits = loadImageEditsFromStorage()
|
|
111
|
+
const savedColorEdits = loadColorEditsFromStorage()
|
|
112
|
+
const savedAttributeEdits = loadAttributeEditsFromStorage()
|
|
113
|
+
const currentManifest = signals.manifest.value
|
|
114
|
+
|
|
115
|
+
getAllCmsElements().forEach(el => {
|
|
116
|
+
const cmsId = el.getAttribute(CSS.ID_ATTRIBUTE)
|
|
117
|
+
if (!cmsId) return
|
|
118
|
+
|
|
119
|
+
// Skip component elements - they should not be contentEditable
|
|
120
|
+
// Components are marked with data-cms-component-id and are block-level editable
|
|
121
|
+
if (el.hasAttribute(CSS.COMPONENT_ID_ATTRIBUTE)) {
|
|
122
|
+
logDebug(config.debug, 'Skipping component element:', cmsId)
|
|
123
|
+
makeElementNonEditable(el)
|
|
124
|
+
return
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
if (!hasManifestEntry(currentManifest, cmsId)) {
|
|
128
|
+
logDebug(config.debug, 'Skipping element not in manifest:', cmsId)
|
|
129
|
+
makeElementNonEditable(el)
|
|
130
|
+
return
|
|
131
|
+
}
|
|
132
|
+
|
|
133
|
+
// Check if this is a markdown content element
|
|
134
|
+
// Markdown elements use WYSIWYG editing instead of contentEditable
|
|
135
|
+
if (el.hasAttribute(MARKDOWN_ATTRIBUTE)) {
|
|
136
|
+
logDebug(config.debug, 'Markdown element detected:', cmsId)
|
|
137
|
+
makeElementNonEditable(el)
|
|
138
|
+
// Add click handler for markdown elements to open the editor
|
|
139
|
+
setupMarkdownClickHandler(config, el, cmsId, onStateChange)
|
|
140
|
+
return
|
|
141
|
+
}
|
|
142
|
+
|
|
143
|
+
// Check if this is an image element
|
|
144
|
+
// Image elements open the media library for replacement
|
|
145
|
+
if (el.hasAttribute(IMAGE_ATTRIBUTE)) {
|
|
146
|
+
logDebug(config.debug, 'Image element detected:', cmsId)
|
|
147
|
+
makeElementNonEditable(el)
|
|
148
|
+
setupImageClickHandler(config, el as HTMLImageElement, cmsId, savedImageEdits[cmsId], onStateChange)
|
|
149
|
+
return
|
|
150
|
+
}
|
|
151
|
+
|
|
152
|
+
makeElementEditable(el)
|
|
153
|
+
|
|
154
|
+
// Suppress browser native contentEditable undo/redo (we handle it ourselves)
|
|
155
|
+
el.addEventListener('beforeinput', (e) => {
|
|
156
|
+
if (e.inputType === 'historyUndo' || e.inputType === 'historyRedo') {
|
|
157
|
+
e.preventDefault()
|
|
158
|
+
}
|
|
159
|
+
})
|
|
160
|
+
|
|
161
|
+
// Setup color tracking for elements with colorClasses in manifest
|
|
162
|
+
setupColorTracking(config, el, cmsId, savedColorEdits[cmsId])
|
|
163
|
+
|
|
164
|
+
// Setup attribute tracking for elements with editable attributes in manifest
|
|
165
|
+
setupAttributeTracking(config, el, cmsId, savedAttributeEdits[cmsId])
|
|
166
|
+
|
|
167
|
+
if (!signals.pendingChanges.value.has(cmsId)) {
|
|
168
|
+
const originalHTML = el.innerHTML
|
|
169
|
+
const originalText = getEditableTextFromElement(el)
|
|
170
|
+
|
|
171
|
+
logDebug(config.debug, 'Setting up element:', cmsId, 'originalText:', originalText)
|
|
172
|
+
|
|
173
|
+
const childCmsElements = getChildCmsElements(el)
|
|
174
|
+
const savedEdit = savedEdits[cmsId]
|
|
175
|
+
|
|
176
|
+
let currentHTML = originalHTML
|
|
177
|
+
let newText = originalText
|
|
178
|
+
let isDirty = false
|
|
179
|
+
|
|
180
|
+
if (savedEdit) {
|
|
181
|
+
// Use currentHTML for visual display, newText for the placeholder representation
|
|
182
|
+
currentHTML = savedEdit.currentHTML
|
|
183
|
+
newText = savedEdit.newText
|
|
184
|
+
isDirty = true
|
|
185
|
+
el.innerHTML = currentHTML
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// Check for styled content after restoring HTML
|
|
189
|
+
const hasStyled = hasStyledContent(el)
|
|
190
|
+
|
|
191
|
+
signals.setPendingChange(cmsId, {
|
|
192
|
+
element: el,
|
|
193
|
+
originalHTML,
|
|
194
|
+
originalText,
|
|
195
|
+
newText,
|
|
196
|
+
currentHTML,
|
|
197
|
+
isDirty,
|
|
198
|
+
childCmsElements,
|
|
199
|
+
hasStyledContent: hasStyled,
|
|
200
|
+
})
|
|
201
|
+
}
|
|
202
|
+
|
|
203
|
+
el.addEventListener('input', (e) => {
|
|
204
|
+
const currentId = signals.currentEditingId.value
|
|
205
|
+
logDebug(
|
|
206
|
+
config.debug,
|
|
207
|
+
'Input event on',
|
|
208
|
+
cmsId,
|
|
209
|
+
'currentEditingId:',
|
|
210
|
+
currentId,
|
|
211
|
+
'target:',
|
|
212
|
+
(e.target as HTMLElement).getAttribute('data-cms-id'),
|
|
213
|
+
)
|
|
214
|
+
if (currentId === cmsId) {
|
|
215
|
+
e.stopPropagation()
|
|
216
|
+
logDebug(config.debug, 'Handling input for', cmsId)
|
|
217
|
+
handleElementChange(config, cmsId, el, onStateChange)
|
|
218
|
+
} else {
|
|
219
|
+
logDebug(config.debug, 'Skipping input - not current editing element, expected:', currentId)
|
|
220
|
+
}
|
|
221
|
+
})
|
|
222
|
+
|
|
223
|
+
el.addEventListener(
|
|
224
|
+
'click',
|
|
225
|
+
(e) => {
|
|
226
|
+
if (e.detail !== 1) return
|
|
227
|
+
|
|
228
|
+
const innermostCms = findInnermostCmsElement(e.target)
|
|
229
|
+
|
|
230
|
+
if (innermostCms) {
|
|
231
|
+
const targetId = innermostCms.getAttribute('data-cms-id')
|
|
232
|
+
innermostCms.focus()
|
|
233
|
+
signals.setCurrentEditingId(targetId)
|
|
234
|
+
// Update chat context if chat is open
|
|
235
|
+
if (signals.isChatOpen.value && targetId) {
|
|
236
|
+
signals.setChatContextElement(targetId)
|
|
237
|
+
}
|
|
238
|
+
logDebug(config.debug, 'Click - focusing innermost CMS element:', targetId)
|
|
239
|
+
onStateChange?.()
|
|
240
|
+
}
|
|
241
|
+
},
|
|
242
|
+
true,
|
|
243
|
+
)
|
|
244
|
+
|
|
245
|
+
el.addEventListener(
|
|
246
|
+
'focus',
|
|
247
|
+
(e) => {
|
|
248
|
+
if (e.target === el) {
|
|
249
|
+
signals.setCurrentEditingId(cmsId)
|
|
250
|
+
// Update chat context if chat is open
|
|
251
|
+
if (signals.isChatOpen.value && cmsId) {
|
|
252
|
+
signals.setChatContextElement(cmsId)
|
|
253
|
+
}
|
|
254
|
+
logDebug(config.debug, 'Focus on', cmsId)
|
|
255
|
+
onStateChange?.()
|
|
256
|
+
}
|
|
257
|
+
},
|
|
258
|
+
false,
|
|
259
|
+
)
|
|
260
|
+
|
|
261
|
+
el.addEventListener('blur', (e) => {
|
|
262
|
+
// Don't clear currentEditingId if clicking on CMS UI elements
|
|
263
|
+
const relatedTarget = (e as FocusEvent).relatedTarget as HTMLElement | null
|
|
264
|
+
|
|
265
|
+
// Check if we're focusing on another CMS element
|
|
266
|
+
if (relatedTarget?.hasAttribute(CSS.ID_ATTRIBUTE)) {
|
|
267
|
+
return // Let the new element's focus handler set the ID
|
|
268
|
+
}
|
|
269
|
+
|
|
270
|
+
// Check if we're clicking on CMS UI (toolbar, tooltip, chat) using data-cms-ui attribute
|
|
271
|
+
if (relatedTarget?.hasAttribute(CSS.UI_ATTRIBUTE) || relatedTarget?.closest(`[${CSS.UI_ATTRIBUTE}]`)) {
|
|
272
|
+
return // Keep current selection
|
|
273
|
+
}
|
|
274
|
+
|
|
275
|
+
// Allow a small delay to check if we're clicking inside CMS UI
|
|
276
|
+
setTimeout(() => {
|
|
277
|
+
const activeElement = document.activeElement as HTMLElement | null
|
|
278
|
+
|
|
279
|
+
// Check if active element is inside CMS UI
|
|
280
|
+
if (activeElement?.hasAttribute(CSS.UI_ATTRIBUTE) || activeElement?.closest(`[${CSS.UI_ATTRIBUTE}]`)) {
|
|
281
|
+
return // Keep current selection
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
// Only clear if we actually lost focus to something else
|
|
285
|
+
if (signals.currentEditingId.value === cmsId) {
|
|
286
|
+
signals.setCurrentEditingId(null)
|
|
287
|
+
onStateChange?.()
|
|
288
|
+
}
|
|
289
|
+
}, TIMING.BLUR_DELAY_MS)
|
|
290
|
+
})
|
|
291
|
+
})
|
|
292
|
+
|
|
293
|
+
// Check for pending entry navigation (from collections browser cross-page navigation)
|
|
294
|
+
const pendingEntry = loadPendingEntryNavigation()
|
|
295
|
+
if (pendingEntry) {
|
|
296
|
+
const collectionDef = signals.manifest.value.collectionDefinitions?.[pendingEntry.collectionName]
|
|
297
|
+
if (collectionDef) {
|
|
298
|
+
signals.openMarkdownEditorForEntry(
|
|
299
|
+
pendingEntry.collectionName,
|
|
300
|
+
pendingEntry.slug,
|
|
301
|
+
pendingEntry.sourcePath,
|
|
302
|
+
collectionDef,
|
|
303
|
+
)
|
|
304
|
+
}
|
|
305
|
+
}
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
/**
|
|
309
|
+
* Stop edit mode - disables inline editing.
|
|
310
|
+
*/
|
|
311
|
+
export function stopEditMode(onStateChange?: () => void): void {
|
|
312
|
+
signals.setEditing(false)
|
|
313
|
+
signals.setShowingOriginal(false)
|
|
314
|
+
enableAllInteractiveElements()
|
|
315
|
+
cleanupHighlightSystem()
|
|
316
|
+
onStateChange?.()
|
|
317
|
+
|
|
318
|
+
// Close all open dialogs
|
|
319
|
+
signals.closeAttributeEditor()
|
|
320
|
+
signals.closeSeoEditor()
|
|
321
|
+
signals.closeColorEditor()
|
|
322
|
+
signals.resetMediaLibraryState()
|
|
323
|
+
signals.resetMarkdownEditorState()
|
|
324
|
+
signals.resetCreatePageState()
|
|
325
|
+
|
|
326
|
+
getAllCmsElements().forEach(el => {
|
|
327
|
+
makeElementNonEditable(el)
|
|
328
|
+
})
|
|
329
|
+
}
|
|
330
|
+
|
|
331
|
+
/**
|
|
332
|
+
* Handle element content change - tracks dirty state.
|
|
333
|
+
*/
|
|
334
|
+
export function handleElementChange(
|
|
335
|
+
config: CmsConfig,
|
|
336
|
+
cmsId: string,
|
|
337
|
+
el: HTMLElement,
|
|
338
|
+
onStateChange?: () => void,
|
|
339
|
+
): void {
|
|
340
|
+
logDebug(config.debug, 'handleElementChange called for', cmsId)
|
|
341
|
+
const change = signals.getPendingChange(cmsId)
|
|
342
|
+
|
|
343
|
+
if (!change) {
|
|
344
|
+
logDebug(config.debug, 'ERROR: No change tracked for', cmsId)
|
|
345
|
+
logDebug(config.debug, 'Available IDs in pendingChanges:', Array.from(signals.pendingChanges.value.keys()))
|
|
346
|
+
logDebug(config.debug, 'Element:', el.tagName, el.textContent?.substring(0, 50))
|
|
347
|
+
return
|
|
348
|
+
}
|
|
349
|
+
|
|
350
|
+
const newHTML = el.innerHTML
|
|
351
|
+
const hasStyled = hasStyledContent(el)
|
|
352
|
+
|
|
353
|
+
// For styled content, use innerHTML as newText to preserve the styled spans
|
|
354
|
+
// For plain text, use the extracted text
|
|
355
|
+
const newText = hasStyled ? getEditableHtmlFromElement(el) : getEditableTextFromElement(el)
|
|
356
|
+
|
|
357
|
+
const textChanged = newText !== change.originalText
|
|
358
|
+
// Also consider as changed if HTML differs (e.g., styling like bold was applied)
|
|
359
|
+
const htmlChanged = newHTML !== change.originalHTML
|
|
360
|
+
const isDirty = textChanged || htmlChanged
|
|
361
|
+
|
|
362
|
+
const updatedChildElements = change.childCmsElements?.map(child => {
|
|
363
|
+
const childEl = el.querySelector(`[data-cms-id="${child.id}"]`)
|
|
364
|
+
if (childEl) {
|
|
365
|
+
return { ...child, currentHTML: childEl.outerHTML }
|
|
366
|
+
}
|
|
367
|
+
return child
|
|
368
|
+
})
|
|
369
|
+
|
|
370
|
+
// Record undo action before updating signal
|
|
371
|
+
if (!isApplyingUndoRedo) {
|
|
372
|
+
recordTextChange({
|
|
373
|
+
type: 'text',
|
|
374
|
+
cmsId,
|
|
375
|
+
element: el,
|
|
376
|
+
previousHTML: change.currentHTML,
|
|
377
|
+
previousText: change.newText,
|
|
378
|
+
currentHTML: newHTML,
|
|
379
|
+
currentText: newText,
|
|
380
|
+
wasDirty: change.isDirty,
|
|
381
|
+
})
|
|
382
|
+
}
|
|
383
|
+
|
|
384
|
+
// Update the change in signals
|
|
385
|
+
signals.updatePendingChange(cmsId, (c) => ({
|
|
386
|
+
...c,
|
|
387
|
+
newText,
|
|
388
|
+
currentHTML: newHTML,
|
|
389
|
+
isDirty,
|
|
390
|
+
childCmsElements: updatedChildElements,
|
|
391
|
+
hasStyledContent: hasStyled,
|
|
392
|
+
}))
|
|
393
|
+
|
|
394
|
+
logDebug(config.debug, `Change tracked for ${cmsId}:`, {
|
|
395
|
+
originalText: change.originalText,
|
|
396
|
+
newText,
|
|
397
|
+
isDirty,
|
|
398
|
+
textChanged,
|
|
399
|
+
htmlChanged,
|
|
400
|
+
hasStyledContent: hasStyled,
|
|
401
|
+
})
|
|
402
|
+
|
|
403
|
+
saveEditsToStorage(signals.pendingChanges.value)
|
|
404
|
+
onStateChange?.()
|
|
405
|
+
}
|
|
406
|
+
|
|
407
|
+
/**
|
|
408
|
+
* Toggle showing original content vs edited content.
|
|
409
|
+
*/
|
|
410
|
+
export function toggleShowOriginal(
|
|
411
|
+
config: CmsConfig,
|
|
412
|
+
onStateChange?: () => void,
|
|
413
|
+
): void {
|
|
414
|
+
const newShowingOriginal = !signals.showingOriginal.value
|
|
415
|
+
signals.setShowingOriginal(newShowingOriginal)
|
|
416
|
+
|
|
417
|
+
signals.pendingChanges.value.forEach((change) => {
|
|
418
|
+
if (newShowingOriginal) {
|
|
419
|
+
change.element.innerHTML = change.originalHTML
|
|
420
|
+
makeElementNonEditable(change.element)
|
|
421
|
+
} else {
|
|
422
|
+
change.element.innerHTML = change.currentHTML || change.originalHTML
|
|
423
|
+
makeElementEditable(change.element)
|
|
424
|
+
}
|
|
425
|
+
})
|
|
426
|
+
|
|
427
|
+
// Toggle image sources between original and new
|
|
428
|
+
signals.pendingImageChanges.value.forEach((change) => {
|
|
429
|
+
if (newShowingOriginal) {
|
|
430
|
+
change.element.src = change.originalSrc
|
|
431
|
+
change.element.alt = change.originalAlt
|
|
432
|
+
// Restore original srcset when showing original
|
|
433
|
+
if (change.originalSrcSet) {
|
|
434
|
+
change.element.setAttribute('srcset', change.originalSrcSet)
|
|
435
|
+
}
|
|
436
|
+
} else {
|
|
437
|
+
change.element.src = change.newSrc
|
|
438
|
+
change.element.alt = change.newAlt
|
|
439
|
+
// Clear srcset when showing new image so browser uses src
|
|
440
|
+
if (change.isDirty) {
|
|
441
|
+
change.element.removeAttribute('srcset')
|
|
442
|
+
}
|
|
443
|
+
}
|
|
444
|
+
})
|
|
445
|
+
|
|
446
|
+
onStateChange?.()
|
|
447
|
+
}
|
|
448
|
+
|
|
449
|
+
/**
|
|
450
|
+
* Discard all pending changes and restore original content.
|
|
451
|
+
* Note: Confirmation is handled by the caller (e.g., toolbar).
|
|
452
|
+
*/
|
|
453
|
+
export function discardAllChanges(onStateChange?: () => void): void {
|
|
454
|
+
signals.pendingChanges.value.forEach((change) => {
|
|
455
|
+
change.element.innerHTML = change.originalHTML
|
|
456
|
+
makeElementNonEditable(change.element)
|
|
457
|
+
})
|
|
458
|
+
|
|
459
|
+
// Restore original image sources
|
|
460
|
+
signals.pendingImageChanges.value.forEach((change) => {
|
|
461
|
+
change.element.src = change.originalSrc
|
|
462
|
+
change.element.alt = change.originalAlt
|
|
463
|
+
// Restore original srcset
|
|
464
|
+
if (change.originalSrcSet) {
|
|
465
|
+
change.element.setAttribute('srcset', change.originalSrcSet)
|
|
466
|
+
}
|
|
467
|
+
})
|
|
468
|
+
|
|
469
|
+
// Restore original color classes
|
|
470
|
+
signals.pendingColorChanges.value.forEach((change) => {
|
|
471
|
+
const { element, originalClasses, newClasses } = change
|
|
472
|
+
// Remove new color classes and add back original ones
|
|
473
|
+
const classes = element.className.split(/\s+/).filter(Boolean)
|
|
474
|
+
const newClassValues = new Set(Object.values(newClasses).map(a => a.value).filter(Boolean))
|
|
475
|
+
const originalClassValues = new Set(Object.values(originalClasses).map(a => a.value).filter(Boolean))
|
|
476
|
+
|
|
477
|
+
// Filter out new classes
|
|
478
|
+
const filtered = classes.filter(c => !newClassValues.has(c))
|
|
479
|
+
// Add back original classes
|
|
480
|
+
originalClassValues.forEach(c => {
|
|
481
|
+
if (!filtered.includes(c)) {
|
|
482
|
+
filtered.push(c)
|
|
483
|
+
}
|
|
484
|
+
})
|
|
485
|
+
element.className = filtered.join(' ')
|
|
486
|
+
|
|
487
|
+
// Clear inline color styles
|
|
488
|
+
element.style.backgroundColor = ''
|
|
489
|
+
element.style.color = ''
|
|
490
|
+
})
|
|
491
|
+
|
|
492
|
+
// Restore original attributes
|
|
493
|
+
signals.pendingAttributeChanges.value.forEach((change) => {
|
|
494
|
+
const { element, originalAttributes } = change
|
|
495
|
+
applyAttributesToElement(element, originalAttributes)
|
|
496
|
+
})
|
|
497
|
+
|
|
498
|
+
cleanupHighlightSystem()
|
|
499
|
+
signals.clearPendingChanges()
|
|
500
|
+
signals.clearPendingImageChanges()
|
|
501
|
+
signals.clearPendingColorChanges()
|
|
502
|
+
signals.clearPendingAttributeChanges()
|
|
503
|
+
clearAllEditsFromStorage()
|
|
504
|
+
clearHistory()
|
|
505
|
+
stopEditMode(onStateChange)
|
|
506
|
+
}
|
|
507
|
+
|
|
508
|
+
function findSeoSourceById(
|
|
509
|
+
seoData: import('./types').PageSeoData | undefined,
|
|
510
|
+
id: string,
|
|
511
|
+
): { sourcePath: string; sourceLine: number; sourceSnippet: string; content: string } | null {
|
|
512
|
+
if (!seoData) return null
|
|
513
|
+
|
|
514
|
+
const fields = [
|
|
515
|
+
seoData.title,
|
|
516
|
+
seoData.description,
|
|
517
|
+
seoData.keywords,
|
|
518
|
+
seoData.canonical,
|
|
519
|
+
...(seoData.openGraph ? Object.values(seoData.openGraph) : []),
|
|
520
|
+
...(seoData.twitterCard ? Object.values(seoData.twitterCard) : []),
|
|
521
|
+
]
|
|
522
|
+
|
|
523
|
+
for (const field of fields) {
|
|
524
|
+
if (field && (field as any).id === id) {
|
|
525
|
+
return {
|
|
526
|
+
sourcePath: field.sourcePath ?? '',
|
|
527
|
+
sourceLine: field.sourceLine ?? 0,
|
|
528
|
+
sourceSnippet: field.sourceSnippet ?? '',
|
|
529
|
+
content: (field as any).content ?? (field as any).href ?? '',
|
|
530
|
+
}
|
|
531
|
+
}
|
|
532
|
+
}
|
|
533
|
+
return null
|
|
534
|
+
}
|
|
535
|
+
|
|
536
|
+
/**
|
|
537
|
+
* Save all dirty changes to the server.
|
|
538
|
+
*/
|
|
539
|
+
export async function saveAllChanges(
|
|
540
|
+
config: CmsConfig,
|
|
541
|
+
onStateChange?: () => void,
|
|
542
|
+
): Promise<{ success: boolean; updated: number; errors?: Array<{ cmsId: string; error: string }> }> {
|
|
543
|
+
const dirtyChanges = signals.dirtyChanges.value
|
|
544
|
+
const dirtyImageChanges = signals.dirtyImageChanges.value
|
|
545
|
+
const dirtyColorChanges = signals.dirtyColorChanges.value
|
|
546
|
+
const dirtyAttributeChanges = signals.dirtyAttributeChanges.value
|
|
547
|
+
const dirtySeoChanges = signals.dirtySeoChanges.value
|
|
548
|
+
|
|
549
|
+
if (
|
|
550
|
+
dirtyChanges.length === 0 && dirtyImageChanges.length === 0 && dirtyColorChanges.length === 0 && dirtyAttributeChanges.length === 0
|
|
551
|
+
&& dirtySeoChanges.length === 0
|
|
552
|
+
) {
|
|
553
|
+
return { success: true, updated: 0 }
|
|
554
|
+
}
|
|
555
|
+
|
|
556
|
+
signals.isSaving.value = true
|
|
557
|
+
try {
|
|
558
|
+
const manifest = signals.manifest.value
|
|
559
|
+
console.log('[CMS] Manifest entries keys:', Object.keys(manifest.entries).slice(0, 10))
|
|
560
|
+
|
|
561
|
+
const changes: ChangePayload[] = dirtyChanges.map(([cmsId, change]) => {
|
|
562
|
+
const entry = manifest.entries[cmsId]
|
|
563
|
+
|
|
564
|
+
// Debug: log entry lookup
|
|
565
|
+
if (!entry) {
|
|
566
|
+
console.warn(`[CMS] No manifest entry found for ${cmsId}. Available keys:`, Object.keys(manifest.entries))
|
|
567
|
+
} else if (!entry.sourcePath) {
|
|
568
|
+
console.warn(`[CMS] Entry ${cmsId} has no sourcePath:`, JSON.stringify(entry, null, 2))
|
|
569
|
+
}
|
|
570
|
+
|
|
571
|
+
const payload: ChangePayload = {
|
|
572
|
+
cmsId,
|
|
573
|
+
newValue: change.newText,
|
|
574
|
+
originalValue: entry?.text ?? change.originalText,
|
|
575
|
+
sourcePath: entry?.sourcePath ?? '',
|
|
576
|
+
sourceLine: entry?.sourceLine ?? 0,
|
|
577
|
+
sourceSnippet: entry?.sourceSnippet ?? '',
|
|
578
|
+
}
|
|
579
|
+
|
|
580
|
+
if (change.childCmsElements && change.childCmsElements.length > 0) {
|
|
581
|
+
payload.childCmsIds = change.childCmsElements.map(c => c.id)
|
|
582
|
+
}
|
|
583
|
+
|
|
584
|
+
// Include HTML content when there are styled spans
|
|
585
|
+
if (change.hasStyledContent) {
|
|
586
|
+
payload.hasStyledContent = true
|
|
587
|
+
payload.htmlValue = getEditableHtmlFromElement(change.element)
|
|
588
|
+
}
|
|
589
|
+
|
|
590
|
+
return payload
|
|
591
|
+
})
|
|
592
|
+
|
|
593
|
+
// Add image changes to the payload
|
|
594
|
+
dirtyImageChanges.forEach(([cmsId, change]) => {
|
|
595
|
+
const entry = manifest.entries[cmsId]
|
|
596
|
+
changes.push({
|
|
597
|
+
cmsId,
|
|
598
|
+
newValue: change.newSrc,
|
|
599
|
+
originalValue: change.originalSrc,
|
|
600
|
+
sourcePath: entry?.sourcePath ?? '',
|
|
601
|
+
sourceLine: entry?.sourceLine ?? 0,
|
|
602
|
+
sourceSnippet: entry?.sourceSnippet ?? '',
|
|
603
|
+
imageChange: {
|
|
604
|
+
newSrc: change.newSrc,
|
|
605
|
+
newAlt: change.newAlt,
|
|
606
|
+
},
|
|
607
|
+
})
|
|
608
|
+
})
|
|
609
|
+
|
|
610
|
+
// Add color changes to the payload
|
|
611
|
+
dirtyColorChanges.forEach(([cmsId, change]) => {
|
|
612
|
+
// For each color type that changed, add a separate change entry
|
|
613
|
+
const { originalClasses, newClasses } = change
|
|
614
|
+
const entry = manifest.entries[cmsId]
|
|
615
|
+
const colorTypes = ['bg', 'text', 'border', 'hoverBg', 'hoverText'] as const
|
|
616
|
+
|
|
617
|
+
// Find the best source info from any color type that has it
|
|
618
|
+
// (all color types share the same class attribute on the same element)
|
|
619
|
+
let sharedSourcePath: string | undefined
|
|
620
|
+
let sharedSourceLine: number | undefined
|
|
621
|
+
let sharedSourceSnippet: string | undefined
|
|
622
|
+
for (const ct of colorTypes) {
|
|
623
|
+
const orig = originalClasses[ct]
|
|
624
|
+
const curr = newClasses[ct]
|
|
625
|
+
const sp = curr?.sourcePath ?? orig?.sourcePath
|
|
626
|
+
const sl = curr?.sourceLine ?? orig?.sourceLine
|
|
627
|
+
if (sp && sl) {
|
|
628
|
+
sharedSourcePath = sp
|
|
629
|
+
sharedSourceLine = sl
|
|
630
|
+
sharedSourceSnippet = curr?.sourceSnippet ?? orig?.sourceSnippet
|
|
631
|
+
break
|
|
632
|
+
}
|
|
633
|
+
}
|
|
634
|
+
|
|
635
|
+
for (const colorType of colorTypes) {
|
|
636
|
+
const origAttr = originalClasses[colorType]
|
|
637
|
+
const newAttr = newClasses[colorType]
|
|
638
|
+
if (newAttr?.value && newAttr.value !== origAttr?.value) {
|
|
639
|
+
const bestSourcePath = newAttr.sourcePath ?? origAttr?.sourcePath ?? sharedSourcePath
|
|
640
|
+
const bestSourceLine = newAttr.sourceLine ?? origAttr?.sourceLine ?? sharedSourceLine
|
|
641
|
+
const bestSourceSnippet = newAttr.sourceSnippet ?? origAttr?.sourceSnippet ?? sharedSourceSnippet
|
|
642
|
+
changes.push({
|
|
643
|
+
cmsId,
|
|
644
|
+
newValue: '',
|
|
645
|
+
originalValue: '',
|
|
646
|
+
sourcePath: bestSourcePath ?? entry?.sourcePath ?? '',
|
|
647
|
+
sourceLine: bestSourceLine ?? entry?.sourceLine ?? 0,
|
|
648
|
+
sourceSnippet: bestSourceSnippet ?? entry?.sourceSnippet ?? '',
|
|
649
|
+
colorChange: {
|
|
650
|
+
oldClass: origAttr?.value || '',
|
|
651
|
+
newClass: newAttr.value,
|
|
652
|
+
type: colorType,
|
|
653
|
+
sourcePath: bestSourcePath,
|
|
654
|
+
sourceLine: bestSourceLine,
|
|
655
|
+
sourceSnippet: bestSourceSnippet,
|
|
656
|
+
},
|
|
657
|
+
})
|
|
658
|
+
}
|
|
659
|
+
}
|
|
660
|
+
})
|
|
661
|
+
|
|
662
|
+
// Add attribute changes to the payload
|
|
663
|
+
dirtyAttributeChanges.forEach(([cmsId, change]) => {
|
|
664
|
+
const { originalAttributes, newAttributes } = change
|
|
665
|
+
const entry = manifest.entries[cmsId]
|
|
666
|
+
const attributeChanges = buildAttributeChangePayload(originalAttributes, newAttributes)
|
|
667
|
+
|
|
668
|
+
if (attributeChanges.length > 0) {
|
|
669
|
+
// Use source info from first changed attribute, or fall back to element-level
|
|
670
|
+
const firstChange = attributeChanges[0]!
|
|
671
|
+
changes.push({
|
|
672
|
+
cmsId,
|
|
673
|
+
newValue: '',
|
|
674
|
+
originalValue: '',
|
|
675
|
+
sourcePath: firstChange.sourcePath ?? entry?.sourcePath ?? '',
|
|
676
|
+
sourceLine: firstChange.sourceLine ?? entry?.sourceLine ?? 0,
|
|
677
|
+
sourceSnippet: firstChange.sourceSnippet ?? entry?.sourceSnippet ?? '',
|
|
678
|
+
attributeChanges,
|
|
679
|
+
})
|
|
680
|
+
}
|
|
681
|
+
})
|
|
682
|
+
|
|
683
|
+
// Add SEO changes to the payload
|
|
684
|
+
dirtySeoChanges.forEach(([cmsId, change]) => {
|
|
685
|
+
const seoData = (manifest as any).seo as import('./types').PageSeoData | undefined
|
|
686
|
+
const sourceInfo = findSeoSourceById(seoData, cmsId)
|
|
687
|
+
changes.push({
|
|
688
|
+
cmsId,
|
|
689
|
+
newValue: change.newValue,
|
|
690
|
+
originalValue: sourceInfo?.content ?? change.originalValue,
|
|
691
|
+
sourcePath: sourceInfo?.sourcePath ?? '',
|
|
692
|
+
sourceLine: sourceInfo?.sourceLine ?? 0,
|
|
693
|
+
sourceSnippet: sourceInfo?.sourceSnippet ?? '',
|
|
694
|
+
})
|
|
695
|
+
})
|
|
696
|
+
|
|
697
|
+
const result = await saveBatchChanges(config.apiBase, {
|
|
698
|
+
changes,
|
|
699
|
+
meta: {
|
|
700
|
+
source: 'inline-editor',
|
|
701
|
+
url: window.location.href,
|
|
702
|
+
},
|
|
703
|
+
})
|
|
704
|
+
|
|
705
|
+
// Update all dirty text changes to mark as saved
|
|
706
|
+
signals.batch(() => {
|
|
707
|
+
dirtyChanges.forEach(([cmsId, change]) => {
|
|
708
|
+
signals.updatePendingChange(cmsId, (c) => ({
|
|
709
|
+
...c,
|
|
710
|
+
originalText: c.newText,
|
|
711
|
+
originalHTML: c.element.innerHTML,
|
|
712
|
+
currentHTML: c.element.innerHTML,
|
|
713
|
+
isDirty: false,
|
|
714
|
+
}))
|
|
715
|
+
})
|
|
716
|
+
|
|
717
|
+
// Update all dirty image changes to mark as saved
|
|
718
|
+
dirtyImageChanges.forEach(([cmsId, change]) => {
|
|
719
|
+
signals.updatePendingImageChange(cmsId, (c) => ({
|
|
720
|
+
...c,
|
|
721
|
+
originalSrc: c.newSrc,
|
|
722
|
+
originalAlt: c.newAlt,
|
|
723
|
+
isDirty: false,
|
|
724
|
+
}))
|
|
725
|
+
})
|
|
726
|
+
|
|
727
|
+
// Update all dirty color changes to mark as saved
|
|
728
|
+
dirtyColorChanges.forEach(([cmsId, change]) => {
|
|
729
|
+
signals.updatePendingColorChange(cmsId, (c) => ({
|
|
730
|
+
...c,
|
|
731
|
+
originalClasses: { ...c.newClasses },
|
|
732
|
+
isDirty: false,
|
|
733
|
+
}))
|
|
734
|
+
})
|
|
735
|
+
|
|
736
|
+
// Update all dirty attribute changes to mark as saved
|
|
737
|
+
dirtyAttributeChanges.forEach(([cmsId, change]) => {
|
|
738
|
+
signals.updatePendingAttributeChange(cmsId, (c) => ({
|
|
739
|
+
...c,
|
|
740
|
+
originalAttributes: { ...c.newAttributes },
|
|
741
|
+
isDirty: false,
|
|
742
|
+
}))
|
|
743
|
+
})
|
|
744
|
+
|
|
745
|
+
// Update all dirty SEO changes to mark as saved
|
|
746
|
+
dirtySeoChanges.forEach(([cmsId, change]) => {
|
|
747
|
+
signals.updatePendingSeoChange(cmsId, (c) => ({
|
|
748
|
+
...c,
|
|
749
|
+
originalValue: c.newValue,
|
|
750
|
+
isDirty: false,
|
|
751
|
+
}))
|
|
752
|
+
})
|
|
753
|
+
})
|
|
754
|
+
|
|
755
|
+
clearAllEditsFromStorage()
|
|
756
|
+
clearHistory()
|
|
757
|
+
|
|
758
|
+
// Close all open dialogs after save
|
|
759
|
+
signals.closeAttributeEditor()
|
|
760
|
+
signals.closeSeoEditor()
|
|
761
|
+
signals.closeColorEditor()
|
|
762
|
+
signals.resetMediaLibraryState()
|
|
763
|
+
signals.resetMarkdownEditorState()
|
|
764
|
+
signals.resetCreatePageState()
|
|
765
|
+
|
|
766
|
+
if (result.errors && result.errors.length > 0) {
|
|
767
|
+
console.error('[CMS] Save errors:', result.errors)
|
|
768
|
+
return { success: false, updated: result.updated, errors: result.errors }
|
|
769
|
+
}
|
|
770
|
+
|
|
771
|
+
// Start polling for deployment status after successful save
|
|
772
|
+
startDeploymentPolling(config)
|
|
773
|
+
|
|
774
|
+
onStateChange?.()
|
|
775
|
+
return { success: true, updated: result.updated }
|
|
776
|
+
} catch (err) {
|
|
777
|
+
console.error('[CMS] Save failed:', err)
|
|
778
|
+
// Save all edits to storage on failure so they can be recovered
|
|
779
|
+
saveEditsToStorage(signals.pendingChanges.value)
|
|
780
|
+
saveImageEditsToStorage(signals.pendingImageChanges.value)
|
|
781
|
+
saveColorEditsToStorage(signals.pendingColorChanges.value)
|
|
782
|
+
saveAttributeEditsToStorage(signals.pendingAttributeChanges.value)
|
|
783
|
+
throw err
|
|
784
|
+
} finally {
|
|
785
|
+
signals.isSaving.value = false
|
|
786
|
+
}
|
|
787
|
+
}
|
|
788
|
+
|
|
789
|
+
/**
|
|
790
|
+
* Setup click handler for markdown elements.
|
|
791
|
+
* When a markdown element is clicked, it opens the WYSIWYG editor instead of using contentEditable.
|
|
792
|
+
*/
|
|
793
|
+
function setupMarkdownClickHandler(
|
|
794
|
+
config: CmsConfig,
|
|
795
|
+
el: HTMLElement,
|
|
796
|
+
cmsId: string,
|
|
797
|
+
_onStateChange?: () => void,
|
|
798
|
+
): void {
|
|
799
|
+
// Add visual indicator that this is a markdown-editable element
|
|
800
|
+
el.style.cursor = 'pointer'
|
|
801
|
+
|
|
802
|
+
el.addEventListener('click', async (e) => {
|
|
803
|
+
e.preventDefault()
|
|
804
|
+
e.stopPropagation()
|
|
805
|
+
|
|
806
|
+
logDebug(config.debug, 'Markdown element clicked:', cmsId)
|
|
807
|
+
|
|
808
|
+
// Refresh manifest to get the latest content
|
|
809
|
+
try {
|
|
810
|
+
const newManifest = await fetchManifest()
|
|
811
|
+
signals.setManifest(newManifest)
|
|
812
|
+
} catch (err) {
|
|
813
|
+
console.error('[CMS] Failed to refresh manifest:', err)
|
|
814
|
+
// Continue with current manifest if refresh fails
|
|
815
|
+
}
|
|
816
|
+
|
|
817
|
+
// Get the manifest entry to find the markdown file path
|
|
818
|
+
const manifest = signals.manifest.value
|
|
819
|
+
const entry = manifest.entries[cmsId] as ManifestEntry | undefined
|
|
820
|
+
|
|
821
|
+
if (!entry) {
|
|
822
|
+
signals.showToast('Markdown element not found in manifest', 'error')
|
|
823
|
+
return
|
|
824
|
+
}
|
|
825
|
+
|
|
826
|
+
// Check if it has a content path for markdown
|
|
827
|
+
const contentPath = entry.contentPath
|
|
828
|
+
if (!contentPath) {
|
|
829
|
+
signals.showToast('No markdown file path configured for this element', 'error')
|
|
830
|
+
return
|
|
831
|
+
}
|
|
832
|
+
|
|
833
|
+
// Fetch the markdown content from the API
|
|
834
|
+
try {
|
|
835
|
+
const result = await getMarkdownContent(config.apiBase, contentPath)
|
|
836
|
+
|
|
837
|
+
if (!result) {
|
|
838
|
+
signals.showToast('Markdown content not found', 'error')
|
|
839
|
+
return
|
|
840
|
+
}
|
|
841
|
+
|
|
842
|
+
// Set the markdown page data
|
|
843
|
+
signals.setMarkdownPage({
|
|
844
|
+
filePath: result.filePath,
|
|
845
|
+
slug: entry.collectionSlug || '',
|
|
846
|
+
frontmatter: result.frontmatter as import('./types').BlogFrontmatter,
|
|
847
|
+
content: result.content,
|
|
848
|
+
isDirty: false,
|
|
849
|
+
})
|
|
850
|
+
|
|
851
|
+
// Set the markdown editor to open with this element
|
|
852
|
+
signals.setMarkdownActiveElement(cmsId)
|
|
853
|
+
signals.setMarkdownEditorOpen(true)
|
|
854
|
+
} catch (error) {
|
|
855
|
+
const message = error instanceof Error ? error.message : 'Unknown error'
|
|
856
|
+
signals.showToast(`Failed to load markdown: ${message}`, 'error')
|
|
857
|
+
logDebug(config.debug, 'Failed to fetch markdown content:', error)
|
|
858
|
+
}
|
|
859
|
+
})
|
|
860
|
+
}
|
|
861
|
+
|
|
862
|
+
/**
|
|
863
|
+
* Setup click handler for image elements.
|
|
864
|
+
* When an image is clicked, it opens the media library to select a replacement.
|
|
865
|
+
*/
|
|
866
|
+
function setupImageClickHandler(
|
|
867
|
+
config: CmsConfig,
|
|
868
|
+
el: HTMLImageElement,
|
|
869
|
+
cmsId: string,
|
|
870
|
+
savedEdit: import('./types').SavedImageEdit | undefined,
|
|
871
|
+
onStateChange?: () => void,
|
|
872
|
+
): void {
|
|
873
|
+
// Add visual indicator that this is a replaceable image
|
|
874
|
+
el.style.cursor = 'pointer'
|
|
875
|
+
|
|
876
|
+
// Store original values for change tracking
|
|
877
|
+
// Use getAttribute to get the original attribute value (e.g., "/assets/image.webp")
|
|
878
|
+
// instead of .src which returns the fully resolved URL (e.g., "http://localhost/assets/image.webp")
|
|
879
|
+
const originalSrc = el.getAttribute('src') || el.src
|
|
880
|
+
const originalAlt = el.alt || ''
|
|
881
|
+
const originalSrcSet = el.getAttribute('srcset') || ''
|
|
882
|
+
|
|
883
|
+
// Initialize pending image change if not already tracked
|
|
884
|
+
if (!signals.pendingImageChanges.value.has(cmsId)) {
|
|
885
|
+
// Restore saved edit if present
|
|
886
|
+
if (savedEdit) {
|
|
887
|
+
el.src = savedEdit.newSrc
|
|
888
|
+
el.alt = savedEdit.newAlt
|
|
889
|
+
// Clear srcset so browser uses the new src
|
|
890
|
+
el.removeAttribute('srcset')
|
|
891
|
+
signals.setPendingImageChange(cmsId, {
|
|
892
|
+
element: el,
|
|
893
|
+
originalSrc: savedEdit.originalSrc,
|
|
894
|
+
newSrc: savedEdit.newSrc,
|
|
895
|
+
originalAlt: savedEdit.originalAlt,
|
|
896
|
+
newAlt: savedEdit.newAlt,
|
|
897
|
+
originalSrcSet: savedEdit.originalSrcSet ?? originalSrcSet,
|
|
898
|
+
isDirty: true,
|
|
899
|
+
})
|
|
900
|
+
logDebug(config.debug, 'Restored saved image edit:', cmsId, savedEdit)
|
|
901
|
+
} else {
|
|
902
|
+
signals.setPendingImageChange(cmsId, {
|
|
903
|
+
element: el,
|
|
904
|
+
originalSrc,
|
|
905
|
+
newSrc: originalSrc,
|
|
906
|
+
originalAlt,
|
|
907
|
+
newAlt: originalAlt,
|
|
908
|
+
originalSrcSet,
|
|
909
|
+
isDirty: false,
|
|
910
|
+
})
|
|
911
|
+
}
|
|
912
|
+
}
|
|
913
|
+
|
|
914
|
+
el.addEventListener('click', (e) => {
|
|
915
|
+
e.preventDefault()
|
|
916
|
+
e.stopPropagation()
|
|
917
|
+
|
|
918
|
+
logDebug(config.debug, 'Image element clicked:', cmsId)
|
|
919
|
+
|
|
920
|
+
// Open media library with callback to handle image replacement
|
|
921
|
+
signals.openMediaLibraryWithCallback((url: string, alt: string) => {
|
|
922
|
+
logDebug(config.debug, 'Image replacement selected:', { cmsId, url, alt })
|
|
923
|
+
|
|
924
|
+
// Record undo action before mutation
|
|
925
|
+
const currentChange = signals.getPendingImageChange(cmsId)
|
|
926
|
+
if (!isApplyingUndoRedo && currentChange) {
|
|
927
|
+
recordChange({
|
|
928
|
+
type: 'image',
|
|
929
|
+
cmsId,
|
|
930
|
+
element: el,
|
|
931
|
+
previousSrc: currentChange.newSrc,
|
|
932
|
+
previousAlt: currentChange.newAlt,
|
|
933
|
+
currentSrc: url,
|
|
934
|
+
currentAlt: alt || currentChange.originalAlt,
|
|
935
|
+
wasDirty: currentChange.isDirty,
|
|
936
|
+
})
|
|
937
|
+
}
|
|
938
|
+
|
|
939
|
+
// Update the image element
|
|
940
|
+
el.src = url
|
|
941
|
+
// Clear srcset so browser uses the new src
|
|
942
|
+
el.removeAttribute('srcset')
|
|
943
|
+
if (alt) {
|
|
944
|
+
el.alt = alt
|
|
945
|
+
}
|
|
946
|
+
|
|
947
|
+
// Track the change
|
|
948
|
+
const isDirty = url !== (currentChange?.originalSrc ?? originalSrc)
|
|
949
|
+
|
|
950
|
+
signals.updatePendingImageChange(cmsId, (change) => ({
|
|
951
|
+
...change,
|
|
952
|
+
newSrc: url,
|
|
953
|
+
newAlt: alt || change.originalAlt,
|
|
954
|
+
isDirty,
|
|
955
|
+
}))
|
|
956
|
+
|
|
957
|
+
saveImageEditsToStorage(signals.pendingImageChanges.value)
|
|
958
|
+
onStateChange?.()
|
|
959
|
+
})
|
|
960
|
+
})
|
|
961
|
+
}
|
|
962
|
+
|
|
963
|
+
/**
|
|
964
|
+
* Initialize color change tracking for elements with color classes.
|
|
965
|
+
* Color editing is now triggered via the outline component's color swatches.
|
|
966
|
+
*/
|
|
967
|
+
function setupColorTracking(
|
|
968
|
+
config: CmsConfig,
|
|
969
|
+
el: HTMLElement,
|
|
970
|
+
cmsId: string,
|
|
971
|
+
savedEdit: import('./types').SavedColorEdit | undefined,
|
|
972
|
+
): void {
|
|
973
|
+
// Get the manifest entry to find the color classes
|
|
974
|
+
const manifest = signals.manifest.value
|
|
975
|
+
const entry = manifest.entries[cmsId]
|
|
976
|
+
|
|
977
|
+
if (!entry?.colorClasses) {
|
|
978
|
+
return
|
|
979
|
+
}
|
|
980
|
+
|
|
981
|
+
logDebug(config.debug, 'Setting up color tracking for:', cmsId, entry.colorClasses)
|
|
982
|
+
|
|
983
|
+
// Initialize pending color change if not already tracked
|
|
984
|
+
if (!signals.pendingColorChanges.value.has(cmsId)) {
|
|
985
|
+
// Restore saved edit if present
|
|
986
|
+
if (savedEdit) {
|
|
987
|
+
// Apply saved color classes to the element
|
|
988
|
+
const classes = el.className.split(/\s+/).filter(Boolean)
|
|
989
|
+
const originalClassValues = new Set(Object.values(savedEdit.originalClasses).map(a => a.value).filter(Boolean))
|
|
990
|
+
const newClassValues = new Set(Object.values(savedEdit.newClasses).map(a => a.value).filter(Boolean))
|
|
991
|
+
|
|
992
|
+
// Remove original classes and add new ones
|
|
993
|
+
const filtered = classes.filter(c => !originalClassValues.has(c))
|
|
994
|
+
newClassValues.forEach(c => {
|
|
995
|
+
if (!filtered.includes(c)) {
|
|
996
|
+
filtered.push(c)
|
|
997
|
+
}
|
|
998
|
+
})
|
|
999
|
+
el.className = filtered.join(' ')
|
|
1000
|
+
|
|
1001
|
+
signals.setPendingColorChange(cmsId, {
|
|
1002
|
+
element: el,
|
|
1003
|
+
cmsId,
|
|
1004
|
+
originalClasses: savedEdit.originalClasses,
|
|
1005
|
+
newClasses: savedEdit.newClasses,
|
|
1006
|
+
isDirty: true,
|
|
1007
|
+
})
|
|
1008
|
+
logDebug(config.debug, 'Restored saved color edit:', cmsId, savedEdit)
|
|
1009
|
+
} else {
|
|
1010
|
+
const originalClasses = deepCopyColorClasses(entry.colorClasses)
|
|
1011
|
+
signals.setPendingColorChange(cmsId, {
|
|
1012
|
+
element: el,
|
|
1013
|
+
cmsId,
|
|
1014
|
+
originalClasses,
|
|
1015
|
+
newClasses: deepCopyColorClasses(entry.colorClasses),
|
|
1016
|
+
isDirty: false,
|
|
1017
|
+
})
|
|
1018
|
+
}
|
|
1019
|
+
}
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
/**
|
|
1023
|
+
* Handle color change from the color toolbar.
|
|
1024
|
+
* Called when user selects a new color.
|
|
1025
|
+
*/
|
|
1026
|
+
export function handleColorChange(
|
|
1027
|
+
config: CmsConfig,
|
|
1028
|
+
cmsId: string,
|
|
1029
|
+
colorType: 'bg' | 'text' | 'border' | 'hoverBg' | 'hoverText',
|
|
1030
|
+
oldClass: string,
|
|
1031
|
+
newClass: string,
|
|
1032
|
+
onStateChange?: () => void,
|
|
1033
|
+
previousClassName?: string,
|
|
1034
|
+
previousStyleCssText?: string,
|
|
1035
|
+
): void {
|
|
1036
|
+
const change = signals.getPendingColorChange(cmsId)
|
|
1037
|
+
if (!change) {
|
|
1038
|
+
logDebug(config.debug, 'No color change tracked for', cmsId)
|
|
1039
|
+
return
|
|
1040
|
+
}
|
|
1041
|
+
|
|
1042
|
+
// Record undo action (DOM is already mutated by applyColorChange in color-toolbar)
|
|
1043
|
+
if (!isApplyingUndoRedo && previousClassName !== undefined) {
|
|
1044
|
+
const prevClasses: Record<string, Attribute> = {}
|
|
1045
|
+
for (const [key, attr] of Object.entries(change.newClasses)) {
|
|
1046
|
+
prevClasses[key] = { ...attr }
|
|
1047
|
+
}
|
|
1048
|
+
|
|
1049
|
+
// Compute what the new classes will be after this change
|
|
1050
|
+
const nextClasses: Record<string, Attribute> = { ...change.newClasses }
|
|
1051
|
+
const existingAttrForNext = nextClasses[colorType] || change.originalClasses[colorType]
|
|
1052
|
+
nextClasses[colorType] = { ...(existingAttrForNext || {}), value: newClass }
|
|
1053
|
+
|
|
1054
|
+
recordChange({
|
|
1055
|
+
type: 'color',
|
|
1056
|
+
cmsId,
|
|
1057
|
+
element: change.element,
|
|
1058
|
+
previousClassName,
|
|
1059
|
+
currentClassName: change.element.className, // Already mutated by applyColorChange
|
|
1060
|
+
previousStyleCssText: previousStyleCssText ?? '',
|
|
1061
|
+
currentStyleCssText: change.element.style.cssText, // Already mutated by applyColorChange
|
|
1062
|
+
previousClasses: prevClasses,
|
|
1063
|
+
currentClasses: nextClasses,
|
|
1064
|
+
wasDirty: change.isDirty,
|
|
1065
|
+
})
|
|
1066
|
+
}
|
|
1067
|
+
|
|
1068
|
+
// Update the new classes - preserve source info from original, update value
|
|
1069
|
+
const newClasses = { ...change.newClasses }
|
|
1070
|
+
const existingAttr = newClasses[colorType] || change.originalClasses[colorType]
|
|
1071
|
+
newClasses[colorType] = {
|
|
1072
|
+
...(existingAttr || {}),
|
|
1073
|
+
value: newClass,
|
|
1074
|
+
}
|
|
1075
|
+
|
|
1076
|
+
// Check if dirty (any class value different from original)
|
|
1077
|
+
let isDirty = false
|
|
1078
|
+
const allKeys = new Set([...Object.keys(change.originalClasses), ...Object.keys(newClasses)])
|
|
1079
|
+
for (const key of allKeys) {
|
|
1080
|
+
if (change.originalClasses[key]?.value !== newClasses[key]?.value) {
|
|
1081
|
+
isDirty = true
|
|
1082
|
+
break
|
|
1083
|
+
}
|
|
1084
|
+
}
|
|
1085
|
+
|
|
1086
|
+
signals.updatePendingColorChange(cmsId, (c) => ({
|
|
1087
|
+
...c,
|
|
1088
|
+
newClasses,
|
|
1089
|
+
isDirty,
|
|
1090
|
+
}))
|
|
1091
|
+
|
|
1092
|
+
logDebug(config.debug, 'Color change recorded:', { cmsId, colorType, oldClass, newClass, isDirty })
|
|
1093
|
+
|
|
1094
|
+
saveColorEditsToStorage(signals.pendingColorChanges.value)
|
|
1095
|
+
onStateChange?.()
|
|
1096
|
+
}
|
|
1097
|
+
|
|
1098
|
+
// ============================================================================
|
|
1099
|
+
// Deployment Status Polling
|
|
1100
|
+
// ============================================================================
|
|
1101
|
+
|
|
1102
|
+
const DEPLOYMENT_POLL_INTERVAL_MS = 3000
|
|
1103
|
+
const DEPLOYMENT_SUCCESS_HIDE_DELAY_MS = 5000
|
|
1104
|
+
const DEPLOYMENT_INITIAL_DELAY_MS = 2000
|
|
1105
|
+
const DEPLOYMENT_MAX_WAIT_ATTEMPTS = 10 // Keep polling for up to 30 seconds waiting for deployment to start
|
|
1106
|
+
|
|
1107
|
+
let deploymentPollTimer: ReturnType<typeof setInterval> | null = null
|
|
1108
|
+
let deploymentHideTimer: ReturnType<typeof setTimeout> | null = null
|
|
1109
|
+
let deploymentWaitAttempts = 0
|
|
1110
|
+
let deploymentStartTimestamp: string | null = null
|
|
1111
|
+
let deploymentCallback: ((status: 'completed' | 'failed' | 'timeout') => void) | null = null
|
|
1112
|
+
|
|
1113
|
+
export interface DeploymentPollingOptions {
|
|
1114
|
+
/** Called when deployment completes, fails, or times out */
|
|
1115
|
+
onComplete?: (status: 'completed' | 'failed' | 'timeout') => void
|
|
1116
|
+
}
|
|
1117
|
+
|
|
1118
|
+
/**
|
|
1119
|
+
* Start polling for deployment status after a save operation.
|
|
1120
|
+
* Polls the API every 3 seconds until deployment completes or fails.
|
|
1121
|
+
* Waits for deployment to appear for up to 30 seconds before giving up.
|
|
1122
|
+
*/
|
|
1123
|
+
export function startDeploymentPolling(config: CmsConfig, options?: DeploymentPollingOptions): void {
|
|
1124
|
+
// Clear any existing timers
|
|
1125
|
+
stopDeploymentPolling()
|
|
1126
|
+
|
|
1127
|
+
// Reset wait attempts counter and store the timestamp when we started
|
|
1128
|
+
deploymentWaitAttempts = 0
|
|
1129
|
+
deploymentStartTimestamp = new Date().toISOString()
|
|
1130
|
+
deploymentCallback = options?.onComplete ?? null
|
|
1131
|
+
|
|
1132
|
+
// Set initial status to indicate deployment started
|
|
1133
|
+
signals.updateDeploymentState({
|
|
1134
|
+
status: 'pending',
|
|
1135
|
+
isPolling: true,
|
|
1136
|
+
error: null,
|
|
1137
|
+
})
|
|
1138
|
+
|
|
1139
|
+
const poll = async () => {
|
|
1140
|
+
try {
|
|
1141
|
+
const status: DeploymentStatusResponse = await getDeploymentStatus(config.apiBase)
|
|
1142
|
+
|
|
1143
|
+
if (status.currentDeployment) {
|
|
1144
|
+
// Found an active deployment - reset wait counter
|
|
1145
|
+
deploymentWaitAttempts = 0
|
|
1146
|
+
|
|
1147
|
+
signals.updateDeploymentState({
|
|
1148
|
+
status: status.currentDeployment.status,
|
|
1149
|
+
})
|
|
1150
|
+
|
|
1151
|
+
// Check if deployment is still active
|
|
1152
|
+
const isActive = ['pending', 'queued', 'running'].includes(status.currentDeployment.status)
|
|
1153
|
+
|
|
1154
|
+
if (!isActive) {
|
|
1155
|
+
// Deployment finished
|
|
1156
|
+
const cb = deploymentCallback
|
|
1157
|
+
stopDeploymentPolling()
|
|
1158
|
+
|
|
1159
|
+
if (status.currentDeployment.status === 'completed') {
|
|
1160
|
+
// Update last deployed timestamp
|
|
1161
|
+
if (status.lastSuccessfulDeployment) {
|
|
1162
|
+
signals.setLastDeployedAt(status.lastSuccessfulDeployment.completedAt)
|
|
1163
|
+
}
|
|
1164
|
+
|
|
1165
|
+
// Auto-hide after 5 seconds for successful deployments
|
|
1166
|
+
deploymentHideTimer = setTimeout(() => {
|
|
1167
|
+
signals.resetDeploymentState()
|
|
1168
|
+
}, DEPLOYMENT_SUCCESS_HIDE_DELAY_MS)
|
|
1169
|
+
|
|
1170
|
+
cb?.('completed')
|
|
1171
|
+
} else {
|
|
1172
|
+
// For failed deployments, keep showing until user dismisses
|
|
1173
|
+
cb?.('failed')
|
|
1174
|
+
}
|
|
1175
|
+
}
|
|
1176
|
+
} else {
|
|
1177
|
+
// No active deployment found
|
|
1178
|
+
deploymentWaitAttempts++
|
|
1179
|
+
|
|
1180
|
+
// Check if we have a recent successful deployment (completed after we started polling)
|
|
1181
|
+
if (status.lastSuccessfulDeployment && deploymentStartTimestamp) {
|
|
1182
|
+
const lastDeployTime = new Date(status.lastSuccessfulDeployment.completedAt).getTime()
|
|
1183
|
+
const startTime = new Date(deploymentStartTimestamp).getTime()
|
|
1184
|
+
|
|
1185
|
+
if (lastDeployTime > startTime) {
|
|
1186
|
+
// Deployment completed after we started - show success
|
|
1187
|
+
signals.updateDeploymentState({
|
|
1188
|
+
status: 'completed',
|
|
1189
|
+
lastDeployedAt: status.lastSuccessfulDeployment.completedAt,
|
|
1190
|
+
isPolling: false,
|
|
1191
|
+
})
|
|
1192
|
+
|
|
1193
|
+
// Auto-hide after 5 seconds
|
|
1194
|
+
deploymentHideTimer = setTimeout(() => {
|
|
1195
|
+
signals.resetDeploymentState()
|
|
1196
|
+
}, DEPLOYMENT_SUCCESS_HIDE_DELAY_MS)
|
|
1197
|
+
|
|
1198
|
+
const cb = deploymentCallback
|
|
1199
|
+
stopDeploymentPolling()
|
|
1200
|
+
cb?.('completed')
|
|
1201
|
+
return
|
|
1202
|
+
}
|
|
1203
|
+
}
|
|
1204
|
+
|
|
1205
|
+
// Keep waiting if we haven't exceeded max attempts
|
|
1206
|
+
if (deploymentWaitAttempts >= DEPLOYMENT_MAX_WAIT_ATTEMPTS) {
|
|
1207
|
+
// Give up waiting - deployment may have failed to start
|
|
1208
|
+
console.warn('[CMS] No deployment found after waiting, giving up')
|
|
1209
|
+
const cb = deploymentCallback
|
|
1210
|
+
signals.resetDeploymentState()
|
|
1211
|
+
stopDeploymentPolling()
|
|
1212
|
+
cb?.('timeout')
|
|
1213
|
+
}
|
|
1214
|
+
// Otherwise keep polling with "pending" status
|
|
1215
|
+
}
|
|
1216
|
+
} catch (error) {
|
|
1217
|
+
console.error('[CMS] Failed to fetch deployment status:', error)
|
|
1218
|
+
signals.updateDeploymentState({
|
|
1219
|
+
status: 'failed',
|
|
1220
|
+
error: error instanceof Error ? error.message : 'Unknown error',
|
|
1221
|
+
isPolling: false,
|
|
1222
|
+
})
|
|
1223
|
+
const cb = deploymentCallback
|
|
1224
|
+
stopDeploymentPolling()
|
|
1225
|
+
cb?.('failed')
|
|
1226
|
+
}
|
|
1227
|
+
}
|
|
1228
|
+
|
|
1229
|
+
// Delay initial poll to allow deployment to be registered
|
|
1230
|
+
setTimeout(() => {
|
|
1231
|
+
poll()
|
|
1232
|
+
// Then poll every 3 seconds
|
|
1233
|
+
deploymentPollTimer = setInterval(poll, DEPLOYMENT_POLL_INTERVAL_MS)
|
|
1234
|
+
}, DEPLOYMENT_INITIAL_DELAY_MS)
|
|
1235
|
+
}
|
|
1236
|
+
|
|
1237
|
+
/**
|
|
1238
|
+
* Stop polling for deployment status.
|
|
1239
|
+
*/
|
|
1240
|
+
export function stopDeploymentPolling(): void {
|
|
1241
|
+
if (deploymentPollTimer) {
|
|
1242
|
+
clearInterval(deploymentPollTimer)
|
|
1243
|
+
deploymentPollTimer = null
|
|
1244
|
+
}
|
|
1245
|
+
deploymentWaitAttempts = 0
|
|
1246
|
+
deploymentStartTimestamp = null
|
|
1247
|
+
deploymentCallback = null
|
|
1248
|
+
signals.setDeploymentPolling(false)
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
/**
|
|
1252
|
+
* Dismiss the deployment status indicator.
|
|
1253
|
+
* Used when user clicks on a failed deployment status.
|
|
1254
|
+
*/
|
|
1255
|
+
export function dismissDeploymentStatus(): void {
|
|
1256
|
+
if (deploymentHideTimer) {
|
|
1257
|
+
clearTimeout(deploymentHideTimer)
|
|
1258
|
+
deploymentHideTimer = null
|
|
1259
|
+
}
|
|
1260
|
+
stopDeploymentPolling()
|
|
1261
|
+
signals.resetDeploymentState()
|
|
1262
|
+
}
|
|
1263
|
+
|
|
1264
|
+
// ============================================================================
|
|
1265
|
+
// Attribute Tracking
|
|
1266
|
+
// ============================================================================
|
|
1267
|
+
|
|
1268
|
+
/**
|
|
1269
|
+
* Initialize attribute change tracking for elements with editable attributes.
|
|
1270
|
+
* Called during edit mode setup.
|
|
1271
|
+
*/
|
|
1272
|
+
function setupAttributeTracking(
|
|
1273
|
+
config: CmsConfig,
|
|
1274
|
+
el: HTMLElement,
|
|
1275
|
+
cmsId: string,
|
|
1276
|
+
savedEdit: SavedAttributeEdit | undefined,
|
|
1277
|
+
): void {
|
|
1278
|
+
// Get the manifest entry to find the attributes
|
|
1279
|
+
const manifest = signals.manifest.value
|
|
1280
|
+
const entry = manifest.entries[cmsId]
|
|
1281
|
+
|
|
1282
|
+
// Check if element has any editable attributes
|
|
1283
|
+
if (!entry?.attributes || Object.keys(entry.attributes).length === 0) {
|
|
1284
|
+
return
|
|
1285
|
+
}
|
|
1286
|
+
|
|
1287
|
+
logDebug(config.debug, 'Setting up attribute tracking for:', cmsId)
|
|
1288
|
+
|
|
1289
|
+
// Initialize pending attribute change if not already tracked
|
|
1290
|
+
if (!signals.pendingAttributeChanges.value.has(cmsId)) {
|
|
1291
|
+
// Build original attributes from manifest entry (flat map)
|
|
1292
|
+
const originalAttributes = deepCopyAttributes(entry.attributes)
|
|
1293
|
+
|
|
1294
|
+
// Restore saved edit if present
|
|
1295
|
+
if (savedEdit) {
|
|
1296
|
+
// Apply saved attribute values to the element
|
|
1297
|
+
applyAttributesToElement(el, savedEdit.newAttributes)
|
|
1298
|
+
|
|
1299
|
+
signals.setPendingAttributeChange(cmsId, {
|
|
1300
|
+
element: el,
|
|
1301
|
+
cmsId,
|
|
1302
|
+
originalAttributes: savedEdit.originalAttributes,
|
|
1303
|
+
newAttributes: savedEdit.newAttributes,
|
|
1304
|
+
isDirty: true,
|
|
1305
|
+
})
|
|
1306
|
+
logDebug(config.debug, 'Restored saved attribute edit:', cmsId, savedEdit)
|
|
1307
|
+
} else {
|
|
1308
|
+
// Create deep copy for newAttributes to avoid shared references
|
|
1309
|
+
const newAttributes = deepCopyAttributes(entry.attributes)
|
|
1310
|
+
|
|
1311
|
+
signals.setPendingAttributeChange(cmsId, {
|
|
1312
|
+
element: el,
|
|
1313
|
+
cmsId,
|
|
1314
|
+
originalAttributes,
|
|
1315
|
+
newAttributes,
|
|
1316
|
+
isDirty: false,
|
|
1317
|
+
})
|
|
1318
|
+
}
|
|
1319
|
+
}
|
|
1320
|
+
}
|
|
1321
|
+
|
|
1322
|
+
/**
|
|
1323
|
+
* Deep copy flat attribute map to avoid shared object references.
|
|
1324
|
+
*/
|
|
1325
|
+
function deepCopyAttributes(attrs: Record<string, Attribute>): Record<string, Attribute> {
|
|
1326
|
+
const copy: Record<string, Attribute> = {}
|
|
1327
|
+
for (const [key, attr] of Object.entries(attrs)) {
|
|
1328
|
+
copy[key] = { ...attr }
|
|
1329
|
+
}
|
|
1330
|
+
return copy
|
|
1331
|
+
}
|
|
1332
|
+
|
|
1333
|
+
/**
|
|
1334
|
+
* Deep copy color classes map to avoid shared references.
|
|
1335
|
+
*/
|
|
1336
|
+
function deepCopyColorClasses(classes: Record<string, Attribute>): Record<string, Attribute> {
|
|
1337
|
+
const copy: Record<string, Attribute> = {}
|
|
1338
|
+
for (const [key, attr] of Object.entries(classes)) {
|
|
1339
|
+
copy[key] = { ...attr }
|
|
1340
|
+
}
|
|
1341
|
+
return copy
|
|
1342
|
+
}
|
|
1343
|
+
|
|
1344
|
+
/**
|
|
1345
|
+
* Handle attribute change from the attribute editor.
|
|
1346
|
+
* Called when user modifies an attribute value.
|
|
1347
|
+
*/
|
|
1348
|
+
export function handleAttributeChange(
|
|
1349
|
+
config: CmsConfig,
|
|
1350
|
+
cmsId: string,
|
|
1351
|
+
attributeName: string,
|
|
1352
|
+
newValue: string | boolean | number | undefined,
|
|
1353
|
+
onStateChange?: () => void,
|
|
1354
|
+
): void {
|
|
1355
|
+
const change = signals.getPendingAttributeChange(cmsId)
|
|
1356
|
+
if (!change) {
|
|
1357
|
+
logDebug(config.debug, 'No attribute change tracked for', cmsId)
|
|
1358
|
+
return
|
|
1359
|
+
}
|
|
1360
|
+
|
|
1361
|
+
// Record undo action before mutation
|
|
1362
|
+
if (!isApplyingUndoRedo) {
|
|
1363
|
+
const prevAttrs: Record<string, Attribute> = {}
|
|
1364
|
+
for (const [key, attr] of Object.entries(change.newAttributes)) {
|
|
1365
|
+
prevAttrs[key] = { ...attr }
|
|
1366
|
+
}
|
|
1367
|
+
|
|
1368
|
+
const nextAttrs: Record<string, Attribute> = { ...change.newAttributes }
|
|
1369
|
+
const existingAttrForNext = nextAttrs[attributeName] || change.originalAttributes[attributeName]
|
|
1370
|
+
nextAttrs[attributeName] = {
|
|
1371
|
+
...(existingAttrForNext || {}),
|
|
1372
|
+
value: newValue === undefined ? '' : String(newValue),
|
|
1373
|
+
}
|
|
1374
|
+
|
|
1375
|
+
recordChange({
|
|
1376
|
+
type: 'attribute',
|
|
1377
|
+
cmsId,
|
|
1378
|
+
element: change.element,
|
|
1379
|
+
previousAttributes: prevAttrs,
|
|
1380
|
+
currentAttributes: nextAttrs,
|
|
1381
|
+
wasDirty: change.isDirty,
|
|
1382
|
+
})
|
|
1383
|
+
}
|
|
1384
|
+
|
|
1385
|
+
// Update the new attributes — preserve source info, update value
|
|
1386
|
+
const newAttributes = { ...change.newAttributes }
|
|
1387
|
+
const existingAttr = newAttributes[attributeName] || change.originalAttributes[attributeName]
|
|
1388
|
+
newAttributes[attributeName] = {
|
|
1389
|
+
...(existingAttr || {}),
|
|
1390
|
+
value: newValue === undefined ? '' : String(newValue),
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
// Apply the change to the DOM element
|
|
1394
|
+
applyAttributeToElement(change.element, attributeName, newValue)
|
|
1395
|
+
|
|
1396
|
+
// Check if dirty (any attribute different from original)
|
|
1397
|
+
const isDirty = checkAttributesDirty(change.originalAttributes, newAttributes)
|
|
1398
|
+
|
|
1399
|
+
signals.updatePendingAttributeChange(cmsId, (c) => ({
|
|
1400
|
+
...c,
|
|
1401
|
+
newAttributes,
|
|
1402
|
+
isDirty,
|
|
1403
|
+
}))
|
|
1404
|
+
|
|
1405
|
+
logDebug(config.debug, 'Attribute change recorded:', { cmsId, attributeName, newValue, isDirty })
|
|
1406
|
+
|
|
1407
|
+
saveAttributeEditsToStorage(signals.pendingAttributeChanges.value)
|
|
1408
|
+
onStateChange?.()
|
|
1409
|
+
}
|
|
1410
|
+
|
|
1411
|
+
/** Boolean attribute names that use presence/absence rather than string values */
|
|
1412
|
+
const BOOLEAN_ATTRIBUTES = new Set([
|
|
1413
|
+
'disabled',
|
|
1414
|
+
'required',
|
|
1415
|
+
'readonly',
|
|
1416
|
+
'multiple',
|
|
1417
|
+
'controls',
|
|
1418
|
+
'autoplay',
|
|
1419
|
+
'muted',
|
|
1420
|
+
'loop',
|
|
1421
|
+
'novalidate',
|
|
1422
|
+
'download',
|
|
1423
|
+
'aria-hidden',
|
|
1424
|
+
'aria-expanded',
|
|
1425
|
+
'aria-disabled',
|
|
1426
|
+
])
|
|
1427
|
+
|
|
1428
|
+
/**
|
|
1429
|
+
* Apply a single attribute to a DOM element.
|
|
1430
|
+
* Attribute name is the DOM attribute name directly (e.g., 'href', 'aria-label').
|
|
1431
|
+
*/
|
|
1432
|
+
function applyAttributeToElement(
|
|
1433
|
+
element: HTMLElement,
|
|
1434
|
+
attributeName: string,
|
|
1435
|
+
value: string | boolean | number | undefined,
|
|
1436
|
+
): void {
|
|
1437
|
+
// Handle boolean attributes
|
|
1438
|
+
if (typeof value === 'boolean' || BOOLEAN_ATTRIBUTES.has(attributeName)) {
|
|
1439
|
+
const boolVal = value === true || value === 'true'
|
|
1440
|
+
if (boolVal) {
|
|
1441
|
+
element.setAttribute(attributeName, '')
|
|
1442
|
+
} else {
|
|
1443
|
+
element.removeAttribute(attributeName)
|
|
1444
|
+
}
|
|
1445
|
+
return
|
|
1446
|
+
}
|
|
1447
|
+
|
|
1448
|
+
// Handle undefined/empty - remove attribute
|
|
1449
|
+
if (value === undefined || value === '') {
|
|
1450
|
+
element.removeAttribute(attributeName)
|
|
1451
|
+
return
|
|
1452
|
+
}
|
|
1453
|
+
|
|
1454
|
+
// Set the attribute
|
|
1455
|
+
element.setAttribute(attributeName, String(value))
|
|
1456
|
+
}
|
|
1457
|
+
|
|
1458
|
+
/**
|
|
1459
|
+
* Apply all attributes from a flat Record<string, Attribute> to an element.
|
|
1460
|
+
*/
|
|
1461
|
+
function applyAttributesToElement(element: HTMLElement, attributes: Record<string, Attribute>): void {
|
|
1462
|
+
for (const [attrName, attr] of Object.entries(attributes)) {
|
|
1463
|
+
applyAttributeToElement(element, attrName, attr.value)
|
|
1464
|
+
}
|
|
1465
|
+
}
|
|
1466
|
+
|
|
1467
|
+
/**
|
|
1468
|
+
* Check if any attributes have changed from original.
|
|
1469
|
+
*/
|
|
1470
|
+
function checkAttributesDirty(original: Record<string, Attribute>, current: Record<string, Attribute>): boolean {
|
|
1471
|
+
const allKeys = new Set([...Object.keys(original), ...Object.keys(current)])
|
|
1472
|
+
for (const key of allKeys) {
|
|
1473
|
+
if (original[key]?.value !== current[key]?.value) {
|
|
1474
|
+
return true
|
|
1475
|
+
}
|
|
1476
|
+
}
|
|
1477
|
+
return false
|
|
1478
|
+
}
|
|
1479
|
+
|
|
1480
|
+
/**
|
|
1481
|
+
* Build attribute change payload for API from original and new attributes.
|
|
1482
|
+
* Includes per-attribute source info from the Attribute objects.
|
|
1483
|
+
*/
|
|
1484
|
+
function buildAttributeChangePayload(
|
|
1485
|
+
original: Record<string, Attribute>,
|
|
1486
|
+
current: Record<string, Attribute>,
|
|
1487
|
+
): AttributeChangePayload[] {
|
|
1488
|
+
const changes: AttributeChangePayload[] = []
|
|
1489
|
+
const allKeys = new Set([...Object.keys(original), ...Object.keys(current)])
|
|
1490
|
+
|
|
1491
|
+
for (const attrName of allKeys) {
|
|
1492
|
+
const origAttr = original[attrName]
|
|
1493
|
+
const currAttr = current[attrName]
|
|
1494
|
+
|
|
1495
|
+
if (origAttr?.value !== currAttr?.value) {
|
|
1496
|
+
// Use source info from the original attribute (where it was defined)
|
|
1497
|
+
const sourceAttr = origAttr || currAttr
|
|
1498
|
+
changes.push({
|
|
1499
|
+
attributeName: attrName,
|
|
1500
|
+
oldValue: origAttr?.value,
|
|
1501
|
+
newValue: currAttr?.value,
|
|
1502
|
+
sourcePath: sourceAttr?.sourcePath,
|
|
1503
|
+
sourceLine: sourceAttr?.sourceLine,
|
|
1504
|
+
sourceSnippet: sourceAttr?.sourceSnippet,
|
|
1505
|
+
})
|
|
1506
|
+
}
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
return changes
|
|
1510
|
+
}
|