Skip to content
Open
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
5 changes: 5 additions & 0 deletions .changeset/shared-inspector-select.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,5 @@
---
'@open-slide/core': patch
---

Make the inspector selectable for elements rendered by imported/shared components.
9 changes: 9 additions & 0 deletions packages/core/e2e/fixture/components/shared.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import type { ReactNode } from 'react';

export function Heading({ children }: { children: ReactNode }) {
return <div style={{ fontSize: 64, fontWeight: 700 }}>{children}</div>;
}

export function Card({ children }: { children: ReactNode }) {
return <div style={{ padding: 24, border: '1px solid #444', borderRadius: 12 }}>{children}</div>;
}
18 changes: 18 additions & 0 deletions packages/core/e2e/fixture/slides/shared-select/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
import type { Page, SlideMeta } from '@open-slide/core';
import { Card, Heading } from '../../components/shared';

export const meta: SlideMeta = {
title: 'Shared Select',
createdAt: '2026-01-01T00:00:00.000Z',
};

const Only: Page = () => (
<>
<Heading>Shared heading click target</Heading>
<Card>
<Heading>Nested shared heading</Heading>
</Card>
</>
);

export default [Only] satisfies Page[];
2 changes: 1 addition & 1 deletion packages/core/e2e/fixture/tsconfig.json
Original file line number Diff line number Diff line change
Expand Up @@ -13,5 +13,5 @@
"skipLibCheck": true,
"types": ["@open-slide/core/env"]
},
"include": ["slides/**/*", "open-slide.config.ts"]
"include": ["slides/**/*", "components/**/*", "open-slide.config.ts"]
}
20 changes: 20 additions & 0 deletions packages/core/e2e/tests/inspector.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -140,4 +140,24 @@ test.describe('inspector editing', () => {
await editorCanvas(page).getByText('Editable headline').click();
await expect(page.locator('aside[data-inspector-ui]')).toBeVisible();
});

test('selecting text inside an imported shared component opens the panel', async ({ page }) => {
await openSlide(page, 'shared-select');
await page.getByTitle('Inspect').click();
await editorCanvas(page).getByText('Shared heading click target').click();

const panel = page.locator('aside[data-inspector-ui]');
await expect(panel).toBeVisible();
await expect(panel.getByPlaceholder('Element text')).toHaveValue('Shared heading click target');
});

