@nuasite/cms 0.37.0 → 0.39.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 +11524 -11456
- package/package.json +1 -1
- package/src/dev-middleware.ts +51 -6
- package/src/editor/components/editable-highlights.tsx +18 -56
- package/src/editor/components/markdown-editor-overlay.tsx +2 -0
- package/src/editor/constants.ts +2 -0
- package/src/editor/dom.ts +37 -0
- package/src/editor/editor.ts +66 -8
- package/src/editor/index.tsx +11 -6
- package/src/editor/markdown-api.ts +9 -0
- package/src/editor/signals.ts +113 -7
- package/src/editor/storage.ts +172 -196
- package/src/editor/types.ts +2 -0
- package/src/handlers/api-routes.ts +27 -14
- package/src/handlers/request-utils.ts +10 -1
- package/src/index.ts +21 -10
- package/src/source-finder/cross-file-tracker.ts +16 -6
- package/src/source-finder/element-finder.ts +135 -3
- package/src/source-finder/search-index.ts +362 -98
- package/src/source-finder/snippet-utils.ts +45 -42
- package/src/source-finder/source-lookup.ts +5 -2
package/package.json
CHANGED
package/src/dev-middleware.ts
CHANGED
|
@@ -63,6 +63,8 @@ export interface DevMiddlewareOptions {
|
|
|
63
63
|
* corrupted by injected markers).
|
|
64
64
|
*/
|
|
65
65
|
isPublicStaticFile?: (urlPath: string) => boolean
|
|
66
|
+
/** Maximum upload size in bytes for /_nua/cms/media/upload. */
|
|
67
|
+
maxUploadSize: number
|
|
66
68
|
}
|
|
67
69
|
|
|
68
70
|
export function createDevMiddleware(
|
|
@@ -71,10 +73,17 @@ export function createDevMiddleware(
|
|
|
71
73
|
manifestWriter: ManifestWriter,
|
|
72
74
|
componentDefinitions: Record<string, ComponentDefinition>,
|
|
73
75
|
idCounter: { value: number },
|
|
74
|
-
options: DevMiddlewareOptions
|
|
76
|
+
options: DevMiddlewareOptions,
|
|
75
77
|
) {
|
|
76
78
|
const isPublicStaticFile = options.isPublicStaticFile ?? (() => false)
|
|
77
79
|
|
|
80
|
+
// Tracks in-flight Phase 2 enhancement promises per page so that requests for the
|
|
81
|
+
// page manifest can await source-path resolution before responding. Without this,
|
|
82
|
+
// hosts that serve HTML faster than the source-finder pipeline completes (e.g.
|
|
83
|
+
// pletivo, sandbox runtimes) leak a partial manifest where entries have tag/text
|
|
84
|
+
// but no sourcePath, which the editor then treats as locked elements.
|
|
85
|
+
const pendingPhase2: Map<string, Promise<void>> = new Map()
|
|
86
|
+
|
|
78
87
|
// Serve uploaded media files directly from disk.
|
|
79
88
|
// Vite's public dir middleware caches file listings, so newly uploaded files
|
|
80
89
|
// may not be available immediately. This middleware bypasses that cache.
|
|
@@ -122,7 +131,15 @@ export function createDevMiddleware(
|
|
|
122
131
|
|
|
123
132
|
const route = url.replace('/_nua/cms/', '').split('?')[0]!
|
|
124
133
|
|
|
125
|
-
handleCmsApiRoute(
|
|
134
|
+
handleCmsApiRoute({
|
|
135
|
+
req,
|
|
136
|
+
res,
|
|
137
|
+
route,
|
|
138
|
+
manifestWriter,
|
|
139
|
+
contentDir: config.contentDir,
|
|
140
|
+
mediaAdapter: options.mediaAdapter,
|
|
141
|
+
maxUploadSize: options.maxUploadSize,
|
|
142
|
+
})
|
|
126
143
|
.catch((error) => {
|
|
127
144
|
console.error('[astro-cms] API error:', error)
|
|
128
145
|
sendError(res, 'Internal server error', 500)
|
|
@@ -209,7 +226,7 @@ export function createDevMiddleware(
|
|
|
209
226
|
})
|
|
210
227
|
|
|
211
228
|
// Serve per-page manifest endpoints (e.g., /about.json for /about page)
|
|
212
|
-
server.middlewares.use((req, res, next) => {
|
|
229
|
+
server.middlewares.use(async (req, res, next) => {
|
|
213
230
|
const url = (req.url || '').split('?')[0]!
|
|
214
231
|
|
|
215
232
|
// Match /*.json pattern (but not files that actually exist)
|
|
@@ -224,6 +241,18 @@ export function createDevMiddleware(
|
|
|
224
241
|
pagePath = '/'
|
|
225
242
|
}
|
|
226
243
|
|
|
244
|
+
// If Phase 2 source resolution is still in flight for this page, wait for it
|
|
245
|
+
// so the response includes full sourcePath data — avoids the race where the
|
|
246
|
+
// editor fetches a partial manifest and locks elements it can't yet edit.
|
|
247
|
+
const inFlight = pendingPhase2.get(pagePath)
|
|
248
|
+
if (inFlight) {
|
|
249
|
+
try {
|
|
250
|
+
await inFlight
|
|
251
|
+
} catch {
|
|
252
|
+
// fall through — serve whatever partial manifest we have
|
|
253
|
+
}
|
|
254
|
+
}
|
|
255
|
+
|
|
227
256
|
const pageData = manifestWriter.getPageManifest(pagePath)
|
|
228
257
|
|
|
229
258
|
// Only serve if we have manifest data for this page
|
|
@@ -328,9 +357,25 @@ export function createDevMiddleware(
|
|
|
328
357
|
}
|
|
329
358
|
res.end(transformed, ...args)
|
|
330
359
|
|
|
331
|
-
// Phase 2
|
|
332
|
-
//
|
|
333
|
-
|
|
360
|
+
// Phase 2 runs after the HTML is sent so the page renders without waiting on
|
|
361
|
+
// source resolution. The manifest endpoint awaits this promise (via
|
|
362
|
+
// pendingPhase2) so it never returns entries with missing sourcePath.
|
|
363
|
+
const phase2 = enhanceManifestInBackground(
|
|
364
|
+
pagePath,
|
|
365
|
+
entries,
|
|
366
|
+
components,
|
|
367
|
+
collection,
|
|
368
|
+
seo,
|
|
369
|
+
colDefs,
|
|
370
|
+
config,
|
|
371
|
+
manifestWriter,
|
|
372
|
+
)
|
|
373
|
+
pendingPhase2.set(pagePath, phase2)
|
|
374
|
+
phase2.finally(() => {
|
|
375
|
+
if (pendingPhase2.get(pagePath) === phase2) {
|
|
376
|
+
pendingPhase2.delete(pagePath)
|
|
377
|
+
}
|
|
378
|
+
})
|
|
334
379
|
})
|
|
335
380
|
.catch((error) => {
|
|
336
381
|
console.error('[cms] Error transforming HTML:', error)
|
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
import { useEffect, useRef } from 'preact/hooks'
|
|
2
2
|
import { Z_INDEX } from '../constants'
|
|
3
|
-
import { getOutlineColor } from '../dom'
|
|
3
|
+
import { getOutlineColor, isElementVisible } from '../dom'
|
|
4
4
|
import * as signals from '../signals'
|
|
5
5
|
|
|
6
6
|
export interface EditableHighlightsProps {
|
|
@@ -164,72 +164,34 @@ function collectEditableElements(): HighlightRect[] {
|
|
|
164
164
|
const highlights: HighlightRect[] = []
|
|
165
165
|
const manifest = signals.manifest.value
|
|
166
166
|
|
|
167
|
-
|
|
168
|
-
const textElements = document.querySelectorAll('[data-cms-id]')
|
|
169
|
-
const componentElements = document.querySelectorAll('[data-cms-component-id]')
|
|
170
|
-
const imageElements = document.querySelectorAll('img[data-cms-img]')
|
|
171
|
-
const bgImageElements = document.querySelectorAll('[data-cms-bg-img]')
|
|
172
|
-
|
|
173
|
-
// Process text elements
|
|
174
|
-
textElements.forEach((el) => {
|
|
175
|
-
const cmsId = el.getAttribute('data-cms-id')
|
|
167
|
+
const tryPush = (el: Element, cmsId: string | null, type: HighlightRect['type']) => {
|
|
176
168
|
if (!cmsId) return
|
|
177
|
-
|
|
178
|
-
//
|
|
179
|
-
if (el.hasAttribute('data-cms-component-id') || el.tagName === 'IMG') return
|
|
180
|
-
|
|
181
|
-
// Skip if not in manifest (invalid element)
|
|
182
|
-
if (!manifest.entries[cmsId]) return
|
|
183
|
-
|
|
169
|
+
// Cheap rect gate first — skips most off-screen / collapsed elements without
|
|
170
|
+
// touching computed style. Only survivors pay the visibility check cost.
|
|
184
171
|
const rect = el.getBoundingClientRect()
|
|
185
|
-
// Skip elements not in viewport or too small
|
|
186
172
|
if (rect.width < 10 || rect.height < 10) return
|
|
187
173
|
if (rect.bottom < 0 || rect.top > window.innerHeight) return
|
|
188
174
|
if (rect.right < 0 || rect.left > window.innerWidth) return
|
|
175
|
+
if (!isElementVisible(el)) return
|
|
176
|
+
highlights.push({ cmsId, type, rect })
|
|
177
|
+
}
|
|
189
178
|
|
|
190
|
-
|
|
179
|
+
document.querySelectorAll('[data-cms-id]').forEach((el) => {
|
|
180
|
+
// Routed to the component/image branches below.
|
|
181
|
+
if (el.hasAttribute('data-cms-component-id') || el.tagName === 'IMG') return
|
|
182
|
+
const cmsId = el.getAttribute('data-cms-id')
|
|
183
|
+
if (cmsId && !manifest.entries[cmsId]) return
|
|
184
|
+
tryPush(el, cmsId, el.hasAttribute('data-cms-bg-img') ? 'image' : 'text')
|
|
191
185
|
})
|
|
192
186
|
|
|
193
|
-
|
|
194
|
-
componentElements.forEach((el) => {
|
|
187
|
+
document.querySelectorAll('[data-cms-component-id]').forEach((el) => {
|
|
195
188
|
const componentId = el.getAttribute('data-cms-component-id')
|
|
196
|
-
if (!componentId) return
|
|
197
|
-
|
|
198
|
-
// Skip if not in manifest
|
|
199
|
-
if (!manifest.components[componentId]) return
|
|
200
|
-
|
|
201
|
-
const rect = el.getBoundingClientRect()
|
|
202
|
-
if (rect.width < 10 || rect.height < 10) return
|
|
203
|
-
if (rect.bottom < 0 || rect.top > window.innerHeight) return
|
|
204
|
-
if (rect.right < 0 || rect.left > window.innerWidth) return
|
|
205
|
-
|
|
206
|
-
highlights.push({ cmsId: componentId, type: 'component', rect })
|
|
189
|
+
if (componentId && !manifest.components[componentId]) return
|
|
190
|
+
tryPush(el, componentId, 'component')
|
|
207
191
|
})
|
|
208
192
|
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
const cmsId = el.getAttribute('data-cms-img')
|
|
212
|
-
if (!cmsId) return
|
|
213
|
-
|
|
214
|
-
const rect = el.getBoundingClientRect()
|
|
215
|
-
if (rect.width < 10 || rect.height < 10) return
|
|
216
|
-
if (rect.bottom < 0 || rect.top > window.innerHeight) return
|
|
217
|
-
if (rect.right < 0 || rect.left > window.innerWidth) return
|
|
218
|
-
|
|
219
|
-
highlights.push({ cmsId, type: 'image', rect })
|
|
220
|
-
})
|
|
221
|
-
|
|
222
|
-
// Process background image elements
|
|
223
|
-
bgImageElements.forEach((el) => {
|
|
224
|
-
const cmsId = el.getAttribute('data-cms-id')
|
|
225
|
-
if (!cmsId) return
|
|
226
|
-
|
|
227
|
-
const rect = el.getBoundingClientRect()
|
|
228
|
-
if (rect.width < 10 || rect.height < 10) return
|
|
229
|
-
if (rect.bottom < 0 || rect.top > window.innerHeight) return
|
|
230
|
-
if (rect.right < 0 || rect.left > window.innerWidth) return
|
|
231
|
-
|
|
232
|
-
highlights.push({ cmsId, type: 'image', rect })
|
|
193
|
+
document.querySelectorAll('img[data-cms-img]').forEach((el) => {
|
|
194
|
+
tryPush(el, el.getAttribute('data-cms-img'), 'image')
|
|
233
195
|
})
|
|
234
196
|
|
|
235
197
|
return highlights
|
|
@@ -17,6 +17,7 @@ import {
|
|
|
17
17
|
startRedirectCountdown,
|
|
18
18
|
updateMarkdownFrontmatter,
|
|
19
19
|
} from '../signals'
|
|
20
|
+
import { clearMarkdownDraft } from '../storage'
|
|
20
21
|
import { CreateModeFrontmatter, EditModeFrontmatter } from './frontmatter-fields'
|
|
21
22
|
import { FrontmatterSidebar, partitionFields } from './frontmatter-sidebar'
|
|
22
23
|
import { MarkdownInlineEditor } from './markdown-inline-editor'
|
|
@@ -163,6 +164,7 @@ export function MarkdownEditorOverlay() {
|
|
|
163
164
|
isMarkdownPreview.value = false
|
|
164
165
|
setIsSaving(false)
|
|
165
166
|
|
|
167
|
+
clearMarkdownDraft(currentPage.filePath)
|
|
166
168
|
showToast('Content saved', 'success')
|
|
167
169
|
// Clear pending entry navigation so editor doesn't auto-open after save
|
|
168
170
|
sessionStorage.removeItem(STORAGE_KEYS.PENDING_ENTRY_NAVIGATION)
|
package/src/editor/constants.ts
CHANGED
|
@@ -93,6 +93,8 @@ export const STORAGE_KEYS = {
|
|
|
93
93
|
SETTINGS: 'cms-settings',
|
|
94
94
|
PENDING_ENTRY_NAVIGATION: 'cms-pending-entry-navigation',
|
|
95
95
|
IS_EDITING: 'cms-is-editing',
|
|
96
|
+
/** Prefix for per-file markdown draft entries; full key is `${MARKDOWN_DRAFT_PREFIX}${filePath}`. */
|
|
97
|
+
MARKDOWN_DRAFT_PREFIX: 'cms-markdown-draft:',
|
|
96
98
|
} as const
|
|
97
99
|
|
|
98
100
|
/**
|
package/src/editor/dom.ts
CHANGED
|
@@ -51,6 +51,43 @@ export function getOutlineColor(): string {
|
|
|
51
51
|
return isPageDark() ? '#FFFFFF' : '#1A1A1A'
|
|
52
52
|
}
|
|
53
53
|
|
|
54
|
+
/**
|
|
55
|
+
* Check if an element is actually visible to the user. Catches `display:none`,
|
|
56
|
+
* `visibility:hidden|collapse`, `opacity:0`, and `content-visibility` on the
|
|
57
|
+
* element or any ancestor — `getBoundingClientRect` alone reports a non-zero
|
|
58
|
+
* box for `opacity:0`/`visibility:hidden`, so highlight overlays would otherwise
|
|
59
|
+
* draw over hidden mobile menus and modal panels.
|
|
60
|
+
*/
|
|
61
|
+
export function isElementVisible(el: Element): boolean {
|
|
62
|
+
const anyEl = el as Element & {
|
|
63
|
+
checkVisibility?: (opts?: {
|
|
64
|
+
checkOpacity?: boolean
|
|
65
|
+
checkVisibilityCSS?: boolean
|
|
66
|
+
contentVisibilityAuto?: boolean
|
|
67
|
+
opacityProperty?: boolean
|
|
68
|
+
visibilityProperty?: boolean
|
|
69
|
+
}) => boolean
|
|
70
|
+
}
|
|
71
|
+
if (typeof anyEl.checkVisibility === 'function') {
|
|
72
|
+
return anyEl.checkVisibility({
|
|
73
|
+
checkOpacity: true,
|
|
74
|
+
checkVisibilityCSS: true,
|
|
75
|
+
contentVisibilityAuto: true,
|
|
76
|
+
opacityProperty: true,
|
|
77
|
+
visibilityProperty: true,
|
|
78
|
+
})
|
|
79
|
+
}
|
|
80
|
+
let cur: Element | null = el
|
|
81
|
+
while (cur && cur !== document.documentElement) {
|
|
82
|
+
const cs = getComputedStyle(cur)
|
|
83
|
+
if (cs.display === 'none') return false
|
|
84
|
+
if (cs.visibility === 'hidden' || cs.visibility === 'collapse') return false
|
|
85
|
+
if (parseFloat(cs.opacity) === 0) return false
|
|
86
|
+
cur = cur.parentElement
|
|
87
|
+
}
|
|
88
|
+
return true
|
|
89
|
+
}
|
|
90
|
+
|
|
54
91
|
/** Style element for contenteditable focus styles injected into the host page */
|
|
55
92
|
let focusStyleElement: HTMLStyleElement | null = null
|
|
56
93
|
|
package/src/editor/editor.ts
CHANGED
|
@@ -100,6 +100,47 @@ export function notifyLockedElement(): void {
|
|
|
100
100
|
signals.showToast("This text can't be edited here — no source file is linked to it", 'info')
|
|
101
101
|
}
|
|
102
102
|
|
|
103
|
+
/**
|
|
104
|
+
* Manifest is built in two phases (fast HTML marking, then background source
|
|
105
|
+
* resolution). Entering edit mode before phase 2 completes can pre-lock elements
|
|
106
|
+
* that later gain a valid sourcePath, leaving stale locks on the DOM.
|
|
107
|
+
*
|
|
108
|
+
* On click, re-check the local snapshot, then re-fetch from the server in case
|
|
109
|
+
* phase 2 finished since the editor took its snapshot. Toggle edit mode once
|
|
110
|
+
* afterwards to make the now-unlocked element editable.
|
|
111
|
+
*/
|
|
112
|
+
let inFlightLockedFetch: Promise<unknown> | null = null
|
|
113
|
+
function handleLockedClick(event: Event): void {
|
|
114
|
+
const target = event.currentTarget as HTMLElement | null
|
|
115
|
+
const id = target?.getAttribute(CSS.ID_ATTRIBUTE)
|
|
116
|
+
if (!target || !id) {
|
|
117
|
+
notifyLockedElement()
|
|
118
|
+
return
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
if (signals.manifest.value.entries[id]?.sourcePath) {
|
|
122
|
+
target.removeAttribute(CSS.LOCKED_ATTRIBUTE)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
// In-memory signal can lag phase-2 writes. Coalesce concurrent clicks into
|
|
127
|
+
// one fetch so the dev server doesn't get hammered.
|
|
128
|
+
if (!inFlightLockedFetch) {
|
|
129
|
+
inFlightLockedFetch = fetchManifest()
|
|
130
|
+
.then((fresh) => signals.setManifest(fresh))
|
|
131
|
+
.catch(() => {})
|
|
132
|
+
.finally(() => {
|
|
133
|
+
inFlightLockedFetch = null
|
|
134
|
+
})
|
|
135
|
+
}
|
|
136
|
+
inFlightLockedFetch.then(() => {
|
|
137
|
+
if (signals.manifest.value.entries[id]?.sourcePath) {
|
|
138
|
+
target.removeAttribute(CSS.LOCKED_ATTRIBUTE)
|
|
139
|
+
}
|
|
140
|
+
})
|
|
141
|
+
notifyLockedElement()
|
|
142
|
+
}
|
|
143
|
+
|
|
103
144
|
/** Test-only: reset toast throttle state between test cases. */
|
|
104
145
|
export function _resetToastThrottles(): void {
|
|
105
146
|
lastFormattingBlockedToastAt = 0
|
|
@@ -167,6 +208,27 @@ export async function startEditMode(
|
|
|
167
208
|
editModeAbortController = new AbortController()
|
|
168
209
|
const editModeSignal = editModeAbortController.signal
|
|
169
210
|
|
|
211
|
+
const savedEdits = loadEditsFromStorage()
|
|
212
|
+
const savedImageEdits = loadImageEditsFromStorage()
|
|
213
|
+
const savedColorEdits = loadColorEditsFromStorage()
|
|
214
|
+
const savedAttributeEdits = loadAttributeEditsFromStorage()
|
|
215
|
+
|
|
216
|
+
// Paint saved edits before awaiting the manifest so slow fetches don't show stale text.
|
|
217
|
+
// The captured pre-paint state is read by the wiring loop later as the pendingChange origin —
|
|
218
|
+
// without it the source writer would search the source file for the already-edited text.
|
|
219
|
+
const preEagerPaintDomState = new Map<string, { html: string; text: string }>()
|
|
220
|
+
for (const [cmsId, edit] of Object.entries(savedEdits)) {
|
|
221
|
+
const el = document.querySelector(`[${CSS.ID_ATTRIBUTE}="${cmsId}"]`) as HTMLElement | null
|
|
222
|
+
if (!el) continue
|
|
223
|
+
preEagerPaintDomState.set(cmsId, {
|
|
224
|
+
html: el.innerHTML,
|
|
225
|
+
text: getEditableTextFromElement(el),
|
|
226
|
+
})
|
|
227
|
+
if (el.innerHTML !== edit.currentHTML) {
|
|
228
|
+
el.innerHTML = edit.currentHTML
|
|
229
|
+
}
|
|
230
|
+
}
|
|
231
|
+
|
|
170
232
|
try {
|
|
171
233
|
const manifest = await fetchManifest()
|
|
172
234
|
signals.setManifest(manifest)
|
|
@@ -176,11 +238,6 @@ export async function startEditMode(
|
|
|
176
238
|
console.error('[CMS] Failed to load manifest:', err)
|
|
177
239
|
return
|
|
178
240
|
}
|
|
179
|
-
|
|
180
|
-
const savedEdits = loadEditsFromStorage()
|
|
181
|
-
const savedImageEdits = loadImageEditsFromStorage()
|
|
182
|
-
const savedColorEdits = loadColorEditsFromStorage()
|
|
183
|
-
const savedAttributeEdits = loadAttributeEditsFromStorage()
|
|
184
241
|
const savedBgImageEdits = loadBgImageEditsFromStorage()
|
|
185
242
|
const currentManifest = signals.manifest.value
|
|
186
243
|
|
|
@@ -246,7 +303,7 @@ export async function startEditMode(
|
|
|
246
303
|
logDebug(config.debug, 'Skipping element without source path:', cmsId)
|
|
247
304
|
makeElementNonEditable(el)
|
|
248
305
|
el.setAttribute(CSS.LOCKED_ATTRIBUTE, 'true')
|
|
249
|
-
el.addEventListener('click',
|
|
306
|
+
el.addEventListener('click', handleLockedClick, { signal: editModeSignal })
|
|
250
307
|
return
|
|
251
308
|
}
|
|
252
309
|
|
|
@@ -302,8 +359,9 @@ export async function startEditMode(
|
|
|
302
359
|
setupAttributeTracking(config, el, cmsId, savedAttributeEdits[cmsId])
|
|
303
360
|
|
|
304
361
|
if (!signals.pendingChanges.value.has(cmsId)) {
|
|
305
|
-
const
|
|
306
|
-
const
|
|
362
|
+
const preEagerPaint = preEagerPaintDomState.get(cmsId)
|
|
363
|
+
const originalHTML = preEagerPaint?.html ?? el.innerHTML
|
|
364
|
+
const originalText = preEagerPaint?.text ?? getEditableTextFromElement(el)
|
|
307
365
|
|
|
308
366
|
logDebug(config.debug, 'Setting up element:', cmsId, 'originalText:', originalText)
|
|
309
367
|
|
package/src/editor/index.tsx
CHANGED
|
@@ -57,7 +57,7 @@ import {
|
|
|
57
57
|
updateSettings,
|
|
58
58
|
} from './signals'
|
|
59
59
|
import * as signals from './signals'
|
|
60
|
-
import { hasPendingEntryNavigation, loadEditingState, loadSettingsFromStorage, saveSettingsToStorage } from './storage'
|
|
60
|
+
import { hasAnyMarkdownDraft, hasPendingEntryNavigation, loadEditingState, loadSettingsFromStorage, saveSettingsToStorage } from './storage'
|
|
61
61
|
import CMS_STYLES from './styles.css?inline'
|
|
62
62
|
import { generateCSSVariables, resolveTheme } from './themes'
|
|
63
63
|
|
|
@@ -124,12 +124,17 @@ const CmsUI = () => {
|
|
|
124
124
|
}
|
|
125
125
|
}, [config, updateUI])
|
|
126
126
|
|
|
127
|
-
//
|
|
127
|
+
// Waits for the manifest before opening — currentPageCollection depends on it.
|
|
128
|
+
const draftAutoOpenedRef = useRef(false)
|
|
129
|
+
const manifestState = signals.manifest.value
|
|
128
130
|
useEffect(() => {
|
|
129
|
-
if (
|
|
130
|
-
|
|
131
|
-
|
|
132
|
-
|
|
131
|
+
if (draftAutoOpenedRef.current) return
|
|
132
|
+
if (!hasPendingEntryNavigation() && !hasAnyMarkdownDraft()) return
|
|
133
|
+
const hasCollections = manifestState.collections && Object.keys(manifestState.collections).length > 0
|
|
134
|
+
if (!hasCollections) return
|
|
135
|
+
draftAutoOpenedRef.current = true
|
|
136
|
+
openMarkdownEditorForCurrentPage()
|
|
137
|
+
}, [manifestState])
|
|
133
138
|
|
|
134
139
|
// Send select-mode click selection to parent window via postMessage
|
|
135
140
|
const prevSelectModeRef = useRef<string | null>(null)
|
|
@@ -76,6 +76,15 @@ export function uploadMedia(
|
|
|
76
76
|
onProgress?: (percent: number) => void,
|
|
77
77
|
options?: { folder?: string; context?: MediaUploadContext },
|
|
78
78
|
): Promise<MediaUploadResponse> {
|
|
79
|
+
if (config.maxUploadSize && file.size > config.maxUploadSize) {
|
|
80
|
+
const limitMb = Math.round(config.maxUploadSize / (1024 * 1024))
|
|
81
|
+
const fileMb = (file.size / (1024 * 1024)).toFixed(1)
|
|
82
|
+
return Promise.resolve({
|
|
83
|
+
success: false,
|
|
84
|
+
error: `File is too large (${fileMb} MB, limit ${limitMb} MB)`,
|
|
85
|
+
})
|
|
86
|
+
}
|
|
87
|
+
|
|
79
88
|
const formData = new FormData()
|
|
80
89
|
formData.append('file', file)
|
|
81
90
|
|
package/src/editor/signals.ts
CHANGED
|
@@ -1,8 +1,9 @@
|
|
|
1
|
-
import { batch, computed, type Signal, signal } from '@preact/signals'
|
|
1
|
+
import { batch, computed, effect, type Signal, signal } from '@preact/signals'
|
|
2
2
|
import { slugifyHref } from '../shared'
|
|
3
3
|
import { fetchManifest, getMarkdownContent } from './api'
|
|
4
4
|
import type { ToastMessage, ToastType } from './components/toast/types'
|
|
5
5
|
import { getConfig } from './config'
|
|
6
|
+
import { clearMarkdownDraft, loadMarkdownDraft, saveMarkdownDraft } from './storage'
|
|
6
7
|
import type {
|
|
7
8
|
AttributeEditorState,
|
|
8
9
|
BlockEditorState,
|
|
@@ -308,6 +309,48 @@ export const currentMarkdownPage = computed(
|
|
|
308
309
|
)
|
|
309
310
|
export const isMarkdownPreview = signal(false)
|
|
310
311
|
|
|
312
|
+
const DRAFT_PERSIST_DEBOUNCE_MS = 500
|
|
313
|
+
let draftPersistTimer: ReturnType<typeof setTimeout> | null = null
|
|
314
|
+
let lastPersistedSnapshot: string | null = null
|
|
315
|
+
|
|
316
|
+
function getPersistablePage(): MarkdownPageEntry | null {
|
|
317
|
+
const state = markdownEditorState.value
|
|
318
|
+
const page = state.currentPage
|
|
319
|
+
if (!state.isOpen || !page || !page.isDirty || state.mode !== 'edit' || !page.filePath) return null
|
|
320
|
+
return page
|
|
321
|
+
}
|
|
322
|
+
|
|
323
|
+
function persistCurrentDraftIfDirty(): void {
|
|
324
|
+
const page = getPersistablePage()
|
|
325
|
+
if (!page) return
|
|
326
|
+
const snapshot = `${page.filePath}${JSON.stringify(page.frontmatter)}${page.content}`
|
|
327
|
+
if (snapshot === lastPersistedSnapshot) return
|
|
328
|
+
lastPersistedSnapshot = snapshot
|
|
329
|
+
saveMarkdownDraft(page.filePath, page.frontmatter, page.content)
|
|
330
|
+
}
|
|
331
|
+
|
|
332
|
+
function flushPendingDraft(): void {
|
|
333
|
+
if (draftPersistTimer) {
|
|
334
|
+
clearTimeout(draftPersistTimer)
|
|
335
|
+
draftPersistTimer = null
|
|
336
|
+
}
|
|
337
|
+
persistCurrentDraftIfDirty()
|
|
338
|
+
}
|
|
339
|
+
|
|
340
|
+
effect(() => {
|
|
341
|
+
if (!getPersistablePage()) return
|
|
342
|
+
if (draftPersistTimer) clearTimeout(draftPersistTimer)
|
|
343
|
+
draftPersistTimer = setTimeout(() => {
|
|
344
|
+
draftPersistTimer = null
|
|
345
|
+
persistCurrentDraftIfDirty()
|
|
346
|
+
}, DRAFT_PERSIST_DEBOUNCE_MS)
|
|
347
|
+
})
|
|
348
|
+
|
|
349
|
+
// `pagehide` fires reliably on tab close, reload, and back/forward nav, unlike `beforeunload` which is throttled.
|
|
350
|
+
if (typeof window !== 'undefined') {
|
|
351
|
+
window.addEventListener('pagehide', flushPendingDraft)
|
|
352
|
+
}
|
|
353
|
+
|
|
311
354
|
// ============================================================================
|
|
312
355
|
// MDX Component Block State Signals
|
|
313
356
|
// ============================================================================
|
|
@@ -853,6 +896,65 @@ function parseFrontmatterValue(value: string): unknown {
|
|
|
853
896
|
return value
|
|
854
897
|
}
|
|
855
898
|
|
|
899
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
900
|
+
if (a === b) return true
|
|
901
|
+
if (a === null || b === null || typeof a !== typeof b || typeof a !== 'object') return false
|
|
902
|
+
if (Array.isArray(a)) {
|
|
903
|
+
if (!Array.isArray(b) || a.length !== b.length) return false
|
|
904
|
+
return a.every((v, i) => deepEqual(v, b[i]))
|
|
905
|
+
}
|
|
906
|
+
const aObj = a as Record<string, unknown>
|
|
907
|
+
const bObj = b as Record<string, unknown>
|
|
908
|
+
const aKeys = Object.keys(aObj)
|
|
909
|
+
if (aKeys.length !== Object.keys(bObj).length) return false
|
|
910
|
+
return aKeys.every((k) => deepEqual(aObj[k], bObj[k]))
|
|
911
|
+
}
|
|
912
|
+
|
|
913
|
+
function formatDraftAge(savedAt: number): string {
|
|
914
|
+
const elapsedMs = Date.now() - savedAt
|
|
915
|
+
const minutes = Math.floor(elapsedMs / 60_000)
|
|
916
|
+
if (minutes < 1) return 'just now'
|
|
917
|
+
if (minutes < 60) return `${minutes} minute${minutes === 1 ? '' : 's'} ago`
|
|
918
|
+
const hours = Math.floor(minutes / 60)
|
|
919
|
+
if (hours < 24) return `${hours} hour${hours === 1 ? '' : 's'} ago`
|
|
920
|
+
return new Date(savedAt).toLocaleString()
|
|
921
|
+
}
|
|
922
|
+
|
|
923
|
+
/**
|
|
924
|
+
* If a sessionStorage draft exists for `filePath` and differs from the freshly fetched
|
|
925
|
+
* content, ask the user whether to restore it. Returns the (possibly overridden) frontmatter
|
|
926
|
+
* and content. The `isDirty` flag indicates whether the result came from a recovered draft.
|
|
927
|
+
*/
|
|
928
|
+
async function maybeRecoverDraft(
|
|
929
|
+
filePath: string,
|
|
930
|
+
fetchedFrontmatter: Record<string, unknown>,
|
|
931
|
+
fetchedContent: string,
|
|
932
|
+
): Promise<{ frontmatter: Record<string, unknown>; content: string; isDirty: boolean }> {
|
|
933
|
+
const draft = loadMarkdownDraft(filePath)
|
|
934
|
+
if (!draft) return { frontmatter: fetchedFrontmatter, content: fetchedContent, isDirty: false }
|
|
935
|
+
|
|
936
|
+
const sameContent = draft.content === fetchedContent
|
|
937
|
+
const sameFrontmatter = deepEqual(draft.frontmatter, fetchedFrontmatter)
|
|
938
|
+
if (sameContent && sameFrontmatter) {
|
|
939
|
+
clearMarkdownDraft(filePath)
|
|
940
|
+
return { frontmatter: fetchedFrontmatter, content: fetchedContent, isDirty: false }
|
|
941
|
+
}
|
|
942
|
+
|
|
943
|
+
const recover = await showConfirmDialog({
|
|
944
|
+
title: 'Recover unsaved changes?',
|
|
945
|
+
message: `An unsaved draft from ${formatDraftAge(draft.savedAt)} exists for this entry. Recover it, or discard?`,
|
|
946
|
+
confirmLabel: 'Recover draft',
|
|
947
|
+
cancelLabel: 'Discard',
|
|
948
|
+
variant: 'warning',
|
|
949
|
+
})
|
|
950
|
+
|
|
951
|
+
if (recover) {
|
|
952
|
+
return { frontmatter: draft.frontmatter, content: draft.content, isDirty: true }
|
|
953
|
+
}
|
|
954
|
+
clearMarkdownDraft(filePath)
|
|
955
|
+
return { frontmatter: fetchedFrontmatter, content: fetchedContent, isDirty: false }
|
|
956
|
+
}
|
|
957
|
+
|
|
856
958
|
/**
|
|
857
959
|
* Open the markdown editor for the current page's collection entry.
|
|
858
960
|
* Refreshes the manifest first to ensure we have the latest content.
|
|
@@ -896,14 +998,16 @@ export async function openMarkdownEditorForCurrentPage(): Promise<boolean> {
|
|
|
896
998
|
// Look up collection definition for schema-aware field rendering
|
|
897
999
|
const collectionDefinition = manifest.value.collectionDefinitions?.[collection.collectionName]
|
|
898
1000
|
|
|
1001
|
+
const recovered = await maybeRecoverDraft(collection.sourcePath, frontmatter, content)
|
|
1002
|
+
|
|
899
1003
|
markdownEditorState.value = {
|
|
900
1004
|
isOpen: true,
|
|
901
1005
|
currentPage: {
|
|
902
1006
|
filePath: collection.sourcePath,
|
|
903
1007
|
slug: collection.collectionSlug,
|
|
904
|
-
frontmatter: frontmatter as import('./types').BlogFrontmatter,
|
|
905
|
-
content,
|
|
906
|
-
isDirty:
|
|
1008
|
+
frontmatter: recovered.frontmatter as import('./types').BlogFrontmatter,
|
|
1009
|
+
content: recovered.content,
|
|
1010
|
+
isDirty: recovered.isDirty,
|
|
907
1011
|
},
|
|
908
1012
|
activeElementId: collection.wrapperId ?? null,
|
|
909
1013
|
mode: 'edit',
|
|
@@ -1114,14 +1218,16 @@ export async function openMarkdownEditorForEntry(
|
|
|
1114
1218
|
console.error('[CMS] Failed to fetch markdown content for entry:', err)
|
|
1115
1219
|
}
|
|
1116
1220
|
|
|
1221
|
+
const recovered = await maybeRecoverDraft(sourcePath, frontmatter, content)
|
|
1222
|
+
|
|
1117
1223
|
markdownEditorState.value = {
|
|
1118
1224
|
isOpen: true,
|
|
1119
1225
|
currentPage: {
|
|
1120
1226
|
filePath: sourcePath,
|
|
1121
1227
|
slug,
|
|
1122
|
-
frontmatter: frontmatter as import('./types').BlogFrontmatter,
|
|
1123
|
-
content,
|
|
1124
|
-
isDirty:
|
|
1228
|
+
frontmatter: recovered.frontmatter as import('./types').BlogFrontmatter,
|
|
1229
|
+
content: recovered.content,
|
|
1230
|
+
isDirty: recovered.isDirty,
|
|
1125
1231
|
},
|
|
1126
1232
|
activeElementId: null,
|
|
1127
1233
|
mode: 'edit',
|