|
<script lang="ts"> |
|
import * as PIXI from 'pixi.js-legacy' |
|
import { install as installUnsafeEval } from '@pixi/unsafe-eval' |
|
import type { FederatedPointerEvent, FederatedWheelEvent } from '@pixi/events' |
|
import { EventSystem } from '@pixi/events' |
|
import { Renderer } from '@pixi/core' |
|
import { |
|
defineComponent, |
|
nextTick as nextTickVue, |
|
onMounted, |
|
onUnmounted, |
|
ref, |
|
watch, |
|
watchEffect, |
|
} from 'vue' |
|
import { SharedData, isMac } from '@vue-devtools/shared-utils' |
|
import { useApps } from '@front/features/apps' |
|
import { onKeyUp } from '@front/util/keyboard' |
|
import { useDarkMode } from '@front/util/theme' |
|
import { boostColor, dimColor } from '@front/util/color' |
|
import { formatTime } from '@front/util/format' |
|
import { Queue } from '@front/util/queue' |
|
import { addNonReactiveProperties, nonReactive } from '@front/util/reactivity' |
|
import { |
|
getGroupsAroundPosition, |
|
onEventAdd, |
|
onTimelineReset, |
|
selectEvent, |
|
useCursor, |
|
useLayers, |
|
useMarkers, |
|
useSelectedEvent, |
|
useTime, |
|
} from './composable' |
|
import type { |
|
Layer, |
|
TimelineEvent, |
|
TimelineMarker, |
|
} from './composable' |
|
|
|
PIXI.settings.ROUND_PIXELS = true |
|
PIXI.settings.SCALE_MODE = PIXI.SCALE_MODES.NEAREST |
|
|
|
delete Renderer.__plugins.interaction |
|
|
|
const LAYER_SIZE = 16 |
|
const GROUP_SIZE = 6 |
|
const MIN_CAMERA_SIZE = 0.001 |
|
|
|
|
|
const taskPriority = { |
|
normal: 0, |
|
addEventUpdate: 2, |
|
updateEvents: 3, |
|
runPositionUpdate: 4, |
|
runVerticalPositionUpdate: 5, |
|
updateCamera: 9, |
|
render: 10, |
|
} |
|
|
|
installUnsafeEval(PIXI) |
|
|
|
export default defineComponent({ |
|
setup() { |
|
const wrapper = ref<HTMLElement>(null) |
|
|
|
const { currentAppId } = useApps() |
|
const { startTime, endTime, minTime, maxTime } = useTime() |
|
const { darkMode } = useDarkMode() |
|
|
|
|
|
const nonReactiveState = { |
|
startTime: nonReactive(startTime), |
|
endTime: nonReactive(endTime), |
|
minTime: nonReactive(minTime), |
|
darkMode: nonReactive(darkMode), |
|
} |
|
|
|
|
|
|
|
const microTasks: ([() => void, number])[] = [] |
|
function runMicroTasks() { |
|
if (!microTasks.length) { |
|
return |
|
} |
|
const tasks = microTasks.slice().sort((a, b) => a[1] - b[1]) |
|
microTasks.length = 0 |
|
for (const [task] of tasks) { |
|
task() |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
|
|
function nextTick(task: () => void, priority: number = taskPriority.normal) { |
|
microTasks.push([task, priority]) |
|
} |
|
|
|
|
|
|
|
|
|
function getTimePosition(time: number) { |
|
return (time - nonReactiveState.minTime.value) / (nonReactiveState.endTime.value - nonReactiveState.startTime.value) * getAppWidth() |
|
} |
|
|
|
|
|
|
|
type ResetCb = () => void |
|
|
|
const resetCbs: ResetCb[] = [] |
|
|
|
function onReset(cb: ResetCb) { |
|
resetCbs.push(cb) |
|
} |
|
|
|
function reset() { |
|
for (const cb of resetCbs) { |
|
cb() |
|
} |
|
} |
|
|
|
watch(currentAppId, () => reset()) |
|
|
|
onTimelineReset(reset) |
|
|
|
|
|
|
|
let app: PIXI.Application |
|
|
|
let mainRenderTexture: PIXI.RenderTexture |
|
let mainRenderContainer: PIXI.Container |
|
|
|
let verticalScrollingContainer: PIXI.Container |
|
let horizontalScrollingContainer: PIXI.Container |
|
|
|
onMounted(() => { |
|
app = new PIXI.Application({ |
|
resizeTo: wrapper.value, |
|
antialias: true, |
|
autoDensity: true, |
|
resolution: window.devicePixelRatio, |
|
}) |
|
if (!('events' in app.renderer)) { |
|
app.renderer.addSystem(EventSystem, 'events') |
|
} |
|
app.stage.interactive = true |
|
app.stage.hitArea = new PIXI.Rectangle(0, 0, 100000, 100000) |
|
updateBackground() |
|
wrapper.value.appendChild(app.view) |
|
|
|
|
|
|
|
app.view.style.opacity = '0' |
|
app.renderer.on('postrender', () => { |
|
app.view.style.opacity = '1' |
|
}) |
|
|
|
|
|
app.ticker.add(() => { |
|
runMicroTasks() |
|
}) |
|
|
|
verticalScrollingContainer = new PIXI.Container() |
|
|
|
horizontalScrollingContainer = new PIXI.Container() |
|
verticalScrollingContainer.addChild(horizontalScrollingContainer) |
|
|
|
|
|
if (app.renderer.type === PIXI.RENDERER_TYPE.WEBGL) { |
|
mainRenderTexture = PIXI.RenderTexture.create({ |
|
width: getAppWidth(), |
|
height: getAppHeight(), |
|
resolution: window.devicePixelRatio, |
|
}) |
|
mainRenderTexture.framebuffer.multisample = PIXI.MSAA_QUALITY.LOW |
|
const mainRenderSprite = new PIXI.Sprite(mainRenderTexture) |
|
app.stage.addChild(mainRenderSprite) |
|
|
|
mainRenderContainer = new PIXI.Container() |
|
mainRenderContainer.addChild(verticalScrollingContainer) |
|
} |
|
else { |
|
app.stage.addChild(verticalScrollingContainer) |
|
} |
|
}) |
|
|
|
onUnmounted(() => { |
|
app.destroy() |
|
}) |
|
|
|
function getAppWidth() { |
|
return app.view.width / window.devicePixelRatio |
|
} |
|
|
|
function getAppHeight() { |
|
return app.view.height / window.devicePixelRatio |
|
} |
|
|
|
|
|
|
|
let drawScheduled = false |
|
|
|
function draw() { |
|
if (!drawScheduled && app.renderer.type === PIXI.RENDERER_TYPE.WEBGL) { |
|
drawScheduled = true |
|
nextTick(() => { |
|
app.renderer.render(mainRenderContainer, { |
|
renderTexture: mainRenderTexture, |
|
}) |
|
const renderer = app.renderer as PIXI.Renderer |
|
renderer.framebuffer.blit() |
|
drawScheduled = false |
|
}, taskPriority.render) |
|
} |
|
} |
|
|
|
|
|
|
|
let interactionDrawBlocked = false |
|
let interactionDrawBlockedTimeout |
|
let interactionDrawScheduled = false |
|
|
|
|
|
|
|
|
|
|
|
async function interactionDraw() { |
|
await nextTickVue() |
|
if (!interactionDrawBlocked) { |
|
interactionDrawScheduled = false |
|
draw() |
|
} |
|
else { |
|
interactionDrawScheduled = true |
|
} |
|
} |
|
|
|
|
|
|
|
|
|
|
|
function blockInteractionDraw() { |
|
interactionDrawBlocked = true |
|
clearTimeout(interactionDrawBlockedTimeout) |
|
interactionDrawBlockedTimeout = setTimeout(() => { |
|
interactionDrawBlocked = false |
|
if (interactionDrawScheduled) { |
|
interactionDrawScheduled = false |
|
draw() |
|
} |
|
}, 500) |
|
} |
|
|
|
watch(startTime, blockInteractionDraw) |
|
watch(endTime, blockInteractionDraw) |
|
|
|
|
|
|
|
function updateBackground() { |
|
if (nonReactiveState.darkMode.value) { |
|
app && (app.renderer.backgroundColor = 0x262626) |
|
} |
|
else { |
|
app && (app.renderer.backgroundColor = 0xFFFFFF) |
|
} |
|
} |
|
|
|
watchEffect(() => { |
|
updateBackground() |
|
}) |
|
|
|
|
|
|
|
const { currentAppMarkers } = useMarkers() |
|
|
|
let markerContainer: PIXI.Graphics |
|
|
|
onMounted(() => { |
|
markerContainer = new PIXI.Graphics() |
|
app.stage.addChild(markerContainer) |
|
drawMarkers() |
|
}) |
|
|
|
function drawMarkers() { |
|
markerContainer.clear() |
|
for (const marker of currentAppMarkers.value) { |
|
markerContainer.lineStyle(1, marker.color, 0.5, 0, true) |
|
const x = getTimePosition(marker.time) |
|
marker.x = x |
|
markerContainer.moveTo(x, 0) |
|
markerContainer.lineTo(x, getAppHeight()) |
|
} |
|
markerContainer.x = horizontalScrollingContainer.x |
|
} |
|
|
|
watch(currentAppMarkers, () => { |
|
if (markerContainer) { |
|
drawMarkers() |
|
} |
|
}) |
|
|
|
function getMarkerAtPosition(targetX: number): TimelineMarker | null { |
|
let choice: TimelineMarker = null |
|
let dist: number |
|
|
|
for (const marker of currentAppMarkers.value) { |
|
const globalX = marker.x + markerContainer.x |
|
const currentDist = Math.abs(targetX - globalX) |
|
if (currentDist <= 20 && (currentDist < dist || !choice)) { |
|
dist = currentDist |
|
choice = marker |
|
} |
|
} |
|
|
|
return choice |
|
} |
|
|
|
|
|
|
|
const { |
|
layers, |
|
vScroll, |
|
hoverLayerId, |
|
selectedLayer, |
|
} = useLayers() |
|
|
|
let layerContainers: PIXI.Container[] = [] |
|
let layersMap: Record<Layer['id'], { layer: Layer, container: PIXI.Container }> = {} |
|
|
|
function initLayers() { |
|
let y = 0 |
|
for (const layer of layers.value) { |
|
const container = new PIXI.Container() |
|
container.y = y |
|
y += (layer.height + 1) * LAYER_SIZE |
|
horizontalScrollingContainer.addChild(container) |
|
// allow z-index sorting |
|
container.sortableChildren = true |
|
layerContainers.push(container) |
|
layersMap[layer.id] = { |
|
layer, |
|
container, |
|
} |
|
} |
|
} |
|
|
|
function updateLayerPositions() { |
|
let y = 0 |
|
for (const layer of layers.value) { |
|
const payload = layersMap[layer.id] |
|
if (payload) { |
|
payload.container.y = y |
|
} |
|
y += (layer.height + 1) * LAYER_SIZE |
|
} |
|
} |
|
|
|
onMounted(() => { |
|
initLayers() |
|
}) |
|
|
|
function resetLayers() { |
|
for (const container of layerContainers) { |
|
container.destroy() |
|
} |
|
layerContainers = [] |
|
layersMap = {} |
|
initLayers() |
|
resetEvents() |
|
} |
|
|
|
onReset(() => { |
|
resetLayers() |
|
}) |
|
|
|
watch(() => layers.value.map(l => l.id).join(','), () => { |
|
resetLayers() |
|
}) |
|
|
|
|
|
|
|
let applyLayersNewHeightTimer |
|
|
|
function applyLayersNewHeight() { |
|
clearTimeout(applyLayersNewHeightTimer) |
|
applyLayersNewHeightTimer = setTimeout(() => { |
|
updateLayerPositions() |
|
drawLayerBackgroundEffects() |
|
}, 0) |
|
} |
|
|
|
const layerHeightUpdateTimers: Record<string, any> = {} |
|
|
|
function queueLayerHeightUpdate(layer: Layer) { |
|
clearTimeout(layerHeightUpdateTimers[layer.id]) |
|
const apply = () => { |
|
layer.height = layer.newHeight |
|
applyLayersNewHeight() |
|
} |
|
if (layer.height < layer.newHeight) { |
|
apply() |
|
} |
|
else { |
|
layerHeightUpdateTimers[layer.id] = setTimeout(apply, 500) |
|
} |
|
} |
|
|
|
|
|
|
|
let layerHoverEffect: PIXI.Graphics |
|
|
|
onMounted(() => { |
|
layerHoverEffect = new PIXI.Graphics() |
|
layerHoverEffect.alpha = 0.1 |
|
layerHoverEffect.visible = false |
|
verticalScrollingContainer.addChild(layerHoverEffect) |
|
}) |
|
|
|
function getLayerY(layer: Layer) { |
|
return layers.value.slice(0, layers.value.indexOf(layer)).reduce((sum, layer) => sum + (layer.height + 1) * LAYER_SIZE, 0) |
|
} |
|
|
|
function drawLayerBackgroundEffects() { |
|
if (!layerHoverEffect) { |
|
return |
|
} |
|
|
|
const layerIds = [ |
|
{ |
|
id: hoverLayerId.value, |
|
alpha: 1, |
|
}, |
|
{ |
|
id: hoverLayerId.value !== selectedLayer.value?.id ? selectedLayer.value?.id : null, |
|
alpha: 0.5, |
|
}, |
|
].filter(({ id }) => id != null) |
|
|
|
if (layerIds.length) { |
|
layerHoverEffect.clear() |
|
layerIds.forEach(({ id, alpha }) => drawLayerBackground(id, alpha)) |
|
layerHoverEffect.visible = true |
|
} |
|
else { |
|
layerHoverEffect.visible = false |
|
} |
|
|
|
interactionDraw() |
|
} |
|
|
|
function drawLayerBackground(layerId: Layer['id'], alpha = 1) { |
|
if (!layersMap[layerId]) { |
|
return |
|
} |
|
const { layer } = layersMap[layerId] |
|
layerHoverEffect.beginFill(layer.color, alpha) |
|
layerHoverEffect.drawRect(0, getLayerY(layer), getAppWidth(), (layer.height + 1) * LAYER_SIZE) |
|
} |
|
|
|
watch(hoverLayerId, () => { |
|
drawLayerBackgroundEffects() |
|
}) |
|
|
|
watch(selectedLayer, () => { |
|
drawLayerBackgroundEffects() |
|
}) |
|
|
|
function updateLayerHover(event: FederatedPointerEvent) { |
|
let { globalY } = event |
|
globalY -= verticalScrollingContainer.y |
|
if (globalY >= 0) { |
|
let y = 0 |
|
// Find the hovering layer depending on each layer's height |
|
for (const layer of layers.value) { |
|
y += (layer.height + 1) * LAYER_SIZE |
|
if (globalY <= y) { |
|
hoverLayerId.value = layer.id |
|
return |
|
} |
|
} |
|
} |
|
clearLayerHover() |
|
} |
|
|
|
function clearLayerHover() { |
|
hoverLayerId.value = null |
|
} |
|
|
|
|
|
|
|
const { selectedEvent } = useSelectedEvent() |
|
const nonReactiveSelectedEvent = nonReactive(selectedEvent) |
|
|
|
let events: TimelineEvent[] = [] |
|
|
|
const updateEventPositionQueue = new Queue<TimelineEvent>() |
|
const updateEventVerticalPositionQueue = new Queue<TimelineEvent>() |
|
let eventPositionUpdateInProgress = false |
|
|
|
function queueEventPositionUpdate(events: TimelineEvent[], force = false) { |
|
for (const event of events) { |
|
if (!event.container) { |
|
continue |
|
} |
|
|
|
if (force) { |
|
event.forcePositionUpdate = true |
|
} |
|
|
|
updateEventPositionQueue.add(event) |
|
} |
|
|
|
|
|
if (!eventPositionUpdateInProgress) { |
|
eventPositionUpdateInProgress = true |
|
nextTick(() => { |
|
runEventPositionUpdate() |
|
eventPositionUpdateInProgress = false |
|
}, taskPriority.runPositionUpdate) |
|
} |
|
} |
|
|
|
function runEventPositionUpdate() { |
|
let event: TimelineEvent |
|
// eslint-disable-next-line no-cond-assign |
|
while ((event = updateEventPositionQueue.shift())) { |
|
if (!event.container) { |
|
continue |
|
} |
|
|
|
|
|
const ignored = isEventIgnored(event) |
|
event.container.visible = !ignored |
|
if (ignored) { |
|
continue |
|
} |
|
|
|
|
|
event.container.x = getTimePosition(event.time) |
|
|
|
|
|
const force = event.forcePositionUpdate |
|
event.forcePositionUpdate = false |
|
if (!force && event.layer.groupsOnly) { |
|
continue |
|
} |
|
|
|
|
|
updateEventVerticalPositionQueue.add(event) |
|
} |
|
|
|
|
|
while ((event = updateEventVerticalPositionQueue.shift())) { |
|
computeEventVerticalPosition(event) |
|
} |
|
} |
|
|
|
let isEventIgnoredCache: Record<TimelineEvent['id'], boolean> = {} |
|
|
|
function isEventIgnored(event: TimelineEvent) { |
|
let result = isEventIgnoredCache[event.id] |
|
if (result == null) { |
|
result = event.layer.ignoreNoDurationGroups && event.group?.nonReactiveDuration <= 0 |
|
isEventIgnoredCache[event.id] = result |
|
} |
|
return result |
|
} |
|
|
|
function computeEventVerticalPosition(event: TimelineEvent) { |
|
let y = 0 |
|
if (event.group && event !== event.group.firstEvent) { |
|
// If the event is inside a group, just use the group position |
|
y = event.group.y |
|
} |
|
else { |
|
const firstEvent = event.group ? event.group.firstEvent : event |
|
const lastEvent = event.group ? event.group.lastEvent : event |
|
|
|
// Collision offset for non-flamecharts |
|
const offset = event.layer.groupsOnly ? 0 : 12 |
|
// For flamechart allow 1-pixel overlap at the end of a group |
|
const lastOffset = event.layer.groupsOnly && event.group?.nonReactiveDuration > 0 ? -1 : 0 |
|
// Flamechart uses time instead of pixel position |
|
const getPos = event.layer.groupsOnly ? (time: number) => time : getTimePosition |
|
|
|
const firstPos = getPos(firstEvent.time) |
|
const lastPos = event.group ? getPos(lastEvent.time) : firstPos |
|
|
|
// Check for 'collision' with other event groups |
|
const otherGroups = event.layer.groupsOnly ? getGroupsAroundPosition(event.layer, firstEvent.time, lastEvent.time) : event.layer.groups |
|
const l = otherGroups.length |
|
let checkAgain = true |
|
while (checkAgain) { |
|
checkAgain = false |
|
for (let i = 0; i < l; i++) { |
|
const otherGroup = otherGroups[i] |
|
|
|
if ( |
|
// Different group |
|
( |
|
!event.group |
|
|| event.group !== otherGroup |
|
) |
|
// Same row |
|
&& otherGroup.y === y |
|
) { |
|
const otherGroupFirstPos = getPos(otherGroup.firstEvent.time) |
|
const otherGroupLastPos = getPos(otherGroup.lastEvent.time) |
|
|
|
// First position is inside other group |
|
const firstEventIntersection = ( |
|
firstPos >= otherGroupFirstPos - offset |
|
&& firstPos <= otherGroupLastPos + offset + lastOffset |
|
) |
|
|
|
if (firstEventIntersection || ( |
|
// Additional checks if group |
|
event.group && ( |
|
( |
|
// Last position is inside other group |
|
lastPos >= otherGroupFirstPos - offset - lastOffset |
|
&& lastPos <= otherGroupLastPos + offset |
|
) || ( |
|
// Other group is inside current group |
|
firstPos < otherGroupFirstPos - offset |
|
&& lastPos > otherGroupLastPos + offset |
|
) |
|
) |
|
)) { |
|
// Collision! |
|
if (event.group && event.group.nonReactiveDuration > otherGroup.nonReactiveDuration && firstEvent.time <= otherGroup.firstEvent.time) { |
|
// Invert positions because current group has higher priority |
|
if (!updateEventVerticalPositionQueue.has(otherGroup.firstEvent)) { |
|
queueEventPositionUpdate([otherGroup.firstEvent], event.layer.groupsOnly) |
|
} |
|
} |
|
else { |
|
// Offset the current group/event |
|
y++ |
|
// We need to check all the layers again since we moved the event |
|
checkAgain = true |
|
break |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
if (event.group) { |
|
event.group.y = y |
|
} |
|
|
|
|
|
if (y + 1 > event.layer.newHeight) { |
|
const oldLayerHeight = event.layer.newHeight |
|
const newLayerHeight = event.layer.newHeight = y + 1 |
|
if (oldLayerHeight !== newLayerHeight) { |
|
queueLayerHeightUpdate(event.layer) |
|
} |
|
} |
|
} |
|
event.container.y = (y + 1) * LAYER_SIZE |
|
} |
|
|
|
let addEventUpdateQueued = false |
|
|
|
function addEvent(event: TimelineEvent, layerContainer: PIXI.Container) { |
|
// Container |
|
let eventContainer: PIXI.Container |
|
|
|
if (!event.layer.groupsOnly || (event.group?.firstEvent === event)) { |
|
eventContainer = new PIXI.Container() |
|
addNonReactiveProperties(event, { |
|
container: eventContainer, |
|
}) |
|
layerContainer.addChild(eventContainer) |
|
} |
|
|
|
|
|
if (event.group) { |
|
if (event.group.firstEvent === event) { |
|
const groupG = new PIXI.Graphics() |
|
addNonReactiveProperties(event, { |
|
groupG, |
|
groupT: null, |
|
groupText: null, |
|
}) |
|
eventContainer.addChild(groupG) |
|
event.group.oldSize = null |
|
event.group.oldSelected = null |
|
drawEventGroup(event) |
|
} |
|
else if (event.group.lastEvent === event) { |
|
drawEventGroup(event.group.firstEvent) |
|
// We need to check for collisions again |
|
if (!addEventUpdateQueued) { |
|
addEventUpdateQueued = true |
|
nextTick(() => { |
|
queueEventsUpdate() |
|
addEventUpdateQueued = false |
|
}, taskPriority.addEventUpdate) |
|
} |
|
} |
|
} |
|
|
|
|
|
if (eventContainer) { |
|
const g = new PIXI.Graphics() |
|
addNonReactiveProperties(event, { |
|
g, |
|
}) |
|
eventContainer.addChild(g) |
|
} |
|
|
|
events.push(event) |
|
|
|
refreshEventGraphics(event) |
|
if (event.container) { |
|
queueEventPositionUpdate([event], true) |
|
} |
|
else { |
|
queueEventPositionUpdate([event.group.firstEvent], true) |
|
} |
|
|
|
return event |
|
} |
|
|
|
function initEvents() { |
|
for (const k in layersMap) { |
|
const { layer, container } = layersMap[k] |
|
for (const event of layer.events) { |
|
addEvent(event, container) |
|
} |
|
} |
|
} |
|
|
|
onMounted(() => { |
|
initEvents() |
|
}) |
|
|
|
function clearEvents() { |
|
for (const e of events) { |
|
e.g?.destroy() |
|
e.g = null |
|
|
|
if (e.groupT) { |
|
e.groupT.destroy() |
|
e.groupT = null |
|
} |
|
|
|
if (e.groupG) { |
|
e.groupG.destroy() |
|
e.groupG = null |
|
} |
|
|
|
e.container?.destroy() |
|
e.container = null |
|
} |
|
events = [] |
|
isEventIgnoredCache = {} |
|
} |
|
|
|
function resetEvents() { |
|
clearEvents() |
|
initEvents() |
|
} |
|
|
|
onUnmounted(() => { |
|
clearEvents() |
|
}) |
|
|
|
onEventAdd((event: TimelineEvent) => { |
|
if (event.appId !== 'all' && event.appId !== currentAppId.value) { |
|
return |
|
} |
|
|
|
const layer = layersMap[event.layer.id] |
|
if (layer) { |
|
addEvent(event, layer.container) |
|
} |
|
}) |
|
|
|
let eventsUpdateQueued = false |
|
|
|
function queueEventsUpdate() { |
|
if (eventsUpdateQueued) { |
|
return |
|
} |
|
eventsUpdateQueued = true |
|
nextTick(() => { |
|
updateEvents() |
|
eventsUpdateQueued = false |
|
}, taskPriority.updateEvents) |
|
} |
|
|
|
function updateEvents() { |
|
for (const layer of layers.value) { |
|
if (!layer.groupsOnly) { |
|
layer.newHeight = 1 |
|
} |
|
} |
|
updateLayerPositions() |
|
queueEventPositionUpdate(events) |
|
for (const layer of layers.value) { |
|
const groups = getGroupsAroundPosition(layer, nonReactiveState.startTime.value, nonReactiveState.endTime.value) |
|
for (const group of groups) { |
|
drawEventGroup(group.firstEvent) |
|
} |
|
} |
|
draw() |
|
} |
|
|
|
watch(startTime, () => queueEventsUpdate()) |
|
watch(endTime, () => queueEventsUpdate()) |
|
watch(minTime, () => queueEventsUpdate()) |
|
|
|
|
|
|
|
function getEventAtPosition(targetX: number, targetY: number): TimelineEvent | null { |
|
let choice: TimelineEvent |
|
|
|
let y = 0 |
|
for (const layer of layers.value) { |
|
y += (layer.height + 1) * LAYER_SIZE |
|
if (targetY - verticalScrollingContainer.y < y) { |
|
let distance = Number.POSITIVE_INFINITY |
|
for (const e of layer.events) { |
|
if (isEventIgnored(e)) { |
|
continue |
|
} |
|
|
|
if (layer.groupsOnly) { |
|
// We find the group inside of which the mouse is |
|
const bounds = e.group.firstEvent.groupG.getBounds() |
|
if (bounds.contains(targetX, targetY)) { |
|
choice = e |
|
break |
|
} |
|
} |
|
else { |
|
if (!e.g) { |
|
continue |
|
} |
|
|
|
const globalPosition = e.g.getGlobalPosition() |
|
const d = Math.abs(globalPosition.x - targetX) + Math.abs(globalPosition.y - targetY) |
|
|
|
if ((!choice || d < distance) && d < 200) { |
|
choice = e |
|
distance = d |
|
} |
|
} |
|
} |
|
break |
|
} |
|
} |
|
|
|
return choice |
|
} |
|
|
|
onMounted(() => { |
|
// @ts-expect-error type issue |
|
app.stage.addEventListener('click', (event: FederatedPointerEvent) => { |
|
// eslint-disable-next-line ts/no-use-before-define |
|
if (cameraDragging) { |
|
return |
|
} |
|
const choice = getEventAtPosition(event.globalX, event.globalY) |
|
if (choice) { |
|
selectEvent(choice) |
|
draw() |
|
} |
|
}) |
|
}) |
|
|
|
function drawEvent(selected: boolean, event: TimelineEvent) { |
|
if (event?.container) { |
|
let color = event.layer.color |
|
if (event.logType === 'error') { |
|
color = 0xE53E3E |
|
} |
|
else if (event.logType === 'warning') { |
|
color = 0xECC94B |
|
} |
|
|
|
if (event.g) { |
|
/** @type {PIXI.Graphics} */ |
|
const g = event.g |
|
let size = 3 |
|
g.clear() |
|
if (!event.layer.groupsOnly) { |
|
if (selected) { |
|
// Border-only style |
|
size-- |
|
g.lineStyle(2, boostColor(color, nonReactiveState.darkMode.value)) |
|
g.beginFill(dimColor(color, nonReactiveState.darkMode.value)) |
|
if (!event.group || event.group.firstEvent !== event) { |
|
event.container.zIndex = 999999999 |
|
} |
|
} |
|
else { |
|
g.beginFill(color) |
|
if (!event.group || event.group.firstEvent !== event) { |
|
event.container.zIndex = size |
|
} |
|
} |
|
g.drawCircle(0, 0, size + (selected ? 1 : 0)) |
|
} |
|
else { |
|
drawEventGroup(event) |
|
} |
|
} |
|
} |
|
} |
|
|
|
const drawSelectedEvent = drawEvent.bind(null, true) |
|
const drawUnselectedEvent = drawEvent.bind(null, false) |
|
|
|
function refreshEventGraphics(event: TimelineEvent) { |
|
if (nonReactiveSelectedEvent.value === event) { |
|
drawSelectedEvent(event) |
|
} |
|
else { |
|
drawUnselectedEvent(event) |
|
} |
|
} |
|
|
|
watch(selectedEvent, (event, oldEvent) => { |
|
drawUnselectedEvent(oldEvent) |
|
drawSelectedEvent(event) |
|
}) |
|
|
|
|
|
|
|
function selectPreviousEvent() { |
|
let index |
|
if (nonReactiveSelectedEvent.value) { |
|
index = events.indexOf(nonReactiveSelectedEvent.value) |
|
} |
|
else { |
|
index = events.length |
|
} |
|
|
|
let fullLoops = 0 |
|
do { |
|
index-- |
|
if (index < 0) { |
|
index = events.length - 1 |
|
fullLoops++ |
|
} |
|
} while (isEventIgnored(events[index]) && fullLoops < 2) |
|
|
|
if (events[index]) { |
|
selectEvent(events[index]) |
|
} |
|
} |
|
|
|
function selectNextEvent() { |
|
let index |
|
if (nonReactiveSelectedEvent.value) { |
|
index = events.indexOf(nonReactiveSelectedEvent.value) |
|
} |
|
else { |
|
index = -1 |
|
} |
|
|
|
let fullLoops = 0 |
|
do { |
|
index++ |
|
if (index >= events.length) { |
|
index = 0 |
|
fullLoops++ |
|
} |
|
} while (isEventIgnored(events[index]) && fullLoops < 2) |
|
|
|
if (events[index]) { |
|
selectEvent(events[index]) |
|
} |
|
} |
|
|
|
onKeyUp((event) => { |
|
if (event.key === 'ArrowLeft') { |
|
selectPreviousEvent() |
|
} |
|
else if (event.key === 'ArrowRight') { |
|
selectNextEvent() |
|
} |
|
}) |
|
|
|
|
|
|
|
let eventTooltip: PIXI.Container |
|
let eventTooltipTitle: PIXI.Text |
|
let eventTooltipText: PIXI.Text |
|
let eventTooltipGraphics: PIXI.Graphics |
|
let hoverEvent: TimelineEvent |
|
|
|
onMounted(() => { |
|
eventTooltip = new PIXI.Container() |
|
eventTooltip.visible = false |
|
app.stage.addChild(eventTooltip) |
|
|
|
eventTooltipGraphics = new PIXI.Graphics() |
|
eventTooltip.addChild(eventTooltipGraphics) |
|
|
|
eventTooltipTitle = new PIXI.Text('', { |
|
fontSize: 12, |
|
fill: 0x000000, |
|
fontWeight: 'bold', |
|
}) |
|
eventTooltipTitle.x = 4 |
|
eventTooltipTitle.y = 4 |
|
eventTooltip.addChild(eventTooltipTitle) |
|
|
|
eventTooltipText = new PIXI.Text('', { |
|
fontSize: 12, |
|
fill: 0x000000, |
|
}) |
|
eventTooltipText.alpha = 0.7 |
|
eventTooltipText.x = 4 |
|
eventTooltipText.y = eventTooltipTitle.height + 4 |
|
eventTooltip.addChild(eventTooltipText) |
|
|
|
|
|
app.stage.addEventListener('pointermove', (mouseEvent: FederatedPointerEvent) => { |
|
const text: string[] = [] |
|
|
|
// eslint-disable-next-line ts/no-use-before-define |
|
if (!cameraDragging) { |
|
// Event tooltip |
|
const event = getEventAtPosition(mouseEvent.globalX, mouseEvent.globalY) |
|
if (event) { |
|
text.push(event.title ?? 'Event') |
|
if (event.subtitle) { |
|
text.push(event.subtitle) |
|
} |
|
text.push(formatTime(event.time / 1000, 'ms')) |
|
|
|
if (event.group) { |
|
text.push(`Group: ${event.group.nonReactiveDuration / 1000}ms (${event.group.events.length} event${event.group.events.length > 1 ? 's' : ''})`) |
|
} |
|
|
|
if (event?.container) { |
|
event.container.alpha = 0.5 |
|
} |
|
} |
|
else { |
|
// Marker tooltip |
|
const marker = getMarkerAtPosition(mouseEvent.globalX) |
|
if (marker) { |
|
text.push(marker.label) |
|
text.push(formatTime(marker.time / 1000, 'ms')) |
|
text.push('(marker)') |
|
} |
|
} |
|
if (event !== hoverEvent) { |
|
if (hoverEvent?.container) { |
|
hoverEvent.container.alpha = 1 |
|
} |
|
interactionDraw() |
|
} |
|
hoverEvent = event |
|
} |
|
|
|
if (text.length) { |
|
// Draw tooltip |
|
eventTooltipTitle.text = text[0] |
|
eventTooltipText.text = text.slice(1).join('\n') |
|
|
|
eventTooltipGraphics.clear() |
|
eventTooltipGraphics.beginFill(0xFFFFFF) |
|
eventTooltipGraphics.lineStyle(1, 0x000000, 0.2, 1) |
|
const width = Math.max(eventTooltipTitle.width, eventTooltipText.width) + 8 |
|
const height = eventTooltipTitle.height + (text.length > 1 ? eventTooltipText.height : 0) + 8 |
|
eventTooltipGraphics.drawRoundedRect(0, 0, width, height, 4) |
|
|
|
eventTooltip.x = mouseEvent.globalX + 12 |
|
if (eventTooltip.x + eventTooltip.width > app.renderer.width) { |
|
eventTooltip.x = mouseEvent.globalX - eventTooltip.width - 12 |
|
} |
|
eventTooltip.y = mouseEvent.globalY + 12 |
|
if (eventTooltip.y + eventTooltip.height > app.renderer.height) { |
|
eventTooltip.y = mouseEvent.globalY - eventTooltip.height - 12 |
|
} |
|
eventTooltip.visible = true |
|
} |
|
else { |
|
if (hoverEvent?.container) { |
|
hoverEvent.container.alpha = 1 |
|
} |
|
eventTooltip.visible = false |
|
} |
|
}) |
|
}) |
|
|
|
|
|
|
|
function drawEventGroup(event: TimelineEvent) { |
|
if (event.groupG) { |
|
const drawAsSelected = event === nonReactiveSelectedEvent.value && event.layer.groupsOnly |
|
|
|
/** @type {PIXI.Graphics} */ |
|
const g = event.groupG |
|
const size = getTimePosition(event.group.lastEvent.time) - getTimePosition(event.group.firstEvent.time) |
|
if (size !== event.group.oldSize || drawAsSelected !== event.group.oldSelected) { |
|
g.clear() |
|
if (event.layer.groupsOnly) { |
|
if (drawAsSelected) { |
|
g.lineStyle(2, boostColor(event.layer.color, nonReactiveState.darkMode.value)) |
|
g.beginFill(dimColor(event.layer.color, nonReactiveState.darkMode.value, 30)) |
|
} |
|
else { |
|
g.beginFill(event.layer.color, 0.5) |
|
} |
|
} |
|
else { |
|
g.lineStyle(1, dimColor(event.layer.color, nonReactiveState.darkMode.value)) |
|
g.beginFill(dimColor(event.layer.color, nonReactiveState.darkMode.value, 25)) |
|
} |
|
if (event.layer.groupsOnly) { |
|
g.drawRect(0, -LAYER_SIZE / 2, size - 1, LAYER_SIZE - 1) |
|
} |
|
else { |
|
// Some adjustements were made on the vertical position and size to snap border pixels to the screen's grid (LoDPI) |
|
g.drawRoundedRect(-GROUP_SIZE, -GROUP_SIZE + 0.5, size + GROUP_SIZE * 2, GROUP_SIZE * 2 - 1, GROUP_SIZE) |
|
} |
|
} |
|
|
|
|
|
if (event.layer.groupsOnly && event.title && size > 32) { |
|
let t = event.groupT |
|
let text = event.groupText |
|
if (!text) { |
|
text = `${SharedData.debugInfo ? `${event.id} ` : ''}${event.title} ${event.subtitle}` |
|
event.groupText = text |
|
} |
|
if (!t) { |
|
t = event.groupT = new PIXI.BitmapText('', { |
|
fontName: nonReactiveState.darkMode.value ? 'roboto-white' : 'roboto-black', |
|
}) |
|
t.x = 1 |
|
t.y = Math.round(-t.height / 2) |
|
t.dirty = false |
|
event.container.addChild(t) |
|
} |
|
t.text = text.slice(0, Math.floor((size - 1) / 6)) |
|
} |
|
else if (event.groupT) { |
|
event.groupT.destroy() |
|
event.groupT = null |
|
} |
|
|
|
event.group.oldSize = size |
|
event.group.oldSelected = drawAsSelected |
|
} |
|
} |
|
|
|
|
|
|
|
const { cursorTime } = useCursor() |
|
|
|
let timeCursor: PIXI.Graphics |
|
|
|
onMounted(() => { |
|
timeCursor = new PIXI.Graphics() |
|
timeCursor.visible = false |
|
drawTimeCursor() |
|
app.stage.addChild(timeCursor) |
|
}) |
|
|
|
function drawTimeCursor() { |
|
timeCursor.clear() |
|
timeCursor.lineStyle(1, 0x888888, 0.2) |
|
timeCursor.moveTo(0.5, 0) |
|
timeCursor.lineTo(0.5, getAppHeight()) |
|
} |
|
|
|
function updateCursorPosition(event: FederatedPointerEvent) { |
|
const { globalX } = event |
|
timeCursor.x = globalX |
|
timeCursor.visible = true |
|
cursorTime.value = globalX / getAppWidth() * (endTime.value - startTime.value) + startTime.value |
|
} |
|
|
|
function clearCursor() { |
|
timeCursor.visible = false |
|
cursorTime.value = null |
|
} |
|
|
|
|
|
|
|
let timeGrid: PIXI.Graphics |
|
|
|
onMounted(() => { |
|
timeGrid = new PIXI.Graphics() |
|
timeGrid.visible = SharedData.timelineTimeGrid |
|
drawTimeGrid() |
|
app.stage.addChild(timeGrid) |
|
}) |
|
|
|
function drawTimeGrid() { |
|
if (!timeGrid.visible || !app.view.width) { |
|
return |
|
} |
|
|
|
const size = endTime.value - startTime.value |
|
const ratio = size / getAppWidth() |
|
let timeInterval = 10 |
|
let width = timeInterval / ratio |
|
|
|
if (size <= MIN_CAMERA_SIZE * 3) { |
|
// Every ms |
|
timeInterval = 1 |
|
width = timeInterval / ratio |
|
} |
|
else { |
|
while (width < 20) { |
|
timeInterval *= 10 |
|
width *= 10 |
|
} |
|
} |
|
|
|
const offset = startTime.value % timeInterval / ratio |
|
|
|
timeGrid.clear() |
|
timeGrid.lineStyle(1, 0x888888, 0.075) |
|
for (let x = -offset; x < getAppWidth(); x += width) { |
|
timeGrid.moveTo(x + 0.5, 0) |
|
timeGrid.lineTo(x + 0.5, getAppHeight()) |
|
} |
|
} |
|
|
|
watch(() => SharedData.timelineTimeGrid, (value) => { |
|
timeGrid.visible = value |
|
if (value) { |
|
drawTimeGrid() |
|
} |
|
}) |
|
|
|
|
|
|
|
let cameraUpdateQueued = false |
|
|
|
function queueCameraUpdate() { |
|
if (cameraUpdateQueued) { |
|
return |
|
} |
|
cameraUpdateQueued = true |
|
nextTick(() => { |
|
updateCamera() |
|
cameraUpdateQueued = false |
|
}, taskPriority.updateCamera) |
|
} |
|
|
|
function updateCamera() { |
|
horizontalScrollingContainer.x = -getTimePosition(nonReactiveState.startTime.value) |
|
drawLayerBackgroundEffects() |
|
drawTimeGrid() |
|
drawMarkers() |
|
} |
|
|
|
watch(startTime, () => queueCameraUpdate()) |
|
watch(endTime, () => queueCameraUpdate()) |
|
|
|
onMounted(() => { |
|
queueCameraUpdate() |
|
// @ts-expect-error type issue |
|
app.stage.addEventListener('wheel', onMouseWheel) |
|
}) |
|
|
|
function onMouseWheel(event: FederatedWheelEvent) { |
|
event.preventDefault() |
|
|
|
const size = endTime.value - startTime.value |
|
const viewWidth = getAppWidth() |
|
|
|
if (!event.ctrlKey && !event.altKey && !event.nativeEvent.shiftKey) { |
|
const centerRatio = event.globalX / viewWidth |
|
const center = size * centerRatio + startTime.value |
|
|
|
let newSize = size + event.deltaY / viewWidth * size * 4 |
|
if (newSize < MIN_CAMERA_SIZE) { |
|
newSize = MIN_CAMERA_SIZE |
|
} |
|
|
|
let start = center - newSize * centerRatio |
|
let end = center + newSize * (1 - centerRatio) |
|
if (start < minTime.value) { |
|
start = minTime.value |
|
} |
|
if (end > maxTime.value) { |
|
end = maxTime.value |
|
} |
|
startTime.value = start |
|
endTime.value = end |
|
} |
|
else { |
|
let deltaX = event.deltaX |
|
|
|
if (deltaX === 0 && event.nativeEvent.shiftKey && event.deltaY !== 0) { |
|
// Horitonzal scroll with vertical mouse wheel and shift key |
|
deltaX = event.deltaY |
|
} |
|
if (event.altKey) { |
|
deltaX = 0 |
|
} |
|
|
|
if (deltaX !== 0) { |
|
// Horizontal scroll |
|
const delta = deltaX / viewWidth * size |
|
let start = startTime.value += delta |
|
if (start < minTime.value) { |
|
start = minTime.value |
|
} |
|
else if (start + size >= maxTime.value) { |
|
start = maxTime.value - size |
|
} |
|
startTime.value = start |
|
endTime.value = start + size |
|
} |
|
else if (event.deltaY !== 0) { |
|
// Vertical scroll |
|
const layersScroller = document.querySelector('[data-scroller="layers"]') |
|
if (layersScroller) { |
|
const speed = isMac ? Math.abs(event.deltaY) : LAYER_SIZE * 4 |
|
if (event.deltaY < 0) { |
|
layersScroller.scrollTop -= speed |
|
} |
|
else { |
|
layersScroller.scrollTop += speed |
|
} |
|
} |
|
} |
|
} |
|
} |
|
|
|
|
|
|
|
function updateVScroll() { |
|
if (verticalScrollingContainer) { |
|
verticalScrollingContainer.y = -vScroll.value |
|
draw() |
|
} |
|
} |
|
|
|
watch(vScroll, () => { |
|
updateVScroll() |
|
}) |
|
|
|
onMounted(() => { |
|
updateVScroll() |
|
}) |
|
|
|
|
|
|
|
let cameraDragging = false |
|
let startDragX: number |
|
let startDragY: number |
|
let startDragTime: number |
|
let startDragScrollTop: number |
|
|
|
let layersScroller: HTMLElement |
|
|
|
onMounted(() => { |
|
layersScroller = document.querySelector('[data-scroller="layers"]') |
|
|
|
// @ts-expect-error type issue |
|
app.stage.addEventListener('pointerdown', (event: FederatedPointerEvent) => { |
|
startDragX = event.globalX |
|
startDragY = event.globalY |
|
startDragTime = startTime.value |
|
startDragScrollTop = layersScroller.scrollTop |
|
// @ts-expect-error type issue |
|
app.stage.addEventListener('pointermove', onCameraDraggingMouseMove) |
|
window.addEventListener('mouseup', onCameraDraggingMouseUp) |
|
}) |
|
}) |
|
|
|
function onCameraDraggingMouseMove(event: FederatedPointerEvent) { |
|
const x = event.globalX |
|
const y = event.globalY |
|
if (!cameraDragging && (Math.abs(x - startDragX) > 5 || Math.abs(y - startDragY) > 5)) { |
|
cameraDragging = true |
|
} |
|
|
|
if (cameraDragging) { |
|
const deltaX = (startDragX - x) |
|
const deltaY = (startDragY - y) |
|
|
|
// Horizontal |
|
const size = endTime.value - startTime.value |
|
const viewWidth = getAppWidth() |
|
const delta = deltaX / viewWidth * size |
|
let start = startTime.value = startDragTime + delta |
|
if (start < minTime.value) { |
|
start = minTime.value |
|
} |
|
else if (start + size >= maxTime.value) { |
|
start = maxTime.value - size |
|
} |
|
startTime.value = start |
|
endTime.value = start + size |
|
|
|
|
|
layersScroller.scrollTop = startDragScrollTop + deltaY |
|
} |
|
} |
|
|
|
function onCameraDraggingMouseUp() { |
|
cameraDragging = false |
|
removeOnCameraDraggingEvents() |
|
} |
|
|
|
function removeOnCameraDraggingEvents() { |
|
app.stage?.removeListener('pointermove', onCameraDraggingMouseMove) |
|
window.removeEventListener('mouseup', onCameraDraggingMouseUp) |
|
} |
|
|
|
onUnmounted(() => { |
|
removeOnCameraDraggingEvents() |
|
}) |
|
|
|
|
|
|
|
function onResize() { |
|
// Prevent flashing (will be set back to 1 in postrender event listener) |
|
app.view.style.opacity = '0' |
|
app.queueResize() |
|
setTimeout(() => { |
|
mainRenderTexture?.resize(getAppWidth(), getAppHeight()) |
|
queueEventsUpdate() |
|
drawLayerBackgroundEffects() |
|
drawTimeCursor() |
|
drawTimeGrid() |
|
draw() |
|
}, 100) |
|
} |
|
|
|
|
|
|
|
let mouseIn = false |
|
|
|
function onMouseMove(event: FederatedPointerEvent) { |
|
if (event.global.x < 0 |
|
|| event.global.y < 0 |
|
|| event.global.x > app.screen.width |
|
|| event.global.y > app.screen.height) { |
|
if (mouseIn) { |
|
mouseIn = false |
|
onMouseOut() |
|
} |
|
return |
|
} |
|
mouseIn = true |
|
updateLayerHover(event) |
|
updateCursorPosition(event) |
|
} |
|
|
|
function onMouseOut() { |
|
clearLayerHover() |
|
clearCursor() |
|
} |
|
|
|
onMounted(() => { |
|
// @ts-expect-error type issue |
|
app.stage.addEventListener('pointermove', onMouseMove) |
|
}) |
|
|
|
return { |
|
wrapper, |
|
onResize, |
|
onMouseOut, |
|
} |
|
}, |
|
}) |
|
</script> |
|
|
|
<template> |
|
<div |
|
ref="wrapper" |
|
class="relative overflow-hidden" |
|
data-id="timeline-view-wrapper" |
|
@contextmenu.prevent |
|
@mouseout="onMouseOut" |
|
> |
|
<resize-observer @notify="onResize" /> |
|
</div> |
|
</template> |
|
|