Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
75 changes: 75 additions & 0 deletions src/__tests__/canvas/readonlyRegion.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,75 @@
/**
* closestReadonlyRegion — the boundary resolver that decides whether a hovered
* / double-clicked canvas element is read-only composed chrome or the active
* document's own editable content.
*
* Regression: the active page's content is spliced into a wrapping template's
* outlet, so editable nodes live INSIDE the read-only wrapper element. Walking
* straight to the nearest `[data-instatic-readonly-*]` ancestor wrongly treated
* that editable content as read-only — the hover hint fired over the whole page
* and double-click opened the template instead of editing the node.
*/
import { describe, it, expect } from 'bun:test'
import { closestReadonlyRegion, isElementLike } from '@site/canvas/readonlyRegion'

function readonly(tag: string): HTMLElement {
const el = document.createElement(tag)
el.setAttribute('data-instatic-readonly-label', 'Main template')
el.setAttribute('data-instatic-readonly-kind', 'page')
el.setAttribute('data-instatic-readonly-id', 'tpl-main')
return el
}

describe('closestReadonlyRegion', () => {
it('returns null for editable content spliced inside a read-only template wrapper', () => {
// <div readonly="Main template"><h1 data-node-id>ABOUT</h1></div>
const wrapper = readonly('div')
const heading = document.createElement('h1')
heading.setAttribute('data-node-id', 'abc')
wrapper.appendChild(heading)
expect(closestReadonlyRegion(heading)).toBeNull()
})

it('returns null for a descendant of editable content even when both sit in a readonly wrapper', () => {
const wrapper = readonly('div')
const heading = document.createElement('h1')
heading.setAttribute('data-node-id', 'abc')
const span = document.createElement('span') // module-internal markup, unmarked
heading.appendChild(span)
wrapper.appendChild(heading)
expect(closestReadonlyRegion(span)).toBeNull()
})

it('returns the region element for read-only template chrome', () => {
const nav = readonly('nav')
expect(closestReadonlyRegion(nav)?.getAttribute('data-instatic-readonly-label')).toBe('Main template')
})

it('returns the read-only ancestor for an unmarked child of chrome (e.g. a logo image)', () => {
const nav = readonly('nav')
const img = document.createElement('img')
nav.appendChild(img)
const region = closestReadonlyRegion(img)
expect(region?.getAttribute('data-instatic-readonly-id')).toBe('tpl-main')
expect(region?.getAttribute('data-instatic-readonly-kind')).toBe('page')
})

it('returns null for a plain editable node with no readonly ancestor', () => {
const h = document.createElement('h1')
h.setAttribute('data-node-id', 'x')
expect(closestReadonlyRegion(h)).toBeNull()
})

it('returns null for non-element targets', () => {
expect(closestReadonlyRegion(null)).toBeNull()
expect(closestReadonlyRegion(document.createTextNode('x') as unknown as EventTarget)).toBeNull()
})
})

describe('isElementLike', () => {
it('duck-types cross-realm elements via closest', () => {
expect(isElementLike(document.createElement('div'))).toBe(true)
expect(isElementLike(null)).toBe(false)
expect(isElementLike(document.createTextNode('x') as unknown as EventTarget)).toBe(false)
})
})
18 changes: 5 additions & 13 deletions src/admin/pages/site/canvas/BreakpointFrame.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,16 +35,9 @@ import { cn } from '@ui/cn'
import { useEditorPermissions } from '@site/editorPermissionsContext'
import { useEditorStore } from '@site/store/store'
import { clientPointToEditorDoc } from './canvasDomGeometry'
import { closestReadonlyRegion } from './readonlyRegion'
import styles from './BreakpointFrame.module.css'

/**
* Cross-realm-safe Element check — the iframe is a different realm, so
* `instanceof Element` is false for nodes inside it. Duck-type `closest`.
*/
function isElementLike(value: EventTarget | null): value is Element {
return value != null && typeof (value as { closest?: unknown }).closest === 'function'
}

