diff --git a/.changeset/inspector-agenda-pick.md b/.changeset/inspector-agenda-pick.md new file mode 100644 index 00000000..11cd17c3 --- /dev/null +++ b/.changeset/inspector-agenda-pick.md @@ -0,0 +1,5 @@ +--- +'@open-slide/core': patch +--- + +Keep inspector selection on tagged inline text inside untagged Agenda-style wrappers. diff --git a/packages/core/src/app/lib/inspector/pick-target.test.ts b/packages/core/src/app/lib/inspector/pick-target.test.ts new file mode 100644 index 00000000..90dc61b4 --- /dev/null +++ b/packages/core/src/app/lib/inspector/pick-target.test.ts @@ -0,0 +1,102 @@ +import { afterAll, beforeAll, describe, expect, it, vi } from 'vitest'; +import { pickInspectorTarget } from './pick-target.ts'; + +class FakeText { + readonly nodeType = 3; + constructor(readonly textContent: string) {} +} + +class FakeHTMLElement { + parentElement: FakeHTMLElement | null = null; + childNodes: Array = []; + dataset: Record = {}; + private root: FakeHTMLElement | null = null; + + constructor(readonly tagName: string) {} + + get textContent(): string { + return this.childNodes + .map((n) => (n instanceof FakeText ? n.textContent : n.textContent)) + .join(''); + } + + hasAttribute(name: string): boolean { + return name === 'data-slide-loc' && this.dataset.slideLoc !== undefined; + } + + closest(selector: string): FakeHTMLElement | null { + if (selector !== '[data-inspector-root]') return null; + return this.root; + } + + contains(other: FakeHTMLElement): boolean { + for (let cur: FakeHTMLElement | null = other; cur; cur = cur.parentElement) { + if (cur === this) return true; + } + return false; + } + + append(...nodes: Array) { + for (const n of nodes) { + if (n instanceof FakeHTMLElement) n.parentElement = this; + this.childNodes.push(n); + } + } + + markRoot() { + this.root = this; + const walk = (el: FakeHTMLElement) => { + el.root = this; + for (const child of el.childNodes) { + if (child instanceof FakeHTMLElement) walk(child); + } + }; + walk(this); + } +} + +function tagged(tag: string, text: string, loc = '10:4'): FakeHTMLElement { + const el = new FakeHTMLElement(tag); + el.dataset.slideLoc = loc; + el.append(new FakeText(text)); + return el; +} + +function untagged(tag: string, ...children: Array): FakeHTMLElement { + const el = new FakeHTMLElement(tag); + el.append(...children); + return el; +} + +beforeAll(() => { + vi.stubGlobal('HTMLElement', FakeHTMLElement); +}); + +afterAll(() => { + vi.unstubAllGlobals(); +}); + +describe('pickInspectorTarget', () => { + it('keeps a tagged inline under an untagged Agenda-style list item', () => { + const span = tagged('SPAN', 'What changed?'); + const li = untagged('LI', span); + const root = untagged('DIV', li); + root.markRoot(); + + expect(pickInspectorTarget(span as unknown as HTMLElement)).toBe(span); + }); + + it('still promotes nested inline marks into a tagged paragraph', () => { + const strong = untagged('STRONG', new FakeText('bold')); + const p = untagged('P', new FakeText('a '), strong); + p.dataset.slideLoc = '12:2'; + const root = untagged('DIV', p); + root.markRoot(); + + expect(pickInspectorTarget(strong as unknown as HTMLElement)).toBe(p); + }); + + it('returns null for a null start element', () => { + expect(pickInspectorTarget(null)).toBeNull(); + }); +}); diff --git a/packages/core/src/app/lib/inspector/pick-target.ts b/packages/core/src/app/lib/inspector/pick-target.ts index 91f994a3..d918606b 100644 --- a/packages/core/src/app/lib/inspector/pick-target.ts +++ b/packages/core/src/app/lib/inspector/pick-target.ts @@ -25,9 +25,19 @@ export function pickInspectorTarget(el: HTMLElement | null): HTMLElement | null if (!el) return null; const root = el.closest('[data-inspector-root]'); const startedOnInlineText = INLINE_TEXT_TAGS.has(el.tagName); + // Agenda-style helpers wrap slide-tagged inlines in untagged hosts + // (`
  • `). Promote to the tagged inline, not the + // wrapper — findSlideSource only walks ancestors via closest(). + let taggedInline: HTMLElement | null = null; for (let cur: HTMLElement | null = el; cur && root?.contains(cur); cur = cur.parentElement) { - if (startedOnInlineText && INLINE_TEXT_TAGS.has(cur.tagName)) continue; - if (isEditableTextContainer(cur)) return cur; + if (startedOnInlineText && INLINE_TEXT_TAGS.has(cur.tagName)) { + if (cur.hasAttribute('data-slide-loc')) taggedInline ??= cur; + continue; + } + if (isEditableTextContainer(cur)) { + if (taggedInline && !cur.hasAttribute('data-slide-loc')) return taggedInline; + return cur; + } } return el; }