@nuasite/cms 0.37.0 → 0.38.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -14,7 +14,7 @@
14
14
  "directory": "packages/astro-cms"
15
15
  },
16
16
  "license": "Apache-2.0",
17
- "version": "0.37.0",
17
+ "version": "0.38.0",
18
18
  "module": "src/index.ts",
19
19
  "types": "src/index.ts",
20
20
  "type": "module",
@@ -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
- // Query all elements with CMS data attributes
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
- // Skip if this is also a component or image
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
- highlights.push({ cmsId, type: 'text', rect })
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
- // Process component elements
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
- // Process image elements
210
- imageElements.forEach((el) => {
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
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
 
@@ -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
@@ -246,7 +287,7 @@ export async function startEditMode(
246
287
  logDebug(config.debug, 'Skipping element without source path:', cmsId)
247
288
  makeElementNonEditable(el)
248
289
  el.setAttribute(CSS.LOCKED_ATTRIBUTE, 'true')
249
- el.addEventListener('click', notifyLockedElement, { signal: editModeSignal })
290
+ el.addEventListener('click', handleLockedClick, { signal: editModeSignal })
250
291
  return
251
292
  }
252
293
 
@@ -6,10 +6,19 @@ import { escapeRegex, resolveSourcePath } from '../utils'
6
6
  import { buildDefinitionPath, parseExpressionPath } from './ast-extractors'
7
7
  import { getCachedParsedFile } from './ast-parser'
8
8
  import { findComponentProp, findExpressionProp, findSpreadProp } from './element-finder'
9
+ import { wildcardPathToRegexBody } from './search-index'
9
10
  import { normalizeText } from './snippet-utils'
10
11
  import type { ImportInfo, SourceLocation, VariableDefinition } from './types'
11
12
  import { getExportedDefinitions, resolveImportPath } from './variable-extraction'
12
13
 
14
+ /**
15
+ * Compile a wildcard path like `navItems[*].label` into a regex matching
16
+ * concrete definition paths (`navItems[0].label`, `navItems[3].label`, …).
17
+ */
18
+ function buildWildcardPathRegex(wildcardPath: string): RegExp {
19
+ return new RegExp('^' + wildcardPathToRegexBody(wildcardPath) + '$')
20
+ }
21
+
13
22
  // ============================================================================
14
23
  // Expression Prop Search
15
24
  // ============================================================================
@@ -104,16 +113,17 @@ async function searchDirForExpressionProp(
104
113
  if (exprPropMatch) {
105
114
  // The expression text might be a simple variable like 'navItems'
106
115
  const exprText = exprPropMatch.expressionText
107
-
108
- // Build the corresponding path in the parent's variable definitions
109
- // e.g., if expressionPath is 'items[0]' and exprText is 'navItems',
110
- // we look for 'navItems[0]' in the parent's definitions
116
+ // Substitute the parent's local name for the child's:
117
+ // child `items[0]` + parent `navItems` → `navItems[0]`
118
+ // May contain `[*]` wildcards when the child resolved a `.map()` callback
119
+ // param compare via regex to match concrete indices in the parent.
111
120
  const parentPath = expressionPath.replace(/^[^.[]+/, exprText)
121
+ const parentPathRegex = parentPath.includes('[*]') ? buildWildcardPathRegex(parentPath) : null
112
122
 
113
- // Check if the value is in local variable definitions
114
123
  for (const def of cached.variableDefinitions) {
115
124
  const defPath = buildDefinitionPath(def)
116
- if (defPath === parentPath) {
125
+ const matches = parentPathRegex ? parentPathRegex.test(defPath) : defPath === parentPath
126
+ if (matches) {
117
127
  const normalizedDef = normalizeText(def.value)
118
128
  if (normalizedDef === normalizedSearch) {
119
129
  return {
@@ -1,6 +1,7 @@
1
1
  import type { ComponentNode, ElementNode, Node as AstroNode, TextNode } from '@astrojs/compiler/types'
2
2
 
3
3
  import { buildDefinitionPath, parseExpressionPath } from './ast-extractors'
4
+ import { makeLeafPathRegex, resolveMapChain } from './search-index'
4
5
  import { normalizeText } from './snippet-utils'
5
6
  import type {
6
7
  ComponentPropMatch,
@@ -100,7 +101,9 @@ export function findElementWithText(
100
101
  return match?.[1] ?? exprPath
101
102
  }
102
103
 
103
- function visit(node: AstroNode) {
104
+ function visit(node: AstroNode, parentExpression: AstroNode | null) {
105
+ const currentExpr = node.type === 'expression' ? node : parentExpression
106
+
104
107
  // Check if this is an element or component matching our tag
105
108
  if ((node.type === 'element' || node.type === 'component') && node.name.toLowerCase() === tagLower) {
106
109
  const elemNode = node as ElementNode | ComponentNode
@@ -166,6 +169,43 @@ export function findElementWithText(
166
169
  importInfo,
167
170
  expressionPath: exprPath,
168
171
  })
172
+ } else if (currentExpr) {
173
+ // `baseVar` may be a `.map()` callback parameter or destructured
174
+ // property. Resolve it to the source array; if the array is local
175
+ // take the leaf, otherwise surface as a cross-file prop candidate.
176
+ const mapMatch = resolveMapForLocal(
177
+ exprPath,
178
+ currentExpr,
179
+ variableDefinitions,
180
+ normalizedSearch,
181
+ )
182
+ if (mapMatch && bestScore < 100) {
183
+ bestScore = 100
184
+ bestMatch = {
185
+ line,
186
+ type: 'variable',
187
+ variableName: mapMatch.variableName,
188
+ definitionLine: mapMatch.definitionLine,
189
+ }
190
+ return
191
+ }
192
+
193
+ if (!mapMatch) {
194
+ const mapPropCandidate = resolveMapForProp(
195
+ exprPath,
196
+ currentExpr,
197
+ propAliases,
198
+ )
199
+ if (mapPropCandidate) {
200
+ propCandidates.push({
201
+ line,
202
+ type: 'variable',
203
+ usesProp: true,
204
+ propName: mapPropCandidate.propName,
205
+ expressionPath: mapPropCandidate.expressionPath,
206
+ })
207
+ }
208
+ }
169
209
  }
170
210
  }
171
211
  }
@@ -224,7 +264,7 @@ export function findElementWithText(
224
264
  // Recursively visit children
225
265
  if ('children' in node && Array.isArray(node.children)) {
226
266
  for (const child of node.children) {
227
- visit(child)
267
+ visit(child, currentExpr)
228
268
  }
229
269
  }
230
270
  }
@@ -245,10 +285,102 @@ export function findElementWithText(
245
285
  return null
246
286
  }
247
287
 
248
- visit(ast)
288
+ visit(ast, null)
249
289
  return { bestMatch, propCandidates, importCandidates }
250
290
  }
251
291
 
292
+ /**
293
+ * Collect the joined text of every direct text child of an expression node.
294
+ * Used to feed `resolveMapChain` with the surrounding `.map(...)` source.
295
+ */
296
+ function collectExpressionText(parentExpression: AstroNode): string[] {
297
+ const exprTexts: string[] = []
298
+ if ('children' in parentExpression && Array.isArray(parentExpression.children)) {
299
+ for (const child of parentExpression.children) {
300
+ if (child.type === 'text' && (child as TextNode).value) {
301
+ exprTexts.push((child as TextNode).value)
302
+ }
303
+ }
304
+ }
305
+ return exprTexts
306
+ }
307
+
308
+ /**
309
+ * Resolve a `.map()` loop variable reference (`item.label` or destructured `label`)
310
+ * against local variable definitions, returning the concrete element whose value
311
+ * matches `normalizedSearch`. Returns null when the chain doesn't resolve or no
312
+ * leaf value matches.
313
+ */
314
+ function resolveMapForLocal(
315
+ exprPath: string,
316
+ parentExpression: AstroNode,
317
+ variableDefinitions: VariableDefinition[],
318
+ normalizedSearch: string,
319
+ ): { variableName: string; definitionLine: number } | null {
320
+ const baseMatch = exprPath.match(/^(\w+)(.*)$/)
321
+ if (!baseMatch) return null
322
+ const baseVar = baseMatch[1]!
323
+ const suffix = baseMatch[2] ?? ''
324
+
325
+ const exprTexts = collectExpressionText(parentExpression)
326
+ if (exprTexts.length === 0) return null
327
+
328
+ const resolved = resolveMapChain(exprTexts, baseVar)
329
+ if (!resolved) return null
330
+
331
+ const leafRegex = makeLeafPathRegex({
332
+ arrayPath: resolved.arrayPath,
333
+ leafSuffix: resolved.leafSuffix + suffix,
334
+ })
335
+
336
+ for (const def of variableDefinitions) {
337
+ const defPath = buildDefinitionPath(def)
338
+ if (!leafRegex.test(defPath)) continue
339
+ if (normalizeText(def.value) === normalizedSearch) {
340
+ return { variableName: defPath, definitionLine: def.line }
341
+ }
342
+ }
343
+ return null
344
+ }
345
+
346
+ /**
347
+ * Resolve a `.map()` loop variable reference against prop aliases, returning a
348
+ * cross-file candidate when the source array comes from props. The expression
349
+ * path encodes the wildcard chain (e.g. `items[*].label`) so cross-file lookups
350
+ * can match concrete parent definitions.
351
+ */
352
+ function resolveMapForProp(
353
+ exprPath: string,
354
+ parentExpression: AstroNode,
355
+ propAliases: Map<string, string>,
356
+ ): { propName: string; expressionPath: string } | null {
357
+ const baseMatch = exprPath.match(/^(\w+)(.*)$/)
358
+ if (!baseMatch) return null
359
+ const baseVar = baseMatch[1]!
360
+ const suffix = baseMatch[2] ?? ''
361
+
362
+ const exprTexts = collectExpressionText(parentExpression)
363
+ if (exprTexts.length === 0) return null
364
+
365
+ const resolved = resolveMapChain(exprTexts, baseVar)
366
+ if (!resolved) return null
367
+
368
+ // The head of the resolved arrayPath is the local name of the array; if that
369
+ // local name is a prop alias, we have a cross-file candidate.
370
+ const headMatch = resolved.arrayPath.match(/^(\w+)/)
371
+ if (!headMatch) return null
372
+ const arrayHead = headMatch[1]!
373
+ const actualPropName = propAliases.get(arrayHead)
374
+ if (!actualPropName) return null
375
+
376
+ // Build the expression path the parent should match: e.g. `items[*].label`.
377
+ // Cross-file lookup combines this with the parent's local binding.
378
+ return {
379
+ propName: actualPropName,
380
+ expressionPath: resolved.arrayPath + '[*]' + resolved.leafSuffix + suffix,
381
+ }
382
+ }
383
+
252
384
  // ============================================================================
253
385
  // Component Prop Finding
254
386
  // ============================================================================