interface BreakpointFrameProps {
page: Page
breakpoint: Breakpoint
Expand Down Expand Up @@ -142,11 +135,10 @@ export function BreakpointFrame({
}
// On the active frame, hovering read-only composed content (template
// chrome, an inlined component, an outlet preview) shows a hint naming its
// source. Editable content carries no read-only markers, so it clears it.
const target = event.target
const region = isElementLike(target)
? target.closest('[data-instatic-readonly-label]')
: null
// source. `closestReadonlyRegion` resolves the nearest boundary, so the
// active page's editable content — spliced inside the template wrapper —
// does NOT show the hint.
const region = closestReadonlyRegion(event.target)
const label = region?.getAttribute('data-instatic-readonly-label') ?? null
setReadonlyHint(
label ? { text: `Part of ${label} — double-click to edit`, point: clientPointToEditorDoc(event) } : null,
Expand Down
22 changes: 6 additions & 16 deletions src/admin/pages/site/canvas/IframeFrameSurface.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -84,6 +84,7 @@ import { CANVAS_VIEWPORT_HEIGHT, type CanvasViewport } from './resolveViewportUn
import { useIframeFrameAutoHeight } from './useIframeFrameAutoHeight'
import { applyIframeBodyReset, type IframeInteraction } from './iframeBodyReset'
import { useEditorStore } from '@site/store/store'
import { closestReadonlyRegion, isElementLike } from './readonlyRegion'
import styles from './IframeFrameSurface.module.css'

const IFRAME_SRC_DOC = '<!doctype html><html><head></head><body></body></html>'
Expand All @@ -99,16 +100,6 @@ const EMPTY_RUNTIME_SCRIPTS: InjectableRuntimeScript[] = []
*/
const NAVIGABLE_SELECTOR = 'a[href], area[href], button[type="submit"], input[type="submit"], input[type="image"]'

/**
* Cross-realm-safe "is this an Element?" check. The canvas iframe is same-origin
* but a different realm, so `target instanceof Element` (the host realm's class)
* is false for nodes inside the iframe — duck-type `closest` instead. Mirrors
* the same helper in `NodeRenderer`.
*/
function isElementLike(value: EventTarget | null): value is Element {
return value != null && typeof (value as { closest?: unknown }).closest === 'function'
}

interface IframeFrameSurfaceProps {
/** Stable id used to tag the iframe's `<body>` with `data-breakpoint-id`. */
breakpointId: string
Expand Down Expand Up @@ -305,15 +296,14 @@ export const IframeFrameSurface = forwardRef<IframeFrameSurfaceHandle, IframeFra
// ── Read-only region open ────────────────────────────────────────────
// Double-clicking read-only composed content (template chrome, an inlined
// component, an outlet preview) opens its source document for editing.
// The nearest ancestor carrying `data-instatic-readonly-*` markers names
// the source; editable nodes carry no such markers, so their own
// double-click (enter / inline-edit) is untouched.
// `closestReadonlyRegion` resolves the NEAREST boundary, so the active
// document's own editable nodes — spliced inside the template wrapper — keep
// their own double-click (enter / inline-edit) instead of opening the
// wrapping template.
useEffect(() => {
if (!iframeDoc || !onReadonlyOpen) return
const handleDblClick = (event: MouseEvent) => {
const target = event.target
if (!isElementLike(target)) return
const region = target.closest('[data-instatic-readonly-id]')
const region = closestReadonlyRegion(event.target)
if (!region) return
const id = region.getAttribute('data-instatic-readonly-id')
const kind = region.getAttribute('data-instatic-readonly-kind')
Expand Down
38 changes: 38 additions & 0 deletions src/admin/pages/site/canvas/readonlyRegion.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/**
* Resolving which canvas element belongs to a read-only composed region
* (template chrome, an inlined Visual Component, an outlet preview) vs. the
* active document's own editable content.
*
* Read-only composed nodes carry `data-instatic-readonly-*` markers; the active
* document's editable nodes carry `data-node-id`. The two are mutually
* exclusive on a single element (see `ReadOnlyNodeTree`).
*
* The subtlety: the active document's content is spliced into the wrapping
* template's `base.outlet`, so editable nodes live *inside* the read-only
* wrapper element in the DOM. Walking straight up to the nearest
* `[data-instatic-readonly-*]` ancestor therefore mislabels editable content as
* read-only — which made the hover hint fire over the whole page and the
* double-click-to-open hijack inline editing on any templated page. The fix is
* to resolve the NEAREST boundary of *either* kind: an editable node nearer
* than any read-only marker means the target is editable.
*/

/** Cross-realm-safe Element check — the iframe is a different realm, so
* `instanceof Element` is false for nodes inside it. Duck-type `closest`. */
export function isElementLike(value: EventTarget | null): value is Element {
return value != null && typeof (value as { closest?: unknown }).closest === 'function'
}

/**
* The nearest read-only region element for a target, or `null` when the target
* is the active document's editable content (or outside any region). Callers
* read `data-instatic-readonly-label` / `-kind` / `-id` off the returned element.
*/
export function closestReadonlyRegion(target: EventTarget | null): Element | null {
if (!isElementLike(target)) return null
const boundary = target.closest('[data-node-id], [data-instatic-readonly-label]')
// An editable node (data-node-id) nearer than any read-only marker means the
// target is editable content spliced into a template outlet — not read-only.
if (!boundary || boundary.hasAttribute('data-node-id')) return null
return boundary
}