test('selecting text inside a nested shared component opens the panel', async ({ page }) => {
await openSlide(page, 'shared-select');
await page.getByTitle('Inspect').click();
await editorCanvas(page).getByText('Nested shared heading').click();

const panel = page.locator('aside[data-inspector-ui]');
await expect(panel).toBeVisible();
await expect(panel.getByPlaceholder('Element text')).toHaveValue('Nested shared heading');
});
});
Original file line number Diff line number Diff line change
Expand Up @@ -38,7 +38,7 @@ export function InspectOverlay() {
if (!isInspectableEventTarget(e.target)) return setHover(null);
const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
if (!el) return setHover(null);
const hit = findSlideSource(el, slideId, { hostOnly: true });
const hit = findSlideSource(el, slideId);
if (!hit) return setHover(null);
setHover({ hit });
};
Expand All @@ -47,7 +47,7 @@ export function InspectOverlay() {
if (!isInspectableEventTarget(e.target)) return;
const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
if (!el) return;
const hit = findSlideSource(el, slideId, { hostOnly: true });
const hit = findSlideSource(el, slideId);
if (!hit) return;
e.preventDefault();
e.stopPropagation();
Expand All @@ -59,7 +59,7 @@ export function InspectOverlay() {
if (!isInspectableEventTarget(e.target)) return;
const el = pickInspectorTarget(pickElement(e.clientX, e.clientY));
if (!el) return;
const hit = findSlideSource(el, slideId, { hostOnly: true });
const hit = findSlideSource(el, slideId);
if (!hit) return;
if (!(hit.anchor instanceof HTMLImageElement)) return;
e.preventDefault();
Expand Down
28 changes: 24 additions & 4 deletions packages/core/src/app/components/inspector/inspector-panel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -62,7 +62,7 @@ type RangeStylePreview = {
};

function resolveSelectedTarget(target: SelectedTarget, slideId: string): SelectedTarget {
const hit = findSlideSource(target.anchor, slideId, { hostOnly: true });
const hit = findSlideSource(target.anchor, slideId);
if (!hit) return target;
if (hit.line === target.line && hit.column === target.column && hit.anchor === target.anchor) {
return target;
Expand Down Expand Up @@ -1093,17 +1093,37 @@ function round2(n: number): number {
return Math.round(n * 100) / 100;
}

/**
* Re-resolve a selection whose anchor has been detached, which happens on
* every HMR round that replaces the DOM node behind the selected element.
*
* The `data-slide-loc` query is exact and settles the common case. The scan
* below covers what that attribute cannot: imported and shared components
* carry their call site in the fiber tree rather than the DOM, so they are
* never tagged.
*
* That scan prefers a line *and* column hit before falling back to line alone.
* Several elements routinely originate on one source line, and dropping
* `hostOnly` from the lookup (needed so shared components stay selectable)
* widened the candidate set further, so a line-only match hands back whichever
* element the DOM walk happened to reach first. Keeping the line-only result
* as a last resort means an edit that shifts a column degrades to the previous
* behavior instead of dropping the selection outright.
*/
function findElementByLine(slideId: string, line: number, column: number): HTMLElement | null {
const root = document.querySelector('[data-inspector-root]');
if (!root) return null;
const tagged = root.querySelector<HTMLElement>(`[data-slide-loc="${line}:${column}"]`);
if (tagged) return tagged;
const candidates = root.querySelectorAll<HTMLElement>('*');
let lineOnly: HTMLElement | null = null;
for (const el of candidates) {
const hit = findSlideSource(el, slideId, { hostOnly: true });
if (hit && hit.line === line) return hit.anchor;
const hit = findSlideSource(el, slideId);
if (!hit || hit.line !== line) continue;
if (hit.column === column) return hit.anchor;
lineOnly ??= hit.anchor;
}
return null;
return lineOnly;
}

function useReloadCounter(): number {
Expand Down
191 changes: 182 additions & 9 deletions packages/core/src/app/lib/inspector/fiber.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -3,12 +3,12 @@ import { findSlideSource } from './fiber.ts';

class FakeHTMLElement {
dataset: Record<string, string> = {};
private closestSelf: FakeHTMLElement | null = null;
setClosestSelfForSlideLoc() {
this.closestSelf = this;
}
parentElement: FakeHTMLElement | null = null;
closest(selector: string): FakeHTMLElement | null {
if (selector === '[data-slide-loc]') return this.closestSelf;
if (selector !== '[data-slide-loc]') return null;
for (let cur: FakeHTMLElement | null = this; cur; cur = cur.parentElement) {
if (cur.dataset.slideLoc) return cur;
}
return null;
}
}
Expand All @@ -20,11 +20,15 @@ type FakeFiber = {
_debugSource?: DebugSource;
};

function makeEl(opts: { slideLoc?: string; fiber?: FakeFiber } = {}): FakeHTMLElement {
function makeEl(
opts: { slideLoc?: string; fiber?: FakeFiber; parent?: FakeHTMLElement } = {},
): FakeHTMLElement {
const el = new FakeHTMLElement();
if (opts.slideLoc) {
el.dataset.slideLoc = opts.slideLoc;
el.setClosestSelfForSlideLoc();
}
if (opts.parent) {
el.parentElement = opts.parent;
}
if (opts.fiber) {
(el as unknown as Record<string, FakeFiber>).__reactFiber$test = opts.fiber;
Expand All @@ -37,15 +41,22 @@ function makeFiber(opts: {
line?: number;
column?: number;
host?: boolean;
hostEl?: FakeHTMLElement;
parent?: FakeFiber | null;
}): FakeFiber {
const source: DebugSource | undefined =
opts.fileName !== undefined
? { fileName: opts.fileName, lineNumber: opts.line, columnNumber: opts.column }
: undefined;
let stateNode: unknown;
if (opts.hostEl) {
stateNode = opts.hostEl;
} else if (opts.host) {
stateNode = new FakeHTMLElement();
}
return {
return: opts.parent ?? null,
stateNode: opts.host ? new FakeHTMLElement() : undefined,
stateNode,
_debugSource: source,
};
}
Expand All @@ -59,14 +70,24 @@ afterAll(() => {
});

describe('findSlideSource primary path', () => {
it('reads line:column from data-slide-loc', () => {
it('reads line:column from data-slide-loc on the element itself', () => {
const el = makeEl({ slideLoc: '42:7' });
const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
expect(hit).not.toBeNull();
expect(hit?.line).toBe(42);
expect(hit?.column).toBe(7);
expect(hit?.anchor).toBe(el as unknown as HTMLElement);
});

it('falls back to a tagged ancestor when fiber debug source is missing', () => {
const wrapper = makeEl({ slideLoc: '5:2' });
const el = makeEl({ parent: wrapper });
const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
expect(hit).not.toBeNull();
expect(hit?.line).toBe(5);
expect(hit?.column).toBe(2);
expect(hit?.anchor).toBe(wrapper as unknown as HTMLElement);
});
});

describe('findSlideSource fallback', () => {
Expand Down Expand Up @@ -151,4 +172,156 @@ describe('findSlideSource fallback', () => {
expect(hit?.line).toBe(99);
expect(hit?.column).toBe(3);
});

it('selects an imported component call site and keeps the host anchor', () => {
const hostEl = makeEl();
const callSite = makeFiber({
fileName: '/repo/slides/cover/index.tsx',
line: 20,
column: 4,
});
const libraryHost = makeFiber({
fileName: '/repo/components/Heading.tsx',
line: 3,
column: 2,
hostEl,
parent: callSite,
});
const el = makeEl({ fiber: libraryHost });
// Point the leaf fiber's stateNode at the clicked element itself.
libraryHost.stateNode = el;

const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
expect(hit).not.toBeNull();
expect(hit?.line).toBe(20);
expect(hit?.column).toBe(4);
expect(hit?.anchor).toBe(el as unknown as HTMLElement);
});

it('anchors an imported call site to the nearest host fiber, not the clicked node', () => {
const hostEl = makeEl();
const callSite = makeFiber({
fileName: '/repo/slides/cover/index.tsx',
line: 20,
column: 4,
});
const libraryHost = makeFiber({
fileName: '/repo/components/Heading.tsx',
line: 3,
column: 2,
hostEl,
parent: callSite,
});
const leaf = makeFiber({ parent: libraryHost });
const el = makeEl({ fiber: leaf });

const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
expect(hit).not.toBeNull();
expect(hit?.line).toBe(20);
expect(hit?.column).toBe(4);
// Assert against hostEl rather than el. `anchor` is seeded with the clicked
// element, so the sibling test above would still pass if the walk never
// recognised libraryHost as a host fiber; only a distinct host element
// proves the `instanceof HTMLElement` branch actually ran.
expect(hit?.anchor).toBe(hostEl as unknown as HTMLElement);
expect(hit?.anchor).not.toBe(el as unknown as HTMLElement);
});

it('rejects imported call sites when hostOnly is set', () => {
const callSite = makeFiber({
fileName: '/repo/slides/cover/index.tsx',
line: 20,
column: 4,
});
const libraryHost = makeFiber({
fileName: '/repo/components/Heading.tsx',
line: 3,
column: 2,
host: true,
parent: callSite,
});
const el = makeEl({ fiber: libraryHost });
libraryHost.stateNode = el;

const hit = findSlideSource(el as unknown as HTMLElement, 'cover', { hostOnly: true });
expect(hit).toBeNull();
});

it('prefers a shared call site over a tagged slide ancestor', () => {
const wrapper = makeEl({ slideLoc: '8:2' });
const callSite = makeFiber({
fileName: '/repo/slides/cover/index.tsx',
line: 12,
column: 6,
});
const libraryHost = makeFiber({
fileName: '/repo/components/Heading.tsx',
line: 2,
column: 0,
host: true,
parent: callSite,
});
const el = makeEl({ fiber: libraryHost, parent: wrapper });
libraryHost.stateNode = el;

const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
expect(hit).not.toBeNull();
expect(hit?.line).toBe(12);
expect(hit?.column).toBe(6);
expect(hit?.anchor).toBe(el as unknown as HTMLElement);
});

it('resolves nested shared components to the nearest slide call site', () => {
const outerCall = makeFiber({
fileName: '/repo/slides/cover/index.tsx',
line: 30,
column: 2,
});
const innerCall = makeFiber({
fileName: '/repo/slides/cover/index.tsx',
line: 31,
column: 4,
parent: outerCall,
});
// Inner library host under <Heading>, which is itself under <Card>.
const libraryHost = makeFiber({
fileName: '/repo/components/Heading.tsx',
line: 2,
column: 0,
host: true,
parent: innerCall,
});
const el = makeEl({ fiber: libraryHost });
libraryHost.stateNode = el;

const hit = findSlideSource(el as unknown as HTMLElement, 'cover');
expect(hit).not.toBeNull();
expect(hit?.line).toBe(31);
expect(hit?.column).toBe(4);
});

it('maps a text-bearing host under a shared component to the call site', () => {
// Mirrors clicking the text node "Click me" inside <Heading>Click me</Heading>:
// elementsFromPoint returns the host div; fiber points at the library file.
const callSite = makeFiber({
fileName: '/repo/slides/demo/index.tsx',
line: 7,
column: 15,
});
const libraryHost = makeFiber({
fileName: '/repo/components/Heading.tsx',
line: 2,
column: 4,
host: true,
parent: callSite,
});
const el = makeEl({ fiber: libraryHost });
libraryHost.stateNode = el;

const hit = findSlideSource(el as unknown as HTMLElement, 'demo');
expect(hit).not.toBeNull();
expect(hit?.line).toBe(7);
expect(hit?.column).toBe(15);
expect(hit?.anchor).toBe(el as unknown as HTMLElement);
});
});
Loading