import { PANEL_TYP_EMPTY, PANEL_TYP_STATS, PANEL_TYP_ROLLBOX, PANEL_TYP_TEXTBOX, PANEL_TYP_RULES, PANEL_TYP_UNIT_CONVERTER, PANEL_TYP_CREATURE_SCALED_CR, PANEL_TYP_CREATURE_SCALED_SPELL_SUMMON, PANEL_TYP_CREATURE_SCALED_CLASS_SUMMON, PANEL_TYP_TIME_TRACKER, PANEL_TYP_MONEY_CONVERTER, PANEL_TYP_TUBE, PANEL_TYP_TWITCH, PANEL_TYP_TWITCH_CHAT, PANEL_TYP_ADVENTURES, PANEL_TYP_BOOKS, PANEL_TYP_COUNTER, PANEL_TYP_IMAGE, PANEL_TYP_ADVENTURE_DYNAMIC_MAP, PANEL_TYP_GENERIC_EMBED, PANEL_TYP_ERROR, PANEL_TYP_BLANK, } from "./dmscreen/dmscreen-consts.js"; import {DmMapper} from "./dmscreen/dmscreen-mapper.js"; import {MoneyConverter} from "./dmscreen/dmscreen-moneyconverter.js"; import { TimerTrackerMoonSpriteLoader, TimeTracker, } from "./dmscreen/dmscreen-timetracker.js"; import {Counter} from "./dmscreen/dmscreen-counter.js"; import { PanelContentManager_InitiativeTracker, PanelContentManager_InitiativeTrackerCreatureViewer, PanelContentManager_InitiativeTrackerPlayerViewV0, PanelContentManager_InitiativeTrackerPlayerViewV1, PanelContentManagerFactory, } from "./dmscreen/dmscreen-panels.js"; const UP = "UP"; const RIGHT = "RIGHT"; const LEFT = "LEFT"; const DOWN = "DOWN"; const AX_X = "AXIS_X"; const AX_Y = "AXIS_Y"; const EVT_NAMESPACE = ".dm_screen"; const TITLE_LOADING = "Loading..."; class Board { constructor () { this.panels = {}; this.exiledPanels = []; this.$creen = $(`.dm-screen`); this.width = this.getInitialWidth(); this.height = this.getInitialHeight(); this.sideMenu = new SideMenu(this); this.menu = new AddMenu(); this.isFullscreen = false; this.isLocked = false; this.isAlertOnNav = false; this.nextId = 1; this.hoveringPanel = null; this.availContent = {}; this.availRules = {}; this.availAdventures = {}; this.availBooks = {}; this.$cbConfirmTabClose = null; this.$btnFullscreen = null; this.$btnLockPanels = null; this._pDoSaveStateDebounced = MiscUtil.debounce(() => StorageUtil.pSet(VeCt.STORAGE_DMSCREEN, this.getSaveableState()), 25); } getInitialWidth () { const scW = this.$creen.width(); return Math.floor(scW / 360); } getInitialHeight () { const scH = this.$creen.height(); return Math.floor(scH / 280); } getNextId () { return this.nextId++; } get$creen () { return this.$creen; } getWidth () { return this.width; } getHeight () { return this.height; } getConfirmTabClose () { return this.$cbConfirmTabClose == null ? false : this.$cbConfirmTabClose.prop("checked"); } setDimensions (width, height) { const oldWidth = this.width; const oldHeight = this.height; if (width) this.width = Math.max(width, 1); if (height) this.height = Math.max(height, 1); if (!(oldWidth === width && oldHeight === height)) { this.doAdjust$creenCss(); if (width < oldWidth || height < oldHeight) this.doCullPanels(oldWidth, oldHeight); this.sideMenu.doUpdateDimensions(); } this.doCheckFillSpaces(); this.$creen.trigger("panelResize"); } doCullPanels (oldWidth, oldHeight) { for (let x = oldWidth - 1; x >= 0; x--) { for (let y = oldHeight - 1; y >= 0; y--) { const p = this.getPanel(x, y); if (!p) continue; // happens when a large panel gets shrunk if (x >= this.width && y >= this.height) { if (p.canShrinkBottom() && p.canShrinkRight()) { p.doShrinkBottom(); p.doShrinkRight(); } else p.exile(); } else if (x >= this.width) { if (p.canShrinkRight()) p.doShrinkRight(); else p.exile(); } else if (y >= this.height) { if (p.canShrinkBottom()) p.doShrinkBottom(); else p.exile(); } } } } doAdjust$creenCss () { // assumes 7px grid spacing this.$creen.css({ marginTop: this.isFullscreen ? 0 : 3, }); } getPanelDimensions () { const w = this.$creen.outerWidth(); const h = this.$creen.outerHeight(); return { pxWidth: w / this.width, pxHeight: h / this.height, }; } doShowLoading () { $(`
Loading...
`).css({ gridColumnStart: "1", gridColumnEnd: String(this.width + 1), gridRowStart: "1", gridRowEnd: String(this.height + 1), }).appendTo(this.$creen); } doHideLoading () { this.$creen.find(`.dm-screen-loading`).remove(); } async pInitialise () { this.doAdjust$creenCss(); this.doShowLoading(); await Promise.all([ PrereleaseUtil.pInit(), BrewUtil2.pInit(), ]); await ExcludeUtil.pInitialise(); await Promise.all([ TimerTrackerMoonSpriteLoader.pInit(), this.pLoadIndex(), adventureLoader.pInit(), bookLoader.pInit(), ]); if (this.hasSavedStateUrl()) { await this.pDoLoadUrlState(); } else if (await this.pHasSavedState()) { await this.pDoLoadState(); } this.doCheckFillSpaces({isSkipSave: true}); this.initGlobalHandlers(); await this._pLoadTempData(); window.dispatchEvent(new Event("toolsLoaded")); } initGlobalHandlers () { window.onhashchange = () => this.pDoLoadUrlState(); } async _pLoadTempData () { const temp = await StorageUtil.pGet(VeCt.STORAGE_DMSCREEN_TEMP_SUBLIST); if (!temp) return; try { await this._pLoadTempData_({temp}); } finally { await StorageUtil.pRemove(VeCt.STORAGE_DMSCREEN_TEMP_SUBLIST); } } async _pLoadTempData_ ({temp}) { const entityInfos = await ListUtil.pGetSublistEntities_fromHover({ exportedSublist: temp.exportedSublist, page: temp.page, }); const len = entityInfos.length; if (!len) return; const entities = entityInfos.map(it => it.entity); this.doMassPopulate_Entities({ page: temp.page, entities, isTabs: temp.isTabs, }); } async pLoadIndex () { await SearchUiUtil.pDoGlobalInit(); // region rules await (async () => { const data = await DataUtil.loadJSON("data/generated/bookref-dmscreen-index.json"); this.availRules.ALL = elasticlunr(function () { this.addField("b"); this.addField("s"); this.addField("p"); this.addField("n"); this.addField("h"); this.setRef("id"); }); SearchUtil.removeStemmer(this.availRules.ALL); data.data.forEach(d => { d.n = data._meta.name[d.b]; d.b = data._meta.id[d.b]; d.s = data._meta.section[d.s]; this.availRules.ALL.addDoc(d); }); })(); // endregion // region adventures/books const adventureOrBookIdToSource = {}; // adventures await this._pDoBuildAdventureOrBookIndex({ adventureOrBookIdToSource, dataPath: `data/adventures.json`, dataProp: "adventure", page: UrlUtil.PG_ADVENTURE, indexStorage: this.availAdventures, indexIdField: "a", }); // books await this._pDoBuildAdventureOrBookIndex({ adventureOrBookIdToSource, dataPath: `data/books.json`, dataProp: "book", page: UrlUtil.PG_BOOK, indexStorage: this.availBooks, indexIdField: "b", }); // endregion // search this.availContent = await SearchUiUtil.pGetContentIndices(); // add tabs const omniTab = new AddMenuSearchTab({board: this, indexes: this.availContent}); const ruleTab = new AddMenuSearchTab({board: this, indexes: this.availRules, subType: "rule"}); const adventureTab = new AddMenuSearchTab({board: this, indexes: this.availAdventures, subType: "adventure", adventureOrBookIdToSource}); const bookTab = new AddMenuSearchTab({board: this, indexes: this.availBooks, subType: "book", adventureOrBookIdToSource}); const embedTab = new AddMenuVideoTab({board: this}); const imageTab = new AddMenuImageTab({board: this}); const specialTab = new AddMenuSpecialTab({board: this}); this.menu .addTab(omniTab) .addTab(ruleTab) .addTab(adventureTab) .addTab(bookTab) .addTab(imageTab) .addTab(embedTab) .addTab(specialTab); this.menu.render(); this.sideMenu.render(); this.doHideLoading(); } async _pDoBuildAdventureOrBookIndex ( { adventureOrBookIdToSource, dataPath, dataProp, page, indexStorage, indexIdField, }, ) { const data = await DataUtil.loadJSON(dataPath); adventureOrBookIdToSource[dataProp] = adventureOrBookIdToSource[dataProp] || {}; indexStorage.ALL = elasticlunr(function () { this.addField(indexIdField); this.addField("c"); this.addField("n"); this.addField("p"); this.addField("o"); this.setRef("id"); }); SearchUtil.removeStemmer(indexStorage.ALL); let bookOrAdventureId = 0; const handleAdventureOrBook = (adventureOrBook, isBrew) => { if (ExcludeUtil.isExcluded(UrlUtil.URL_TO_HASH_BUILDER[page](adventureOrBook), dataProp, adventureOrBook.source, {isNoCount: true})) return; adventureOrBookIdToSource[dataProp][adventureOrBook.id] = adventureOrBook.source; indexStorage[adventureOrBook.id] = elasticlunr(function () { this.addField(indexIdField); this.addField("c"); this.addField("n"); this.addField("p"); this.addField("o"); this.setRef("id"); }); SearchUtil.removeStemmer(indexStorage[adventureOrBook.id]); adventureOrBook.contents.forEach((chap, i) => { const chapDoc = { [indexIdField]: adventureOrBook.id, n: adventureOrBook.name, c: chap.name, p: i, id: bookOrAdventureId++, }; if (chap.ordinal) chapDoc.o = Parser.bookOrdinalToAbv(chap.ordinal, true); if (isBrew) chapDoc.w = true; indexStorage.ALL.addDoc(chapDoc); indexStorage[adventureOrBook.id].addDoc(chapDoc); }); }; data[dataProp].forEach(adventureOrBook => handleAdventureOrBook(adventureOrBook)); ((await PrereleaseUtil.pGetBrewProcessed())[dataProp] || []).forEach(adventureOrBook => handleAdventureOrBook(adventureOrBook, true)); ((await BrewUtil2.pGetBrewProcessed())[dataProp] || []).forEach(adventureOrBook => handleAdventureOrBook(adventureOrBook, true)); } getPanel (x, y) { return Object.values(this.panels).find(p => { // x <= pX < x+w && y <= pY < y+h return (p.x <= x) && (x < (p.x + p.width)) && (p.y <= y) && (y < (p.y + p.height)); }); } getPanels (x, y, w = 1, h = 1) { const out = []; for (let wOffset = 0; wOffset < w; ++wOffset) { for (let hOffset = 0; hOffset < h; ++hOffset) { out.push(this.getPanel(x + wOffset, y + hOffset)); } } return out.filter(it => it); } getPanelPx (xPx, hPx) { const dim = this.getPanelDimensions(); return this.getPanel(Math.floor(xPx / dim.pxWidth), Math.floor(hPx / dim.pxHeight)); } setHoveringPanel (panel) { this.hoveringPanel = panel; } setVisiblyHoveringPanel (isVis) { Object.values(this.panels).forEach(p => p.removeHoverClass()); if (isVis && this.hoveringPanel) this.hoveringPanel.addHoverClass(); } exilePanel (id) { const panelK = Object.keys(this.panels).find(k => this.panels[k].id === id); if (panelK) { const toExile = this.panels[panelK]; if (!toExile.getEmpty()) { delete this.panels[panelK]; this.exiledPanels.unshift(toExile); const toDestroy = this.exiledPanels.splice(10); toDestroy.forEach(p => p.destroy()); this.sideMenu.doUpdateHistory(); } else this.destroyPanel(id); this.doSaveStateDebounced(); } } recallPanel (panel) { const ix = this.exiledPanels.findIndex(p => p.id === panel.id); if (~ix) this.exiledPanels.splice(ix, 1); this.panels[panel.id] = panel; this.fireBoardEvent({type: "panelIdSetActive", payload: {type: panel.type}}); this.doSaveStateDebounced(); } destroyPanel (id) { const panelK = Object.keys(this.panels).find(k => this.panels[k].id === id); if (panelK) delete this.panels[panelK]; this.doSaveStateDebounced(); } doCheckFillSpaces ({isSkipSave = false} = {}) { const panelsToRender = []; for (let x = 0; x < this.width; x++) { for (let y = 0; y < this.height; ++y) { const pnl = this.getPanel(x, y); if (!pnl) { const nuPnl = new Panel(this, x, y); this.panels[nuPnl.id] = nuPnl; this.fireBoardEvent({type: "panelIdSetActive", payload: {type: nuPnl.type}}); panelsToRender.push(nuPnl); } } } panelsToRender.forEach(p => p.render()); if (!isSkipSave) this.doSaveStateDebounced(); } hasSavedStateUrl () { return window.location.hash.length; } async pDoLoadUrlState () { if (window.location.hash.length) { const toLoad = JSON.parse(decodeURIComponent(window.location.hash.slice(1))); this.doReset(); await this.pDoLoadStateFrom(toLoad); } window.location.hash = ""; } async pHasSavedState () { return !!await StorageUtil.pGet(VeCt.STORAGE_DMSCREEN); } getSaveableState () { return { w: this.width, h: this.height, ctc: this.getConfirmTabClose(), fs: this.isFullscreen, lk: this.isLocked, ps: Object.values(this.panels).map(p => p.getSaveableState()), ex: this.exiledPanels.map(p => p.getSaveableState()), }; } doSaveStateDebounced () { this._pDoSaveStateDebounced(); } async pDoLoadStateFrom (toLoad) { if (this.$cbConfirmTabClose) this.$cbConfirmTabClose.prop("checked", !!toLoad.ctc); if (this.$btnFullscreen && (toLoad.fs !== !!this.isFullscreen)) this.$btnFullscreen.click(); if (this.$btnLockPanels && (toLoad.lk !== !!this.isLocked)) this.$btnLockPanels.click(); // re-exile const toReExile = toLoad.ex.filter(Boolean).reverse(); for (const saved of toReExile) { const p = await Panel.fromSavedState(this, saved); if (p) { this.panels[p.id] = p; this.fireBoardEvent({type: "panelIdSetActive", payload: {type: p.type}}); p.exile(); } } this.setDimensions(toLoad.w, toLoad.h); // FIXME is this necessary? // reload // fill content first; empties can fill any remaining space const toReload = toLoad.ps.filter(Boolean).filter(saved => saved.t !== PANEL_TYP_EMPTY); for (const saved of toReload) { const p = await Panel.fromSavedState(this, saved); if (p) { this.panels[p.id] = p; this.fireBoardEvent({type: "panelIdSetActive", payload: {type: p.type}}); } } this.setDimensions(toLoad.w, toLoad.h); } async pDoLoadState () { let toLoad; try { toLoad = await StorageUtil.pGet(VeCt.STORAGE_DMSCREEN); } catch (e) { JqueryUtil.doToast({ content: `Error when loading DM screen! Purged saved data. ${VeCt.STR_SEE_CONSOLE}`, type: "danger", }); await StorageUtil.pRemove(VeCt.STORAGE_DMSCREEN); setTimeout(() => { throw e; }); return; } try { await this.pDoLoadStateFrom(toLoad); } catch (e) { await this._pDoLoadState_pHandleError({toLoad, e}); } } async _pDoLoadState_pHandleError ({toLoad, e}) { setTimeout(() => { throw e; }); const {$modalInner, doClose, pGetResolved} = UiUtil.getShowModal({ isMinHeight0: true, isHeaderBorder: true, title: "Failed to Load", isPermanent: true, }); const handleClickDownload = () => { DataUtil.userDownload(`dm-screen`, toLoad, {fileType: "dm-screen"}); }; const $btnDownload = $(``) .on("click", () => handleClickDownload()); const handleClickPurge = async () => { if (!await InputUiUtil.pGetUserBoolean({title: "Purge", htmlDescription: "Are you sure?", textYes: "Yes", textNo: "Cancel"})) return; await StorageUtil.pRemove(VeCt.STORAGE_DMSCREEN); doClose(true); }; const $btnPurge = $(``) .on("click", () => handleClickPurge()); const $txtDownload = $(`download a backup of your save`) .on("click", () => handleClickDownload()); const $txtPurge = $(`purge the save`) .on("click", () => handleClickPurge()); $$($modalInner)`
Failed to load saved DM Screen. ${VeCt.STR_SEE_CONSOLE}
Please ${$txtDownload}, then ${$txtPurge} if you wish to continue.
If you suspect this is the result of a bug, or need help recovering lost data, drop past our Discord.
${$btnDownload} ${$btnPurge}
`; return pGetResolved(); } doReset () { this.exiledPanels.forEach(p => p.destroy()); this.exiledPanels = []; this.sideMenu.doUpdateHistory(); Object.values(this.panels).forEach(p => p.destroy()); this.panels = {}; this.setDimensions(this.getInitialWidth(), this.getInitialHeight()); } setHoveringButton (panel) { this.resetHoveringButton(panel); panel.$btnAddInner.addClass("faux-hover"); } resetHoveringButton (panel) { Object.values(this.panels).forEach(p => { if (panel && panel.id === p.id) return; p.$btnAddInner.removeClass("faux-hover"); }); } addPanel (panel) { this.panels[panel.id] = panel; panel.render(); this.fireBoardEvent({type: "panelIdSetActive", payload: {type: panel.type}}); this.doSaveStateDebounced(); } disablePanelMoves () { Object.values(this.panels).forEach(p => p.toggleMovable(false)); } doBindAlertOnNavigation () { if (this.isAlertOnNav) return; this.isAlertOnNav = true; $(window).on("beforeunload", evt => { const message = `Temporary data and connections will be lost.`; (evt || window.event).message = message; return message; }); } getPanelsByType (type) { return Object.values(this.panels).filter(p => p.tabDatas.length && p.tabDatas.find(td => td.type === type)); } doMassPopulate_Entities ( { page, entities, isTabs, panel = null, }, ) { if (panel) { return this._doMassPopulate_Entities_forPanel({ page, entities, isTabs, panel, }); } let panels = this.getPanels(0, 0, this.width, this.height); if (isTabs) { const panel = panels.find(it => it.getEmpty()); return this._doMassPopulate_Entities_forPanel({ page, entities, isTabs, panel, }); } const availablePanels = panels.filter(it => it.getEmpty()).length; // Prefer to increase the number of panels on the vertical axis if (availablePanels < entities.length) { const diff = entities.length - availablePanels; const heightIncrease = Math.ceil(diff / this.width); this.setDimensions(this.width, this.height + heightIncrease); panels = this.getPanels(0, 0, this.width, this.height); } let ixEntity = 0; for (const panel of panels) { if (!panel.getEmpty()) continue; const ent = entities[ixEntity]; const hash = UrlUtil.URL_TO_HASH_BUILDER[page](ent); this._doMassPopulate_Entities_doPopulatePanel({page, ent, panel, hash}); ++ixEntity; if (ixEntity >= entities.length) break; } } _doMassPopulate_Entities_doPopulatePanel ({page, ent, panel, hash}) { ent?._scaledCr ? panel.doPopulate_StatsScaledCr(page, ent.source, hash, ent._scaledCr) : panel.doPopulate_Stats(page, ent.source, hash); } _doMassPopulate_Entities_forPanel ( { page, entities, panel, }, ) { panel.setIsTabs(true); entities.forEach(ent => { const hash = UrlUtil.URL_TO_HASH_BUILDER[page](ent); this._doMassPopulate_Entities_doPopulatePanel({page, ent, panel, hash}); }); } /** * @param {string} opts.type * @param {?object} opts.payload */ fireBoardEvent (opts) { const {type} = opts; if (!type) throw new Error(`Event type must be specified!`); Object.values(this.panels) .forEach(panel => this._fireBoardEvent_panel({panel, ...opts})); this.exiledPanels .forEach(panel => this._fireBoardEvent_panel({panel, ...opts})); } _fireBoardEvent_panel ({panel, ...opts}) { panel.fireBoardEvent({...opts}); } } class SideMenu { constructor (board) { this.board = board; this.$mnu = $(`.sidemenu`); this.$mnu.on("mouseover", () => { this.board.setHoveringPanel(null); this.board.setVisiblyHoveringPanel(false); this.board.resetHoveringButton(); }); this.$iptWidth = null; this.$iptHeight = null; this.$wrpHistory = null; } render () { const renderDivider = () => this.$mnu.append(`
`); const $wrpResizeW = $(`
Width
`).appendTo(this.$mnu); const $iptWidth = $(``).appendTo($wrpResizeW); this.$iptWidth = $iptWidth; const $wrpResizeH = $(`
Height
`).appendTo(this.$mnu); const $iptHeight = $(``).appendTo($wrpResizeH); this.$iptHeight = $iptHeight; const $wrpSetDim = $(`
`).appendTo(this.$mnu); const $btnSetDim = $(`
`).appendTo($wrpSetDim); $btnSetDim.on("click", () => { const w = Number($iptWidth.val()); const h = Number($iptHeight.val()); if ((w > 10 || h > 10) && !window.confirm("That's a lot of panels. You sure?")) return; this.board.setDimensions(w, h); }); renderDivider(); const $wrpFullscreen = $(`
`).appendTo(this.$mnu); const $btnFullscreen = $(``).appendTo($wrpFullscreen); this.board.$btnFullscreen = $btnFullscreen; $btnFullscreen.on("click", () => { this.board.isFullscreen = !this.board.isFullscreen; if (this.board.isFullscreen) $(`body`).addClass(`is-fullscreen`); else $(`body`).removeClass(`is-fullscreen`); this.board.doAdjust$creenCss(); this.board.doSaveStateDebounced(); this.board.$creen.trigger("panelResize"); }); const $btnLockPanels = $(``).appendTo($wrpFullscreen); this.board.$btnLockPanels = $btnLockPanels; $btnLockPanels.on("click", () => { this.board.isLocked = !this.board.isLocked; if (this.board.isLocked) { this.board.disablePanelMoves(); $(`body`).addClass(`dm-screen-locked`); $btnLockPanels.removeClass(`btn-danger`).addClass(`btn-success`); } else { $(`body`).removeClass(`dm-screen-locked`); $btnLockPanels.addClass(`btn-danger`).removeClass(`btn-success`); } this.board.doSaveStateDebounced(); }); renderDivider(); const $wrpSaveLoad = $(`
`).appendTo(this.$mnu); const $wrpSaveLoadFile = $(`
`).appendTo($wrpSaveLoad); const $btnSaveFile = $(``).appendTo($wrpSaveLoadFile); $btnSaveFile.on("click", () => { DataUtil.userDownload(`dm-screen`, this.board.getSaveableState(), {fileType: "dm-screen"}); }); const $btnLoadFile = $(``).appendTo($wrpSaveLoadFile); $btnLoadFile.on("click", async () => { const {jsons, errors} = await DataUtil.pUserUpload({expectedFileTypes: ["dm-screen"]}); DataUtil.doHandleFileLoadErrorsGeneric(errors); if (!jsons?.length) return; this.board.doReset(); await this.board.pDoLoadStateFrom(jsons[0]); }); const $wrpSaveLoadUrl = $(`
`).appendTo($wrpSaveLoad); const $btnSaveLink = $(``).appendTo($wrpSaveLoadUrl); $btnSaveLink.on("click", async () => { const encoded = `${window.location.href.split("#")[0]}#${encodeURIComponent(JSON.stringify(this.board.getSaveableState()))}`; await MiscUtil.pCopyTextToClipboard(encoded); JqueryUtil.showCopiedEffect($btnSaveLink); }); renderDivider(); const $wrpCbConfirm = $(`
`).appendTo(this.$mnu); this.board.$cbConfirmTabClose = $(``).appendTo($wrpCbConfirm.find(`label`)); renderDivider(); const $wrpReset = $(`
`).appendTo(this.$mnu); const $btnReset = $(``).appendTo($wrpReset); $btnReset.on("click", () => { if (window.confirm("Are you sure?")) { this.board.doReset(); } }); renderDivider(); this.$wrpHistory = $(`
`).appendTo(this.$mnu); } doUpdateDimensions () { this.$iptWidth.val(this.board.width); this.$iptHeight.val(this.board.height); } doUpdateHistory () { this.board.exiledPanels.forEach(p => p.get$ContentWrapper().detach()); this.$wrpHistory.children().remove(); if (this.board.exiledPanels.length) { const $wrpHistHeader = $(`
Recently Removed
`).appendTo(this.$wrpHistory); const $btnHistClear = $(``).appendTo($wrpHistHeader); $btnHistClear.on("click", () => { this.board.exiledPanels.forEach(p => p.destroy()); this.board.exiledPanels = []; this.doUpdateHistory(); }); } this.board.exiledPanels.forEach((p, i) => { const $wrpHistItem = $(`
`).appendTo(this.$wrpHistory); const $cvrHistItem = $(`
`).appendTo($wrpHistItem); const $btnRemove = $(`
`).appendTo($cvrHistItem); const $ctrlMove = $(`
`).appendTo($cvrHistItem); $btnRemove.on("click", () => { this.board.exiledPanels[i].destroy(); this.board.exiledPanels.splice(i, 1); this.doUpdateHistory(); }); const $contents = p.get$ContentWrapper(); $wrpHistItem.append($contents); $ctrlMove.on("mousedown touchstart", (e) => { this.board.setVisiblyHoveringPanel(true); const $body = $(`body`); MiscUtil.clearSelection(); $body.css("userSelect", "none"); const w = $contents.width(); const h = $contents.height(); const offset = $contents.offset(); const offsetX = EventUtil.getClientX(e) - offset.left; const offsetY = EventUtil.getClientY(e) - offset.top; $body.append($contents); $(`.panel-control-move`).hide(); $contents.css("overflow-y", "hidden"); Panel.setMovingCss(e, $contents, w, h, offsetX, offsetY, 61); $wrpHistItem.css("box-shadow", "none"); $btnRemove.hide(); $ctrlMove.hide(); this.board.get$creen().addClass("board-content-hovering"); p.get$Content().addClass("panel-content-hovering"); Panel.bindMovingEvents(this.board, $contents, offsetX, offsetY); $(document).on(`mouseup${EVT_NAMESPACE} touchend${EVT_NAMESPACE}`, () => { this.board.setVisiblyHoveringPanel(false); $(document).off(`mousemove${EVT_NAMESPACE} touchmove${EVT_NAMESPACE}`).off(`mouseup${EVT_NAMESPACE} touchend${EVT_NAMESPACE}`); $body.css("userSelect", ""); $contents.css("overflow-y", ""); Panel.unsetMovingCss($contents); $wrpHistItem.css("box-shadow", ""); $btnRemove.show(); $ctrlMove.show(); this.board.get$creen().removeClass("board-content-hovering"); p.get$Content().removeClass("panel-content-hovering"); if (!this.board.hoveringPanel || p.id === this.board.hoveringPanel.id) $wrpHistItem.append($contents); else { this.board.recallPanel(p); const her = this.board.hoveringPanel; if (her.getEmpty()) { her.setFromPeer(p.getPanelMeta(), p.$content, p.isMovable()); p.destroy(); } else { const herMeta = her.getPanelMeta(); const $herContent = her.get$Content(); her.setFromPeer(p.getPanelMeta(), p.get$Content(), p.isMovable()); p.setFromPeer(herMeta, $herContent, her.isMovable()); p.exile(); } // clean any lingering hidden scrollbar her.$pnl.removeClass("panel-mode-move"); her.doShowJoystick(); this.doUpdateHistory(); } MiscUtil.clearSelection(); this.board.doSaveStateDebounced(); }); }); }); this.board.doSaveStateDebounced(); } } class Panel { constructor (board, x, y, width = 1, height = 1, title = "") { this.id = board.getNextId(); this.board = board; this.x = x; this.y = y; this.width = width; this.height = height; this.title = title; this.isDirty = true; this.isContentDirty = false; this.isLocked = false; // unused this.type = PANEL_TYP_EMPTY; this.contentMeta = null; // info used during saved state re-load this.isMousedown = false; this.isTabs = false; this.tabIndex = null; this.tabDatas = []; this.tabCanRename = false; this.tabRenamed = false; this.$btnAdd = null; this.$btnAddInner = null; this.$content = null; this.joyMenu = null; this.$pnl = null; this.$pnlWrpContent = null; this.$pnlTitle = null; this.$pnlAddTab = null; this.$pnlWrpTabs = null; this.$pnlTabs = null; } static async fromSavedState (board, saved) { const existing = board.getPanels(saved.x, saved.y, saved.w, saved.h); if (saved.t === PANEL_TYP_EMPTY && existing.length) return null; // cull empties else if (existing.length) existing.forEach(p => p.destroy()); // prefer more recent panels const panel = new Panel(board, saved.x, saved.y, saved.w, saved.h); panel.render(); const pLoadState = async (saved, skipSetTab, ixTab) => { // TODO(Future) refactor other panels to use this const isViaPcm = await PanelContentManagerFactory.pFromSavedState({board, saved, ixTab, panel}); if (isViaPcm) return; const handleTabRenamed = (panel) => { if (saved.r != null) panel.tabDatas[ixTab].tabRenamed = true; }; switch (saved.t) { case PANEL_TYP_EMPTY: return panel; case PANEL_TYP_STATS: { const page = saved.c.p; const source = saved.c.s; const hash = saved.c.u; await panel.doPopulate_Stats(page, source, hash, skipSetTab, saved.r); handleTabRenamed(panel); return panel; } case PANEL_TYP_CREATURE_SCALED_CR: { const page = saved.c.p; const source = saved.c.s; const hash = saved.c.u; const cr = saved.c.cr; await panel.doPopulate_StatsScaledCr(page, source, hash, cr, skipSetTab, saved.r); handleTabRenamed(panel); return panel; } case PANEL_TYP_CREATURE_SCALED_SPELL_SUMMON: { const page = saved.c.p; const source = saved.c.s; const hash = saved.c.u; const summonSpellLevel = saved.c.ssl; await panel.doPopulate_StatsScaledSpellSummonLevel(page, source, hash, summonSpellLevel, skipSetTab, saved.r); handleTabRenamed(panel); return panel; } case PANEL_TYP_CREATURE_SCALED_CLASS_SUMMON: { const page = saved.c.p; const source = saved.c.s; const hash = saved.c.u; const summonClassLevel = saved.c.csl; await panel.doPopulate_StatsScaledClassSummonLevel(page, source, hash, summonClassLevel, skipSetTab, saved.r); handleTabRenamed(panel); return panel; } case PANEL_TYP_RULES: { const book = saved.c.b; const chapter = saved.c.c; const header = saved.c.h; await panel.doPopulate_Rules(book, chapter, header, skipSetTab, saved.r); handleTabRenamed(panel); return panel; } case PANEL_TYP_ADVENTURES: { const adventure = saved.c.a; const chapter = saved.c.c; await panel.doPopulate_Adventures(adventure, chapter, skipSetTab, saved.r); handleTabRenamed(panel); return panel; } case PANEL_TYP_BOOKS: { const book = saved.c.b; const chapter = saved.c.c; await panel.doPopulate_Books(book, chapter, skipSetTab, saved.r); handleTabRenamed(panel); return panel; } case PANEL_TYP_ROLLBOX: Renderer.dice.bindDmScreenPanel(panel, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_TEXTBOX: panel.doPopulate_TextBox(saved.s.x, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_COUNTER: panel.doPopulate_Counter(saved.s, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_UNIT_CONVERTER: panel.doPopulate_UnitConverter(saved.s, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_MONEY_CONVERTER: panel.doPopulate_MoneyConverter(saved.s, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_TIME_TRACKER: panel.doPopulate_TimeTracker(saved.s, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_TUBE: panel.doPopulate_YouTube(saved.c.u, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_TWITCH: panel.doPopulate_Twitch(saved.c.u, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_TWITCH_CHAT: panel.doPopulate_TwitchChat(saved.c.u, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_GENERIC_EMBED: panel.doPopulate_GenericEmbed(saved.c.u, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_IMAGE: panel.doPopulate_Image(saved.c.u, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_ADVENTURE_DYNAMIC_MAP: panel.doPopulate_AdventureBookDynamicMap(saved.s, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_ERROR: panel.doPopulate_Error(saved.s, saved.r); handleTabRenamed(panel); return panel; case PANEL_TYP_BLANK: panel.doPopulate_Blank(saved.r); handleTabRenamed(panel); return panel; default: throw new Error(`Unhandled panel type ${saved.t}`); } }; if (saved.a) { panel.setIsTabs(true); // If tab data is untyped, replace it with a blank panel, to avoid breaking "active tab" index. // This can happen if a "blank space" panel is mixed in with other tabs. saved.a.forEach(it => it.t = it.t ?? PANEL_TYP_BLANK); for (let ix = 0; ix < saved.a.length; ++ix) { const tab = saved.a[ix]; await pLoadState(tab, true, ix); } panel.setActiveTab(saved.b); } else { await pLoadState(saved); } return panel; } static _get$eleLoading (message = "Loading") { return $(`
${message}...
`); } static setMovingCss (evt, $ele, w, h, offsetX, offsetY, zIndex) { $ele.css({ width: w, height: h, position: "fixed", top: EventUtil.getClientY(evt) - offsetY, left: EventUtil.getClientX(evt) - offsetX, zIndex: zIndex, pointerEvents: "none", transform: "rotate(-4deg)", background: "none", }); } static unsetMovingCss ($ele) { $ele.css({ width: "", height: "", position: "", top: "", left: "", zIndex: "", pointerEvents: "", transform: "", background: "", }); } static bindMovingEvents (board, $content, offsetX, offsetY) { $(document).off(`mousemove${EVT_NAMESPACE} touchmove${EVT_NAMESPACE}`).off(`mouseup${EVT_NAMESPACE} touchend${EVT_NAMESPACE}`); $(document).on(`mousemove${EVT_NAMESPACE} touchmove${EVT_NAMESPACE}`, (e) => { board.setVisiblyHoveringPanel(true); $content.css({ top: EventUtil.getClientY(e) - offsetY, left: EventUtil.getClientX(e) - offsetX, }); }); } static isNonExilableType (type) { return type === PANEL_TYP_ROLLBOX || type === PANEL_TYP_TUBE || type === PANEL_TYP_TWITCH; } // region Panel population doPopulate_Empty (ixOpt) { this.close$TabContent(ixOpt); } doPopulate_Loading (message) { return this.set$ContentTab( PANEL_TYP_EMPTY, null, Panel._get$eleLoading(message), TITLE_LOADING, ); } doPopulate_Stats (page, source, hash, skipSetTab, title) { // FIXME skipSetTab is never used const meta = {p: page, s: source, u: hash}; const ix = this.set$TabLoading( PANEL_TYP_STATS, meta, ); return DataLoader.pCacheAndGet( page, source, hash, ).then(it => { if (!it) { setTimeout(() => { throw new Error(`Failed to load entity: "${hash}" (${source}) from ${page}`); }); return this.doPopulate_Error({message: `Failed to load ${hash} from page ${page}! (Content does not exist.)`}, title); } const fn = Renderer.hover.getFnRenderCompact(page); const $contentInner = $(`
`); const $contentStats = $(``).appendTo($contentInner); $contentStats.append(fn(it)); const fnBind = Renderer.hover.getFnBindListenersCompact(page); if (fnBind) fnBind(it, $contentInner[0]); this._stats_bindCrScaleClickHandler(it, meta, $contentInner, $contentStats); this._stats_bindSummonScaleClickHandler(it, meta, $contentInner, $contentStats); this.set$Tab( ix, PANEL_TYP_STATS, meta, $contentInner, title || it.name, true, !!title, ); }); } _stats_bindCrScaleClickHandler (mon, meta, $contentInner, $contentStats) { const self = this; $contentStats.off("click", ".mon__btn-scale-cr").on("click", ".mon__btn-scale-cr", function (evt) { evt.stopPropagation(); const win = (evt.view || {}).window; const $this = $(this); const lastCr = self.contentMeta.cr != null ? Parser.numberToCr(self.contentMeta.cr) : mon.cr ? (mon.cr.cr || mon.cr) : null; Renderer.monster.getCrScaleTarget({ win, $btnScale: $this, initialCr: lastCr, isCompact: true, cbRender: (targetCr) => { const originalCr = Parser.crToNumber(mon.cr) === targetCr; const doRender = (toRender) => { $contentStats.empty().append(Renderer.monster.getCompactRenderedString(toRender, {isShowScalers: true, isScaledCr: !originalCr})); const nxtMeta = { ...meta, cr: targetCr, }; if (originalCr) delete nxtMeta.cr; self.set$Tab( self.tabIndex, originalCr ? PANEL_TYP_STATS : PANEL_TYP_CREATURE_SCALED_CR, nxtMeta, $contentInner, toRender._displayName || toRender.name, true, ); }; if (originalCr) { doRender(mon); } else { ScaleCreature.scale(mon, targetCr).then(toRender => doRender(toRender)); } }, }); }); $contentStats.off("click", ".mon__btn-reset-cr").on("click", ".mon__btn-reset-cr", function () { $contentStats.empty().append(Renderer.monster.getCompactRenderedString(mon, {isShowScalers: true, isScaledCr: false})); self.set$Tab( self.tabIndex, PANEL_TYP_STATS, meta, $contentInner, mon.name, true, ); }); } _stats_bindSummonScaleClickHandler (mon, meta, $contentInner, $contentStats) { const self = this; $contentStats .off("change", `[name="mon__sel-summon-spell-level"]`) .on("change", `[name="mon__sel-summon-spell-level"]`, async function () { const $selSummonSpellLevel = $(this); const spellLevel = Number($selSummonSpellLevel.val()); if (~spellLevel) { const nxtMeta = { ...meta, ssl: spellLevel, }; ScaleSpellSummonedCreature.scale(mon, spellLevel) .then(toRender => { $contentStats.empty().append(Renderer.monster.getCompactRenderedString(toRender, {isShowScalers: true, isScaledSpellSummon: true})); self._stats_doUpdateSummonScaleDropdowns(toRender, $contentStats); self.set$Tab( self.tabIndex, PANEL_TYP_CREATURE_SCALED_SPELL_SUMMON, nxtMeta, $contentInner, mon._displayName || mon.name, true, ); }); } else { $contentStats.empty().append(Renderer.monster.getCompactRenderedString(mon, {isShowScalers: true, isScaledCr: false, isScaledSpellSummon: false})); self._stats_doUpdateSummonScaleDropdowns(mon, $contentStats); self.set$Tab( self.tabIndex, PANEL_TYP_STATS, meta, $contentInner, mon.name, true, ); } }); $contentStats .off("change", `[name="mon__sel-summon-class-level"]`) .on("change", `[name="mon__sel-summon-class-level"]`, async function () { const $selSummonClassLevel = $(this); const classLevel = Number($selSummonClassLevel.val()); if (~classLevel) { const nxtMeta = { ...meta, csl: classLevel, }; ScaleClassSummonedCreature.scale(mon, classLevel) .then(toRender => { $contentStats.empty().append(Renderer.monster.getCompactRenderedString(toRender, {isShowScalers: true, isScaledClassSummon: true})); self._stats_doUpdateSummonScaleDropdowns(toRender, $contentStats); self.set$Tab( self.tabIndex, PANEL_TYP_CREATURE_SCALED_CLASS_SUMMON, nxtMeta, $contentInner, mon._displayName || mon.name, true, ); }); } else { $contentStats.empty().append(Renderer.monster.getCompactRenderedString(mon, {isShowScalers: true, isScaledCr: false, isScaledClassSummon: false})); self._stats_doUpdateSummonScaleDropdowns(mon, $contentStats); self.set$Tab( self.tabIndex, PANEL_TYP_STATS, meta, $contentInner, mon.name, true, ); } }); } _stats_doUpdateSummonScaleDropdowns (scaledMon, $contentStats) { $contentStats .find(`[name="mon__sel-summon-spell-level"]`) .val(scaledMon._summonedBySpell_level != null ? `${scaledMon._summonedBySpell_level}` : "-1"); $contentStats .find(`[name="mon__sel-summon-class-level"]`) .val(scaledMon._summonedByClass_level != null ? `${scaledMon._summonedByClass_level}` : "-1"); } doPopulate_StatsScaledCr (page, source, hash, targetCr, skipSetTab, title) { // FIXME skipSetTab is never used const meta = {p: page, s: source, u: hash, cr: targetCr}; const ix = this.set$TabLoading( PANEL_TYP_CREATURE_SCALED_CR, meta, ); return DataLoader.pCacheAndGet( page, source, hash, ).then(it => { ScaleCreature.scale(it, targetCr).then(initialRender => { const $contentInner = $(`
`); const $contentStats = $(`
`).appendTo($contentInner); $contentStats.append(Renderer.monster.getCompactRenderedString(initialRender, {isShowScalers: true, isScaledCr: true})); this._stats_bindCrScaleClickHandler(it, meta, $contentInner, $contentStats); this.set$Tab( ix, PANEL_TYP_CREATURE_SCALED_CR, meta, $contentInner, title || initialRender._displayName || initialRender.name, true, !!title, ); }); }); } doPopulate_StatsScaledSpellSummonLevel (page, source, hash, summonSpellLevel, skipSetTab, title) { // FIXME skipSetTab is never used const meta = {p: page, s: source, u: hash, ssl: summonSpellLevel}; const ix = this.set$TabLoading( PANEL_TYP_CREATURE_SCALED_SPELL_SUMMON, meta, ); return DataLoader.pCacheAndGet( page, source, hash, ).then(it => { ScaleSpellSummonedCreature.scale(it, summonSpellLevel).then(scaledMon => { const $contentInner = $(`
`); const $contentStats = $(`
`).appendTo($contentInner); $contentStats.append(Renderer.monster.getCompactRenderedString(scaledMon, {isShowScalers: true, isScaledSpellSummon: true})); this._stats_doUpdateSummonScaleDropdowns(scaledMon, $contentStats); this._stats_bindSummonScaleClickHandler(it, meta, $contentInner, $contentStats); this.set$Tab( ix, PANEL_TYP_CREATURE_SCALED_SPELL_SUMMON, meta, $contentInner, title || scaledMon._displayName || scaledMon.name, true, !!title, ); }); }); } doPopulate_StatsScaledClassSummonLevel (page, source, hash, summonClassLevel, skipSetTab, title) { // FIXME skipSetTab is never used const meta = {p: page, s: source, u: hash, csl: summonClassLevel}; const ix = this.set$TabLoading( PANEL_TYP_CREATURE_SCALED_CLASS_SUMMON, meta, ); return DataLoader.pCacheAndGet( page, source, hash, ).then(it => { ScaleClassSummonedCreature.scale(it, summonClassLevel).then(scaledMon => { const $contentInner = $(`
`); const $contentStats = $(`
`).appendTo($contentInner); $contentStats.append(Renderer.monster.getCompactRenderedString(scaledMon, {isShowScalers: true, isScaledClassSummon: true})); this._stats_doUpdateSummonScaleDropdowns(scaledMon, $contentStats); this._stats_bindSummonScaleClickHandler(it, meta, $contentInner, $contentStats); this.set$Tab( ix, PANEL_TYP_CREATURE_SCALED_CLASS_SUMMON, meta, $contentInner, title || scaledMon._displayName || scaledMon.name, true, !!title, ); }); }); } doPopulate_Rules (book, chapter, header, skipSetTab, title) { // FIXME skipSetTab is never used const meta = {b: book, c: chapter, h: header}; const ix = this.set$TabLoading( PANEL_TYP_RULES, meta, ); return RuleLoader.pFill(book).then(() => { const rule = RuleLoader.getFromCache(book, chapter, header); const it = Renderer.rule.getCompactRenderedString(rule); this.set$Tab( ix, PANEL_TYP_RULES, meta, $(`
${it}
`), title || rule.name || "", true, !!title, ); }); } doPopulate_Adventures (adventure, chapter, skipSetTab, title) { // FIXME skipSetTab is never used const meta = {a: adventure, c: chapter}; const ix = this.set$TabLoading( PANEL_TYP_ADVENTURES, meta, ); return adventureLoader.pFill(adventure).then(() => { const data = adventureLoader.getFromCache(adventure, chapter); const view = new AdventureOrBookView("a", this, adventureLoader, ix, meta); this.set$Tab( ix, PANEL_TYP_ADVENTURES, meta, $(`
`).append(view.$getEle()), title || data?.chapter?.name || "", true, !!title, ); }); } doPopulate_Books (book, chapter, skipSetTab, title) { // FIXME skipSetTab is never used const meta = {b: book, c: chapter}; const ix = this.set$TabLoading( PANEL_TYP_BOOKS, meta, ); return bookLoader.pFill(book).then(() => { const data = bookLoader.getFromCache(book, chapter); const view = new AdventureOrBookView("b", this, bookLoader, ix, meta); this.set$Tab( ix, PANEL_TYP_BOOKS, meta, $(`
`).append(view.$getEle()), title || data?.chapter?.name || "", true, !!title, ); }); } set$ContentTab (type, contentMeta, $content, title, tabCanRename, tabRenamed) { const ix = this.isTabs ? this.getNextTabIndex() : 0; return this.set$Tab(ix, type, contentMeta, $content, title, tabCanRename, tabRenamed); } doPopulate_Rollbox (title) { this.set$ContentTab( PANEL_TYP_ROLLBOX, null, $(`
`).append(Renderer.dice.get$Roller().addClass("rollbox-panel")), title || "Dice Roller", true, !!title, ); } doPopulate_Counter (state = {}, title) { this.set$ContentTab( PANEL_TYP_COUNTER, state, $(`
`).append(Counter.$getCounter(this.board, state)), title || "Counter", true, ); } doPopulate_UnitConverter (state = {}, title) { this.set$ContentTab( PANEL_TYP_UNIT_CONVERTER, state, $(`
`).append(UnitConverter.make$Converter(this.board, state)), title || "Unit Converter", true, ); } doPopulate_MoneyConverter (state = {}, title) { this.set$ContentTab( PANEL_TYP_MONEY_CONVERTER, state, $(`
`).append(MoneyConverter.make$Converter(this.board, state)), title || "Money Converter", true, ); } doPopulate_TimeTracker (state = {}, title) { this.set$ContentTab( PANEL_TYP_TIME_TRACKER, state, $(`
`).append(TimeTracker.$getTracker(this.board, state)), title || "Time Tracker", true, ); } doPopulate_TextBox (content, title = "Notes") { this.set$ContentTab( PANEL_TYP_TEXTBOX, null, $(`
`).append(NoteBox.make$Notebox(this.board, content)), title, true, ); } doPopulate_YouTube (url, title = "YouTube") { const meta = {u: url}; this.set$ContentTab( PANEL_TYP_TUBE, meta, $(`