diff --git a/src/interface/keyframeEasingsPopup.ts b/src/interface/keyframeEasingsPopup.ts index 5d155b6b..07ade3d3 100644 --- a/src/interface/keyframeEasingsPopup.ts +++ b/src/interface/keyframeEasingsPopup.ts @@ -2,52 +2,44 @@ // - 選択中 keyframe (= .keyframe.selected) を mouseover した時に keyframe element の右側に popup mount // - popup 内は keyframeEasingsPopup.svelte (= 既存 easing UI + XYZ input) // - mouse が keyframe / popup の両方から離れた時に unmount (= 100ms grace で chatter 防止) -// - blockbench-anim-ux (= 別 plugin) が居る場合は popout 子窓内でも動作する (= window.AnimUX 経由) +// - blockbench-anim-ux (= 別 plugin) が居る場合は popout 子窓内でも動作する import { registerPatch } from 'blockbench-patch-manager' import { injectComponent } from 'svelte-patching-tools' import { activeProjectIsBlueprintFormat } from '../formats/blueprint' import KeyframeEasingsPopupSvelte from '../svelteComponents/keyframeEasingsPopup.svelte' import { isFirstKeyframe } from '../panels/easings/easings.svelte' +import { subscribeAnimUxDocuments } from '../util/animUxTimeline' -// blockbench-anim-ux (= sibling plugin) が公開する optional API。 -// 居ない場合は素の document.addEventListener にフォールバックする。 -type AnimUxExternal = { - version: string - addDocumentListener( - type: string, - fn: EventListenerOrEventListenerObject, - opts?: boolean | AddEventListenerOptions, - ): () => void - getActivePopoutDocument(): Document | null -} - -function getAnimUx(): AnimUxExternal | undefined { - return (window as unknown as { AnimUX?: AnimUxExternal }).AnimUX -} - -// 親 document に直登録 + 必要なら popout 子窓にも attach する helper。 -// anim_ux 未 install / 旧 version (= addDocumentListener 未対応) でも害なく degrade する。 function attachDocListener( type: string, fn: EventListenerOrEventListenerObject, opts?: boolean | AddEventListenerOptions, ): () => void { - const animUx = getAnimUx() - if (animUx?.addDocumentListener) { - try { - return animUx.addDocumentListener(type, fn, opts) - } catch (e) { - console.warn('[AJ] AnimUX.addDocumentListener failed, fallback to document', e) + let attachedDocuments: Document[] = [] + + const detach = (): void => { + for (const ownerDocument of attachedDocuments) { + ownerDocument.removeEventListener( + type, + fn, + opts as boolean | EventListenerOptions | undefined, + ) } + attachedDocuments = [] } - document.addEventListener(type, fn, opts) - return (): void => { - try { - document.removeEventListener(type, fn, opts as boolean | EventListenerOptions | undefined) - } catch { - /* noop */ + + const unsubscribeDocuments = subscribeAnimUxDocuments(documents => { + detach() + attachedDocuments = [...new Set(documents)] + for (const ownerDocument of attachedDocuments) { + ownerDocument.addEventListener(type, fn, opts) } + }) + + return (): void => { + unsubscribeDocuments() + detach() } } @@ -354,8 +346,8 @@ function applyToActiveInput(active: HTMLInputElement, delta: number): void { // popup が popout 子窓に乗っている場合は parent realm の Event を投げると realm 不一致を踏むので、 // active 自身の owner realm の Event constructor を使う。 const ownerWin = active.ownerDocument?.defaultView - const EventCtor = (ownerWin?.Event as typeof Event | undefined) ?? Event - active.dispatchEvent(new EventCtor('input', { bubbles: true })) + const eventCtor = (ownerWin?.Event as typeof Event | undefined) ?? Event + active.dispatchEvent(new eventCtor('input', { bubbles: true })) } // event の発生元 document を割り出す helper。 @@ -371,7 +363,7 @@ function onAxisKeyCapture(e: KeyboardEvent): void { // plain も含めて全部 capture で処理 (= type=text にしたので browser 標準 Arrow は無関係、 // 全 case で自前 getInterval が必要) const active = getEventDocument(e).activeElement as HTMLInputElement | null - if (!active || !active.matches(`#${POPUP_ID} input[data-axis]`)) return + if (!active?.matches(`#${POPUP_ID} input[data-axis]`)) return e.preventDefault() e.stopPropagation() @@ -383,7 +375,7 @@ function onAxisKeyCapture(e: KeyboardEvent): void { function onAxisWheelCapture(e: WheelEvent): void { const active = getEventDocument(e).activeElement as HTMLInputElement | null - if (!active || !active.matches(`#${POPUP_ID} input[data-axis]`)) return + if (!active?.matches(`#${POPUP_ID} input[data-axis]`)) return if (e.target !== active) return e.preventDefault() @@ -399,8 +391,7 @@ registerPatch({ apply: () => { const cssDeletable = Blockbench.addCSS(POPUP_CSS) - // anim_ux 経由で attach すると popout 中の子窓 document にも自動で追従する。 - // 未 install / 旧 version では document.addEventListener にフォールバック (= attachDocListener 内処理)。 + // anim_ux の document stream に attach し、未 install 時は親 document へフォールバックする。 const detachOnMouseOver = attachDocListener('mouseover', onMouseOver, true) const detachOnMouseOut = attachDocListener('mouseout', onMouseOut, true) const detachOnAxisKey = attachDocListener('keydown', onAxisKeyCapture, true) diff --git a/src/mods/keyframeEasingVisualMod.ts b/src/mods/keyframeEasingVisualMod.ts index b82988c4..92824fb5 100644 --- a/src/mods/keyframeEasingVisualMod.ts +++ b/src/mods/keyframeEasingVisualMod.ts @@ -7,6 +7,7 @@ import { registerPatch } from 'blockbench-patch-manager' import { activeProjectIsBlueprintFormat } from '../formats/blueprint' import EVENTS from '../util/events' +import { subscribeAnimUxDocuments } from '../util/animUxTimeline' type EasingDir = 'in' | 'out' | 'inout' @@ -80,17 +81,12 @@ function parseEasing(easing: string | undefined): { type: string; dir: EasingDir return { type: m[2].toLowerCase(), dir: m[1].toLowerCase() as EasingDir } } -// anim_ux v0.6+ が公開する popout 子窓 document。 popout 中は keyframe DOM が子窓側に -// adoptNode 移植されるため、 親 document の querySelector では見つからない。 attach 中の -// すべての document を順に探して最初に当たった要素に attribute を当てる。 -let popoutDoc: Document | null = null - -function getActiveDocs(): Document[] { - return popoutDoc ? [document, popoutDoc] : [document] -} +// popout 中は keyframe DOM が子窓側へ adoptNode 移植されるため、親 document の +// querySelector では見つからない。現在の document stream を順に探して要素へ attribute を当てる。 +let activeDocuments: readonly Document[] = [] function findKeyframeElement(uuid: string): HTMLElement | null { - for (const doc of getActiveDocs()) { + for (const doc of activeDocuments) { const el = doc.querySelector(`.keyframe[id="${uuid}"]`) if (el) return el } @@ -136,79 +132,67 @@ function findKeyframeByUuid(uuid: string): _Keyframe | undefined { return undefined } -// MutationObserver の addedNodes に直接 .keyframe が来るとは限らず、 BB が channel 親要素ごと -// 再描画した場合は親 element が addedNodes に入る (= reviewer 指摘 B3)。 加えて easing 変更時は -// UPDATE_KEYFRAME_SELECTION が発火しないため event 経路では visual 更新されない (= reviewer 指摘 B6)。 +// MutationObserver の addedNodes に直接 .keyframe が来るとは限らず、BB が channel 親要素ごと +// 再描画した場合は親 element が addedNodes に入る。加えて easing 変更時は +// UPDATE_KEYFRAME_SELECTION が発火しないため、event 経路では visual 更新されない。 // addedNode 自身 + 子孫の .keyframe を再帰探索して個別 applyDataset で attribute 再付与する。 function syncSubtreeKeyframes(root: Node): void { - if (!(root instanceof HTMLElement)) return - if (root.classList?.contains('keyframe') && root.id) { - const kf = findKeyframeByUuid(root.id) + if (root.nodeType !== 1) return + const element = root as Element + if (element.classList.contains('keyframe') && element.id) { + const kf = findKeyframeByUuid(element.id) if (kf) applyDataset(kf) } - root.querySelectorAll?.('.keyframe').forEach(child => { + element.querySelectorAll('.keyframe').forEach(child => { if (!child.id) return const kf = findKeyframeByUuid(child.id) if (kf) applyDataset(kf) }) } -// popout 子窓 document への CSS + MutationObserver attach 状態。 同時に複数子窓を想定しない -// (= anim_ux は同時 1 子窓のみ) ため単一 slot で管理。 -interface PopoutAttach { +interface DocumentAttach { doc: Document - baseStyle: HTMLStyleElement - curveStyle: HTMLStyleElement + baseStyle?: HTMLStyleElement + curveStyle?: HTMLStyleElement observer: MutationObserver } -let popoutAttach: PopoutAttach | null = null -function attachPopoutDoc(doc: Document): void { - if (popoutAttach?.doc === doc) return - if (popoutAttach) detachPopoutDoc() - // CSS は親 head 経由 (Blockbench.addCSS) では popout 子窓に伝播しないので、 - // 子窓 head に直接