UNPKG

@croquet/microverse-library

Version:

An npm package version of Microverse

916 lines (781 loc) 36.7 kB
// the following import statement is solely for the type checking and // autocompletion features in IDE. A Behavior cannot inherit from // another behavior or a base class but can use the methods and // properties of the card to which it is installed. // The prototype classes ActorBehavior and PawnBehavior provide // the features defined at the card object. import {ActorBehavior, PawnBehavior} from "../PrototypeBehavior"; class PDFActor extends ActorBehavior { setup() { // these will be initialised by the first client to load the doc and figure out // a suitable aspect ratio. pageGapPercent is needed for calculating overall // scroll position. if (this.numPages === undefined) { this.measuredDocLocation = null; // pdfLocation for the latest doc for which we have measurements this.numPages = null; this.pageGapPercent = null; this.scrollState = null; } this.addButtons(); this.listen("docLoaded", "docLoaded"); this.listen("changePage", "changePage"); this.listen("scrollByPercent", "scrollByPercent"); this.listen("requestScrollPosition", "requestScrollPosition"); this.listen("setCardData", "cardDataUpdated"); this.subscribe(this.id, "buttonPageChange", "changePage"); this.subscribe(this.sessionId, "resetAppState", "resetAppState"); } viewJoined(_viewId) { } viewExited(_viewId) { } resetAppState() { this.scrollState = { page: 1, percent: 0 }; this.scrollState.upAvailable = false; this.scrollState.downAvailable = true; this.publish(this.id, "updateButtons"); } addButtons() { // from chevron-up-solid-thicker const chevronSVG = "data:image/svg+xml;base64,PD94bWwgdmVyc2lvbj0iMS4wIiBlbmNvZGluZz0iVVRGLTgiPz4KPHN2ZyBpZD0iTGF5ZXJfMSIgZGF0YS1uYW1lPSJMYXllciAxIiB4bWxucz0iaHR0cDovL3d3dy53My5vcmcvMjAwMC9zdmciIHZpZXdCb3g9IjAgMCA0NzAuMTQgMjc4LjA0Ij4KICA8cGF0aCBkPSJtNDU3LjU0LDIwNC42TDI2NS41NCwxMi42Yy04LjEyLTguMTItMTguOTMtMTIuNi0zMC40My0xMi42LTEwLjgyLDAtMjEuMDIsMy45Ni0yOC45NSwxMS4xOS0uNTYuMzgtMS4wOC44Mi0xLjU4LDEuMzFMMTIuNTgsMjA0LjVjLTE2Ljc4LDE2Ljc4LTE2Ljc4LDQ0LjA4LDAsNjAuODYsMTYuNzgsMTYuNzgsNDQuMDgsMTYuNzgsNjAuODUsMEwyMzUuMDYsMTAzLjgzbDE2MS42MiwxNjEuNjJjOC4zOSw4LjM5LDE5LjQxLDEyLjU4LDMwLjQzLDEyLjU4czIyLjA0LTQuMTksMzAuNDMtMTIuNThjOC4xMi04LjEyLDEyLjYtMTguOTMsMTIuNi0zMC40M3MtNC40Ny0yMi4zMS0xMi42LTMwLjQzWiIvPgo8L3N2Zz4="; const buttonSpecs = { up: { svg: chevronSVG, scale: 0.7, position: [0, 0.05] }, down: { svg: chevronSVG, scale: 0.7, position: [0, 0.05], rotation: [0, 0, Math.PI] }, }; const size = this.buttonSize = 0.1; const CIRCLE_RATIO = 1.25; // ratio of button circle to inner svg const makeButton = symbol => { const { svg, scale, position, rotation } = buttonSpecs[symbol]; const button = this.createCard({ name: "button", dataLocation: svg, fileName: "/svg.svg", // ignored modelType: "svg", shadow: true, singleSided: true, scale: [size * scale / CIRCLE_RATIO, size * scale / CIRCLE_RATIO, 1], rotation: rotation || [0, 0, 0], depth: 0.01, type: "2d", fullBright: true, behaviorModules: ["PDFButton"], parent: this, noSave: true, }); button.call("PDFButton$PDFButtonActor", "setProperties", { name: symbol, svgScale: scale, svgPosition: position || [0, 0] }); return button; } this.buttons = {}; ["up", "down"].forEach(buttonName => { this.buttons[buttonName] = makeButton(buttonName); }); } docLoaded(data) { // might be sent by multiple clients. the first one delivering the measurements for // the current pdfLocation gets to set them, and reset the scroll. const { pdfLocation } = data; if (pdfLocation === this._cardData.pdfLocation && pdfLocation !== this.measuredDocLocation) { this.measuredDocLocation = pdfLocation; this.numPages = data.numPages; this.pageGapPercent = data.pageGapPercent; this.maxScrollPosition = data.maxScrollPosition; this.scrollState = null; } if (this.scrollState === null) this.scrollState = { page: 1, percent: 0 }; this.annotateAndAnnounceScroll(); } cardDataUpdated(data) { if (data.height !== undefined) { const offsetX = this.buttonSize * 3 / 4; const offsetY = data.height / 2 + this.buttonSize * 3 / 4; const depth = this._cardData.depth || 0.05; const { up, down } = this.buttons; up.translateTo([-offsetX, -offsetY, depth]); down.translateTo([offsetX, -offsetY, depth]); this.say("sizeSet"); } } changePage(increment) { // increment is only ever +/- 1 let { page, percent } = this.scrollState; const { page: maxScrollPage, percent: maxScrollPercent } = this.maxScrollPosition; if (increment === 1) { // going forwards // when paging forward from the maximum scroll position, jump to the start // of the document. also do so if we're already on the last page. if (page === this.numPages || page === maxScrollPage && percent === maxScrollPercent) page = 1; else page++; } else { // going backwards if (percent <= 0) page--; // (whereas if going backwards from somewhere down a page, we just go to top of that page) if (page === 0) page = this.numPages; // subject to reduction by normalizeScroll } this.scrollState = this.normalizeScroll(page, 0); this.annotateAndAnnounceScroll(); } requestScrollPosition({ page, percent }) { const { page: newPage, percent: newPercent } = this.normalizeScroll(page, percent); this.announceScrollIfNew(newPage, newPercent); } scrollByPercent(increment) { let { page, percent } = this.scrollState; percent += increment; const { page: newPage, percent: newPercent } = this.normalizeScroll(page, percent); this.announceScrollIfNew(newPage, newPercent); } normalizeScroll(page, percent) { // note that scroll is advanced at a constant rate based on percent of the // viewer height, which is set from the first page. a uniquely tall page // in the document will therefore scroll proportionally faster, and a short // page slower. in most documents this effect shouldn't be too noticeable. const gapPercent = this.pageGapPercent; const { page: lastPage, percent: lastPercent } = this.maxScrollPosition; while (percent < -gapPercent && page > 1) { page--; percent += 100 + gapPercent; } while (percent > 100 && page < lastPage) { page++; percent -= 100 + gapPercent; } if (page === 1 && percent < 0) percent = 0; else if (page > lastPage || (page === lastPage && percent > lastPercent)) { page = lastPage; percent = lastPercent; } return { page, percent }; } announceScrollIfNew(page, percent) { const { page: oldPage, percent: oldPercent } = this.scrollState; if (page !== oldPage || percent !== oldPercent) { this.scrollState = { page, percent }; this.annotateAndAnnounceScroll(); } } annotateAndAnnounceScroll() { // @@ at some point we'll add annotation for scroll being in progress, and under whose control const { page, percent } = this.scrollState; const { page: lastPage, percent: lastPercent } = this.maxScrollPosition; this.scrollState.upAvailable = page !== 1 || percent !== 0; this.scrollState.downAvailable = page !== lastPage || percent !== lastPercent; this.say("drawAtScrollPosition"); this.publish(this.id, "updateButtons"); } } class PDFPawn extends PawnBehavior { setup() { if (!window.pdfjsPromise) { window.pdfjsPromise = new Promise(resolve => { const s = document.createElement('script'); s.setAttribute('src', 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.min.js'); s.onload = () => { const pdfjsLib = window['pdfjs-dist/build/pdf']; pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdn.jsdelivr.net/npm/pdfjs-dist@2.16.105/build/pdf.worker.min.js'; resolve(pdfjsLib); }; document.body.appendChild(s); }); } this.addEventListener("pointerDown", "onPointerDown"); this.addEventListener("pointerMove", "onPointerMove"); this.addEventListener("pointerUp", "onPointerUp"); this.addEventListener("keyDown", "onKeyDown"); // this.addEventListener("keyUp", "onKeyUp"); this.addEventListener("pointerWheel", "onPointerWheel"); this.listen("cardDataSet", "cardDataUpdated"); this.listen("sizeSet", "sizeSet"); this.listen("drawAtScrollPosition", "drawAtScrollPosition"); this.listen("updateShape", "updateShape"); this.subscribe(this.id, "buttonPressed", "buttonPressed"); let moduleName = this._behavior.module.externalName; this.addUpdateRequest([`${moduleName}$PDFPawn`, "update"]); this.TEXTURE_SIZE = 2048; this.initializeDocProperties(); // on a behavior reload, the pdf will typically already be loaded if (this.hasLatestPDF()) this.measureDocument(); else this.loadDocument(this.actorPDFLocation()); } actorPDFLocation() { return this.actor._cardData.pdfLocation } hasLatestPDF() { return !!this.pdf && this.pdfLocation === this.actorPDFLocation() } initializeDocProperties() { // if this is a reload, discard any GPU resources we were holding onto if (this.pages) { const meshPool = this.pageMeshPool; [...this.shape.children].forEach(o => { if (o.name === "page") { this.shape.remove(o); meshPool.push(o); } }); meshPool.forEach(mesh => { mesh.geometry.dispose(); mesh.material.dispose(); }); this.pages.forEach(pageEntry => { if (pageEntry.texture) pageEntry.texture.dispose(); }); } this.numPages = null; // also held by actor this.pages = []; // sparse array of page number to details this.pageGap = null; this.visiblePages = []; // sparse array of page number to time page became visible this.renderQueue = []; // page numbers to render when we have time this.renderingPage = false; // false at startup, to trigger immediate first render this.pageMeshPool = []; } cardDataUpdated(data) { const { pdfLocation } = data.v; if (pdfLocation === data.o.pdfLocation) return; this.cancelRenderInProgress(); this.initializeDocProperties(); this.loadDocument(pdfLocation); } updateShape() { // the shape has been updated, which means that at some point - possibly not // yet - any existing shape will be dismantled and rebuilt (by // CardPawn.updateShape). // we schedule a minimal wait to ensure that that has happened, then force a // re-render. setTimeout(() => this.drawAtScrollPosition(), 0); } async loadDocument(pdfLocation) { const assetManager = this.service("AssetManager").assetManager; this.pdf = null; this.pdfLocation = null; let objectURL; try { const buffer = await assetManager.fillCacheIfAbsent(pdfLocation, () => { return this.getBuffer(pdfLocation); }, this.id); objectURL = URL.createObjectURL(new Blob([buffer])); const pdfjsLib = await window.pdfjsPromise; const pdf = await pdfjsLib.getDocument(objectURL).promise; if (pdfLocation === this.actorPDFLocation()) { this.pdf = pdf; this.pdfLocation = pdfLocation; console.log(`PDF with ${this.pdf.numPages} pages loaded`); this.measureDocument(); // async } } catch (err) { // PDF loading error this.say("assetLoadError", {message: err.message, path: pdfLocation}); console.error(err.message); } if (objectURL) URL.revokeObjectURL(objectURL); } async measureDocument() { const numPages = this.numPages = this.pdf.numPages; const firstPage = this.ensurePageEntry(1); await firstPage.pageReadyP; if (!this.hasLatestPDF()) return; // it's been replaced while we were preparing const { pdfLocation } = this; const actorHasMeasurements = this.actor.measuredDocLocation === pdfLocation; // gap is arbitrarily set as 2% of page height for landscape, // 1% for portrait, 1.5% for square. const { width: firstWidth, height: firstHeight } = firstPage; const gapPercent = firstWidth > firstHeight ? 2 : firstWidth === firstHeight ? 1.5 : 1; this.adjustCardSize(firstWidth, firstHeight, gapPercent, !actorHasMeasurements); // includes setting pageGap, which we need in order to render anything // the actor needs to know the number of pages, the page gap percent (although moot if only one page), and the maximum scroll position (page and percent). if (!actorHasMeasurements) { let lastScroll; if (numPages === 1) { lastScroll = { page: 1, percent: 0 }; } else { let lastPage = numPages; let percentToFill = 100; // percent of card to fill with the bottom of the document let lastPercent; while (percentToFill > gapPercent) { const lastPageEntry = this.ensurePageEntry(lastPage); await lastPageEntry.pageReadyP; const { width, height } = lastPageEntry; // how tall is this page relative to the first page, when rendered at the same width? const pageHeightRatio = height / width * firstWidth / firstHeight; percentToFill -= pageHeightRatio * 100; if (percentToFill < 0) { // can't show all of this page. we've reached the end. lastPercent = -percentToFill / pageHeightRatio; } else if (percentToFill <= gapPercent) { // can show all of the page, plus some amount of gap lastPercent = -percentToFill; } else { // we're showing the whole page, plus its preceding gap percentToFill -= gapPercent; lastPage --; } if (lastPercent === -0) lastPercent = 0; // would otherwise mess up tests in the model } lastScroll = { page: lastPage, percent: lastPercent }; } this.say("docLoaded", { pdfLocation, pageGapPercent: gapPercent, numPages, maxScrollPosition: lastScroll }); } } sizeSet() { this.updateButtons(); } drawAtScrollPosition() { if (!this.hasLatestPDF() || !this.pageGap) return; const { scrollState } = this.actor; if (!scrollState) return; // where we already have a mesh for a page we're going to display, be sure // to reuse it const meshPool = this.pageMeshPool; [...this.shape.children].forEach(o => { if (o.name === "page") { this.shape.remove(o); meshPool.push(o); } }); meshPool.forEach(mesh => { if (!this.visiblePages[mesh.lastAssignedPage]) delete mesh.lastAssignedPage; }); const { depth } = this.actor._cardData; const { cardWidth, cardHeight } = this; const { page, percent } = scrollState; let p = page, yStart = percent / 100, shownHeight = 0; while (true) { const pageEntry = this.ensurePageEntry(p); const { renderResult, aspectRatio } = pageEntry; if (aspectRatio === undefined) return; // not ready yet const fullPageHeight = cardWidth / aspectRatio; if (renderResult) { // if possible, reuse the mesh that already has this page's texture let pageMesh; let existingIndex = meshPool.findIndex(mesh => mesh.lastAssignedPage === p); if (existingIndex < 0) existingIndex = meshPool.findIndex(mesh => !mesh.lastAssignedPage); if (existingIndex < 0) pageMesh = this.makePageMesh(); else pageMesh = meshPool.splice(existingIndex, 1)[0]; pageMesh.lastAssignedPage = p; pageMesh.geometry.dispose(); const imageTop = Math.max(0, yStart); // proportion from top of image const topGap = yStart < 0 ? -yStart * cardHeight : 0; const pageHeight = Math.min((1 - imageTop) * fullPageHeight, cardHeight - shownHeight - topGap); const imageBottom = imageTop + pageHeight / fullPageHeight; const geo = pageMesh.geometry = new Microverse.THREE.PlaneGeometry(cardWidth, pageHeight); this.shape.add(pageMesh); const pageY = cardHeight / 2 - shownHeight - topGap - pageHeight / 2; pageMesh.position.set(0, pageY, depth / 2 + 0.003); const uv = geo.attributes.uv; uv.setXY(0, 0, 1 - imageTop); uv.setXY(1, 1, 1 - imageTop); uv.setXY(2, 0, 1 - imageBottom); uv.setXY(3, 1, 1 - imageBottom); uv.needsUpdate = true; if (!pageEntry.texture) pageEntry.texture = new Microverse.THREE.Texture(renderResult); pageEntry.texture.colorSpace = Microverse.THREE.SRGBColorSpace; if (pageMesh.material.map !== pageEntry.texture) { pageMesh.material.map = pageEntry.texture; pageEntry.texture.needsUpdate = true; } } shownHeight += fullPageHeight - fullPageHeight * yStart + this.pageGap; // whether or not page is being shown if (p === this.numPages || shownHeight >= cardHeight) break; else { p++; yStart = 0; } } this.updateButtons(); } updateButtons() { const { scrollState } = this.actor; if (!scrollState) return; const { upAvailable, downAvailable } = scrollState; if (upAvailable === undefined) return; // not ready yet this.buttonState = { up: upAvailable, down: downAvailable, }; this.publish(this.id, "updateButtons"); } setButtonHilite(buttonName, hilite) { // not actually needed until we have some toggles const groups = [["up"], ["down"]]; const group = groups.find(g => g.includes(buttonName)); this.publish(this.id, "updateHilites", { buttons: group, hilite }); } makePageMesh() { const { cardWidth, cardHeight } = this; const pageGeometry = new Microverse.THREE.PlaneGeometry(cardWidth, cardHeight); const pageMaterial = new Microverse.THREE.MeshBasicMaterial({ color: "#fff", side: Microverse.THREE.DoubleSide, toneMapped: false }); const pageMesh = new Microverse.THREE.Mesh(pageGeometry, pageMaterial); pageMesh.name = "page"; return pageMesh; } updateVisiblePages() { // this is invoked on every update, so if any page that we // want to test isn't ready yet (PageProxy hasn't been fetched) we don't // wait for it. const { page, percent } = this.actor.scrollState; const { cardWidth, cardHeight } = this; const prevVisible = this.visiblePages; const nowVisible = this.visiblePages = []; let p = page, yStart = percent / 100, shownHeight = 0; while (true) { if (prevVisible[p]) nowVisible[p] = prevVisible[p]; else nowVisible[p] = Date.now(); if (p === this.numPages) return; // end of the doc const pageEntry = this.ensurePageEntry(p); const { aspectRatio } = pageEntry; if (aspectRatio === undefined) return; // not ready yet const fullPageHeight = cardWidth / aspectRatio; shownHeight += fullPageHeight - yStart * fullPageHeight + this.pageGap; if (shownHeight >= cardHeight) return; p++; yStart = 0; } } manageRenderState() { // invoked on every update. schedule rendering for pages that are nearby, // and clean up render results and textures that aren't being used. const { page } = this.actor.scrollState; const { numPages } = this; const queue = this.renderQueue = []; const queueIfNeeded = pageNumber => { if (pageNumber < 1) pageNumber += numPages; else if (pageNumber > numPages) pageNumber -= numPages; const pageEntry = this.ensurePageEntry(pageNumber); if (pageEntry.page && !pageEntry.renderTask) queue.push(pageNumber); }; // queue nearby pages for rendering (up to 3 pages away) const range = Math.min(numPages, 4); for (let diff = 1; diff < range; diff++) { queueIfNeeded(page + diff); queueIfNeeded(page - diff); } // and discard the renderings of pages over 5 away const discardIfOver = 5; this.pages.forEach((pageEntry, otherPage) => { if (otherPage === page) return; const isVisible = !!this.visiblePages[otherPage]; if (pageEntry.renderResult) { // take account of wrapping from last page to first const minDist = otherPage > page ? Math.min(otherPage - page, page + numPages - otherPage) : Math.min(page - otherPage, otherPage + numPages - page); if (minDist > discardIfOver) { // console.log(`rendering for p${otherPage} discarded`); pageEntry.renderResult = null; pageEntry.renderTask = null; } if (pageEntry.texture && !isVisible) { // console.log(`texture for p${otherPage} discarded`); pageEntry.texture.dispose(); pageEntry.texture = null; } } }); } processRenderQueue() { // invoked on every update. // the queue can be used to schedule rendering of pages that aren't currently // on display but might be in the near future. // before even looking at the queue, check that the pages now on display have // been rendered or are at least in progress. scheduling such a page is allowed // to cancel the rendering of any page that isn't on display - but to avoid a // cascade of render starts and cancellations when a user scrolls rapidly through // a fresh document, don't start rendering a page until it has been on display // for a while. // if we're already rendering a page that is currently on display, don't // interfere. const visibles = this.visiblePages; if (this.renderingPage && visibles[this.renderingPage]) return; // the first time we render, no need to confirm that the page is hanging around const RENDER_WAIT = this.renderingPage === false ? 0 : 200; // ms const now = Date.now(); visibles.forEach((visibleTime, pageNumber) => { if (now - visibleTime < RENDER_WAIT) return; const pageEntry = this.ensurePageEntry(pageNumber); if (pageEntry.page && !pageEntry.renderTask) { this.startRendering(pageNumber); // cancelling any other render task return; } }); if (this.renderingPage) return; // not currently a visible page, but allow it to keep running const queue = this.renderQueue; if (queue.length === 0) return; const toRender = queue[0]; const pageEntry = this.ensurePageEntry(toRender); if (!pageEntry.page) return; // try again later queue.shift(); if (!pageEntry.renderTask) this.startRendering(toRender); } startRendering(pageNumber) { // because we allow rendering to be pre-empted, we can't be sure that it will // finish. this.cancelRenderInProgress(); this.renderingPage = pageNumber; const pageEntry = this.ensurePageEntry(pageNumber); const { page, renderScale } = pageEntry; const viewport = page.getViewport({ scale: renderScale }); // console.log({viewport}); // Prepare canvas using PDF page dimensions if (!this.renderCanvas) this.renderCanvas = document.createElement("canvas"); const canvas = this.renderCanvas; const context = canvas.getContext("2d", { willReadFrequently: true }); canvas.height = viewport.height; canvas.width = viewport.width; const renderContext = { canvasContext: context, viewport }; const renderTask = pageEntry.renderTask = page.render(renderContext); renderTask.promise.then( () => { // console.log(`p${pageNumber} rendered`); this.renderingPage = null; pageEntry.renderResult = context.getImageData(0, 0, viewport.width, viewport.height); this.finishedRendering(pageNumber); }, () => { // console.log(`p${pageNumber} cancelled`); pageEntry.renderTask = null; // so we'll try again later } ); } cancelRenderInProgress() { if (this.renderingPage) { const pageEntry = this.ensurePageEntry(this.renderingPage); pageEntry.renderTask.cancel(); // will reject the render promise } } finishedRendering(pageNumber) { if (this.visiblePages[pageNumber]) this.drawAtScrollPosition(); } adjustCardSize(width, height, gapPercent, tellActor) { // width and height are page pixels. // invoked as soon as we discover the size of page 1 const { depth } = this.actor._cardData; const maxDim = Math.max(width, height); const cardScale = 1 / maxDim; const cardWidth = this.cardWidth = width * cardScale; const cardHeight = this.cardHeight = height * cardScale; const obj = this.shape.children.find((o) => o.name === "2d"); if (obj) { obj.geometry.dispose(); obj.geometry = this.squareCornerGeometry(cardWidth, cardHeight, depth); } this.pageGap = cardHeight * gapPercent / 100; // three.js units between displayed pages if (tellActor) this.say("setCardData", { height: cardHeight, width: cardWidth }); } squareCornerGeometry(width, height, depth) { let x = height / 2; let y = width / 2; let z = depth / 2; let shape = new Microverse.THREE.Shape(); shape.moveTo(-x, -y); shape.lineTo(-x, y); shape.lineTo(x, y); shape.lineTo(x, -y); shape.lineTo(-x, -y); let extrudePath = new Microverse.THREE.LineCurve3(new Microverse.THREE.Vector3(0, 0, z), new Microverse.THREE.Vector3(0, 0, -z)); extrudePath.arcLengthDivisions = 3; let geometry = new Microverse.THREE.ExtrudeGeometry(shape, { extrudePath }); geometry.parameters.width = width; geometry.parameters.height = height; geometry.parameters.depth = depth; return geometry; } ensurePageEntry(pageNumber) { const existing = this.pages[pageNumber]; if (existing) return existing; const entry = { renderResult: null, renderTask: null }; this.pages[pageNumber] = entry; entry.pageReadyP = new Promise(resolve => { this.pdf.getPage(pageNumber).then(proxy => { entry.page = proxy; const { width, height } = proxy.getViewport({ scale: 1 }); entry.width = width; entry.height = height; const maxDim = Math.max(width, height); const renderScale = this.TEXTURE_SIZE / maxDim * 0.999; entry.renderScale = renderScale; entry.aspectRatio = width / height; resolve(); }); }); return entry; } onPointerDown(p3d) { if (!p3d.uv) return; this.pointerDownTime = Date.now(); this.pointerDownY = p3d.xyz[1]; const { page, percent } = this.actor.scrollState; this.pointerDownScroll = { page, percent }; this.pointerDragRange = 0; // how far user drags before releasing pointer } onPointerMove(p3d) { if (!p3d.uv) return; const THROTTLE = 50; // ms const now = Date.now(); if (now - (this.lastPointerMove || 0) < THROTTLE) return; this.lastPointerMove = now; const { page, percent } = this.pointerDownScroll; const yScale = this.actor._scale[1]; const percentChange = (p3d.xyz[1] - this.pointerDownY) / this.cardHeight / yScale * 100; this.pointerDragRange = Math.max(this.pointerDragRange, Math.abs(percentChange)); this.say("requestScrollPosition", { page, percent: percent + percentChange }); } onPointerUp(p3d) { if (!p3d.uv) return; if (this.pointerDragRange < 2 && Date.now() - this.pointerDownTime < 250) this.changePage(1); } onPointerWheel(evt) { if (!this.pdf || !this.pageGap) return; const THROTTLE = 50; // ms const now = Date.now(); this.cumulativeWheelDelta = (this.cumulativeWheelDelta || 0) + evt.deltaY; if (now - (this.lastPointerWheel || 0) < THROTTLE) return; this.lastPointerWheel = now; const WHEEL_SCALE = 4; let percent = this.cumulativeWheelDelta * WHEEL_SCALE / this.TEXTURE_SIZE * 100; if (Math.abs(percent) > 50) percent = 50 * Math.sign(percent); this.cumulativeWheelDelta = 0; this.say("scrollByPercent", percent); } onKeyDown(e) { if (e.repeat) return; switch (e.key) { case "ArrowLeft": case "ArrowUp": this.changePage(-1); break; case "ArrowRight": case "ArrowDown": this.changePage(1); break; default: } } buttonPressed(buttonName) { if (buttonName === "up") this.changePage(-1); else if (buttonName === "down") this.changePage(1); } changePage(change) { this.say("changePage", change); } onKeyUp(_e) { } update() { if (this.actor.measuredDocLocation === this.pdfLocation && this.pageGap) { this.updateVisiblePages(); this.manageRenderState(); this.processRenderQueue(); } } teardown() { console.log("PDFPawn teardown"); this.cleanupShape(); this.pageMeshPool.forEach(mesh => { mesh.geometry.dispose(); mesh.material.dispose(); }); this.pages.forEach(pageEntry => { if (pageEntry.texture) pageEntry.texture.dispose(); }); let moduleName = this._behavior.module.externalName; this.removeUpdateRequest([`${moduleName}$PDFPawn`, "update"]); if (this.pdfLocation) { const assetManager = this.service("AssetManager").assetManager; assetManager.revoke(this.pdfLocation, this.id); } } } class PDFButtonActor extends ActorBehavior { // setup() { // } setProperties(props) { this.buttonName = props.name; this.svgScale = props.svgScale; this.svgPosition = props.svgPosition; // [x, y] to nudge position } } class PDFButtonPawn extends PawnBehavior { setup() { this.subscribe(this.id, "2dModelLoaded", "svgLoaded"); this.addEventListener("pointerMove", "nop"); this.addEventListener("pointerEnter", "hilite"); this.addEventListener("pointerLeave", "unhilite"); this.addEventListener("pointerTap", "tapped"); // effectively prevent propagation this.addEventListener("pointerDown", "nop"); this.addEventListener("pointerUp", "nop"); this.removeEventListener("pointerDoubleDown", "onPointerDoubleDown"); this.addEventListener("pointerDoubleDown", "nop"); this.subscribe(this.parent.id, "updateButtons", "updateState"); this.subscribe(this.parent.id, "updateHilites", "updateHilite"); this.enabled = true; } svgLoaded() { // no hit-test response on anything but the hittable mesh set up below const { buttonName, svgScale, svgPosition } = this.actor; const svg = this.shape.children[0]; // apply any specified position nudging svg.position.x += svgPosition[0]; svg.position.y += svgPosition[1]; this.shape.raycast = () => false; svg.traverse(obj => obj.raycast = () => false); const { depth } = this.actor._cardData; const radius = 1.25 / svgScale / 2; const segments = 32; const geometry = new Microverse.THREE.CylinderGeometry(radius, radius, depth, segments); const opacity = (buttonName === "mute" || buttonName === "unmute") ? 0 : 1; const material = new Microverse.THREE.MeshBasicMaterial({ color: 0xa0a0a0, side: Microverse.THREE.DoubleSide, transparent: true, opacity }); const hittableMesh = new Microverse.THREE.Mesh(geometry, material); hittableMesh.rotation.x = Math.PI / 2; hittableMesh.position.z = -depth / 2; this.shape.add(hittableMesh); this.shape.visible = false; // until placed this.updateState(); } updateState() { // invoked on every scroll update, so be efficient const { buttonState } = this.parent; if (!buttonState) return; // size not set yet const wasVisible = this.shape.visible; this.shape.visible = true; const wasEnabled = this.enabled; this.enabled = buttonState[this.actor.buttonName]; if (!wasVisible || this.enabled !== wasEnabled) { this.service("RenderManager").dirtyLayer("pointer"); this.setColor(); } } setColor() { let svg = this.shape.children[0]; if (!svg) return; let color = this.enabled ? (this.entered ? 0x202020 : 0x404040) : 0xc0c0c0; svg.children.forEach(child => child.material[0].color.setHex(color)); } hilite() { this.parent.call("PDFView$PDFPawn", "setButtonHilite", this.actor.buttonName, true); // this.publish(this.parent.id, "interaction"); } unhilite() { this.parent.call("PDFView$PDFPawn", "setButtonHilite", this.actor.buttonName, false); } updateHilite({ buttons, hilite }) { if (!buttons.includes(this.actor.buttonName)) return; this.entered = hilite; this.setColor(); } tapped() { if (!this.enabled) return; this.publish(this.parent.actor.id, "buttonPageChange", this.actor.buttonName === "down" ? 1 : -1); } } export default { modules: [ { name: "PDFView", actorBehaviors: [PDFActor], pawnBehaviors: [PDFPawn], }, { name: "PDFButton", actorBehaviors: [PDFButtonActor], pawnBehaviors: [PDFButtonPawn], } ] } /* globals Microverse */