spine-runtimes/spine-ts/spine-webcomponents/src/SpineWebComponentOverlay.ts

1052 lines
38 KiB
TypeScript

/******************************************************************************
* Spine Runtimes License Agreement
* Last updated July 28, 2023. Replaces all prior versions.
*
* Copyright (c) 2013-2023, Esoteric Software LLC
*
* Integration of the Spine Runtimes into software or otherwise creating
* derivative works of the Spine Runtimes is permitted under the terms and
* conditions of Section 2 of the Spine Editor License Agreement:
* http://esotericsoftware.com/spine-editor-license
*
* Otherwise, it is permitted to integrate the Spine Runtimes into software or
* otherwise create derivative works of the Spine Runtimes (collectively,
* "Products"), provided that each user of the Products must obtain their own
* Spine Editor license and redistribution of the Products in any form must
* include this license and copyright notice.
*
* THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
* EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
* WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
* DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
* DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
* (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
* BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
* ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
* (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THE
* SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
*****************************************************************************/
import { AssetManager, Color, Disposable, Input, LoadingScreen, ManagedWebGLRenderingContext, Physics, SceneRenderer, TimeKeeper, Vector2, Vector3 } from "@esotericsoftware/spine-webgl"
import { SpineWebComponentSkeleton } from "./SpineWebComponentSkeleton.js"
import { AttributeTypes, castValue, Point, Rectangle } from "./wcUtils.js"
interface OverlayAttributes {
overlayId?: string
noAutoParentTransform: boolean
overflowTop: number
overflowBottom: number
overflowLeft: number
overflowRight: number
}
export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes, Disposable {
private static OVERLAY_ID = "spine-overlay-default-identifier";
private static OVERLAY_LIST = new Map<string, SpineWebComponentOverlay>();
/**
* @internal
*/
static getOrCreateOverlay (overlayId: string | null): SpineWebComponentOverlay {
let overlay = SpineWebComponentOverlay.OVERLAY_LIST.get(overlayId || SpineWebComponentOverlay.OVERLAY_ID);
if (!overlay) {
overlay = document.createElement('spine-overlay') as SpineWebComponentOverlay;
overlay.setAttribute('overlay-id', SpineWebComponentOverlay.OVERLAY_ID);
document.body.appendChild(overlay);
}
return overlay;
}
/**
* If true, enables a top-left span showing FPS (it has black text)
*/
public static SHOW_FPS = false;
/**
* A list holding the widgets added to this overlay.
*/
public widgets = new Array<SpineWebComponentSkeleton>();
/**
* The {@link SceneRenderer} used by this overlay.
*/
public renderer: SceneRenderer;
/**
* The {@link AssetManager} used by this overlay.
*/
public assetManager: AssetManager;
/**
* The identifier of this overlay. This is necessary when multiply overlay are created.
* Connected to `overlay-id` attribute.
*/
public overlayId?: string;
/**
* If `false` (default value), the overlay container style will be affected adding `transform: translateZ(0);` to it.
* The `transform` is not affected if it already exists on the container.
* This is necessary to make the scrolling works with containers that scroll in a different way with respect to the page, as explained in {@link appendedToBody}.
* Connected to `no-auto-parent-transform` attribute.
*/
public noAutoParentTransform = false;
/**
* The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
* If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
* This parameter defines, as percentage of the viewport height, the pixels to add to the top of the canvas to prevent this effect.
* Making the canvas too big might reduce performance.
* Default value: 0.2.
* Connected to `overflow-top` attribute.
*/
public overflowTop = .2;
/**
* The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
* If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
* This parameter defines, as percentage of the viewport height, the pixels to add to the bottom of the canvas to prevent this effect.
* Making the canvas too big might reduce performance.
* Default value: 0.
* Connected to `overflow-bottom` attribute.
*/
public overflowBottom = .0;
/**
* The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
* If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
* This parameter defines, as percentage of the viewport width, the pixels to add to the left of the canvas to prevent this effect.
* Making the canvas too big might reduce performance.
* Default value: 0.
* Connected to `overflow-left` attribute.
*/
public overflowLeft = .0;
/**
* The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
* If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
* This parameter defines, as percentage of the viewport width, the pixels to add to the right of the canvas to prevent this effect.
* Making the canvas too big might reduce performance.
* Default value: 0.
* Connected to `overflow-right` attribute.
*/
public overflowRight = .0;
private root: ShadowRoot;
private div: HTMLDivElement;
private boneFollowersParent: HTMLDivElement;
private canvas: HTMLCanvasElement;
private fps: HTMLSpanElement;
private fpsAppended = false;
private intersectionObserver?: IntersectionObserver;
private resizeObserver?: ResizeObserver;
private input?: Input;
private overflowLeftSize = 0;
private overflowTopSize = 0;
private lastCanvasBaseWidth = 0;
private lastCanvasBaseHeight = 0;
private zIndex?: number;
private disposed = false;
private loaded = false;
/**
* appendedToBody is assegned in the connectedCallback.
* When true, the overlay will have the size of the element container in contrast to the default behaviour where the
* overlay has always the size of the screen.
* This is necessary when the overlay is inserted into a container that scroll in a different way with respect to the page.
* Otherwise the following problems might occur:
* 1) For containers appendedToBody, the widget will be slightly slower to scroll than the html behind. The effect is more evident for lower refresh rate display.
* 2) For containers appendedToBody, the widget will overflow the container bounds until the widget html element container is visible
* 3) For fixed containers, the widget will scroll in a jerky way
*
* In order to fix this behaviour, it is necessary to insert a dedicated `spine-overlay` webcomponent as a direct child of the container.
* Moreover, it is necessary to perform the following actions:
* 1) The appendedToBody container must have a `transform` css attribute. If it hasn't this attribute the `spine-overlay` will add it for you.
* If your appendedToBody container has already this css attribute, or if you prefer to add it by yourself (example: `transform: translateZ(0);`), set the `no-auto-parent-transform` to the `spine-overlay`.
* 2) The `spine-overlay` must have an `overlay-id` attribute. Choose the value you prefer.
* 3) Each `spine-skeleton` must have an `overlay-id` attribute. The same as the hosting `spine-overlay`.
* Connected to `appendedToBody` attribute.
*/
private appendedToBody = true;
private hasParentTransform = true;
readonly time = new TimeKeeper();
constructor () {
super();
this.root = this.attachShadow({ mode: "open" });
this.div = document.createElement("div");
this.div.style.position = "absolute";
this.div.style.top = "0";
this.div.style.left = "0";
this.div.style.setProperty("pointer-events", "none");
this.div.style.overflow = "hidden"
// this.div.style.backgroundColor = "rgba(0, 255, 0, 0.1)";
this.root.appendChild(this.div);
this.canvas = document.createElement("canvas");
this.boneFollowersParent = document.createElement("div");
this.div.appendChild(this.canvas);
this.canvas.style.position = "absolute";
this.canvas.style.top = "0";
this.canvas.style.left = "0";
this.div.appendChild(this.boneFollowersParent);
this.boneFollowersParent.style.position = "absolute";
this.boneFollowersParent.style.top = "0";
this.boneFollowersParent.style.left = "0";
this.boneFollowersParent.style.whiteSpace = "nowrap";
this.boneFollowersParent.style.setProperty("pointer-events", "none");
this.boneFollowersParent.style.transform = `translate(0px,0px)`;
this.canvas.style.setProperty("pointer-events", "none");
this.canvas.style.transform = `translate(0px,0px)`;
// this.canvas.style.setProperty("will-change", "transform"); // performance seems to be even worse with this uncommented
this.fps = document.createElement("span");
this.fps.style.position = "fixed";
this.fps.style.top = "0";
this.fps.style.left = "0";
const context = new ManagedWebGLRenderingContext(this.canvas, { alpha: true });
this.renderer = new SceneRenderer(this.canvas, context);
this.assetManager = new AssetManager(context);
}
connectedCallback (): void {
this.appendedToBody = this.parentElement !== document.body;
let overlayId = this.getAttribute('overlay-id');
if (!overlayId) {
overlayId = SpineWebComponentOverlay.OVERLAY_ID;
this.setAttribute('overlay-id', overlayId);
}
const existingOverlay = SpineWebComponentOverlay.OVERLAY_LIST.get(overlayId);
if (existingOverlay && existingOverlay !== this) {
throw new Error(`"SpineWebComponentOverlay - You cannot have two spine-overlay with the same overlay-id: ${overlayId}"`);
}
SpineWebComponentOverlay.OVERLAY_LIST.set(overlayId, this);
// window.addEventListener("scroll", this.scrolledCallback);
if (document.readyState !== "complete") {
window.addEventListener("load", this.loadedCallback);
} else {
this.loadedCallback();
}
window.screen.orientation.addEventListener('change', this.orientationChangedCallback);
this.intersectionObserver = new IntersectionObserver((widgets) => {
for (const elem of widgets) {
const { target, intersectionRatio } = elem;
let { isIntersecting } = elem;
for (const widget of this.widgets) {
if (widget.getHostElement() != target) continue;
// old browsers do not have isIntersecting
if (isIntersecting === undefined) {
isIntersecting = intersectionRatio > 0;
}
widget.onScreen = isIntersecting;
if (isIntersecting) {
widget.onScreenFunction(widget);
widget.onScreenAtLeastOnce = true;
}
}
}
}, { rootMargin: "30px 20px 30px 20px" });
// resize observer is supported by all major browsers today chrome started to support it in version 64 (early 2018)
// we cannot use window.resize event since it does not fire when body resizes, but not the window
// Alternatively, we can store the body size, check the current body size in the loop (like the translateCanvas), and
// if they differs call the resizeCallback. I already tested it, and it works. ResizeObserver should be more efficient.
if (this.appendedToBody) {
// if the element is appendedToBody, the user does not disable translate tweak, and the parent did not have already a transform, add the tweak
if (this.hasCssTweakOff()) {
this.hasParentTransform = false;
} else {
this.parentElement!.style.transform = `translateZ(0)`;
}
this.resizeObserver = new ResizeObserver(this.resizedCallback);
this.resizeObserver.observe(this.parentElement!);
} else {
window.addEventListener("resize", this.resizedCallback)
}
for (const widget of this.widgets) {
this.intersectionObserver?.observe(widget.getHostElement());
}
this.input = this.setupDragUtility();
this.startRenderingLoop();
}
private hasCssTweakOff () {
return this.noAutoParentTransform && getComputedStyle(this.parentElement!).transform === "none";
}
private running = false;
disconnectedCallback (): void {
const id = this.getAttribute('overlay-id');
if (id) SpineWebComponentOverlay.OVERLAY_LIST.delete(id);
// window.removeEventListener("scroll", this.scrolledCallback);
window.removeEventListener("load", this.loadedCallback);
window.removeEventListener("resize", this.resizedCallback);
window.screen.orientation.removeEventListener('change', this.orientationChangedCallback);
this.intersectionObserver?.disconnect();
this.resizeObserver?.disconnect();
this.input?.dispose();
}
static attributesDescription: Record<string, { propertyName: keyof OverlayAttributes, type: AttributeTypes, defaultValue?: any }> = {
"overlay-id": { propertyName: "overlayId", type: "string" },
"no-auto-parent-transform": { propertyName: "noAutoParentTransform", type: "boolean" },
"overflow-top": { propertyName: "overflowTop", type: "number" },
"overflow-bottom": { propertyName: "overflowBottom", type: "number" },
"overflow-left": { propertyName: "overflowLeft", type: "number" },
"overflow-right": { propertyName: "overflowRight", type: "number" },
}
static get observedAttributes (): string[] {
return Object.keys(SpineWebComponentOverlay.attributesDescription);
}
attributeChangedCallback (name: string, oldValue: string | null, newValue: string | null): void {
const { type, propertyName, defaultValue } = SpineWebComponentOverlay.attributesDescription[name];
const val = castValue(type, newValue, defaultValue);
(this as any)[propertyName] = val;
return;
}
private resizedCallback = () => {
this.updateCanvasSize();
}
private orientationChangedCallback = () => {
this.updateCanvasSize();
// after an orientation change the scrolling changes, but the scroll event does not fire
this.scrolledCallback();
}
// right now, we scroll the canvas each frame before rendering loop, that makes scrolling on mobile waaay more smoother
// this is way scroll handler do nothing
private scrolledCallback = () => {
// this.translateCanvas();
}
private loadedCallback = () => {
this.updateCanvasSize();
this.scrolledCallback();
if (!this.loaded) {
this.loaded = true;
this.parentElement!.appendChild(this);
}
}
/**
* Remove the overlay from the DOM, dispose all the contained widgets, and dispose the renderer.
*/
dispose (): void {
for (const widget of [...this.widgets]) widget.dispose();
this.remove();
this.widgets.length = 0;
this.renderer.dispose();
this.disposed = true;
this.assetManager.dispose();
}
/**
* Add the widget to the overlay.
* If the widget is after the overlay in the DOM, the overlay is appended after the widget.
* @param widget The widget to add to the overlay
*/
addWidget (widget: SpineWebComponentSkeleton) {
this.widgets.push(widget);
this.intersectionObserver?.observe(widget.getHostElement());
if (this.loaded) {
const comparison = this.compareDocumentPosition(widget);
// DOCUMENT_POSITION_DISCONNECTED is needed when a widget is inside the overlay (due to followBone)
if ((comparison & Node.DOCUMENT_POSITION_FOLLOWING) && !(comparison & Node.DOCUMENT_POSITION_DISCONNECTED)) {
this.parentElement!.appendChild(this);
}
}
this.updateZIndexIfNecessary(widget);
}
/**
* Remove the widget from the overlay.
* @param widget The widget to remove from the overlay
*/
removeWidget (widget: SpineWebComponentSkeleton) {
const index = this.widgets.findIndex(w => w === widget);
if (index === -1) return false;
this.widgets.splice(index);
this.intersectionObserver?.unobserve(widget.getHostElement());
return true;
}
addSlotFollowerElement (element: HTMLElement) {
this.boneFollowersParent.appendChild(element);
this.resizedCallback();
}
private tempFollowBoneVector = new Vector3();
private startRenderingLoop () {
if (this.running) return;
const updateWidgets = () => {
const delta = this.time.delta;
for (const { skeleton, state, update, onScreen, offScreenUpdateBehaviour, beforeUpdateWorldTransforms, afterUpdateWorldTransforms } of this.widgets) {
if (!skeleton || !state) continue;
if (!onScreen && offScreenUpdateBehaviour === "pause") continue;
if (update) update(delta, skeleton, state)
else {
// delta = 0
state.update(delta);
skeleton.update(delta);
if (onScreen || (!onScreen && offScreenUpdateBehaviour === "pose")) {
state.apply(skeleton);
beforeUpdateWorldTransforms(delta, skeleton, state);
skeleton.updateWorldTransform(Physics.update);
afterUpdateWorldTransforms(delta, skeleton, state);
}
}
}
// fps top-left span
if (SpineWebComponentOverlay.SHOW_FPS) {
if (!this.fpsAppended) {
this.div.appendChild(this.fps);
this.fpsAppended = true;
}
this.fps.innerText = this.time.framesPerSecond.toFixed(2) + " fps";
} else {
if (this.fpsAppended) {
this.div.removeChild(this.fps);
this.fpsAppended = false;
}
}
};
const clear = (r: number, g: number, b: number, a: number) => {
this.renderer.context.gl.clearColor(r, g, b, a);
this.renderer.context.gl.clear(this.renderer.context.gl.COLOR_BUFFER_BIT);
}
const startScissor = (divBounds: Rectangle) => {
this.renderer.end();
this.renderer.begin();
this.renderer.context.gl.enable(this.renderer.context.gl.SCISSOR_TEST);
this.renderer.context.gl.scissor(
this.screenToWorldLength(divBounds.x),
this.canvas.height - this.screenToWorldLength(divBounds.y + divBounds.height),
this.screenToWorldLength(divBounds.width),
this.screenToWorldLength(divBounds.height)
);
}
const endScissor = () => {
this.renderer.end();
this.renderer.context.gl.disable(this.renderer.context.gl.SCISSOR_TEST);
this.renderer.begin();
}
const renderWidgets = () => {
clear(0, 0, 0, 0);
let renderer = this.renderer;
renderer.begin();
let ref: DOMRect;
let offsetLeftForOevrlay = 0;
let offsetTopForOverlay = 0;
if (this.appendedToBody) {
ref = this.parentElement!.getBoundingClientRect();
const computedStyle = getComputedStyle(this.parentElement!);
offsetLeftForOevrlay = ref.left + parseFloat(computedStyle.borderLeftWidth);
offsetTopForOverlay = ref.top + parseFloat(computedStyle.borderTopWidth);
}
const tempVector = new Vector3();
for (const widget of this.widgets) {
const { skeleton, pma, bounds, mode, debug, offsetX, offsetY, xAxis, yAxis, dragX, dragY, fit, noSpinner, loading, clip, isDraggable } = widget;
if (widget.isOffScreenAndWasMoved()) continue;
const elementRef = widget.getHostElement();
const divBounds = elementRef.getBoundingClientRect();
// need to use left and top, because x and y are not available on older browser
divBounds.x = divBounds.left + this.overflowLeftSize;
divBounds.y = divBounds.top + this.overflowTopSize;
if (this.appendedToBody) {
divBounds.x -= offsetLeftForOevrlay;
divBounds.y -= offsetTopForOverlay;
}
const { padLeft, padRight, padTop, padBottom } = widget
const paddingShiftHorizontal = (padLeft - padRight) / 2;
const paddingShiftVertical = (padTop - padBottom) / 2;
// get the desired point into the the div (center by default) in world coordinate
const divX = divBounds.x + divBounds.width * ((xAxis + .5) + paddingShiftHorizontal);
const divY = divBounds.y + divBounds.height * ((-yAxis + .5) + paddingShiftVertical) - 1;
this.screenToWorld(tempVector, divX, divY);
let divOriginX = tempVector.x;
let divOriginY = tempVector.y;
const paddingShrinkWidth = 1 - (padLeft + padRight);
const paddingShrinkHeight = 1 - (padTop + padBottom);
const divWidthWorld = this.screenToWorldLength(divBounds.width * paddingShrinkWidth);
const divHeightWorld = this.screenToWorldLength(divBounds.height * paddingShrinkHeight);
if (clip) startScissor(divBounds);
if (loading) {
if (!noSpinner) {
if (!widget.loadingScreen) widget.loadingScreen = new LoadingScreen(renderer);
widget.loadingScreen!.drawInCoordinates(divOriginX, divOriginY);
}
if (clip) endScissor();
continue;
}
if (skeleton) {
if (mode === "inside") {
let { x: ax, y: ay, width: aw, height: ah } = bounds;
if (aw <= 0 || ah <= 0) continue;
// scale ratio
const scaleWidth = divWidthWorld / aw;
const scaleHeight = divHeightWorld / ah;
// default value is used for fit = none
let ratioW = skeleton.scaleX;
let ratioH = skeleton.scaleY;
if (fit === "fill") { // Fill the target box by distorting the source's aspect ratio.
ratioW = scaleWidth;
ratioH = scaleHeight;
} else if (fit === "width") {
ratioW = scaleWidth;
ratioH = scaleWidth;
} else if (fit === "height") {
ratioW = scaleHeight;
ratioH = scaleHeight;
} else if (fit === "contain") {
// if scaled height is bigger than div height, use height ratio instead
if (ah * scaleWidth > divHeightWorld) {
ratioW = scaleHeight;
ratioH = scaleHeight;
} else {
ratioW = scaleWidth;
ratioH = scaleWidth;
}
} else if (fit === "cover") {
if (ah * scaleWidth < divHeightWorld) {
ratioW = scaleHeight;
ratioH = scaleHeight;
} else {
ratioW = scaleWidth;
ratioH = scaleWidth;
}
} else if (fit === "scaleDown") {
if (aw > divWidthWorld || ah > divHeightWorld) {
if (ah * scaleWidth > divHeightWorld) {
ratioW = scaleHeight;
ratioH = scaleHeight;
} else {
ratioW = scaleWidth;
ratioH = scaleWidth;
}
}
}
// get the center of the bounds
const boundsX = (ax + aw / 2) * ratioW;
const boundsY = (ay + ah / 2) * ratioH;
// get vertices offset: calculate the distance between div center and bounds center
divOriginX = divOriginX - boundsX;
divOriginY = divOriginY - boundsY;
if (fit !== "none") {
// scale the skeleton
skeleton.scaleX = ratioW;
skeleton.scaleY = ratioH;
skeleton.updateWorldTransform(Physics.update);
}
}
const worldOffsetX = divOriginX + offsetX + dragX;
const worldOffsetY = divOriginY + offsetY + dragY;
widget.worldX = worldOffsetX;
widget.worldY = worldOffsetY;
renderer.drawSkeleton(skeleton, pma, -1, -1, (vertices, size, vertexSize) => {
for (let i = 0; i < size; i += vertexSize) {
vertices[i] = vertices[i] + worldOffsetX;
vertices[i + 1] = vertices[i + 1] + worldOffsetY;
}
});
// drawing debug stuff
if (debug) {
// if (true) {
let { x: ax, y: ay, width: aw, height: ah } = bounds;
// show bounds and its center
if (isDraggable) {
renderer.rect(true,
ax * skeleton.scaleX + worldOffsetX,
ay * skeleton.scaleY + worldOffsetY,
aw * skeleton.scaleX,
ah * skeleton.scaleY,
transparentRed);
}
renderer.rect(false,
ax * skeleton.scaleX + worldOffsetX,
ay * skeleton.scaleY + worldOffsetY,
aw * skeleton.scaleX,
ah * skeleton.scaleY,
blue);
const bbCenterX = (ax + aw / 2) * skeleton.scaleX + worldOffsetX;
const bbCenterY = (ay + ah / 2) * skeleton.scaleY + worldOffsetY;
renderer.circle(true, bbCenterX, bbCenterY, 10, blue);
// show skeleton root
const root = skeleton.getRootBone()!;
renderer.circle(true, root.x + worldOffsetX, root.y + worldOffsetY, 10, red);
// show shifted origin
const originX = worldOffsetX - dragX - offsetX;
const originY = worldOffsetY - dragY - offsetY;
renderer.circle(true, originX, originY, 10, green);
// show line from origin to bounds center
renderer.line(originX, originY, bbCenterX, bbCenterY, green);
}
if (clip) endScissor();
}
}
renderer.end();
}
const updateBoneFollowers = () => {
for (const widget of this.widgets) {
if (widget.isOffScreenAndWasMoved() || !widget.skeleton) continue;
for (const boneFollower of widget.boneFollowerList) {
const { slot, bone, element, followAttachmentAttach, followRotation, followOpacity, followScale } = boneFollower;
const { worldX, worldY } = widget;
this.worldToScreen(this.tempFollowBoneVector, bone.worldX + worldX, bone.worldY + worldY);
if (Number.isNaN(this.tempFollowBoneVector.x)) continue;
let x = this.tempFollowBoneVector.x - this.overflowLeftSize;
let y = this.tempFollowBoneVector.y - this.overflowTopSize;
if (!this.appendedToBody) {
x += window.scrollX;
y += window.scrollY;
}
element.style.transform = `translate(calc(-50% + ${x.toFixed(2)}px),calc(-50% + ${y.toFixed(2)}px))`
+ (followRotation ? ` rotate(${-bone.getWorldRotationX()}deg)` : "")
+ (followScale ? ` scale(${bone.getWorldScaleX()}, ${bone.getWorldScaleY()})` : "")
;
element.style.display = ""
if (followAttachmentAttach && !slot.attachment) {
element.style.opacity = "0";
} else if (followOpacity) {
element.style.opacity = `${slot.color.a}`;
}
}
}
}
const loop = () => {
if (this.disposed || !this.isConnected) {
this.running = false;
return;
};
requestAnimationFrame(loop);
if (!this.loaded) return;
this.time.update();
this.translateCanvas();
updateWidgets();
renderWidgets();
updateBoneFollowers();
}
requestAnimationFrame(loop);
this.running = true;
const red = new Color(1, 0, 0, 1);
const green = new Color(0, 1, 0, 1);
const blue = new Color(0, 0, 1, 1);
const transparentWhite = new Color(1, 1, 1, .3);
const transparentRed = new Color(1, 0, 0, .3);
}
public cursorCanvasX = 1;
public cursorCanvasY = 1;
public cursorWorldX = 1;
public cursorWorldY = 1;
private tempVector = new Vector3();
private updateCursor (input: Point) {
this.cursorCanvasX = input.x - window.scrollX;
this.cursorCanvasY = input.y - window.scrollY;
if (this.appendedToBody) {
const ref = this.parentElement!.getBoundingClientRect();
this.cursorCanvasX -= ref.left;
this.cursorCanvasY -= ref.top;
}
let tempVector = this.tempVector;
tempVector.set(this.cursorCanvasX, this.cursorCanvasY, 0);
this.renderer.camera.screenToWorld(tempVector, this.canvas.clientWidth, this.canvas.clientHeight);
if (Number.isNaN(tempVector.x) || Number.isNaN(tempVector.y)) return;
this.cursorWorldX = tempVector.x;
this.cursorWorldY = tempVector.y;
}
private updateWidgetCursor (widget: SpineWebComponentSkeleton): boolean {
if (widget.worldX === Infinity) return false;
widget.cursorWorldX = this.cursorWorldX - widget.worldX;
widget.cursorWorldY = this.cursorWorldY - widget.worldY;
return true;
}
private setupDragUtility (): Input {
// TODO: we should use document - body might have some margin that offset the click events - Meanwhile I take event pageX/Y
const inputManager = new Input(document.body, false)
const inputPointTemp: Point = new Vector2();
const getInput = (ev?: MouseEvent | TouchEvent): Point => {
const originalEvent = ev instanceof MouseEvent ? ev : ev!.changedTouches[0];
inputPointTemp.x = originalEvent.pageX + this.overflowLeftSize;
inputPointTemp.y = originalEvent.pageY + this.overflowTopSize;
return inputPointTemp;
}
let lastX = 0;
let lastY = 0;
inputManager.addListener({
// moved is used to pass cursor position wrt to canvas and widget position and currently is EXPERIMENTAL
moved: (x, y, ev) => {
const input = getInput(ev);
this.updateCursor(input);
for (const widget of this.widgets) {
if (!this.updateWidgetCursor(widget) || !widget.onScreen) continue;
widget.cursorEventUpdate("move", ev);
}
},
down: (x, y, ev) => {
const input = getInput(ev);
this.updateCursor(input);
for (const widget of this.widgets) {
if (!this.updateWidgetCursor(widget) || widget.isOffScreenAndWasMoved()) continue;
widget.cursorEventUpdate("down", ev);
if ((widget.isInteractive && widget.cursorInsideBounds) || (!widget.isInteractive && widget.isCursorInsideBounds())) {
if (!widget.isDraggable) continue;
widget.dragging = true;
ev?.preventDefault();
}
}
lastX = input.x;
lastY = input.y;
},
dragged: (x, y, ev) => {
const input = getInput(ev);
let dragX = input.x - lastX;
let dragY = input.y - lastY;
this.updateCursor(input);
for (const widget of this.widgets) {
if (!this.updateWidgetCursor(widget) || widget.isOffScreenAndWasMoved()) continue;
widget.cursorEventUpdate("drag", ev);
if (!widget.dragging) continue;
const skeleton = widget.skeleton!;
widget.dragX += this.screenToWorldLength(dragX);
widget.dragY -= this.screenToWorldLength(dragY);
skeleton.physicsTranslate(dragX, -dragY);
ev?.preventDefault();
ev?.stopPropagation();
}
lastX = input.x;
lastY = input.y;
},
up: (x, y, ev) => {
for (const widget of this.widgets) {
widget.dragging = false;
if (widget.cursorInsideBounds) {
widget.cursorEventUpdate("up", ev);
}
}
}
});
return inputManager;
}
/*
* Resize/scroll utilities
*/
private updateCanvasSize () {
const { width, height } = this.getViewportSize();
// if the target width/height changes, resize the canvas.
if (this.lastCanvasBaseWidth !== width || this.lastCanvasBaseHeight !== height) {
this.lastCanvasBaseWidth = width;
this.lastCanvasBaseHeight = height;
this.overflowLeftSize = this.overflowLeft * width;
this.overflowTopSize = this.overflowTop * height;
const totalWidth = width * (1 + (this.overflowLeft + this.overflowRight));
const totalHeight = height * (1 + (this.overflowTop + this.overflowBottom));
this.canvas.style.width = totalWidth + "px";
this.canvas.style.height = totalHeight + "px";
this.resize(totalWidth, totalHeight);
}
// temporarely remove the div to get the page size without considering the div
// this is necessary otherwise if the bigger element in the page is remove and the div
// was the second bigger element, now it would be the div to determine the page size
// this.div?.remove(); is it better width/height to zero?
// this.div!.style.width = 0 + "px";
// this.div!.style.height = 0 + "px";
this.div!.style.display = "none";
if (!this.appendedToBody) {
const { width, height } = this.getPageSize();
this.div!.style.width = width + "px";
this.div!.style.height = height + "px";
} else {
if (this.hasCssTweakOff()) {
// this case lags if scrolls or position fixed
// users should never use tweak off, unless the parent container has already a transform
this.div!.style.width = this.parentElement!.clientWidth + "px";
this.div!.style.height = this.parentElement!.clientHeight + "px";
this.canvas.style.transform = `translate(${-this.overflowLeftSize}px,${-this.overflowTopSize}px)`;
} else {
this.div!.style.width = this.parentElement!.scrollWidth + "px";
this.div!.style.height = this.parentElement!.scrollHeight + "px";
}
}
this.div!.style.display = "";
// this.root.appendChild(this.div!);
}
private resize (width: number, height: number) {
let canvas = this.canvas;
canvas.width = Math.round(this.screenToWorldLength(width));
canvas.height = Math.round(this.screenToWorldLength(height));
this.renderer.context.gl.viewport(0, 0, canvas.width, canvas.height);
this.renderer.camera.setViewport(canvas.width, canvas.height);
this.renderer.camera.update();
}
// we need the bounding client rect otherwise decimals won't be returned
// this means that during zoom it might occurs that the div would be resized
// rounded 1px more making a scrollbar appear
private getPageSize () {
return document.body.getBoundingClientRect();
}
private lastViewportWidth = 0;
private lastViewportHeight = 0;
private lastDPR = 0;
private static readonly WIDTH_INCREMENT = 1.15;
private static readonly HEIGHT_INCREMENT = 1.2;
private static readonly MAX_CANVAS_WIDTH = 7000;
private static readonly MAX_CANVAS_HEIGHT = 7000;
// determine the target viewport width and height.
// The target width/height won't change if the viewport shrink to avoid useless re render (especially re render bursts on mobile)
private getViewportSize (): { width: number, height: number } {
if (this.appendedToBody) {
return {
width: this.parentElement!.clientWidth,
height: this.parentElement!.clientHeight,
}
}
let width = window.innerWidth;
let height = window.innerHeight;
const dpr = this.getDevicePixelRatio();
if (dpr !== this.lastDPR) {
this.lastDPR = dpr;
this.lastViewportWidth = this.lastViewportWidth === 0 ? width : width * SpineWebComponentOverlay.WIDTH_INCREMENT;
this.lastViewportHeight = height * SpineWebComponentOverlay.HEIGHT_INCREMENT;
this.updateWidgetScales();
} else {
if (width > this.lastViewportWidth) this.lastViewportWidth = width * SpineWebComponentOverlay.WIDTH_INCREMENT;
if (height > this.lastViewportHeight) this.lastViewportHeight = height * SpineWebComponentOverlay.HEIGHT_INCREMENT;
}
// if the resulting canvas width/height is too high, scale the DPI
if (this.lastViewportHeight * (1 + this.overflowTop + this.overflowBottom) * dpr > SpineWebComponentOverlay.MAX_CANVAS_HEIGHT ||
this.lastViewportWidth * (1 + this.overflowLeft + this.overflowRight) * dpr > SpineWebComponentOverlay.MAX_CANVAS_WIDTH) {
this.dprScale += .5;
return this.getViewportSize();
}
return {
width: this.lastViewportWidth,
height: this.lastViewportHeight,
}
}
/**
* @internal
*/
public getDevicePixelRatio () {
return window.devicePixelRatio / this.dprScale;
}
private dprScale = 1;
private updateWidgetScales () {
for (const widget of this.widgets) {
// inside mode scale automatically to fit the skeleton within its parent
if (widget.mode !== "origin" && widget.fit !== "none") continue;
const skeleton = widget.skeleton;
if (!skeleton) continue;
// I'm not sure about this. With mode origin and fit none:
// case 1) If I comment this scale code, the skeleton is never scaled and will be always at the same size and won't change size while zooming
// case 2) Otherwise, the skeleton is loaded always at the same size, but changes size while zooming
const scale = this.getDevicePixelRatio();
skeleton.scaleX = skeleton.scaleX / widget.dprScale * scale;
skeleton.scaleY = skeleton.scaleY / widget.dprScale * scale;
widget.dprScale = scale;
}
}
// this function is invoked each frame - pay attention to what you add here
private translateCanvas () {
let scrollPositionX = -this.overflowLeftSize;
let scrollPositionY = -this.overflowTopSize;
if (!this.appendedToBody) {
scrollPositionX += window.scrollX;
scrollPositionY += window.scrollY;
} else {
// Ideally this should be the only appendedToBody case (no-auto-parent-transform not enabled or at least an ancestor has transform)
// I'd like to get rid of the else case
if (this.hasParentTransform) {
scrollPositionX += this.parentElement!.scrollLeft;
scrollPositionY += this.parentElement!.scrollTop;
} else {
const { left, top } = this.parentElement!.getBoundingClientRect();
scrollPositionX += left + window.scrollX;
scrollPositionY += top + window.scrollY;
let offsetParent = this.offsetParent;
do {
if (offsetParent === null || offsetParent === document.body) break;
const htmlOffsetParentElement = offsetParent as HTMLElement;
if (htmlOffsetParentElement.style.position === "fixed" || htmlOffsetParentElement.style.position === "sticky" || htmlOffsetParentElement.style.position === "absolute") {
const parentRect = htmlOffsetParentElement.getBoundingClientRect();
this.div.style.transform = `translate(${left - parentRect.left}px,${top - parentRect.top}px)`;
return;
}
offsetParent = htmlOffsetParentElement.offsetParent;
} while (offsetParent);
this.div.style.transform = `translate(${scrollPositionX + this.overflowLeftSize}px,${scrollPositionY + this.overflowTopSize}px)`;
return;
}
}
this.canvas.style.transform = `translate(${scrollPositionX}px,${scrollPositionY}px)`;
}
private updateZIndexIfNecessary (element: HTMLElement) {
let parent: HTMLElement | null = element;
let zIndex: undefined | number;
do {
let currentZIndex = parseInt(getComputedStyle(parent).zIndex);
// searching the shallowest z-index
if (!isNaN(currentZIndex)) zIndex = currentZIndex;
parent = parent.parentElement;
} while (parent && parent !== document.body)
if (zIndex && (!this.zIndex || this.zIndex < zIndex)) {
this.zIndex = zIndex;
this.div.style.zIndex = `${this.zIndex}`;
}
}
/*
* Other utilities
*/
public screenToWorld (vec: Vector3, x: number, y: number) {
vec.set(x, y, 0);
// pay attention that clientWidth/Height rounds the size - if we don't like it, we should use getBoundingClientRect as in getPagSize
this.renderer.camera.screenToWorld(vec, this.canvas.clientWidth, this.canvas.clientHeight);
}
public worldToScreen (vec: Vector3, x: number, y: number) {
vec.set(x, -y, 0);
// pay attention that clientWidth/Height rounds the size - if we don't like it, we should use getBoundingClientRect as in getPagSize
// this.renderer.camera.worldToScreen(vec, this.canvas.clientWidth, this.canvas.clientHeight);
this.renderer.camera.worldToScreen(vec, this.worldToScreenLength(this.renderer.camera.viewportWidth), this.worldToScreenLength(this.renderer.camera.viewportHeight));
}
public screenToWorldLength (length: number) {
return length * this.getDevicePixelRatio();
}
public worldToScreenLength (length: number) {
return length / this.getDevicePixelRatio();
}
}
customElements.define("spine-overlay", SpineWebComponentOverlay);