SpineWebComponentOverlay.ts 38 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556557558559560561562563564565566567568569570571572573574575576577578579580581582583584585586587588589590591592593594595596597598599600601602603604605606607608609610611612613614615616617618619620621622623624625626627628629630631632633634635636637638639640641642643644645646647648649650651652653654655656657658659660661662663664665666667668669670671672673674675676677678679680681682683684685686687688689690691692693694695696697698699700701702703704705706707708709710711712713714715716717718719720721722723724725726727728729730731732733734735736737738739740741742743744745746747748749750751752753754755756757758759760761762763764765766767768769770771772773774775776777778779780781782783784785786787788789790791792793794795796797798799800801802803804805806807808809810811812813814815816817818819820821822823824825826827828829830831832833834835836837838839840841842843844845846847848849850851852853854855856857858859860861862863864865866867868869870871872873874875876877878879880881882883884885886887888889890891892893894895896897898899900901902903904905906907908909910911912913914915916917918919920921922923924925926927928929930931932933934935936937938939940941942943944945946947948949950951952953954955956957958959960961962963964965966967968969970971972973974975976977978979980981982983984985986987988989990991992993994995996997998999100010011002100310041005100610071008100910101011101210131014101510161017101810191020102110221023102410251026102710281029103010311032103310341035103610371038103910401041104210431044104510461047104810491050105110521053105410551056105710581059
  1. /******************************************************************************
  2. * Spine Runtimes License Agreement
  3. * Last updated July 28, 2023. Replaces all prior versions.
  4. *
  5. * Copyright (c) 2013-2023, Esoteric Software LLC
  6. *
  7. * Integration of the Spine Runtimes into software or otherwise creating
  8. * derivative works of the Spine Runtimes is permitted under the terms and
  9. * conditions of Section 2 of the Spine Editor License Agreement:
  10. * http://esotericsoftware.com/spine-editor-license
  11. *
  12. * Otherwise, it is permitted to integrate the Spine Runtimes into software or
  13. * otherwise create derivative works of the Spine Runtimes (collectively,
  14. * "Products"), provided that each user of the Products must obtain their own
  15. * Spine Editor license and redistribution of the Products in any form must
  16. * include this license and copyright notice.
  17. *
  18. * THE SPINE RUNTIMES ARE PROVIDED BY ESOTERIC SOFTWARE LLC "AS IS" AND ANY
  19. * EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED
  20. * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE
  21. * DISCLAIMED. IN NO EVENT SHALL ESOTERIC SOFTWARE LLC BE LIABLE FOR ANY
  22. * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES
  23. * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES,
  24. * BUSINESS INTERRUPTION, OR LOSS OF USE, DATA, OR PROFITS) HOWEVER CAUSED AND
  25. * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT
  26. * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THE
  27. * SPINE RUNTIMES, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE.
  28. *****************************************************************************/
  29. import { AssetManager, Color, Disposable, Input, LoadingScreen, ManagedWebGLRenderingContext, Physics, SceneRenderer, TimeKeeper, Vector2, Vector3 } from "@esotericsoftware/spine-webgl"
  30. import { SpineWebComponentSkeleton } from "./SpineWebComponentSkeleton.js"
  31. import { AttributeTypes, castValue, Point, Rectangle } from "./wcUtils.js"
  32. interface OverlayAttributes {
  33. overlayId?: string
  34. noAutoParentTransform: boolean
  35. overflowTop: number
  36. overflowBottom: number
  37. overflowLeft: number
  38. overflowRight: number
  39. }
  40. export class SpineWebComponentOverlay extends HTMLElement implements OverlayAttributes, Disposable {
  41. private static OVERLAY_ID = "spine-overlay-default-identifier";
  42. private static OVERLAY_LIST = new Map<string, SpineWebComponentOverlay>();
  43. /**
  44. * @internal
  45. */
  46. static getOrCreateOverlay (overlayId: string | null): SpineWebComponentOverlay {
  47. let overlay = SpineWebComponentOverlay.OVERLAY_LIST.get(overlayId || SpineWebComponentOverlay.OVERLAY_ID);
  48. if (!overlay) {
  49. overlay = document.createElement('spine-overlay') as SpineWebComponentOverlay;
  50. overlay.setAttribute('overlay-id', SpineWebComponentOverlay.OVERLAY_ID);
  51. document.body.appendChild(overlay);
  52. }
  53. return overlay;
  54. }
  55. /**
  56. * If true, enables a top-left span showing FPS (it has black text)
  57. */
  58. public static SHOW_FPS = false;
  59. /**
  60. * A list holding the widgets added to this overlay.
  61. */
  62. public widgets = new Array<SpineWebComponentSkeleton>();
  63. /**
  64. * The {@link SceneRenderer} used by this overlay.
  65. */
  66. public renderer: SceneRenderer;
  67. /**
  68. * The {@link AssetManager} used by this overlay.
  69. */
  70. public assetManager: AssetManager;
  71. /**
  72. * The identifier of this overlay. This is necessary when multiply overlay are created.
  73. * Connected to `overlay-id` attribute.
  74. */
  75. public overlayId?: string;
  76. /**
  77. * If `false` (default value), the overlay container style will be affected adding `transform: translateZ(0);` to it.
  78. * The `transform` is not affected if it already exists on the container.
  79. * 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}.
  80. * Connected to `no-auto-parent-transform` attribute.
  81. */
  82. public noAutoParentTransform = false;
  83. /**
  84. * The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
  85. * If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
  86. * This parameter defines, as percentage of the viewport height, the pixels to add to the top of the canvas to prevent this effect.
  87. * Making the canvas too big might reduce performance.
  88. * Default value: 0.2.
  89. * Connected to `overflow-top` attribute.
  90. */
  91. public overflowTop = .2;
  92. /**
  93. * The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
  94. * If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
  95. * This parameter defines, as percentage of the viewport height, the pixels to add to the bottom of the canvas to prevent this effect.
  96. * Making the canvas too big might reduce performance.
  97. * Default value: 0.
  98. * Connected to `overflow-bottom` attribute.
  99. */
  100. public overflowBottom = .0;
  101. /**
  102. * The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
  103. * If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
  104. * This parameter defines, as percentage of the viewport width, the pixels to add to the left of the canvas to prevent this effect.
  105. * Making the canvas too big might reduce performance.
  106. * Default value: 0.
  107. * Connected to `overflow-left` attribute.
  108. */
  109. public overflowLeft = .0;
  110. /**
  111. * The canvas is continuously translated so that it covers the viewport. This translation might be slightly slower during fast scrolling.
  112. * If the canvas has the same size as the viewport, while scrolling it might be slighlty misaligned with the viewport.
  113. * This parameter defines, as percentage of the viewport width, the pixels to add to the right of the canvas to prevent this effect.
  114. * Making the canvas too big might reduce performance.
  115. * Default value: 0.
  116. * Connected to `overflow-right` attribute.
  117. */
  118. public overflowRight = .0;
  119. private root: ShadowRoot;
  120. private div: HTMLDivElement;
  121. private boneFollowersParent: HTMLDivElement;
  122. private canvas: HTMLCanvasElement;
  123. private fps: HTMLSpanElement;
  124. private fpsAppended = false;
  125. private intersectionObserver?: IntersectionObserver;
  126. private resizeObserver?: ResizeObserver;
  127. private input?: Input;
  128. private overflowLeftSize = 0;
  129. private overflowTopSize = 0;
  130. private lastCanvasBaseWidth = 0;
  131. private lastCanvasBaseHeight = 0;
  132. private zIndex?: number;
  133. private disposed = false;
  134. private loaded = false;
  135. private running = false;
  136. private visible = true;
  137. /**
  138. * appendedToBody is assegned in the connectedCallback.
  139. * When false, the overlay will have the size of the element container in contrast to the default behaviour where the
  140. * overlay has always the size of the viewport.
  141. * This is necessary when the overlay is inserted into a container that scroll in a different way with respect to the page.
  142. * Otherwise the following problems might occur:
  143. * 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.
  144. * 2) For containers appendedToBody, the widget will overflow the container bounds until the widget html element container is visible
  145. * 3) For fixed containers, the widget will scroll in a jerky way
  146. *
  147. * In order to fix this behaviour, it is necessary to insert a dedicated `spine-overlay` webcomponent as a direct child of the container.
  148. * Moreover, it is necessary to perform the following actions:
  149. * 1) The appendedToBody container must have a `transform` css attribute. If it hasn't this attribute the `spine-overlay` will add it for you.
  150. * If your appendedToBody container has already this css attribute, or if you prefer to add it by yourself (example: `transform: translateZ(0);`), set the `no-auto-parent-transform` to the `spine-overlay`.
  151. * 2) The `spine-overlay` must have an `overlay-id` attribute. Choose the value you prefer.
  152. * 3) Each `spine-skeleton` must have an `overlay-id` attribute. The same as the hosting `spine-overlay`.
  153. * Connected to `appendedToBody` attribute.
  154. */
  155. private appendedToBody = true;
  156. private hasParentTransform = true;
  157. readonly time = new TimeKeeper();
  158. constructor () {
  159. super();
  160. this.root = this.attachShadow({ mode: "open" });
  161. this.div = document.createElement("div");
  162. this.div.style.position = "absolute";
  163. this.div.style.top = "0";
  164. this.div.style.left = "0";
  165. this.div.style.setProperty("pointer-events", "none");
  166. this.div.style.overflow = "hidden"
  167. // this.div.style.backgroundColor = "rgba(0, 255, 0, 0.1)";
  168. this.root.appendChild(this.div);
  169. this.canvas = document.createElement("canvas");
  170. this.boneFollowersParent = document.createElement("div");
  171. this.div.appendChild(this.canvas);
  172. this.canvas.style.position = "absolute";
  173. this.canvas.style.top = "0";
  174. this.canvas.style.left = "0";
  175. this.div.appendChild(this.boneFollowersParent);
  176. this.boneFollowersParent.style.position = "absolute";
  177. this.boneFollowersParent.style.top = "0";
  178. this.boneFollowersParent.style.left = "0";
  179. this.boneFollowersParent.style.whiteSpace = "nowrap";
  180. this.boneFollowersParent.style.setProperty("pointer-events", "none");
  181. this.boneFollowersParent.style.transform = `translate(0px,0px)`;
  182. this.canvas.style.setProperty("pointer-events", "none");
  183. this.canvas.style.transform = `translate(0px,0px)`;
  184. // this.canvas.style.setProperty("will-change", "transform"); // performance seems to be even worse with this uncommented
  185. this.fps = document.createElement("span");
  186. this.fps.style.position = "fixed";
  187. this.fps.style.top = "0";
  188. this.fps.style.left = "0";
  189. const context = new ManagedWebGLRenderingContext(this.canvas, { alpha: true });
  190. this.renderer = new SceneRenderer(this.canvas, context);
  191. this.assetManager = new AssetManager(context);
  192. }
  193. connectedCallback (): void {
  194. this.appendedToBody = this.parentElement === document.body;
  195. let overlayId = this.getAttribute('overlay-id');
  196. if (!overlayId) {
  197. overlayId = SpineWebComponentOverlay.OVERLAY_ID;
  198. this.setAttribute('overlay-id', overlayId);
  199. }
  200. const existingOverlay = SpineWebComponentOverlay.OVERLAY_LIST.get(overlayId);
  201. if (existingOverlay && existingOverlay !== this) {
  202. throw new Error(`"SpineWebComponentOverlay - You cannot have two spine-overlay with the same overlay-id: ${overlayId}"`);
  203. }
  204. SpineWebComponentOverlay.OVERLAY_LIST.set(overlayId, this);
  205. // window.addEventListener("scroll", this.scrolledCallback);
  206. if (document.readyState !== "complete") {
  207. window.addEventListener("load", this.loadedCallback);
  208. } else {
  209. this.loadedCallback();
  210. }
  211. window.screen.orientation.addEventListener('change', this.orientationChangedCallback);
  212. this.intersectionObserver = new IntersectionObserver((widgets) => {
  213. for (const elem of widgets) {
  214. const { target, intersectionRatio } = elem;
  215. let { isIntersecting } = elem;
  216. for (const widget of this.widgets) {
  217. if (widget.getHostElement() != target) continue;
  218. // old browsers do not have isIntersecting
  219. if (isIntersecting === undefined) {
  220. isIntersecting = intersectionRatio > 0;
  221. }
  222. widget.onScreen = isIntersecting;
  223. if (isIntersecting) {
  224. widget.onScreenFunction(widget);
  225. widget.onScreenAtLeastOnce = true;
  226. }
  227. }
  228. }
  229. }, { rootMargin: "30px 20px 30px 20px" });
  230. // if the element is not appendedToBody, the user does not disable translate tweak, and the parent did not have already a transform, add the tweak
  231. if (!this.appendedToBody) {
  232. if (this.hasCssTweakOff()) {
  233. this.hasParentTransform = false;
  234. } else {
  235. this.parentElement!.style.transform = `translateZ(0)`;
  236. }
  237. } else {
  238. window.addEventListener("resize", this.windowResizeCallback);
  239. }
  240. this.resizeObserver = new ResizeObserver(() => this.resizedCallback());
  241. this.resizeObserver.observe(this.parentElement!);
  242. for (const widget of this.widgets) {
  243. this.intersectionObserver?.observe(widget.getHostElement());
  244. }
  245. this.input = this.setupDragUtility();
  246. document.addEventListener('visibilitychange', this.visibilityChangeCallback);
  247. this.startRenderingLoop();
  248. }
  249. disconnectedCallback (): void {
  250. const id = this.getAttribute('overlay-id');
  251. if (id) SpineWebComponentOverlay.OVERLAY_LIST.delete(id);
  252. // window.removeEventListener("scroll", this.scrolledCallback);
  253. window.removeEventListener("load", this.loadedCallback);
  254. window.removeEventListener("resize", this.windowResizeCallback);
  255. document.removeEventListener('visibilitychange', this.visibilityChangeCallback);
  256. window.screen.orientation.removeEventListener('change', this.orientationChangedCallback);
  257. this.intersectionObserver?.disconnect();
  258. this.resizeObserver?.disconnect();
  259. this.input?.dispose();
  260. }
  261. static attributesDescription: Record<string, { propertyName: keyof OverlayAttributes, type: AttributeTypes, defaultValue?: any }> = {
  262. "overlay-id": { propertyName: "overlayId", type: "string" },
  263. "no-auto-parent-transform": { propertyName: "noAutoParentTransform", type: "boolean" },
  264. "overflow-top": { propertyName: "overflowTop", type: "number" },
  265. "overflow-bottom": { propertyName: "overflowBottom", type: "number" },
  266. "overflow-left": { propertyName: "overflowLeft", type: "number" },
  267. "overflow-right": { propertyName: "overflowRight", type: "number" },
  268. }
  269. static get observedAttributes (): string[] {
  270. return Object.keys(SpineWebComponentOverlay.attributesDescription);
  271. }
  272. attributeChangedCallback (name: string, oldValue: string | null, newValue: string | null): void {
  273. const { type, propertyName, defaultValue } = SpineWebComponentOverlay.attributesDescription[name];
  274. const val = castValue(type, newValue, defaultValue);
  275. (this as any)[propertyName] = val;
  276. return;
  277. }
  278. private visibilityChangeCallback = () => {
  279. if (document.hidden) {
  280. this.visible = false;
  281. } else {
  282. this.visible = true;
  283. this.startRenderingLoop();
  284. }
  285. }
  286. private windowResizeCallback = () => this.resizedCallback(true);
  287. private resizedCallback = (onlyDiv = false) => {
  288. this.updateCanvasSize(onlyDiv);
  289. }
  290. private orientationChangedCallback = () => {
  291. this.updateCanvasSize();
  292. // after an orientation change the scrolling changes, but the scroll event does not fire
  293. this.scrolledCallback();
  294. }
  295. // right now, we scroll the canvas each frame before rendering loop, that makes scrolling on mobile waaay more smoother
  296. // this is way scroll handler do nothing
  297. private scrolledCallback = () => {
  298. // this.translateCanvas();
  299. }
  300. private loadedCallback = () => {
  301. this.updateCanvasSize();
  302. this.scrolledCallback();
  303. if (!this.loaded) {
  304. this.loaded = true;
  305. this.parentElement!.appendChild(this);
  306. }
  307. }
  308. private hasCssTweakOff () {
  309. return this.noAutoParentTransform && getComputedStyle(this.parentElement!).transform === "none";
  310. }
  311. /**
  312. * Remove the overlay from the DOM, dispose all the contained widgets, and dispose the renderer.
  313. */
  314. dispose (): void {
  315. for (const widget of [...this.widgets]) widget.dispose();
  316. this.remove();
  317. this.widgets.length = 0;
  318. this.renderer.dispose();
  319. this.disposed = true;
  320. this.assetManager.dispose();
  321. }
  322. /**
  323. * Add the widget to the overlay.
  324. * If the widget is after the overlay in the DOM, the overlay is appended after the widget.
  325. * @param widget The widget to add to the overlay
  326. */
  327. addWidget (widget: SpineWebComponentSkeleton) {
  328. this.widgets.push(widget);
  329. this.intersectionObserver?.observe(widget.getHostElement());
  330. if (this.loaded) {
  331. const comparison = this.compareDocumentPosition(widget);
  332. // DOCUMENT_POSITION_DISCONNECTED is needed when a widget is inside the overlay (due to followBone)
  333. if ((comparison & Node.DOCUMENT_POSITION_FOLLOWING) && !(comparison & Node.DOCUMENT_POSITION_DISCONNECTED)) {
  334. this.parentElement!.appendChild(this);
  335. }
  336. }
  337. this.updateZIndexIfNecessary(widget);
  338. }
  339. /**
  340. * Remove the widget from the overlay.
  341. * @param widget The widget to remove from the overlay
  342. */
  343. removeWidget (widget: SpineWebComponentSkeleton) {
  344. const index = this.widgets.findIndex(w => w === widget);
  345. if (index === -1) return false;
  346. this.widgets.splice(index);
  347. this.intersectionObserver?.unobserve(widget.getHostElement());
  348. return true;
  349. }
  350. addSlotFollowerElement (element: HTMLElement) {
  351. this.boneFollowersParent.appendChild(element);
  352. this.resizedCallback();
  353. }
  354. private tempFollowBoneVector = new Vector3();
  355. private startRenderingLoop () {
  356. if (this.running) return;
  357. const updateWidgets = () => {
  358. const delta = this.time.delta;
  359. for (const { skeleton, state, update, onScreen, offScreenUpdateBehaviour, beforeUpdateWorldTransforms, afterUpdateWorldTransforms } of this.widgets) {
  360. if (!skeleton || !state) continue;
  361. if (!onScreen && offScreenUpdateBehaviour === "pause") continue;
  362. if (update) update(delta, skeleton, state)
  363. else {
  364. // delta = 0
  365. state.update(delta);
  366. skeleton.update(delta);
  367. if (onScreen || (!onScreen && offScreenUpdateBehaviour === "pose")) {
  368. state.apply(skeleton);
  369. beforeUpdateWorldTransforms(delta, skeleton, state);
  370. skeleton.updateWorldTransform(Physics.update);
  371. afterUpdateWorldTransforms(delta, skeleton, state);
  372. }
  373. }
  374. }
  375. // fps top-left span
  376. if (SpineWebComponentOverlay.SHOW_FPS) {
  377. if (!this.fpsAppended) {
  378. this.div.appendChild(this.fps);
  379. this.fpsAppended = true;
  380. }
  381. this.fps.innerText = this.time.framesPerSecond.toFixed(2) + " fps";
  382. } else {
  383. if (this.fpsAppended) {
  384. this.div.removeChild(this.fps);
  385. this.fpsAppended = false;
  386. }
  387. }
  388. };
  389. const clear = (r: number, g: number, b: number, a: number) => {
  390. this.renderer.context.gl.clearColor(r, g, b, a);
  391. this.renderer.context.gl.clear(this.renderer.context.gl.COLOR_BUFFER_BIT);
  392. }
  393. const startScissor = (divBounds: Rectangle) => {
  394. this.renderer.end();
  395. this.renderer.begin();
  396. this.renderer.context.gl.enable(this.renderer.context.gl.SCISSOR_TEST);
  397. this.renderer.context.gl.scissor(
  398. this.screenToWorldLength(divBounds.x),
  399. this.canvas.height - this.screenToWorldLength(divBounds.y + divBounds.height),
  400. this.screenToWorldLength(divBounds.width),
  401. this.screenToWorldLength(divBounds.height)
  402. );
  403. }
  404. const endScissor = () => {
  405. this.renderer.end();
  406. this.renderer.context.gl.disable(this.renderer.context.gl.SCISSOR_TEST);
  407. this.renderer.begin();
  408. }
  409. const renderWidgets = () => {
  410. clear(0, 0, 0, 0);
  411. let renderer = this.renderer;
  412. renderer.begin();
  413. let ref: DOMRect;
  414. let offsetLeftForOevrlay = 0;
  415. let offsetTopForOverlay = 0;
  416. if (!this.appendedToBody) {
  417. ref = this.parentElement!.getBoundingClientRect();
  418. const computedStyle = getComputedStyle(this.parentElement!);
  419. offsetLeftForOevrlay = ref.left + parseFloat(computedStyle.borderLeftWidth);
  420. offsetTopForOverlay = ref.top + parseFloat(computedStyle.borderTopWidth);
  421. }
  422. const tempVector = new Vector3();
  423. for (const widget of this.widgets) {
  424. const { skeleton, pma, bounds, debug, offsetX, offsetY, dragX, dragY, fit, spinner, loading, clip, drag } = widget;
  425. if (widget.isOffScreenAndWasMoved()) continue;
  426. const elementRef = widget.getHostElement();
  427. const divBounds = elementRef.getBoundingClientRect();
  428. // need to use left and top, because x and y are not available on older browser
  429. divBounds.x = divBounds.left + this.overflowLeftSize;
  430. divBounds.y = divBounds.top + this.overflowTopSize;
  431. if (!this.appendedToBody) {
  432. divBounds.x -= offsetLeftForOevrlay;
  433. divBounds.y -= offsetTopForOverlay;
  434. }
  435. const { padLeft, padRight, padTop, padBottom, xAxis, yAxis } = widget
  436. const paddingShiftHorizontal = (padLeft - padRight) / 2;
  437. const paddingShiftVertical = (padTop - padBottom) / 2;
  438. // get the desired point into the the div (center by default) in world coordinate
  439. const divX = divBounds.x + divBounds.width * ((xAxis + .5) + paddingShiftHorizontal);
  440. const divY = divBounds.y + divBounds.height * ((-yAxis + .5) + paddingShiftVertical) - 1;
  441. this.screenToWorld(tempVector, divX, divY);
  442. let divOriginX = tempVector.x;
  443. let divOriginY = tempVector.y;
  444. const paddingShrinkWidth = 1 - (padLeft + padRight);
  445. const paddingShrinkHeight = 1 - (padTop + padBottom);
  446. const divWidthWorld = this.screenToWorldLength(divBounds.width * paddingShrinkWidth);
  447. const divHeightWorld = this.screenToWorldLength(divBounds.height * paddingShrinkHeight);
  448. if (clip) startScissor(divBounds);
  449. if (loading) {
  450. if (spinner) {
  451. if (!widget.loadingScreen) widget.loadingScreen = new LoadingScreen(renderer);
  452. widget.loadingScreen!.drawInCoordinates(divOriginX, divOriginY);
  453. }
  454. if (clip) endScissor();
  455. continue;
  456. }
  457. if (skeleton) {
  458. if (fit !== "origin") {
  459. let { x: ax, y: ay, width: aw, height: ah } = bounds;
  460. if (aw <= 0 || ah <= 0) continue;
  461. // scale ratio
  462. const scaleWidth = divWidthWorld / aw;
  463. const scaleHeight = divHeightWorld / ah;
  464. // default value is used for fit = none
  465. let ratioW = skeleton.scaleX;
  466. let ratioH = skeleton.scaleY;
  467. if (fit === "fill") { // Fill the target box by distorting the source's aspect ratio.
  468. ratioW = scaleWidth;
  469. ratioH = scaleHeight;
  470. } else if (fit === "width") {
  471. ratioW = scaleWidth;
  472. ratioH = scaleWidth;
  473. } else if (fit === "height") {
  474. ratioW = scaleHeight;
  475. ratioH = scaleHeight;
  476. } else if (fit === "contain") {
  477. // if scaled height is bigger than div height, use height ratio instead
  478. if (ah * scaleWidth > divHeightWorld) {
  479. ratioW = scaleHeight;
  480. ratioH = scaleHeight;
  481. } else {
  482. ratioW = scaleWidth;
  483. ratioH = scaleWidth;
  484. }
  485. } else if (fit === "cover") {
  486. if (ah * scaleWidth < divHeightWorld) {
  487. ratioW = scaleHeight;
  488. ratioH = scaleHeight;
  489. } else {
  490. ratioW = scaleWidth;
  491. ratioH = scaleWidth;
  492. }
  493. } else if (fit === "scaleDown") {
  494. if (aw > divWidthWorld || ah > divHeightWorld) {
  495. if (ah * scaleWidth > divHeightWorld) {
  496. ratioW = scaleHeight;
  497. ratioH = scaleHeight;
  498. } else {
  499. ratioW = scaleWidth;
  500. ratioH = scaleWidth;
  501. }
  502. }
  503. }
  504. // get the center of the bounds
  505. const boundsX = (ax + aw / 2) * ratioW;
  506. const boundsY = (ay + ah / 2) * ratioH;
  507. // get vertices offset: calculate the distance between div center and bounds center
  508. divOriginX = divOriginX - boundsX;
  509. divOriginY = divOriginY - boundsY;
  510. // scale the skeleton
  511. if (fit !== "none" && (skeleton.scaleX !== ratioW || skeleton.scaleY !== ratioH)) {
  512. skeleton.scaleX = ratioW;
  513. skeleton.scaleY = ratioH;
  514. skeleton.updateWorldTransform(Physics.update);
  515. }
  516. }
  517. // const worldOffsetX = divOriginX + offsetX + dragX;
  518. const worldOffsetX = divOriginX + offsetX * window.devicePixelRatio + dragX;
  519. const worldOffsetY = divOriginY + offsetY * window.devicePixelRatio + dragY;
  520. widget.worldX = worldOffsetX;
  521. widget.worldY = worldOffsetY;
  522. renderer.drawSkeleton(skeleton, pma, -1, -1, (vertices, size, vertexSize) => {
  523. for (let i = 0; i < size; i += vertexSize) {
  524. vertices[i] = vertices[i] + worldOffsetX;
  525. vertices[i + 1] = vertices[i + 1] + worldOffsetY;
  526. }
  527. });
  528. // drawing debug stuff
  529. if (debug) {
  530. // if (true) {
  531. let { x: ax, y: ay, width: aw, height: ah } = bounds;
  532. // show bounds and its center
  533. if (drag) {
  534. renderer.rect(true,
  535. ax * skeleton.scaleX + worldOffsetX,
  536. ay * skeleton.scaleY + worldOffsetY,
  537. aw * skeleton.scaleX,
  538. ah * skeleton.scaleY,
  539. transparentRed);
  540. }
  541. renderer.rect(false,
  542. ax * skeleton.scaleX + worldOffsetX,
  543. ay * skeleton.scaleY + worldOffsetY,
  544. aw * skeleton.scaleX,
  545. ah * skeleton.scaleY,
  546. blue);
  547. const bbCenterX = (ax + aw / 2) * skeleton.scaleX + worldOffsetX;
  548. const bbCenterY = (ay + ah / 2) * skeleton.scaleY + worldOffsetY;
  549. renderer.circle(true, bbCenterX, bbCenterY, 10, blue);
  550. // show skeleton root
  551. const root = skeleton.getRootBone()!;
  552. renderer.circle(true, root.x + worldOffsetX, root.y + worldOffsetY, 10, red);
  553. // show shifted origin
  554. renderer.circle(true, divOriginX, divOriginY, 10, green);
  555. // show line from origin to bounds center
  556. renderer.line(divOriginX, divOriginY, bbCenterX, bbCenterY, green);
  557. }
  558. if (clip) endScissor();
  559. }
  560. }
  561. renderer.end();
  562. }
  563. const updateBoneFollowers = () => {
  564. for (const widget of this.widgets) {
  565. if (widget.isOffScreenAndWasMoved() || !widget.skeleton) continue;
  566. for (const boneFollower of widget.boneFollowerList) {
  567. const { slot, bone, element, followVisibility, followRotation, followOpacity, followScale } = boneFollower;
  568. const { worldX, worldY } = widget;
  569. this.worldToScreen(this.tempFollowBoneVector, bone.worldX + worldX, bone.worldY + worldY);
  570. if (Number.isNaN(this.tempFollowBoneVector.x)) continue;
  571. let x = this.tempFollowBoneVector.x - this.overflowLeftSize;
  572. let y = this.tempFollowBoneVector.y - this.overflowTopSize;
  573. if (this.appendedToBody) {
  574. x += window.scrollX;
  575. y += window.scrollY;
  576. }
  577. element.style.transform = `translate(calc(-50% + ${x.toFixed(2)}px),calc(-50% + ${y.toFixed(2)}px))`
  578. + (followRotation ? ` rotate(${-bone.getWorldRotationX()}deg)` : "")
  579. + (followScale ? ` scale(${bone.getWorldScaleX()}, ${bone.getWorldScaleY()})` : "")
  580. ;
  581. element.style.display = ""
  582. if (followVisibility && !slot.attachment) {
  583. element.style.opacity = "0";
  584. } else if (followOpacity) {
  585. element.style.opacity = `${slot.color.a}`;
  586. }
  587. }
  588. }
  589. }
  590. const loop = () => {
  591. if (this.disposed || !this.isConnected || !this.visible) {
  592. this.running = false;
  593. return;
  594. };
  595. requestAnimationFrame(loop);
  596. if (!this.loaded) return;
  597. this.time.update();
  598. this.translateCanvas();
  599. updateWidgets();
  600. renderWidgets();
  601. updateBoneFollowers();
  602. }
  603. requestAnimationFrame(loop);
  604. this.running = true;
  605. const red = new Color(1, 0, 0, 1);
  606. const green = new Color(0, 1, 0, 1);
  607. const blue = new Color(0, 0, 1, 1);
  608. const transparentWhite = new Color(1, 1, 1, .3);
  609. const transparentRed = new Color(1, 0, 0, .3);
  610. }
  611. public pointerCanvasX = 1;
  612. public pointerCanvasY = 1;
  613. public pointerWorldX = 1;
  614. public pointerWorldY = 1;
  615. private tempVector = new Vector3();
  616. private updatePointer (input: Point) {
  617. this.pointerCanvasX = input.x - window.scrollX;
  618. this.pointerCanvasY = input.y - window.scrollY;
  619. if (!this.appendedToBody) {
  620. const ref = this.parentElement!.getBoundingClientRect();
  621. this.pointerCanvasX -= ref.left;
  622. this.pointerCanvasY -= ref.top;
  623. }
  624. let tempVector = this.tempVector;
  625. tempVector.set(this.pointerCanvasX, this.pointerCanvasY, 0);
  626. this.renderer.camera.screenToWorld(tempVector, this.canvas.clientWidth, this.canvas.clientHeight);
  627. if (Number.isNaN(tempVector.x) || Number.isNaN(tempVector.y)) return;
  628. this.pointerWorldX = tempVector.x;
  629. this.pointerWorldY = tempVector.y;
  630. }
  631. private updateWidgetPointer (widget: SpineWebComponentSkeleton): boolean {
  632. if (widget.worldX === Infinity) return false;
  633. widget.pointerWorldX = this.pointerWorldX - widget.worldX;
  634. widget.pointerWorldY = this.pointerWorldY - widget.worldY;
  635. return true;
  636. }
  637. private setupDragUtility (): Input {
  638. // TODO: we should use document - body might have some margin that offset the click events - Meanwhile I take event pageX/Y
  639. const inputManager = new Input(document.body, false)
  640. const inputPointTemp: Point = new Vector2();
  641. const getInput = (ev?: MouseEvent | TouchEvent): Point => {
  642. const originalEvent = ev instanceof MouseEvent ? ev : ev!.changedTouches[0];
  643. inputPointTemp.x = originalEvent.pageX + this.overflowLeftSize;
  644. inputPointTemp.y = originalEvent.pageY + this.overflowTopSize;
  645. return inputPointTemp;
  646. }
  647. let lastX = 0;
  648. let lastY = 0;
  649. inputManager.addListener({
  650. // moved is used to pass pointer position wrt to canvas and widget position and currently is EXPERIMENTAL
  651. moved: (x, y, ev) => {
  652. const input = getInput(ev);
  653. this.updatePointer(input);
  654. for (const widget of this.widgets) {
  655. if (!this.updateWidgetPointer(widget) || !widget.onScreen) continue;
  656. widget.pointerEventUpdate("move", ev);
  657. }
  658. },
  659. down: (x, y, ev) => {
  660. const input = getInput(ev);
  661. this.updatePointer(input);
  662. for (const widget of this.widgets) {
  663. if (!this.updateWidgetPointer(widget) || widget.isOffScreenAndWasMoved()) continue;
  664. widget.pointerEventUpdate("down", ev);
  665. if ((widget.interactive && widget.pointerInsideBounds) || (!widget.interactive && widget.isPointerInsideBounds())) {
  666. if (!widget.drag) continue;
  667. widget.dragging = true;
  668. ev?.preventDefault();
  669. }
  670. }
  671. lastX = input.x;
  672. lastY = input.y;
  673. },
  674. dragged: (x, y, ev) => {
  675. const input = getInput(ev);
  676. let dragX = input.x - lastX;
  677. let dragY = input.y - lastY;
  678. this.updatePointer(input);
  679. for (const widget of this.widgets) {
  680. if (!this.updateWidgetPointer(widget) || widget.isOffScreenAndWasMoved()) continue;
  681. widget.pointerEventUpdate("drag", ev);
  682. if (!widget.dragging) continue;
  683. const skeleton = widget.skeleton!;
  684. widget.dragX += this.screenToWorldLength(dragX);
  685. widget.dragY -= this.screenToWorldLength(dragY);
  686. skeleton.physicsTranslate(dragX, -dragY);
  687. ev?.preventDefault();
  688. ev?.stopPropagation();
  689. }
  690. lastX = input.x;
  691. lastY = input.y;
  692. },
  693. up: (x, y, ev) => {
  694. for (const widget of this.widgets) {
  695. widget.dragging = false;
  696. if (widget.pointerInsideBounds) {
  697. widget.pointerEventUpdate("up", ev);
  698. }
  699. }
  700. }
  701. });
  702. return inputManager;
  703. }
  704. /*
  705. * Resize/scroll utilities
  706. */
  707. private updateCanvasSize (onlyDiv = false) {
  708. const { width, height } = this.getViewportSize();
  709. // if the target width/height changes, resize the canvas.
  710. if (!onlyDiv && this.lastCanvasBaseWidth !== width || this.lastCanvasBaseHeight !== height) {
  711. this.lastCanvasBaseWidth = width;
  712. this.lastCanvasBaseHeight = height;
  713. this.overflowLeftSize = this.overflowLeft * width;
  714. this.overflowTopSize = this.overflowTop * height;
  715. const totalWidth = width * (1 + (this.overflowLeft + this.overflowRight));
  716. const totalHeight = height * (1 + (this.overflowTop + this.overflowBottom));
  717. this.canvas.style.width = totalWidth + "px";
  718. this.canvas.style.height = totalHeight + "px";
  719. this.resize(totalWidth, totalHeight);
  720. }
  721. // temporarely remove the div to get the page size without considering the div
  722. // this is necessary otherwise if the bigger element in the page is remove and the div
  723. // was the second bigger element, now it would be the div to determine the page size
  724. // this.div?.remove(); is it better width/height to zero?
  725. // this.div!.style.width = 0 + "px";
  726. // this.div!.style.height = 0 + "px";
  727. this.div!.style.display = "none";
  728. if (this.appendedToBody) {
  729. const { width, height } = this.getPageSize();
  730. this.div!.style.width = width + "px";
  731. this.div!.style.height = height + "px";
  732. } else {
  733. if (this.hasCssTweakOff()) {
  734. // this case lags if scrolls or position fixed. Users should never use tweak off
  735. this.div!.style.width = this.parentElement!.clientWidth + "px";
  736. this.div!.style.height = this.parentElement!.clientHeight + "px";
  737. this.canvas.style.transform = `translate(${-this.overflowLeftSize}px,${-this.overflowTopSize}px)`;
  738. } else {
  739. this.div!.style.width = this.parentElement!.scrollWidth + "px";
  740. this.div!.style.height = this.parentElement!.scrollHeight + "px";
  741. }
  742. }
  743. this.div!.style.display = "";
  744. // this.root.appendChild(this.div!);
  745. }
  746. private resize (width: number, height: number) {
  747. let canvas = this.canvas;
  748. canvas.width = Math.round(this.screenToWorldLength(width));
  749. canvas.height = Math.round(this.screenToWorldLength(height));
  750. this.renderer.context.gl.viewport(0, 0, canvas.width, canvas.height);
  751. this.renderer.camera.setViewport(canvas.width, canvas.height);
  752. this.renderer.camera.update();
  753. }
  754. // we need the bounding client rect otherwise decimals won't be returned
  755. // this means that during zoom it might occurs that the div would be resized
  756. // rounded 1px more making a scrollbar appear
  757. private getPageSize () {
  758. return document.documentElement.getBoundingClientRect();
  759. }
  760. private lastViewportWidth = 0;
  761. private lastViewportHeight = 0;
  762. private lastDPR = 0;
  763. private static readonly WIDTH_INCREMENT = 1.15;
  764. private static readonly HEIGHT_INCREMENT = 1.2;
  765. private static readonly MAX_CANVAS_WIDTH = 7000;
  766. private static readonly MAX_CANVAS_HEIGHT = 7000;
  767. // determine the target viewport width and height.
  768. // The target width/height won't change if the viewport shrink to avoid useless re render (especially re render bursts on mobile)
  769. private getViewportSize (): { width: number, height: number } {
  770. if (!this.appendedToBody) {
  771. return {
  772. width: this.parentElement!.clientWidth,
  773. height: this.parentElement!.clientHeight,
  774. }
  775. }
  776. let width = window.innerWidth;
  777. let height = window.innerHeight;
  778. const dpr = this.getDevicePixelRatio();
  779. if (dpr !== this.lastDPR) {
  780. this.lastDPR = dpr;
  781. this.lastViewportWidth = this.lastViewportWidth === 0 ? width : width * SpineWebComponentOverlay.WIDTH_INCREMENT;
  782. this.lastViewportHeight = height * SpineWebComponentOverlay.HEIGHT_INCREMENT;
  783. this.updateWidgetScales();
  784. } else {
  785. if (width > this.lastViewportWidth) this.lastViewportWidth = width * SpineWebComponentOverlay.WIDTH_INCREMENT;
  786. if (height > this.lastViewportHeight) this.lastViewportHeight = height * SpineWebComponentOverlay.HEIGHT_INCREMENT;
  787. }
  788. // if the resulting canvas width/height is too high, scale the DPI
  789. if (this.lastViewportHeight * (1 + this.overflowTop + this.overflowBottom) * dpr > SpineWebComponentOverlay.MAX_CANVAS_HEIGHT ||
  790. this.lastViewportWidth * (1 + this.overflowLeft + this.overflowRight) * dpr > SpineWebComponentOverlay.MAX_CANVAS_WIDTH) {
  791. this.dprScale += .5;
  792. return this.getViewportSize();
  793. }
  794. return {
  795. width: this.lastViewportWidth,
  796. height: this.lastViewportHeight,
  797. }
  798. }
  799. /**
  800. * @internal
  801. */
  802. public getDevicePixelRatio () {
  803. return window.devicePixelRatio / this.dprScale;
  804. }
  805. private dprScale = 1;
  806. private updateWidgetScales () {
  807. for (const widget of this.widgets) {
  808. // inside mode scale automatically to fit the skeleton within its parent
  809. if (widget.fit !== "origin" && widget.fit !== "none") continue;
  810. const skeleton = widget.skeleton;
  811. if (!skeleton) continue;
  812. // I'm not sure about this. With mode origin and fit none:
  813. // 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
  814. // case 2) Otherwise, the skeleton is loaded always at the same size, but changes size while zooming
  815. const scale = this.getDevicePixelRatio();
  816. skeleton.scaleX = skeleton.scaleX / widget.dprScale * scale;
  817. skeleton.scaleY = skeleton.scaleY / widget.dprScale * scale;
  818. widget.dprScale = scale;
  819. }
  820. }
  821. // this function is invoked each frame - pay attention to what you add here
  822. private translateCanvas () {
  823. let scrollPositionX = -this.overflowLeftSize;
  824. let scrollPositionY = -this.overflowTopSize;
  825. if (this.appendedToBody) {
  826. scrollPositionX += window.scrollX;
  827. scrollPositionY += window.scrollY;
  828. } else {
  829. // Ideally this should be the only appendedToBody case (no-auto-parent-transform not enabled or at least an ancestor has transform)
  830. // I'd like to get rid of the else case
  831. if (this.hasParentTransform) {
  832. scrollPositionX += this.parentElement!.scrollLeft;
  833. scrollPositionY += this.parentElement!.scrollTop;
  834. } else {
  835. const { left, top } = this.parentElement!.getBoundingClientRect();
  836. scrollPositionX += left + window.scrollX;
  837. scrollPositionY += top + window.scrollY;
  838. let offsetParent = this.offsetParent;
  839. do {
  840. if (offsetParent === null || offsetParent === document.body) break;
  841. const htmlOffsetParentElement = offsetParent as HTMLElement;
  842. if (htmlOffsetParentElement.style.position === "fixed" || htmlOffsetParentElement.style.position === "sticky" || htmlOffsetParentElement.style.position === "absolute") {
  843. const parentRect = htmlOffsetParentElement.getBoundingClientRect();
  844. this.div.style.transform = `translate(${left - parentRect.left}px,${top - parentRect.top}px)`;
  845. return;
  846. }
  847. offsetParent = htmlOffsetParentElement.offsetParent;
  848. } while (offsetParent);
  849. this.div.style.transform = `translate(${scrollPositionX + this.overflowLeftSize}px,${scrollPositionY + this.overflowTopSize}px)`;
  850. return;
  851. }
  852. }
  853. this.canvas.style.transform = `translate(${scrollPositionX}px,${scrollPositionY}px)`;
  854. }
  855. private updateZIndexIfNecessary (element: HTMLElement) {
  856. let parent: HTMLElement | null = element;
  857. let zIndex: undefined | number;
  858. do {
  859. let currentZIndex = parseInt(getComputedStyle(parent).zIndex);
  860. // searching the shallowest z-index
  861. if (!isNaN(currentZIndex)) zIndex = currentZIndex;
  862. parent = parent.parentElement;
  863. } while (parent && parent !== document.body)
  864. if (zIndex && (!this.zIndex || this.zIndex < zIndex)) {
  865. this.zIndex = zIndex;
  866. this.div.style.zIndex = `${this.zIndex}`;
  867. }
  868. }
  869. /*
  870. * Other utilities
  871. */
  872. public screenToWorld (vec: Vector3, x: number, y: number) {
  873. vec.set(x, y, 0);
  874. // pay attention that clientWidth/Height rounds the size - if we don't like it, we should use getBoundingClientRect as in getPagSize
  875. this.renderer.camera.screenToWorld(vec, this.canvas.clientWidth, this.canvas.clientHeight);
  876. }
  877. public worldToScreen (vec: Vector3, x: number, y: number) {
  878. vec.set(x, -y, 0);
  879. // pay attention that clientWidth/Height rounds the size - if we don't like it, we should use getBoundingClientRect as in getPagSize
  880. // this.renderer.camera.worldToScreen(vec, this.canvas.clientWidth, this.canvas.clientHeight);
  881. this.renderer.camera.worldToScreen(vec, this.worldToScreenLength(this.renderer.camera.viewportWidth), this.worldToScreenLength(this.renderer.camera.viewportHeight));
  882. }
  883. public screenToWorldLength (length: number) {
  884. return length * this.getDevicePixelRatio();
  885. }
  886. public worldToScreenLength (length: number) {
  887. return length / this.getDevicePixelRatio();
  888. }
  889. }
  890. customElements.define("spine-overlay", SpineWebComponentOverlay);