diff --git a/backend/api/schemas/security.ts b/backend/api/schemas/security.ts index debb099f8..b48a79fdf 100644 --- a/backend/api/schemas/security.ts +++ b/backend/api/schemas/security.ts @@ -37,10 +37,6 @@ export async function registerSchemas(app: FastifyInstance): Promise { minimum: 0, description: 'Seconds. Must be greater than zero when HSTS is enforced.' }, - disallowFloc: { - type: 'boolean', - description: 'Sends `Permissions-Policy: interest-cohort=()`.' - }, disallowIframe: { type: 'boolean', description: '`X-Frame-Options: DENY` when on, `SAMEORIGIN` when off.' diff --git a/backend/base.yml b/backend/base.yml index 20da7e6fe..4199fb893 100644 --- a/backend/base.yml +++ b/backend/base.yml @@ -74,7 +74,6 @@ defaults: enforceCsp: false trustProxy: false enforceHsts: false - disallowFloc: true hstsDuration: 0 cspDirectives: '' uploadScanSVG: true diff --git a/backend/index.ts b/backend/index.ts index fe3dd02c3..c35fb2185 100644 --- a/backend/index.ts +++ b/backend/index.ts @@ -335,15 +335,6 @@ async function initHTTPServer() { methods: ['GET', 'HEAD', 'POST', 'OPTIONS'] }) - if (security.disallowFloc) { - // -> Helmet dropped its FLoC helper once the proposal was withdrawn, but opting out still costs - // one header and the setting exists - app.addHook('onSend', (req, reply, payload, done) => { - reply.header('Permissions-Policy', 'interest-cohort=()') - done(null, payload) - }) - } - // ---------------------------------------- // Public Assets // ---------------------------------------- diff --git a/backend/locales/en.json b/backend/locales/en.json index 42637c6d7..4ba7a5f2c 100644 --- a/backend/locales/en.json +++ b/backend/locales/en.json @@ -459,8 +459,6 @@ "admin.icons.diskCacheValue": "{count} icons ({size})", "admin.icons.enableSuccess": "The {set} icon set has been enabled.", "admin.icons.filterSets": "Filter icon sets...", - "admin.icons.howItWorks": "How icons are stored", - "admin.icons.howItWorksHint": "Content references an icon by name, e.g. mdi:account-edit. The first time an icon is used it is fetched from Iconify and saved to the database, which is its permanent home. Memory and disk are caches in front of it and can be discarded at any time.", "admin.icons.isEnabled": "Enabled", "admin.icons.loadFailed": "Failed to load the icon sets.", "admin.icons.memoryCache": "Memory cache", @@ -485,8 +483,6 @@ "admin.icons.storedIconsValue": "{count} icons in the database", "admin.icons.subtitle": "Choose which icon sets can be used across the wiki", "admin.icons.title": "Icons", - "admin.icons.upstream": "Icon source", - "admin.icons.upstreamHint": "Icons come from the Iconify API. Once stored, they are served by this wiki and never fetched again — readers never talk to Iconify.", "admin.instances.activeConnections": "Active Connections", "admin.instances.activeListeners": "Active Listeners", "admin.instances.firstSeen": "First Seen", @@ -724,8 +720,6 @@ "admin.security.corsModeHint": "How the API server should handle preflight requests?", "admin.security.corsRegex": "Regex Pattern", "admin.security.corsRegexHint": "Pattern against which the request hostname is matched.", - "admin.security.disallowFloc": "Disallow Google FLoC", - "admin.security.disallowFlocHint": "Broadcast that this website should be opted-out of Google Federed Learning of Cohorts (FLoC). Recommended for privacy.", "admin.security.disallowIframe": "Disallow iFrame Embedding", "admin.security.disallowIframeHint": "Prevents other websites from embedding your wiki in an iframe. This provides clickjacking protection.", "admin.security.disallowOpenRedirect": "Block Open Redirect", diff --git a/backend/models/security.ts b/backend/models/security.ts index da42537eb..53badf67b 100644 --- a/backend/models/security.ts +++ b/backend/models/security.ts @@ -9,7 +9,6 @@ export const SECURITY_FIELDS = [ 'corsConfig', 'corsMode', 'cspDirectives', - 'disallowFloc', 'disallowIframe', 'disallowOpenRedirect', 'enforceCsp', diff --git a/backend/models/settings.ts b/backend/models/settings.ts index e9d4e8aef..eef32dc35 100644 --- a/backend/models/settings.ts +++ b/backend/models/settings.ts @@ -129,7 +129,6 @@ class Settings { corsConfig: '', corsMode: 'OFF', cspDirectives: '', - disallowFloc: true, disallowIframe: true, disallowOpenRedirect: true, enforceCsp: false, diff --git a/blocks/block-pdf/assets.json b/blocks/block-pdf/assets.json new file mode 100644 index 000000000..df372267c --- /dev/null +++ b/blocks/block-pdf/assets.json @@ -0,0 +1,6 @@ +{ + "pdfjs-dist/cmaps": "cmaps", + "pdfjs-dist/standard_fonts": "standard_fonts", + "pdfjs-dist/wasm": "wasm", + "pdfjs-dist/iccs": "iccs" +} diff --git a/blocks/block-pdf/component.js b/blocks/block-pdf/component.js new file mode 100644 index 000000000..373739cad --- /dev/null +++ b/blocks/block-pdf/component.js @@ -0,0 +1,1154 @@ +import { LitElement, html, css } from 'lit' +import { + getDocument, + GlobalWorkerOptions, + OutputScale, + PasswordException, + RenderingCancelledException, + TextLayer +} from 'pdfjs-dist/build/pdf.mjs' + +import { DarkMode } from '../shared/theme.js' + +/* + Where the parsing happens. + + `worker.js` in this directory is compiled to `block-pdf.worker.js` alongside this bundle, so the + address is this file's own, one name over. Resolved from `import.meta.url` rather than written as + `/_blocks/...`: the block knows where it was loaded from, and nothing else here has to agree with + the server about a path. + + Everything pdf.js does with a document happens behind this: reading its structure, decoding its + images, turning glyph runs into drawing operations. Left on the page's thread -- which is what + pdf.js falls back to when the worker cannot be reached -- a document of any size locks the wiki up + while it loads. +*/ +GlobalWorkerOptions.workerSrc = new URL('block-pdf.worker.js', import.meta.url).href + +/* + Where the data files pdf.js fetches for itself live, put there by the build's `blockAssets` step. + + Nothing below is loaded until a document turns out to need it, which is why they are files rather + than part of the bundle -- and why they can be listed unconditionally here. Each covers a case that + is otherwise a silently half-drawn page: + + - `cmaps` are the predefined CJK character maps, for a document naming one instead of embedding it. + - `standard_fonts` are the base 14 fonts, for a reader whose system has nothing to answer with. + - `wasm` decodes JPEG 2000 and JBIG2 images -- the compression scanners tend to produce. + - `iccs` is the CMYK profile, without which those colours are converted by approximation. +*/ +const DATA_URL = new URL('block-pdf/', import.meta.url).href + +/** + * An attribute that means "off" when it says so. + * + * MDC writes every prop with a value — `hideToolbar="false"` is what the block picker produces for a + * toggle that was switched on and off again — and Lit's own Boolean converter reads any string at all + * as true, that one included. + */ +const boolean = { + converter: { + fromAttribute: (value) => value !== null && value !== 'false', + toAttribute: (value) => (value ? 'true' : null) + } +} + +/** The zoom steps, and so also what the toolbar's list offers besides the two fitting modes. */ +const ZOOM_LEVELS = [0.5, 0.75, 1, 1.25, 1.5, 2, 3] + +const MIN_SCALE = 0.1 +const MAX_SCALE = 10 + +/** The space around a page, and between one page and the next. */ +const PAGE_GAP = 12 + +/** + * How far either side of what is on screen to keep drawn. + * + * A page is drawn when it comes within a page of the viewport and thrown away again once it is this + * many pages past it. Every drawn page is a canvas holding its own pixels, so a document read end to + * end would otherwise accumulate all of them; a couple of pages of slack is what makes scrolling back + * a few lines free rather than a redraw. + */ +const KEEP_PAGES = 2 + +/* + What a single page's canvas may cost, before the device's pixel ratio is honoured any further. + + These are pdf.js's own viewer defaults. A canvas past them is drawn at a lower resolution and + scaled up by CSS instead, which is a soft loss of sharpness -- where asking a browser for a canvas + larger than it will allocate is a hard failure, and a page zoomed to 300% on a retina display is + already asking for one. +*/ +const MAX_CANVAS_PIXELS = 2 ** 25 +const MAX_CANVAS_DIM = 32767 + +/** Icons, as the path of a 24x24 MDI glyph. */ +const ICONS = { + previous: 'M15.41,16.58L10.83,12L15.41,7.41L14,6L8,12L14,18L15.41,16.58Z', + next: 'M8.59,16.58L13.17,12L8.59,7.41L10,6L16,12L10,18L8.59,16.58Z', + zoomOut: 'M19,13H5V11H19V13Z', + zoomIn: 'M19,13H13V19H11V13H5V11H11V5H13V11H19V13Z', + open: 'M14,3V5H17.59L7.76,14.83L9.17,16.24L19,6.41V10H21V3M19,19H5V5H12V3H5C3.89,3 3,3.9 3,5V19A2,2 0 0,0 5,21H19A2,2 0 0,0 21,19V12H19V19Z' +} + +/** + * Block PDF + * + * A continuous viewer: every page of the document is laid out at once, and the ones near the + * viewport are drawn. Parsing runs in a worker and pdf.js's own data files are served alongside the + * block — see `DATA_URL` above and `assets.json` beside this file — so a document is read here the + * way it would be in a desktop reader, rather than to the extent a single bundle allows. + */ +export class BlockPdfElement extends LitElement { + /** + * Metadata for the admin area and the editor's block picker. Collected at build time into + * `compiled/blocks.manifest.json`, which the server reads to register the block. Values must be + * plain literals. See `props` in `block-index` for what the picker does with that list. + */ + static definition = { + block: 'pdf', + name: 'PDF Viewer', + description: 'Displays a PDF document in a viewer, page by page.', + icon: 'pdf', + props: [ + { + name: 'src', + type: 'string', + label: 'Document URL', + hint: 'Path or URL of the PDF file to display.', + required: true + }, + { + name: 'page', + type: 'number', + label: 'Opening Page', + hint: 'Page to open the document at.', + default: 1 + }, + { + name: 'zoom', + type: 'select', + label: 'Zoom', + options: ['page-width', 'page-fit', '50%', '75%', '100%', '125%', '150%', '200%', '300%'], + hint: 'Size to draw the pages at. The reader can change it.', + default: 'page-width' + }, + { + name: 'height', + type: 'number', + label: 'Height', + hint: 'Height of the viewer in pixels. 0 lets it grow to the whole document instead.', + default: 1024 + }, + { + name: 'hideToolbar', + type: 'boolean', + label: 'Hide Toolbar', + hint: 'Leave out the page and zoom controls.', + // -> Stated, so that a toggle switched on and then off again writes nothing into the page + default: false + } + ] + } + + static get styles() { + return css` + :host { + display: block; + + --pdf-border: #e0e0e0; + --pdf-toolbar-bg: linear-gradient(to bottom, #fdfdfd, #eeeeee); + --pdf-toolbar-fg: #424242; + --pdf-canvas-bg: #f1f3f5; + --pdf-page-shadow: 0 1px 4px rgb(0 0 0 / 0.25); + } + :host([dark]) { + --pdf-border: rgba(255, 255, 255, 0.15); + --pdf-toolbar-bg: linear-gradient(to bottom, #1b212a, #12161d); + --pdf-toolbar-fg: rgba(255, 255, 255, 0.7); + --pdf-canvas-bg: #12161d; + --pdf-page-shadow: 0 1px 4px rgb(0 0 0 / 0.6); + } + + /* + One raised box, with the toolbar and the pages clipped to its corners. + + -> It also carries the gap below the block. On this element rather than :host: see block-index. + */ + .viewer { + margin-bottom: 16px; + border: 1px solid var(--pdf-border); + border-radius: 6px; + overflow: hidden; + box-shadow: + 0 1px 3px rgb(0 0 0 / 0.1), + 0 1px 2px rgb(0 0 0 / 0.06); + } + :host([dark]) .viewer { + box-shadow: + 0 1px 3px rgb(0 0 0 / 0.5), + 0 1px 2px rgb(0 0 0 / 0.35); + } + + .toolbar { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 4px; + padding: 4px 8px; + border-bottom: 1px solid var(--pdf-border); + background-image: var(--pdf-toolbar-bg); + color: var(--pdf-toolbar-fg); + font-size: 13px; + line-height: 1; + } + + .spacer { + flex: 1 1 auto; + } + + .tool { + display: flex; + align-items: center; + justify-content: center; + width: 28px; + height: 28px; + padding: 0; + border: 0; + border-radius: 4px; + background: transparent; + color: inherit; + text-decoration: none; + cursor: pointer; + } + .tool:hover:not(:disabled) { + background-color: rgb(0 0 0 / 0.07); + color: var(--q-primary, #1976d2); + } + :host([dark]) .tool:hover:not(:disabled) { + background-color: rgb(255 255 255 / 0.1); + } + .tool:disabled { + opacity: 0.4; + cursor: default; + } + .tool svg { + width: 18px; + height: 18px; + fill: currentColor; + } + + .pager { + display: flex; + align-items: center; + gap: 4px; + white-space: nowrap; + } + + /* -> A number input wide enough for four digits, without the spinner taking half of it */ + .pager input { + width: 4ch; + padding: 4px 2px; + border: 1px solid var(--pdf-border); + border-radius: 4px; + background-color: rgb(255 255 255 / 0.6); + color: inherit; + font: inherit; + text-align: center; + appearance: textfield; + -moz-appearance: textfield; + } + .pager input::-webkit-inner-spin-button, + .pager input::-webkit-outer-spin-button { + appearance: none; + margin: 0; + } + :host([dark]) .pager input { + background-color: rgb(0 0 0 / 0.25); + } + + select { + max-width: 9rem; + padding: 4px 6px; + border: 1px solid var(--pdf-border); + border-radius: 4px; + background-color: rgb(255 255 255 / 0.6); + color: inherit; + font: inherit; + cursor: pointer; + } + :host([dark]) select { + background-color: rgb(0 0 0 / 0.25); + } + + /* + The scrolling surface. + + -> scrollbar-gutter keeps the width the pages are fitted to from changing the moment the + scrollbar appears, which at "fit width" is a measurement that decides the very layout that + brings the scrollbar in. + */ + .scroller { + position: relative; + overflow: auto; + scrollbar-gutter: stable; + background-color: var(--pdf-canvas-bg); + } + + .pages { + display: flex; + flex-direction: column; + align-items: center; + gap: ${PAGE_GAP}px; + padding: ${PAGE_GAP}px; + min-width: min-content; + } + + .page { + position: relative; + flex: none; + background-color: #fff; + box-shadow: var(--pdf-page-shadow); + + /* -> What pdf.js sizes the text layer against; see setLayerDimensions in its source. */ + --scale-factor: 1; + --user-unit: 1; + --total-scale-factor: calc(var(--scale-factor) * var(--user-unit)); + --scale-round-x: 1px; + --scale-round-y: 1px; + } + + .page canvas { + display: block; + width: 100%; + height: 100%; + } + + .status { + padding: 2rem 1rem; + color: var(--pdf-toolbar-fg); + text-align: center; + font-size: 13px; + } + + .error { + margin-bottom: 16px; + padding: 1rem; + border: 1px dashed color-mix(in srgb, currentColor 50%, transparent); + border-radius: 5px; + color: var(--q-negative, #c10015); + } + + /* + The text layer: a transparent copy of the page's words, positioned over the drawing so they + can be selected and searched for. Lifted from pdf.js's own stylesheet, which is 160 kB of + viewer chrome this block has no other use for. + */ + .textLayer { + position: absolute; + inset: 0; + overflow: clip; + z-index: 1; + opacity: 1; + line-height: 1; + text-align: initial; + letter-spacing: normal; + word-spacing: normal; + text-size-adjust: none; + forced-color-adjust: none; + transform-origin: 0 0; + caret-color: CanvasText; + + --min-font-size: 1; + --text-scale-factor: calc(var(--total-scale-factor) * var(--min-font-size)); + --min-font-size-inv: calc(1 / var(--min-font-size)); + } + + .textLayer :is(span, br) { + position: absolute; + color: transparent; + white-space: pre; + cursor: text; + transform-origin: 0% 0%; + user-select: text; + } + + .textLayer > :not(.markedContent), + .textLayer .markedContent span:not(.markedContent) { + z-index: 1; + + --font-height: 0; + --scale-x: 1; + --rotate: 0deg; + font-size: calc(var(--text-scale-factor) * var(--font-height)); + transform: rotate(var(--rotate)) scaleX(var(--scale-x)) scale(var(--min-font-size-inv)); + } + + .textLayer .markedContent { + display: contents; + } + + .textLayer span[role='img'] { + user-select: none; + cursor: default; + } + + .textLayer ::selection { + background: color-mix(in srgb, AccentColor, transparent 50%); + color: transparent; + } + + .textLayer br::selection { + background: transparent; + } + ` + } + + static get properties() { + return { + /** + * Path or URL of the PDF file + * @type {string} + */ + src: { type: String }, + + /** + * Page to open the document at + * @type {number} + */ + page: { type: Number }, + + /** + * `page-width`, `page-fit`, or a percentage + * @type {string} + */ + zoom: { type: String }, + + /** + * Height of the viewer in pixels, 0 to grow to the document + * @type {number} + */ + height: { type: Number }, + + /** + * Whether to leave out the page and zoom controls + * @type {boolean} + */ + hideToolbar: boolean, + + // Internal Properties + _error: { state: true }, + _loading: { state: true }, + _progress: { state: true }, + _pageCount: { state: true }, + _currentPage: { state: true }, + _scale: { state: true }, + _zoom: { state: true } + } + } + + constructor() { + super() + this.src = '' + this.page = 1 + this.zoom = 'page-width' + this.height = 1024 + this.hideToolbar = false + + this._error = '' + this._loading = false + this._progress = 0 + this._pageCount = 0 + this._currentPage = 1 + this._scale = 1 + this._zoom = 'page-width' + + this._darkMode = new DarkMode(this) + + /** The address the loaded document was fetched from, so a re-render is not a reload. */ + this._loadedSrc = null + this._loadingTask = null + this._doc = null + /** One entry per page, in order. See `_buildPages`. */ + this._pages = [] + this._observer = null + this._resizeObserver = null + this._resizeFrame = null + } + + get _scroller() { + return this.renderRoot?.querySelector('.scroller') ?? null + } + + get _pagesEl() { + return this.renderRoot?.querySelector('.pages') ?? null + } + + /** Whether the viewer scrolls within itself, rather than being as tall as the document. */ + get _hasInnerScroll() { + return this.height > 0 + } + + connectedCallback() { + super.connectedCallback() + /* + Moved, rather than newly built: leaving took the document and both observers with it, and the + shadow tree they were watching is still here to be watched again. `hasUpdated` is what tells the + two apart -- on a first connection there is no shadow tree yet, and `firstUpdated` does this. + */ + if (this.hasUpdated) { + this._setupObservers() + this._load() + } + } + + firstUpdated() { + this._zoom = this._parseZoom(this.zoom) + this._setupObservers() + } + + _setupObservers() { + /* + -> The scroller is the scrolling ancestor to measure against, unless the block was told to grow + to the document, in which case the page itself is. + */ + this._observer = new IntersectionObserver((entries) => this._onVisibility(entries), { + root: this._hasInnerScroll ? this._scroller : null + }) + this._resizeObserver = new ResizeObserver(() => this._onResize()) + if (this._scroller) { + this._resizeObserver.observe(this._scroller) + } + } + + updated(changed) { + if (changed.has('zoom') && this.zoom !== undefined) { + const parsed = this._parseZoom(this.zoom) + if (parsed !== this._zoom) { + this._setZoom(parsed) + } + } + if (changed.has('src')) { + this._load() + } + } + + disconnectedCallback() { + super.disconnectedCallback() + this._teardown() + this._observer?.disconnect() + this._observer = null + this._resizeObserver?.disconnect() + this._resizeObserver = null + } + + /** + * Read what the author wrote in the `zoom` prop. + * + * Either of the two fitting modes, or a percentage — `150%`, and `150` for an author who left the + * sign off. Anything else is the default rather than an error: a viewer that will not open because + * of the zoom would be a poor trade. + */ + _parseZoom(value) { + const zoom = String(value ?? '').trim() + if (zoom === 'page-fit') { + return 'page-fit' + } + const percentage = Number.parseFloat(zoom) + if (Number.isFinite(percentage) && percentage > 0) { + return Math.min(Math.max(percentage / 100, MIN_SCALE), MAX_SCALE) + } + return 'page-width' + } + + /** What the toolbar's list is showing, in the vocabulary of the `zoom` prop. */ + get _zoomValue() { + return typeof this._zoom === 'number' ? `${Math.round(this._zoom * 100)}%` : this._zoom + } + + /** + * Open the document. + * + * The address is resolved against the page it is written on, so that a relative one means what an + * author writing a link would expect. pdf.js is left to fetch it: it asks for the file in ranges + * where the server offers them, which is what lets a long document start showing before the whole + * of it has arrived. + */ + async _load() { + const src = this.src?.trim() + if (src === this._loadedSrc) { + return + } + this._teardown() + this._loadedSrc = src + this._error = '' + this._pageCount = 0 + this._currentPage = 1 + this._progress = 0 + + if (!src) { + this._error = 'This viewer needs the address of a PDF file.' + return + } + + let url + try { + url = new URL(src, window.location.href).href + } catch { + this._error = `${src} is not an address this viewer can open.` + return + } + + this._loading = true + const task = getDocument({ + url, + // -> pdf.js compiles some fonts and patterns with `eval` where it is allowed to; a wiki is the + // kind of place that turns that off, and the slower path draws the same thing. + isEvalSupported: false, + cMapUrl: `${DATA_URL}cmaps/`, + standardFontDataUrl: `${DATA_URL}standard_fonts/`, + wasmUrl: `${DATA_URL}wasm/`, + iccUrl: `${DATA_URL}iccs/` + }) + this._loadingTask = task + task.onProgress = ({ loaded, total }) => { + this._progress = total > 0 ? Math.min(loaded / total, 1) : 0 + } + + try { + const doc = await task.promise + // -> The block was detached, or asked for another document, while this one was being fetched + if (this._loadingTask !== task) { + doc.destroy() + return + } + this._doc = doc + this._pageCount = doc.numPages + const first = await doc.getPage(1) + this._baseSize = this._rawSize(first.getViewport({ scale: 1 })) + this._loading = false + await this.updateComplete + this._buildPages() + const opening = this._openingPage() + // -> Page 1 is where a viewer already is. Saying so anyway would be harmless in a scroller of + // its own, but a block grown to the whole document scrolls the wiki page to obey, dragging + // the reader down past whatever was written above the block the moment it finishes loading. + if (opening > 1) { + this._goToPage(opening) + } + } catch (err) { + if (this._loadingTask !== task) { + return + } + this._loading = false + this._error = this._explain(err, src) + } + } + + /** The page the author asked the document to open at, within the document it turned out to be. */ + _openingPage() { + const page = Number(this.page) + if (!Number.isFinite(page)) { + return 1 + } + return Math.min(Math.max(Math.trunc(page), 1), this._pageCount) + } + + /** What went wrong, said to whoever is reading the page rather than to a console. */ + _explain(err, src) { + if (err instanceof PasswordException) { + return 'This document is password-protected, and cannot be shown here.' + } + if (err?.name === 'InvalidPDFException') { + return `The file at ${src} is not a PDF, or is damaged.` + } + // -> What pdf.js raises for a response it could not use, `missing` being its word for a 404 + if (err?.name === 'ResponseException') { + return err.missing + ? `No PDF was found at ${src}.` + : `The server would not serve ${src} — ${err.message}` + } + return `This document could not be loaded from ${src} — ${err?.message ?? err}` + } + + /** A page's size before any zoom, in PDF units carried over as they are written. */ + _rawSize(viewport) { + const { pageWidth, pageHeight } = viewport.rawDims + return { width: pageWidth, height: pageHeight, userUnit: viewport.userUnit || 1 } + } + + /** + * Lay the whole document out at once. + * + * Every page gets its box straight away, so the scrollbar is the length of the document from the + * first moment rather than growing as pages arrive. Their sizes are the first page's until each one + * has been drawn and can say otherwise — asking the worker for all of them up front is a round trip + * per page, and a document whose pages are not all the same size is the rare one. + * + * The pages are built by hand rather than rendered from a template: they hold a canvas that must + * survive every re-render of the toolbar above them, and pdf.js writes into both of them itself. + */ + _buildPages() { + const container = this._pagesEl + if (!container) { + return + } + container.replaceChildren() + this._pages = [] + + for (let num = 1; num <= this._pageCount; num++) { + const el = document.createElement('div') + el.className = 'page' + el.dataset.page = String(num) + + const canvas = document.createElement('canvas') + const textLayerEl = document.createElement('div') + textLayerEl.className = 'textLayer' + el.append(canvas, textLayerEl) + container.append(el) + + this._pages.push({ + num, + el, + canvas, + textLayerEl, + raw: this._baseSize, + visible: false, + drawn: false, + renderTask: null, + textLayer: null, + /* + Bumped every time the page is let go of, so that a draw already in flight can tell that what + it was drawing for is gone -- a zoom, a scroll far enough away, or the document itself being + closed. Nothing else can stop it: the work up to the first `renderTask` is a request to the + worker, and there is no handle on that to cancel. + */ + epoch: 0 + }) + } + + this._scale = this._resolveScale() + for (const entry of this._pages) { + this._sizePage(entry) + this._observer?.observe(entry.el) + } + } + + /** Give a page the box its current size and the current zoom put it in. */ + _sizePage(entry) { + const total = this._scale * entry.raw.userUnit + entry.el.style.setProperty('--scale-factor', this._scale) + entry.el.style.setProperty('--user-unit', entry.raw.userUnit) + entry.el.style.width = `${Math.floor(total * entry.raw.width)}px` + entry.el.style.height = `${Math.floor(total * entry.raw.height)}px` + } + + /** + * The zoom, as a number. + * + * The fitting modes are measured against the first page, which is what the pages were laid out to + * before any of them had been read — fitting each page to itself would leave a document scrolling + * through a different size every page. + */ + _resolveScale() { + if (typeof this._zoom === 'number') { + return this._zoom + } + const scroller = this._scroller + const base = this._baseSize + if (!scroller || !base) { + return 1 + } + const width = scroller.clientWidth - PAGE_GAP * 2 + if (width <= 0) { + return 1 + } + const scale = + this._zoom === 'page-fit' + ? Math.min( + width / base.width, + ((this._hasInnerScroll ? scroller.clientHeight : window.innerHeight) - PAGE_GAP * 2) / + base.height + ) + : width / base.width + return Math.min(Math.max(scale, MIN_SCALE), MAX_SCALE) + } + + /** + * Redraw at whatever the zoom now resolves to, keeping the reader where they were. + * + * Everything drawn is thrown away rather than scaled: a canvas stretched to a new size is a blurred + * page, and the text over it would be positioned for the old one. + */ + _applyScale() { + const scale = this._resolveScale() + // -> A resize of a few pixels resolves to a scale a few thousandths different, which is not worth + // redrawing the document over + if (Math.abs(scale - this._scale) < 0.001) { + return + } + const anchor = this._currentPage + this._scale = scale + for (const entry of this._pages) { + this._releasePage(entry) + this._sizePage(entry) + } + this._goToPage(anchor) + this._syncRendering() + } + + _setZoom(zoom) { + this._zoom = zoom + this._applyScale() + } + + /** Step to the next zoom level up or down from wherever the current one landed. */ + _stepZoom(direction) { + const levels = direction > 0 ? ZOOM_LEVELS : [...ZOOM_LEVELS].reverse() + const next = levels.find((level) => + direction > 0 ? level > this._scale + 0.001 : level < this._scale - 0.001 + ) + if (next) { + this._setZoom(next) + } + } + + _onResize() { + if (typeof this._zoom !== 'number') { + // -> Coalesced: a drag of a window edge is a stream of these, and each one would redraw + cancelAnimationFrame(this._resizeFrame) + this._resizeFrame = requestAnimationFrame(() => this._applyScale()) + } + } + + _onVisibility(entries) { + for (const observed of entries) { + const entry = this._pages[Number(observed.target.dataset.page) - 1] + if (entry) { + entry.visible = observed.isIntersecting + } + } + this._trackCurrentPage() + this._syncRendering() + } + + /** + * Name the page the reader is actually looking at. + * + * The one taking up the most of the viewport, rather than the topmost one showing: scrolled to the + * top of a page, the page above is still intersecting by the sliver the gap between them leaves, + * and naming that one would have the counter reading a page behind all the way down the document. + * + * Measured here rather than taken from the rectangles the observer hands over, which are only + * delivered for a page whose intersecting-ness has just changed — so a page that has scrolled off + * behind another still carries the area it had when it filled the screen, and goes on winning. + */ + _trackCurrentPage() { + const root = this._hasInnerScroll + ? this._scroller?.getBoundingClientRect() + : { top: 0, bottom: window.innerHeight } + if (!root) { + return + } + + let showing = null + let mostCovered = 0 + for (const entry of this._pages) { + if (!entry.visible) { + continue + } + const box = entry.el.getBoundingClientRect() + // -> Height alone: the pages are in one column, so nothing is ever beside anything else + const covered = Math.min(box.bottom, root.bottom) - Math.max(box.top, root.top) + if (covered > mostCovered) { + mostCovered = covered + showing = entry + } + } + if (showing) { + this._currentPage = showing.num + } + } + + /** + * Draw what is in front of the reader, and let go of what is well behind them. + * + * The page after the last visible one and the page before the first are drawn too, so that + * scrolling on arrives at a page that is already there rather than at a blank one. + */ + _syncRendering() { + if (this._pages.length < 1) { + return + } + const visible = this._pages.filter((entry) => entry.visible).map((entry) => entry.num) + const anchor = visible.length > 0 ? visible : [this._currentPage] + const from = Math.max(1, Math.min(...anchor) - 1) + const to = Math.min(this._pageCount, Math.max(...anchor) + 1) + + for (const entry of this._pages) { + if (entry.num >= from && entry.num <= to) { + this._renderPage(entry) + } else if (entry.num < from - KEEP_PAGES || entry.num > to + KEEP_PAGES) { + this._releasePage(entry) + } + } + } + + /** + * Draw one page, and lay its text over it. + * + * The canvas is drawn at the device's pixel ratio and shown at the page's size, which is what keeps + * a page sharp on a retina display — bounded by `limitCanvas`, pdf.js's own reckoning of what a + * browser will actually allocate. + */ + async _renderPage(entry) { + const doc = this._doc + if (entry.drawn || !doc) { + return + } + const epoch = entry.epoch + entry.drawn = true + + try { + const page = await doc.getPage(entry.num) + if (entry.epoch !== epoch) { + return + } + + const viewport = page.getViewport({ scale: this._scale }) + const raw = this._rawSize(viewport) + // -> Now that the page itself has been read, its box can stop being the first page's guess + if (raw.width !== entry.raw.width || raw.height !== entry.raw.height) { + entry.raw = raw + this._sizePage(entry) + } + + const outputScale = new OutputScale() + outputScale.limitCanvas(viewport.width, viewport.height, MAX_CANVAS_PIXELS, MAX_CANVAS_DIM) + const canvas = entry.canvas + canvas.width = Math.floor(viewport.width * outputScale.sx) + canvas.height = Math.floor(viewport.height * outputScale.sy) + + const renderTask = page.render({ + canvasContext: canvas.getContext('2d', { alpha: false }), + viewport, + transform: outputScale.scaled ? [outputScale.sx, 0, 0, outputScale.sy, 0, 0] : null, + background: '#ffffff' + }) + entry.renderTask = renderTask + await renderTask.promise + entry.renderTask = null + if (entry.epoch !== epoch) { + return + } + + const textLayer = new TextLayer({ + textContentSource: page.streamTextContent({ + includeMarkedContent: true, + disableNormalization: true + }), + container: entry.textLayerEl, + viewport + }) + entry.textLayer = textLayer + await textLayer.render() + } catch (err) { + // -> A cancelled draw is the ordinary way a page in flight is abandoned, not a failure + if (!(err instanceof RenderingCancelledException) && err?.name !== 'AbortException') { + console.warn(`block-pdf: page ${entry.num} could not be drawn — ${err?.message ?? err}`) + } + entry.renderTask = null + entry.drawn = false + } + } + + /** Give back everything one page is holding, leaving its box where it was. */ + _releasePage(entry) { + entry.epoch++ + entry.renderTask?.cancel() + entry.renderTask = null + entry.textLayer?.cancel() + entry.textLayer = null + entry.textLayerEl.replaceChildren() + // -> Zeroing a canvas is what actually frees its pixels; clearing it only paints over them + entry.canvas.width = 0 + entry.canvas.height = 0 + entry.drawn = false + } + + /** Drop the document, and everything drawn from it. */ + _teardown() { + for (const entry of this._pages) { + this._releasePage(entry) + this._observer?.unobserve(entry.el) + } + this._pages = [] + this._pagesEl?.replaceChildren() + this._loadingTask?.destroy() + this._loadingTask = null + this._doc = null + this._baseSize = null + this._loading = false + // -> So that opening the same address again is a reload rather than nothing at all + this._loadedSrc = null + } + + _goToPage(num) { + const entry = this._pages[num - 1] + if (!entry) { + return + } + this._currentPage = entry.num + if (this._hasInnerScroll) { + this._scroller.scrollTop = entry.el.offsetTop - PAGE_GAP + } else { + entry.el.scrollIntoView({ block: 'start' }) + } + } + + _onPageInput(ev) { + const num = Number.parseInt(ev.target.value, 10) + if (Number.isFinite(num) && num >= 1 && num <= this._pageCount) { + this._goToPage(num) + } else { + ev.target.value = String(this._currentPage) + } + } + + _onZoomSelect(ev) { + this._setZoom(this._parseZoom(ev.target.value)) + } + + _previousPage() { + this._goToPage(Math.max(this._currentPage - 1, 1)) + } + + _nextPage() { + this._goToPage(Math.min(this._currentPage + 1, this._pageCount)) + } + + _zoomIn() { + this._stepZoom(1) + } + + _zoomOut() { + this._stepZoom(-1) + } + + _icon(path) { + return html` ` + } + + _renderToolbar() { + return html` +
+
+ + + / ${this._pageCount || '—'} + +
+ + + + + + + + + + + + ${this._icon(ICONS.open)} + +
+ ` + } + + render() { + if (this._error) { + return html`
${this._error}
` + } + return html` +
+ ${this.hideToolbar ? null : this._renderToolbar()} +
+
+ ${ + this._loading + ? html` +
+ Loading the + document${this._progress > 0 ? ` — ${Math.round(this._progress * 100)}%` : ''}… +
+ ` + : null + } +
+
+ ` + } +} + +window.customElements.define('block-pdf', BlockPdfElement) diff --git a/blocks/block-pdf/worker.js b/blocks/block-pdf/worker.js new file mode 100644 index 000000000..e8e80ac6e --- /dev/null +++ b/blocks/block-pdf/worker.js @@ -0,0 +1,14 @@ +/* + The pdf.js parser, as its own bundle. + + Everything a PDF costs -- reading the file structure, decoding its images, laying out its glyph + runs -- happens here rather than on the page's thread, which is the whole reason a viewer stays + responsive while a hundred-page document loads. It is a separate entry point because a worker is + loaded by URL, not imported: `component.js` points `GlobalWorkerOptions.workerSrc` at whatever + this file compiles to, next to it in /_blocks. + + The rollup config picks this up from the file name alone -- any `worker.js` beside a block's + component becomes `.worker.js` -- so there is nothing to add there for the next block that + needs one. +*/ +export * from 'pdfjs-dist/build/pdf.worker.mjs' diff --git a/blocks/package-lock.json b/blocks/package-lock.json index 908dff43c..125581a1e 100644 --- a/blocks/package-lock.json +++ b/blocks/package-lock.json @@ -19,6 +19,7 @@ "lit": "3.3.3", "mermaid": "11.16.1", "pako": "3.0.1", + "pdfjs-dist": "6.2.108", "uqr": "0.1.3" }, "devDependencies": { @@ -295,6 +296,271 @@ "@chevrotain/types": "~11.1.2" } }, + "node_modules/@napi-rs/canvas": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas/-/canvas-1.0.6.tgz", + "integrity": "sha512-Ud4d0jLr9vSaakvfOTIjgP3hoY8kMIYgHC4mtE4L0bP3St9lf6TyCVsInvFIOEofzcUeMfDydHDy8Fs2WuWz+A==", + "license": "MIT", + "optional": true, + "workspaces": [ + "e2e/*" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + }, + "optionalDependencies": { + "@napi-rs/canvas-android-arm64": "1.0.6", + "@napi-rs/canvas-darwin-arm64": "1.0.6", + "@napi-rs/canvas-darwin-x64": "1.0.6", + "@napi-rs/canvas-linux-arm-gnueabihf": "1.0.6", + "@napi-rs/canvas-linux-arm64-gnu": "1.0.6", + "@napi-rs/canvas-linux-arm64-musl": "1.0.6", + "@napi-rs/canvas-linux-riscv64-gnu": "1.0.6", + "@napi-rs/canvas-linux-x64-gnu": "1.0.6", + "@napi-rs/canvas-linux-x64-musl": "1.0.6", + "@napi-rs/canvas-win32-arm64-msvc": "1.0.6", + "@napi-rs/canvas-win32-x64-msvc": "1.0.6" + } + }, + "node_modules/@napi-rs/canvas-android-arm64": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-android-arm64/-/canvas-android-arm64-1.0.6.tgz", + "integrity": "sha512-CZUmZEyN/cmSN1OhRJ44vBE6xyb5ekBbsIF8dqNgizbnAiHSFNi8eAA4oyyIAe5KzJ72K16OKC2lAynZLhv32Q==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "android" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-arm64": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-arm64/-/canvas-darwin-arm64-1.0.6.tgz", + "integrity": "sha512-FGJsIngRfS9LDaX4t/lFthqn2Km6b61ONzjJFC6+yuabL1NgaK7x0fwrj8276OQFkjCvSpJZ7+CeZWgcy03F2w==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-darwin-x64": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-darwin-x64/-/canvas-darwin-x64-1.0.6.tgz", + "integrity": "sha512-wLDrtfX9V4bu6O55/WwGvPVCjfunL2bEm59lzIFk9ZQOt7oJrPzdx/ytZiQr6bMZ9XOFwvXujszYtpoCK7yUbg==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "darwin" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm-gnueabihf": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm-gnueabihf/-/canvas-linux-arm-gnueabihf-1.0.6.tgz", + "integrity": "sha512-8TllEP/9UPDVxrBN/tnbGBsxxwG8Eo2tgyW8wDy+Gi9rm9si2I6MsDv9MlfI3a7JOuzkBcurHYDHZxwLgzAIdA==", + "cpu": [ + "arm" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-gnu": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-gnu/-/canvas-linux-arm64-gnu-1.0.6.tgz", + "integrity": "sha512-YF15AKoGrgU7aoA03CR+cATCRPQcVt6L14Lb/a9hqK7pT7/DhIkqh4qyl+a2GLfqx++yIIJfBuZ3sHM3zlXM1Q==", + "cpu": [ + "arm64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-arm64-musl": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-arm64-musl/-/canvas-linux-arm64-musl-1.0.6.tgz", + "integrity": "sha512-zXeVWNFpj720HZln0bfAs+1/nP3Wy6Z5hUw60UJu+cvgips5jZtuX2+EjDHFk98q3BY35GaE1TQLwE9S244vPA==", + "cpu": [ + "arm64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-riscv64-gnu": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-riscv64-gnu/-/canvas-linux-riscv64-gnu-1.0.6.tgz", + "integrity": "sha512-kvy1Ypq4ODEI2WRNGvs588qTMcWCjlEIuZRyW8JXpiBnMUirgxQukIDFVDQEcrLlHP5cqZ9cvvfOkBJ7koOktA==", + "cpu": [ + "riscv64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-gnu": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-gnu/-/canvas-linux-x64-gnu-1.0.6.tgz", + "integrity": "sha512-czHnfLgSCl48iluS6bNrytGYhsbSTUuIvMucAwO0mPEj1uhkHk6SUiRI0xoqv0PSFzpGFfSnQvFpm7vV+tDO+A==", + "cpu": [ + "x64" + ], + "libc": [ + "glibc" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-linux-x64-musl": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-linux-x64-musl/-/canvas-linux-x64-musl-1.0.6.tgz", + "integrity": "sha512-afsg6GyfUx8qZzjsywjimXUEWcSWETY5F80hmMb2FWVSXkRbor8IpftY2gXnasq+Y42Xug/76qTHwqvti7HRTw==", + "cpu": [ + "x64" + ], + "libc": [ + "musl" + ], + "license": "MIT", + "optional": true, + "os": [ + "linux" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-arm64-msvc": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-arm64-msvc/-/canvas-win32-arm64-msvc-1.0.6.tgz", + "integrity": "sha512-byzha357piy0wgK6IZOQtU4plpt94f6CxHFS7LhJpWYrDWWT/AitQNxRMfCNNaQHZXUB3BwNhQSfH2RpiZ0WaQ==", + "cpu": [ + "arm64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, + "node_modules/@napi-rs/canvas-win32-x64-msvc": { + "version": "1.0.6", + "resolved": "https://registry.npmjs.org/@napi-rs/canvas-win32-x64-msvc/-/canvas-win32-x64-msvc-1.0.6.tgz", + "integrity": "sha512-gWLOgKGZz3hZhzg49q2PJIURwy2pCWh1zvNFXmLO1yineOPhEpoL3EV6/VIjOiKa1l2P6ieP12Eo385u1MLLVQ==", + "cpu": [ + "x64" + ], + "license": "MIT", + "optional": true, + "os": [ + "win32" + ], + "engines": { + "node": ">= 10" + }, + "funding": { + "type": "github", + "url": "https://github.com/sponsors/Brooooooklyn" + } + }, "node_modules/@napi-rs/lzma-linux-x64-gnu": { "version": "1.5.1", "resolved": "https://registry.npmjs.org/@napi-rs/lzma-linux-x64-gnu/-/lzma-linux-x64-gnu-1.5.1.tgz", @@ -2486,6 +2752,18 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/pdfjs-dist": { + "version": "6.2.108", + "resolved": "https://registry.npmjs.org/pdfjs-dist/-/pdfjs-dist-6.2.108.tgz", + "integrity": "sha512-YxFb+SQcodN2rnX9Tn3dHYlqfb7NjlzzfONPpJd+AKoKtUjEdevTfbC07d5TcczzOK6261auRkP/M8OBHs9vFQ==", + "license": "Apache-2.0", + "engines": { + "node": ">=22.13.0 || >=24" + }, + "optionalDependencies": { + "@napi-rs/canvas": "^1.0.0" + } + }, "node_modules/picomatch": { "version": "4.0.3", "resolved": "https://registry.npmjs.org/picomatch/-/picomatch-4.0.3.tgz", diff --git a/blocks/package.json b/blocks/package.json index 35e8d272f..ff9a4887b 100644 --- a/blocks/package.json +++ b/blocks/package.json @@ -23,6 +23,7 @@ "lit": "3.3.3", "mermaid": "11.16.1", "pako": "3.0.1", + "pdfjs-dist": "6.2.108", "uqr": "0.1.3" }, "devDependencies": { diff --git a/blocks/rollup.config.mjs b/blocks/rollup.config.mjs index c17085fc9..0a81f650f 100644 --- a/blocks/rollup.config.mjs +++ b/blocks/rollup.config.mjs @@ -152,27 +152,97 @@ function blocksManifest () { } } +const IGNORED_DIRS = [ + 'dist/**', + 'node_modules/**' +] + +/** + * Copies the runtime data files a block's library fetches for itself into `compiled//`. + * + * Some libraries deliberately keep part of themselves out of the bundle. pdf.js ships its character + * maps, its fallback fonts, its colour profile and the wasm that decodes JPEG 2000 and JBIG2 images + * as files it asks for only once a document turns out to need one — several megabytes that would + * otherwise be carried into every page showing a PDF, to be read by hardly any of them. They still + * have to be somewhere the browser can ask for them, and for a block that means beside it in + * /_blocks, since a block knows no other path it can reach. + * + * `assets.json` beside a component lists them: a directory to copy — a package subpath, or one + * starting with `./` for a directory of the block's own — mapped to the name it should have under + * `compiled//`. Everything below it is copied, so a block declares four directories rather + * than two hundred files. + */ +function blockAssets () { + return { + name: 'block-assets', + buildStart () { + for (const listPath of glob.sync('@(block-*)/assets.json', { ignore: IGNORED_DIRS })) { + const blockDir = listPath.split('/')[0] + this.addWatchFile(listPath) + const list = JSON.parse(fs.readFileSync(listPath, 'utf8')) + for (const [source, destination] of Object.entries(list)) { + const from = source.startsWith('.') + ? path.resolve(blockDir, source) + : path.resolve('node_modules', source) + if (!fs.existsSync(from)) { + // -> A package that moved its data files between versions, most likely. Silence here + // would be a block that loads and then quietly cannot read half the documents it is + // given, so the build stops instead. + this.error(`${listPath}: "${source}" does not exist — nothing to copy from.`) + } + for (const entry of fs.readdirSync(from, { recursive: true, withFileTypes: true })) { + if (!entry.isFile()) { + continue + } + const filePath = path.join(entry.parentPath, entry.name) + this.emitFile({ + type: 'asset', + fileName: path.posix.join( + blockDir, + destination, + path.relative(from, filePath).split(path.sep).join('/') + ), + source: fs.readFileSync(filePath) + }) + } + } + } + } + } +} + export default { - input: Object.fromEntries( - glob.sync('@(block-*)/component.js', { - ignore: [ - 'dist/**', - 'node_modules/**' - ] - }).map(file => { + input: Object.fromEntries([ + ...glob.sync('@(block-*)/component.js', { ignore: IGNORED_DIRS }).map(file => { const fileParts = file.split('/') return [ fileParts[0], file ] + }), + /* + A `worker.js` beside a component is a second entry point, compiled to `.worker.js`. + + A web worker is loaded by URL rather than imported, so its code cannot be part of the bundle + that starts it -- it has to be a file of its own, sitting in /_blocks where the block can point + at it with `new URL('.worker.js', import.meta.url)`. See `block-pdf`, which runs pdf.js's + parser off the page's thread. + */ + ...glob.sync('@(block-*)/worker.js', { ignore: IGNORED_DIRS }).map(file => { + const fileParts = file.split('/') + return [ + `${fileParts[0]}.worker`, + file + ] }) - ), + ]), output: { dir: 'compiled', format: 'es' }, plugins: [ blocksManifest(), + blockAssets(), cssAsString(), // -> `production` is stated rather than left to be inferred: since v16 the plugin picks the // `development` or `production` export condition off `process.env.NODE_ENV`, and this build diff --git a/frontend/public/_assets/icons/fluent-project-setup.svg b/frontend/public/_assets/icons/fluent-project-setup.svg new file mode 100644 index 000000000..26dd0a7a3 --- /dev/null +++ b/frontend/public/_assets/icons/fluent-project-setup.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/public/_assets/icons/ultraviolet-pdf.svg b/frontend/public/_assets/icons/ultraviolet-pdf.svg new file mode 100644 index 000000000..06e19f8ec --- /dev/null +++ b/frontend/public/_assets/icons/ultraviolet-pdf.svg @@ -0,0 +1 @@ + \ No newline at end of file diff --git a/frontend/src/pages/AdminIcons.vue b/frontend/src/pages/AdminIcons.vue index 45304a82b..66c98aac8 100644 --- a/frontend/src/pages/AdminIcons.vue +++ b/frontend/src/pages/AdminIcons.vue @@ -12,7 +12,17 @@ {{ t('admin.icons.subtitle') }} -
+
+ + {{ t('admin.icons.purgeCacheHint') }} + + - -
- - -
-
{{ set.name }} @@ -165,35 +160,6 @@
- - - - {{ t('admin.icons.purgeCacheHint') }} - - - - - - - - - {{ t('admin.icons.howItWorks') }} - - - - - - {{ t('admin.icons.upstream') }} - {{ t('admin.icons.upstreamHint') }} - -
@@ -345,13 +311,6 @@ function prettyBytes(bytes) { return `${(bytes / 1024 / 1024).toFixed(1)} MB` } -/** - * A few icons of the set to show next to it, from the samples upstream publishes - */ -function sampleRefs(set) { - return (set.info?.samples ?? []).slice(0, 3).map((sample) => `${set.prefix}:${sample}`) -} - /** * The Iconify page for the set, which lists every icon it holds with its name. * diff --git a/frontend/src/pages/AdminSecurity.vue b/frontend/src/pages/AdminSecurity.vue index 6bdf191d2..fc64d2d22 100644 --- a/frontend/src/pages/AdminSecurity.vue +++ b/frontend/src/pages/AdminSecurity.vue @@ -64,19 +64,6 @@ - - - - {{ t(`admin.security.disallowFloc`) }} - {{ t(`admin.security.disallowFlocHint`) }} - - - - - - @@ -447,7 +434,6 @@ const state = reactive({ corsConfig: '', corsMode: 'OFF', cspDirectives: '', - disallowFloc: false, disallowIframe: false, disallowOpenRedirect: false, enforceCsp: false,