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
65 changes: 28 additions & 37 deletions src/interface/keyframeEasingsPopup.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
}

Expand Down Expand Up @@ -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。
Expand All @@ -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()
Expand All @@ -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()
Expand All @@ -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)
Expand Down
173 changes: 61 additions & 112 deletions src/mods/keyframeEasingVisualMod.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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'

Expand Down Expand Up @@ -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<HTMLElement>(`.keyframe[id="${uuid}"]`)
if (el) return el
}
Expand Down Expand Up @@ -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?.<HTMLElement>('.keyframe').forEach(child => {
element.querySelectorAll<HTMLElement>('.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 に直接 <style> を inject する。 親側は別途 Blockbench.addCSS で済んでいる。
const baseStyle = doc.createElement('style')
baseStyle.textContent = BASE_CSS
doc.head.appendChild(baseStyle)
const curveStyle = doc.createElement('style')
curveStyle.textContent = buildCurveCss()
doc.head.appendChild(curveStyle)
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) syncSubtreeKeyframes(node)
}
})
observer.observe(doc.body, { childList: true, subtree: true })
popoutAttach = { doc, baseStyle, curveStyle, observer }
popoutDoc = doc
// popout 開いた瞬間に既に居る keyframe (= adoptNode 直後の DOM) を即時 sync
refreshAllKeyframes()
}
let documentAttaches: DocumentAttach[] = []

function detachPopoutDoc(): void {
if (!popoutAttach) return
popoutAttach.observer.disconnect()
try {
popoutAttach.baseStyle.remove()
} catch {
/* noop */
function detachDocuments(): void {
for (const attachment of documentAttaches) {
attachment.observer.disconnect()
attachment.baseStyle?.remove()
attachment.curveStyle?.remove()
}
try {
popoutAttach.curveStyle.remove()
} catch {
/* noop */
documentAttaches = []
activeDocuments = []
}

function attachDocuments(documents: readonly Document[]): void {
detachDocuments()
for (const doc of new Set(documents)) {
if (!doc.body || !doc.head) continue
let baseStyle: HTMLStyleElement | undefined
let curveStyle: HTMLStyleElement | undefined
if (doc !== document) {
baseStyle = doc.createElement('style')
baseStyle.textContent = BASE_CSS
doc.head.appendChild(baseStyle)
curveStyle = doc.createElement('style')
curveStyle.textContent = buildCurveCss()
doc.head.appendChild(curveStyle)
}
const observer = new MutationObserver(mutations => {
for (const mutation of mutations) {
for (const node of mutation.addedNodes) syncSubtreeKeyframes(node)
}
})
observer.observe(doc.body, { childList: true, subtree: true })
documentAttaches.push({ doc, baseStyle, curveStyle, observer })
}
popoutAttach = null
popoutDoc = null
}

interface AnimUxAPI {
getActivePopoutDocument(): Document | null
}

interface PopoutEventDetail {
document: Document
activeDocuments = documentAttaches.map(attachment => attachment.doc)
refreshAllKeyframes()
}

registerPatch({
Expand All @@ -224,46 +208,14 @@ registerPatch({
)
const unsubProjectSelect = EVENTS.SELECT_AJ_PROJECT.subscribe(refreshAllKeyframes)

// BB が keyframe DOM を再描画する経路 (= 親 channel 再描画 / easing 変更 / animation 切替) で
// AJ 付与 attribute が消失する問題への補修。 addedNodes 内の .keyframe を子孫含めて再付与する。
// document.body 全体 subtree を監視するが、 .keyframe フィルタで処理を限定する。
const observer = new MutationObserver(mutations => {
for (const m of mutations) {
for (const node of m.addedNodes) syncSubtreeKeyframes(node)
}
})
observer.observe(document.body, { childList: true, subtree: true })

// anim_ux v0.6+ の popout event を listen。 popout 子窓には CSS / MutationObserver / dataset
// を別途 attach しないと easing の色変更と背景カーブが反映されない (= 親 document 経路は伝播しない)。
const onPopoutOpen = (e: Event): void => {
const detail = (e as CustomEvent<PopoutEventDetail>).detail
if (detail?.document) attachPopoutDoc(detail.document)
}
const onPopoutClose = (): void => {
detachPopoutDoc()
// popout 閉じた直後、 keyframe DOM が親 document に戻った後の再 sync
refreshAllKeyframes()
}
window.addEventListener('animux:popout-open', onPopoutOpen)
window.addEventListener('animux:popout-close', onPopoutClose)

// plugin 順序依存の安全弁 = AJ load 時点で既に popout 中の場合は event 来ない、
// anim_ux API 経由で現在状態を直接拾う。
const animux = (window as unknown as { AnimUX?: AnimUxAPI }).AnimUX
const existing = animux?.getActivePopoutDocument?.()
if (existing) attachPopoutDoc(existing)

refreshAllKeyframes()
const unsubscribeAnimUxDocuments = subscribeAnimUxDocuments(attachDocuments)

return {
baseCssDeletable,
curveCssDeletable,
unsubKeyframeSelection,
unsubProjectSelect,
observer,
onPopoutOpen,
onPopoutClose,
unsubscribeAnimUxDocuments,
}
},

Expand All @@ -272,25 +224,22 @@ registerPatch({
curveCssDeletable,
unsubKeyframeSelection,
unsubProjectSelect,
observer,
onPopoutOpen,
onPopoutClose,
unsubscribeAnimUxDocuments,
}) => {
window.removeEventListener('animux:popout-open', onPopoutOpen)
window.removeEventListener('animux:popout-close', onPopoutClose)
detachPopoutDoc()
observer.disconnect()
const documents = activeDocuments
unsubscribeAnimUxDocuments()
detachDocuments()
unsubKeyframeSelection()
unsubProjectSelect()
baseCssDeletable.delete()
curveCssDeletable.delete()
document
.querySelectorAll<HTMLElement>(
'.keyframe[data-aj-ease-type], .keyframe[data-aj-ease-dir]'
)
.forEach(el => {
for (const doc of documents) {
doc.querySelectorAll<HTMLElement>('.keyframe[data-aj-ease-type], .keyframe[data-aj-ease-dir]').forEach(
el => {
delete el.dataset.ajEaseType
delete el.dataset.ajEaseDir
})
}
)
}
},
})
Loading
Loading