123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059 |
- /******************************************************************************
- * 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;
- private running = false;
- private visible = true;
- /**
- * appendedToBody is assegned in the connectedCallback.
- * When false, 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 viewport.
- * 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" });
- // if the element is not appendedToBody, the user does not disable translate tweak, and the parent did not have already a transform, add the tweak
- if (!this.appendedToBody) {
- if (this.hasCssTweakOff()) {
- this.hasParentTransform = false;
- } else {
- this.parentElement!.style.transform = `translateZ(0)`;
- }
- } else {
- window.addEventListener("resize", this.windowResizeCallback);
- }
- this.resizeObserver = new ResizeObserver(() => this.resizedCallback());
- this.resizeObserver.observe(this.parentElement!);
- for (const widget of this.widgets) {
- this.intersectionObserver?.observe(widget.getHostElement());
- }
- this.input = this.setupDragUtility();
- document.addEventListener('visibilitychange', this.visibilityChangeCallback);
- this.startRenderingLoop();
- }
- 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.windowResizeCallback);
- document.removeEventListener('visibilitychange', this.visibilityChangeCallback);
- 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 visibilityChangeCallback = () => {
- if (document.hidden) {
- this.visible = false;
- } else {
- this.visible = true;
- this.startRenderingLoop();
- }
- }
- private windowResizeCallback = () => this.resizedCallback(true);
- private resizedCallback = (onlyDiv = false) => {
- this.updateCanvasSize(onlyDiv);
- }
- 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);
- }
- }
- private hasCssTweakOff () {
- return this.noAutoParentTransform && getComputedStyle(this.parentElement!).transform === "none";
- }
- /**
- * 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, debug, offsetX, offsetY, dragX, dragY, fit, spinner, loading, clip, drag } = 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, xAxis, yAxis } = 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 (spinner) {
- if (!widget.loadingScreen) widget.loadingScreen = new LoadingScreen(renderer);
- widget.loadingScreen!.drawInCoordinates(divOriginX, divOriginY);
- }
- if (clip) endScissor();
- continue;
- }
- if (skeleton) {
- if (fit !== "origin") {
- 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;
- // scale the skeleton
- if (fit !== "none" && (skeleton.scaleX !== ratioW || skeleton.scaleY !== ratioH)) {
- skeleton.scaleX = ratioW;
- skeleton.scaleY = ratioH;
- skeleton.updateWorldTransform(Physics.update);
- }
- }
- // const worldOffsetX = divOriginX + offsetX + dragX;
- const worldOffsetX = divOriginX + offsetX * window.devicePixelRatio + dragX;
- const worldOffsetY = divOriginY + offsetY * window.devicePixelRatio + 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 (drag) {
- 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
- renderer.circle(true, divOriginX, divOriginY, 10, green);
- // show line from origin to bounds center
- renderer.line(divOriginX, divOriginY, 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, followVisibility, 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 (followVisibility && !slot.attachment) {
- element.style.opacity = "0";
- } else if (followOpacity) {
- element.style.opacity = `${slot.color.a}`;
- }
- }
- }
- }
- const loop = () => {
- if (this.disposed || !this.isConnected || !this.visible) {
- 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 pointerCanvasX = 1;
- public pointerCanvasY = 1;
- public pointerWorldX = 1;
- public pointerWorldY = 1;
- private tempVector = new Vector3();
- private updatePointer (input: Point) {
- this.pointerCanvasX = input.x - window.scrollX;
- this.pointerCanvasY = input.y - window.scrollY;
- if (!this.appendedToBody) {
- const ref = this.parentElement!.getBoundingClientRect();
- this.pointerCanvasX -= ref.left;
- this.pointerCanvasY -= ref.top;
- }
- let tempVector = this.tempVector;
- tempVector.set(this.pointerCanvasX, this.pointerCanvasY, 0);
- this.renderer.camera.screenToWorld(tempVector, this.canvas.clientWidth, this.canvas.clientHeight);
- if (Number.isNaN(tempVector.x) || Number.isNaN(tempVector.y)) return;
- this.pointerWorldX = tempVector.x;
- this.pointerWorldY = tempVector.y;
- }
- private updateWidgetPointer (widget: SpineWebComponentSkeleton): boolean {
- if (widget.worldX === Infinity) return false;
- widget.pointerWorldX = this.pointerWorldX - widget.worldX;
- widget.pointerWorldY = this.pointerWorldY - 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 pointer position wrt to canvas and widget position and currently is EXPERIMENTAL
- moved: (x, y, ev) => {
- const input = getInput(ev);
- this.updatePointer(input);
- for (const widget of this.widgets) {
- if (!this.updateWidgetPointer(widget) || !widget.onScreen) continue;
- widget.pointerEventUpdate("move", ev);
- }
- },
- down: (x, y, ev) => {
- const input = getInput(ev);
- this.updatePointer(input);
- for (const widget of this.widgets) {
- if (!this.updateWidgetPointer(widget) || widget.isOffScreenAndWasMoved()) continue;
- widget.pointerEventUpdate("down", ev);
- if ((widget.interactive && widget.pointerInsideBounds) || (!widget.interactive && widget.isPointerInsideBounds())) {
- if (!widget.drag) 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.updatePointer(input);
- for (const widget of this.widgets) {
- if (!this.updateWidgetPointer(widget) || widget.isOffScreenAndWasMoved()) continue;
- widget.pointerEventUpdate("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.pointerInsideBounds) {
- widget.pointerEventUpdate("up", ev);
- }
- }
- }
- });
- return inputManager;
- }
- /*
- * Resize/scroll utilities
- */
- private updateCanvasSize (onlyDiv = false) {
- const { width, height } = this.getViewportSize();
- // if the target width/height changes, resize the canvas.
- if (!onlyDiv && 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
- 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.documentElement.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.fit !== "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);
|