diff --git a/spine-ts/spine-widget/example/app.html b/spine-ts/spine-widget/example/app.html index 8d73283c9..50813d632 100644 --- a/spine-ts/spine-widget/example/app.html +++ b/spine-ts/spine-widget/example/app.html @@ -201,7 +201,7 @@
<spine-widget> tag allows you to embed your Spine animations into a web page.
+ The <spine-skeleton> tag allows you to embed your Spine animations into a web page as a web component.
fit attribute.
@@ -222,13 +222,13 @@
fit="none".
@@ -245,20 +245,20 @@
width and height attributes in pixels (without the "px" unit).
@@ -314,15 +314,15 @@
@@ -364,14 +364,14 @@
Move the origin by a percentage of the div's width and height using the x-axis and y-axis attributes, respectively.
offset-x and offset-y to move your skeleton left or right by the specified number of pixels.
@@ -425,13 +425,13 @@
pad-left, pad-right, pad-top, and pad-bottom.
@@ -476,7 +476,7 @@
+>`);
clip in this case to prevent the skeleton from overflowing.
+>`);
@@ -1663,118 +1663,118 @@ function toggleSpinner(element) {
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
@@ -1786,118 +1786,118 @@ function toggleSpinner(element) {
escapeHTMLandInject(`
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
-
+ >
`)
@@ -2059,20 +2059,20 @@ skins.forEach((skin, i) => {
-
+ >
-
+ >
@@ -2895,7 +2895,7 @@ tank.beforeUpdateWorldTransforms = (delta, skeleton, state) => {
If you need to determine the cursor position in the overlay world, you might find useful the following properties.
- For spine-widget:
+ For spine-skeleton:
cursorWorldX and cursorWorldY are the x and y of the cursor relative to the skeleton root (spine world).
worldX and worldY are the x and y of the root relative to the canvas/webgl context origin (spine world).
@@ -2960,14 +2960,14 @@ function createCircleOfDivs(numDivs = 8) {
div.style.fontWeight = 'bold';
div.style.transform = \`translate(\${x}px, \${y}px)\`;
div.innerHTML = \`
-
+ >
\`;
container.appendChild(div);
@@ -3053,14 +3053,14 @@ function createCircleOfDivs(numDivs = 8) {
div.style.fontWeight = 'bold';
div.style.transform = `translate(${x}px, ${y}px)`;
div.innerHTML = `
-
+ >
`;
container.appendChild(div);
@@ -3150,7 +3150,7 @@ const updateControl = (widget, controlBone, mouseX, mouseY, tempVector) => {
- {
animation="emotes/wave"
isinteractive
style="width: 150px; height: 150px;"
- >
+ >
- {
animation="emotes/wave"
isinteractive
style="width: 150px; height: 150px;"
- >
+ >
diff --git a/spine-ts/spine-widget/src/SpineWebComponentOverlay.ts b/spine-ts/spine-widget/src/SpineWebComponentOverlay.ts
new file mode 100644
index 000000000..00cf58c8f
--- /dev/null
+++ b/spine-ts/spine-widget/src/SpineWebComponentOverlay.ts
@@ -0,0 +1,1023 @@
+/******************************************************************************
+ * 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, Input, LoadingScreen, ManagedWebGLRenderingContext, Physics, SceneRenderer, TimeKeeper, Vector2, Vector3 } from "@esotericsoftware/spine-webgl"
+import { SpineWebComponentSkeleton } from "./SpineWebComponentSkeleton"
+import { AttributeTypes, castValue, Point, Rectangle } from "./wcUtils"
+
+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();
+
+ /**
+ * @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;
+ }
+
+ /**
+ * A list holding the widgets added to this overlay.
+ */
+ public widgets = new Array();
+
+ /**
+ * 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 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 scrollable container must have a `transform` css attribute. If it hasn't this attribute the `spine-overlay` will add it for you.
+ * If your scrollable 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 `scrollable` attribute.
+ */
+ private appendedToBody = 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);
+ }
+ [Symbol.dispose](): void {
+ throw new Error("Method not implemented.")
+ }
+
+ 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);
+ }
+ }
+ }
+ }, { 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 scrollable, the user does not disable translate tweak, and the parent did not have already a transform, add the tweak
+ if (this.appendedToBody && !this.noAutoParentTransform && getComputedStyle(this.parentElement!).transform === "none") {
+ 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 = {
+ "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);
+ }
+ }
+ }
+
+ /**
+ * 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 (SpineWebComponentSkeleton.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, onScreen, 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;
+ }
+ }
+
+ 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 scrollable case (no-auto-parent-transform not enabled or at least an ancestor has transform)
+ // I'd like to get rid of the code below
+ if (!this.hasCssTweakOff()) {
+ 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 === 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)`;
+ }
+
+ /*
+ * 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);
diff --git a/spine-ts/spine-widget/src/SpineWebComponentWidget.ts b/spine-ts/spine-widget/src/SpineWebComponentSkeleton.ts
similarity index 51%
rename from spine-ts/spine-widget/src/SpineWebComponentWidget.ts
rename to spine-ts/spine-widget/src/SpineWebComponentSkeleton.ts
index 6940a88a6..5e8f1abb4 100644
--- a/spine-ts/spine-widget/src/SpineWebComponentWidget.ts
+++ b/spine-ts/spine-widget/src/SpineWebComponentSkeleton.ts
@@ -32,24 +32,17 @@ import {
AnimationState,
AnimationStateData,
AtlasAttachmentLoader,
- AssetManager,
- Color,
Disposable,
- Input,
LoadingScreen,
- ManagedWebGLRenderingContext,
MixBlend,
MixDirection,
Physics,
- SceneRenderer,
SkeletonBinary,
SkeletonData,
SkeletonJson,
Skeleton,
TextureAtlas,
- TimeKeeper,
Vector2,
- Vector3,
Utils,
NumberArrayLike,
Slot,
@@ -57,108 +50,16 @@ import {
MeshAttachment,
Bone,
} from "@esotericsoftware/spine-webgl";
-
-interface Point {
- x: number,
- y: number,
-}
-
-interface Rectangle extends Point {
- width: number,
- height: number,
-}
+import { AttributeTypes, castValue, isBase64, Rectangle } from "./wcUtils";
+import { SpineWebComponentOverlay } from "./SpineWebComponentOverlay";
type UpdateSpineWidgetFunction = (delta: number, skeleton: Skeleton, state: AnimationState) => void;
export type OffScreenUpdateBehaviourType = "pause" | "update" | "pose";
-function isOffScreenUpdateBehaviourType (value: string | null): value is OffScreenUpdateBehaviourType {
- return (
- value === "pause" ||
- value === "update" ||
- value === "pose"
- );
-}
-
export type ModeType = "inside" | "origin";
-function isModeType (value: string | null): value is ModeType {
- return (
- value === "inside" ||
- value === "origin"
- );
-}
-
export type FitType = "fill" | "width" | "height" | "contain" | "cover" | "none" | "scaleDown";
-function isFitType (value: string | null): value is FitType {
- return (
- value === "fill" ||
- value === "width" ||
- value === "height" ||
- value === "contain" ||
- value === "cover" ||
- value === "none" ||
- value === "scaleDown"
- );
-}
-
-const animatonTypeRegExp = /\[([^\]]+)\]/g;
-
export type AnimationsInfo = Record }>;
export type AnimationsType = { animationName: string | "#EMPTY#", loop?: boolean, delay?: number, mixDuration?: number };
-
-function castToAnimationsInfo (value: string | null): AnimationsInfo | undefined {
- if (value === null) {
- return undefined;
- }
-
- const matches = value.match(animatonTypeRegExp);
- if (!matches) return undefined;
-
- return matches.reduce((obj, group) => {
- const [trackIndexStringOrLoopDefinition, animationNameOrTrackIndexStringCycle, loop, delayString, mixDurationString] = group.slice(1, -1).split(',').map(v => v.trim());
-
- if (trackIndexStringOrLoopDefinition === "loop") {
- if (!Number.isInteger(Number(animationNameOrTrackIndexStringCycle))) {
- throw new Error(`Track index of cycle in ${group} must be a positive integer number, instead it is ${animationNameOrTrackIndexStringCycle}. Original value: ${value}`);
- }
- const animationInfoObject = obj[animationNameOrTrackIndexStringCycle] ||= { animations: [] };
- animationInfoObject.cycle = true;
- return obj;
- }
-
- const trackIndex = Number(trackIndexStringOrLoopDefinition);
- if (!Number.isInteger(trackIndex)) {
- throw new Error(`Track index in ${group} must be a positive integer number, instead it is ${trackIndexStringOrLoopDefinition}. Original value: ${value}`);
- }
-
- let delay;
- if (delayString !== undefined) {
- delay = parseFloat(delayString);
- if (isNaN(delay)) {
- throw new Error(`Delay in ${group} must be a positive number, instead it is ${delayString}. Original value: ${value}`);
- }
- }
-
- let mixDuration;
- if (mixDurationString !== undefined) {
- mixDuration = parseFloat(mixDurationString);
- if (isNaN(mixDuration)) {
- throw new Error(`mixDuration in ${group} must be a positive number, instead it is ${mixDurationString}. Original value: ${value}`);
- }
- }
-
- const animationInfoObject = obj[trackIndexStringOrLoopDefinition] ||= { animations: [] };
- animationInfoObject.animations.push({
- animationName: animationNameOrTrackIndexStringCycle,
- loop: loop.trim().toLowerCase() === "true",
- delay,
- mixDuration,
- });
- return obj;
- }, {} as AnimationsInfo);
-}
-
-export type AttributeTypes = "string" | "number" | "boolean" | "array-number" | "array-string" | "object" | "fitType" | "modeType" | "offScreenUpdateBehaviourType" | "animationsInfo";
-
export type CursorEventType = "down" | "up" | "enter" | "leave" | "move" | "drag";
export type CursorEventTypesInput = Exclude;
@@ -208,7 +109,7 @@ interface WidgetOverridableMethods {
update?: UpdateSpineWidgetFunction;
beforeUpdateWorldTransforms: UpdateSpineWidgetFunction;
afterUpdateWorldTransforms: UpdateSpineWidgetFunction;
- onScreenFunction: (widget: SpineWebComponentWidget) => void
+ onScreenFunction: (widget: SpineWebComponentSkeleton) => void
}
// Properties that does not map to any widget attribute, but that might be useful
@@ -218,7 +119,7 @@ interface WidgetPublicProperties {
bounds: Rectangle
onScreen: boolean
onScreenAtLeastOnce: boolean
- whenReady: Promise
+ whenReady: Promise
loading: boolean
started: boolean
textureAtlas: TextureAtlas
@@ -234,7 +135,7 @@ interface WidgetInternalProperties {
dragY: number
}
-export class SpineWebComponentWidget extends HTMLElement implements Disposable, WidgetAttributes, WidgetOverridableMethods, WidgetInternalProperties, Partial {
+export class SpineWebComponentSkeleton extends HTMLElement implements Disposable, WidgetAttributes, WidgetOverridableMethods, WidgetInternalProperties, Partial {
/**
* If true, enables a top-left span showing FPS (it has black text)
@@ -668,7 +569,7 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
* By default, the callback call the {@link start} method the first time the widget
* enters the screen viewport.
*/
- public onScreenFunction: (widget: SpineWebComponentWidget) => void = async (widget) => {
+ public onScreenFunction: (widget: SpineWebComponentSkeleton) => void = async (widget) => {
if (widget.loading && !widget.onScreenAtLeastOnce) {
widget.onScreenAtLeastOnce = true;
@@ -841,7 +742,7 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
}
static get observedAttributes (): string[] {
- return Object.keys(SpineWebComponentWidget.attributesDescription);
+ return Object.keys(SpineWebComponentSkeleton.attributesDescription);
}
constructor () {
@@ -910,7 +811,7 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
}
attributeChangedCallback (name: string, oldValue: string | null, newValue: string | null): void {
- const { type, propertyName, defaultValue } = SpineWebComponentWidget.attributesDescription[name];
+ const { type, propertyName, defaultValue } = SpineWebComponentSkeleton.attributesDescription[name];
const val = castValue(type, newValue, defaultValue);
(this as any)[propertyName] = val;
return;
@@ -1202,7 +1103,6 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
*/
public cursorInsideBounds = false;
- private pointTemp = new Vector2();
private verticesTemp = Utils.newFloatArray(2 * 1024);
/**
@@ -1249,12 +1149,15 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
public isCursorInsideBounds (): boolean {
if (this.isOffScreenAndWasMoved() || !this.skeleton) return false;
- this.pointTemp.set(
- this.cursorWorldX / this.skeleton.scaleX,
- this.cursorWorldY / this.skeleton.scaleY,
- );
+ const x = this.cursorWorldX / this.skeleton.scaleX;
+ const y = this.cursorWorldY / this.skeleton.scaleY;
- return inside(this.pointTemp, this.bounds);
+ return (
+ x >= this.bounds.x &&
+ x <= this.bounds.x + this.bounds.width &&
+ y >= this.bounds.y &&
+ y <= this.bounds.y + this.bounds.height
+ );
}
private checkSlotInteraction (type: CursorEventTypesInput, originalEvent?: UIEvent) {
@@ -1427,1012 +1330,16 @@ export class SpineWebComponentWidget extends HTMLElement implements Disposable,
}
-interface OverlayAttributes {
- overlayId?: string
- noAutoParentTransform: boolean
- overflowTop: number
- overflowBottom: number
- overflowLeft: number
- overflowRight: number
+customElements.define("spine-skeleton", SpineWebComponentSkeleton);
+
+export function getSpineWidget (identifier: string): SpineWebComponentSkeleton {
+ return document.querySelector(`spine-skeleton[identifier=${identifier}]`) as SpineWebComponentSkeleton;
}
-class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes, Disposable {
- private static OVERLAY_ID = "spine-overlay-default-identifier";
- private static OVERLAY_LIST = new Map();
+export function createSpineWidget (parameters: WidgetAttributes): SpineWebComponentSkeleton {
+ const widget = document.createElement("spine-skeleton") as SpineWebComponentSkeleton;
- /**
- * @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;
- }
-
- /**
- * A list holding the widgets added to this overlay.
- */
- public widgets = new Array();
-
- /**
- * 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 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 scrollable container must have a `transform` css attribute. If it hasn't this attribute the `spine-overlay` will add it for you.
- * If your scrollable 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-widget` must have an `overlay-id` attribute. The same as the hosting `spine-overlay`.
- * Connected to `scrollable` attribute.
- */
- private appendedToBody = 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);
- }
- }
- }
- }, { 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 scrollable, the user does not disable translate tweak, and the parent did not have already a transform, add the tweak
- if (this.appendedToBody && !this.noAutoParentTransform && getComputedStyle(this.parentElement!).transform === "none") {
- 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 = {
- "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: SpineWebComponentWidget) {
- 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);
- }
- }
- }
-
- /**
- * Remove the widget from the overlay.
- * @param widget The widget to remove from the overlay
- */
- removeWidget (widget: SpineWebComponentWidget) {
- 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 (SpineWebComponentWidget.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, onScreen, 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: SpineWebComponentWidget): 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;
- }
- }
-
- 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 scrollable case (no-auto-parent-transform not enabled or at least an ancestor has transform)
- // I'd like to get rid of the code below
- if (!this.hasCssTweakOff()) {
- 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 === 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)`;
- }
-
- /*
- * 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();
- }
-}
-
-const inside = (point: { x: number; y: number }, rectangle: Rectangle): boolean => {
- return (
- point.x >= rectangle.x &&
- point.x <= rectangle.x + rectangle.width &&
- point.y >= rectangle.y &&
- point.y <= rectangle.y + rectangle.height
- );
-}
-
-customElements.define("spine-widget", SpineWebComponentWidget);
-customElements.define("spine-overlay", SpineWebComponentOverlay);
-
-export function getSpineWidget (identifier: string): SpineWebComponentWidget {
- return document.querySelector(`spine-widget[identifier=${identifier}]`) as SpineWebComponentWidget;
-}
-
-export function createSpineWidget (parameters: WidgetAttributes): SpineWebComponentWidget {
- const widget = document.createElement("spine-widget") as SpineWebComponentWidget;
-
- Object.entries(SpineWebComponentWidget.attributesDescription).forEach(entry => {
+ Object.entries(SpineWebComponentSkeleton.attributesDescription).forEach(entry => {
const [key, { propertyName }] = entry;
const value = parameters[propertyName];
if (value) widget.setAttribute(key, value as any);
@@ -2440,71 +1347,3 @@ export function createSpineWidget (parameters: WidgetAttributes): SpineWebCompon
return widget;
}
-
-function castBoolean (value: string | null, defaultValue = "") {
- return value === "true" || value === "" ? true : false;
-}
-
-function castString (value: string | null, defaultValue = "") {
- return value === null ? defaultValue : value;
-}
-
-function castNumber (value: string | null, defaultValue = 0) {
- if (value === null) return defaultValue;
-
- const parsed = parseFloat(value);
- if (Number.isNaN(parsed)) return defaultValue;
- return parsed;
-}
-
-function castArrayNumber (value: string | null, defaultValue = undefined) {
- if (value === null) return defaultValue;
- return value.split(",").reduce((acc, pageIndex) => {
- const index = parseInt(pageIndex);
- if (!isNaN(index)) acc.push(index);
- return acc;
- }, [] as Array);
-}
-
-function castArrayString (value: string | null, defaultValue = undefined) {
- if (value === null) return defaultValue;
- return value.split(",");
-}
-
-function castObject (value: string | null, defaultValue = undefined) {
- if (value === null) return null;
- return JSON.parse(value);
-}
-
-const base64RegExp = /^(([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==))$/;
-function isBase64 (str: string) {
- return base64RegExp.test(str);
-}
-
-function castValue (type: AttributeTypes, value: string | null, defaultValue?: any) {
- switch (type) {
- case "string":
- return castString(value, defaultValue);
- case "number":
- return castNumber(value, defaultValue);
- case "boolean":
- return castBoolean(value, defaultValue);
- case "array-number":
- return castArrayNumber(value, defaultValue);
- case "array-string":
- return castArrayString(value, defaultValue);
- case "object":
- return castObject(value, defaultValue);
- case "fitType":
- return isFitType(value) ? value : defaultValue;
- case "modeType":
- return isModeType(value) ? value : defaultValue;
- case "offScreenUpdateBehaviourType":
- return isOffScreenUpdateBehaviourType(value) ? value : defaultValue;
- case "animationsInfo":
- return castToAnimationsInfo(value) || defaultValue;
- default:
- break;
- }
-
-}
\ No newline at end of file
diff --git a/spine-ts/spine-widget/src/index.ts b/spine-ts/spine-widget/src/index.ts
index 2003bf32c..4c4406161 100644
--- a/spine-ts/spine-widget/src/index.ts
+++ b/spine-ts/spine-widget/src/index.ts
@@ -1,3 +1,4 @@
-export * from './SpineWebComponentWidget.js';
+export * from './SpineWebComponentSkeleton.js';
+export * from './SpineWebComponentOverlay.js';
export * from "@esotericsoftware/spine-core";
-export * from "@esotericsoftware/spine-webgl";
\ No newline at end of file
+export * from "@esotericsoftware/spine-webgl";
diff --git a/spine-ts/spine-widget/src/wcUtils.ts b/spine-ts/spine-widget/src/wcUtils.ts
new file mode 100644
index 000000000..59b52f72d
--- /dev/null
+++ b/spine-ts/spine-widget/src/wcUtils.ts
@@ -0,0 +1,189 @@
+/******************************************************************************
+ * 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 { AnimationsInfo, FitType, ModeType, OffScreenUpdateBehaviourType } from "./SpineWebComponentSkeleton";
+
+const animatonTypeRegExp = /\[([^\]]+)\]/g;
+export type AttributeTypes = "string" | "number" | "boolean" | "array-number" | "array-string" | "object" | "fitType" | "modeType" | "offScreenUpdateBehaviourType" | "animationsInfo";
+
+export function castValue (type: AttributeTypes, value: string | null, defaultValue?: any) {
+ switch (type) {
+ case "string":
+ return castString(value, defaultValue);
+ case "number":
+ return castNumber(value, defaultValue);
+ case "boolean":
+ return castBoolean(value, defaultValue);
+ case "array-number":
+ return castArrayNumber(value, defaultValue);
+ case "array-string":
+ return castArrayString(value, defaultValue);
+ case "object":
+ return castObject(value, defaultValue);
+ case "fitType":
+ return isFitType(value) ? value : defaultValue;
+ case "modeType":
+ return isModeType(value) ? value : defaultValue;
+ case "offScreenUpdateBehaviourType":
+ return isOffScreenUpdateBehaviourType(value) ? value : defaultValue;
+ case "animationsInfo":
+ return castToAnimationsInfo(value) || defaultValue;
+ default:
+ break;
+ }
+}
+
+function castBoolean (value: string | null, defaultValue = "") {
+ return value === "true" || value === "" ? true : false;
+}
+
+function castString (value: string | null, defaultValue = "") {
+ return value === null ? defaultValue : value;
+}
+
+function castNumber (value: string | null, defaultValue = 0) {
+ if (value === null) return defaultValue;
+
+ const parsed = parseFloat(value);
+ if (Number.isNaN(parsed)) return defaultValue;
+ return parsed;
+}
+
+function castArrayNumber (value: string | null, defaultValue = undefined) {
+ if (value === null) return defaultValue;
+ return value.split(",").reduce((acc, pageIndex) => {
+ const index = parseInt(pageIndex);
+ if (!isNaN(index)) acc.push(index);
+ return acc;
+ }, [] as Array);
+}
+
+function castArrayString (value: string | null, defaultValue = undefined) {
+ if (value === null) return defaultValue;
+ return value.split(",");
+}
+
+function castObject (value: string | null, defaultValue = undefined) {
+ if (value === null) return null;
+ return JSON.parse(value);
+}
+
+
+function castToAnimationsInfo (value: string | null): AnimationsInfo | undefined {
+ if (value === null) {
+ return undefined;
+ }
+
+ const matches = value.match(animatonTypeRegExp);
+ if (!matches) return undefined;
+
+ return matches.reduce((obj, group) => {
+ const [trackIndexStringOrLoopDefinition, animationNameOrTrackIndexStringCycle, loop, delayString, mixDurationString] = group.slice(1, -1).split(',').map(v => v.trim());
+
+ if (trackIndexStringOrLoopDefinition === "loop") {
+ if (!Number.isInteger(Number(animationNameOrTrackIndexStringCycle))) {
+ throw new Error(`Track index of cycle in ${group} must be a positive integer number, instead it is ${animationNameOrTrackIndexStringCycle}. Original value: ${value}`);
+ }
+ const animationInfoObject = obj[animationNameOrTrackIndexStringCycle] ||= { animations: [] };
+ animationInfoObject.cycle = true;
+ return obj;
+ }
+
+ const trackIndex = Number(trackIndexStringOrLoopDefinition);
+ if (!Number.isInteger(trackIndex)) {
+ throw new Error(`Track index in ${group} must be a positive integer number, instead it is ${trackIndexStringOrLoopDefinition}. Original value: ${value}`);
+ }
+
+ let delay;
+ if (delayString !== undefined) {
+ delay = parseFloat(delayString);
+ if (isNaN(delay)) {
+ throw new Error(`Delay in ${group} must be a positive number, instead it is ${delayString}. Original value: ${value}`);
+ }
+ }
+
+ let mixDuration;
+ if (mixDurationString !== undefined) {
+ mixDuration = parseFloat(mixDurationString);
+ if (isNaN(mixDuration)) {
+ throw new Error(`mixDuration in ${group} must be a positive number, instead it is ${mixDurationString}. Original value: ${value}`);
+ }
+ }
+
+ const animationInfoObject = obj[trackIndexStringOrLoopDefinition] ||= { animations: [] };
+ animationInfoObject.animations.push({
+ animationName: animationNameOrTrackIndexStringCycle,
+ loop: loop.trim().toLowerCase() === "true",
+ delay,
+ mixDuration,
+ });
+ return obj;
+ }, {} as AnimationsInfo);
+}
+
+function isFitType (value: string | null): value is FitType {
+ return (
+ value === "fill" ||
+ value === "width" ||
+ value === "height" ||
+ value === "contain" ||
+ value === "cover" ||
+ value === "none" ||
+ value === "scaleDown"
+ );
+}
+
+function isOffScreenUpdateBehaviourType (value: string | null): value is OffScreenUpdateBehaviourType {
+ return (
+ value === "pause" ||
+ value === "update" ||
+ value === "pose"
+ );
+}
+
+function isModeType (value: string | null): value is ModeType {
+ return (
+ value === "inside" ||
+ value === "origin"
+ );
+}
+const base64RegExp = /^(([A-Za-z0-9+/]{4})*([A-Za-z0-9+/]{4}|[A-Za-z0-9+/]{3}=|[A-Za-z0-9+/]{2}==))$/;
+export function isBase64 (str: string) {
+ return base64RegExp.test(str);
+}
+
+export interface Point {
+ x: number,
+ y: number,
+}
+
+export interface Rectangle extends Point {
+ width: number,
+ height: number,
+}
\ No newline at end of file