Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions apps/web/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,7 @@
"mime": "^4.0.4",
"opus-recorder": "^8.0.3",
"pako": "^3.0.0",
"pdfjs-dist": "^6.2.108",
"png-chunks-extract": "^1.0.0",
"posthog-js": "1.418.11",
"qrcode": "1.5.4",
Expand Down
3 changes: 2 additions & 1 deletion apps/web/res/css/views/dialogs/_ForwardDialog.pcss
Original file line number Diff line number Diff line change
Expand Up @@ -37,7 +37,8 @@ Please see LICENSE files in the repository root for full details.
/* We also hide download links to not encourage users to try interacting */
.mx_ReadReceiptGroup,
.mx_E2ePadlock,
.mx_MFileBody [data-type="download"] {
.mx_MFileBody [data-type="download"],
.mx_MFileBody [data-type="actions"] {
display: none;
}
}
Expand Down
44 changes: 44 additions & 0 deletions apps/web/src/@types/pdf-viewer.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,44 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

/**
* Where the reader had got to in a PDF, in pdf.js's own terms.
*
* These are the fields of the `location` pdf.js reports on `updateviewarea`, which is also the shape
* it accepts back as an `XYZ` destination — so restoring is a matter of handing it straight back. The
* offsets are in PDF user-space units on {@link page} rather than pixels in the scroll container, so
* they survive a change of zoom, of panel width, or of device.
*/
export interface PdfViewerState {
/** 1-based number of the page at the top of the view. */
page: number;
/**
* The zoom, either as a percentage (`123.45`) or as one of pdf.js's named scales — `"page-width"`,
* `"page-fit"`, `"auto"` — which are recomputed from the panel size rather than fixed.
*/
scale: number | string;
/** Horizontal offset into {@link page}, in PDF user-space units. */
left: number;
/** Vertical offset into {@link page}, in PDF user-space units. */
top: number;
/** When this position was last recorded, in milliseconds since the epoch. Used to evict old entries. */
updatedAt: number;
}

/**
* The handle on a PDF that {@link PdfViewer} needs: something to key the saved reading position on,
* and a way to get at the bytes. `blob` is expected to decrypt transparently for encrypted media, so
* the viewer never has to care whether the room is encrypted.
*/
export interface PdfMedia {
/** The MXC URI of the file, used as the key for its saved reading position. */
uri: string;
/** The file name, as sent. */
name?: string;
/** Resolves the file contents, decrypting first if the media is encrypted. */
blob: () => Promise<Blob>;
}
7 changes: 7 additions & 0 deletions apps/web/src/components/structures/RightPanel.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -31,6 +31,7 @@
import { UPDATE_EVENT } from "../../stores/AsyncStore";
import { type IRightPanelCard, type IRightPanelCardState } from "../../stores/right-panel/RightPanelStoreIPanelState";
import { Action } from "../../dispatcher/actions";
import { PdfViewerCard } from "../views/right_panel/PdfViewerCard";
import { type XOR } from "../../@types/common";
import ExtensionsCard from "../views/right_panel/ExtensionsCard";
import MemberListView from "../views/rooms/MemberList/MemberListView";
Expand Down Expand Up @@ -277,6 +278,12 @@
card = <WidgetCard room={this.props.room} widgetId={cardState.widgetId} onClose={this.onClose} />;
}
break;

case RightPanelPhases.PdfViewer:
if (!!cardState?.pdfViewerEvent) {

Check warning on line 283 in apps/web/src/components/structures/RightPanel.tsx

View check run for this annotation

SonarQubeCloud / SonarCloud Code Analysis

Redundant double negation.

See more on https://sonarcloud.io/project/issues?id=element-web&issues=AaBreo_KnVXTSnpEZHEU&open=AaBreo_KnVXTSnpEZHEU&pullRequest=34928
card = <PdfViewerCard mxEvent={cardState.pdfViewerEvent} onClose={this.onClose} />;
}
break;
}

return (
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
exports[`MBodyFactory > renderMBody > renders download button for m.file in file rendering type 1`] = `
<div>
<span
class="_content_p4b0p_8 mx_MFileBody"
class="_content_1dw8a_8 mx_MFileBody"
>
<div
data-type="download"
Expand Down Expand Up @@ -40,7 +40,7 @@ exports[`MBodyFactory > renderMBody > renders download button for m.file in file
exports[`MBodyFactory > renderMBody fallback shows m.audio generic placeholder when showFileInfo is true 1`] = `
<div>
<span
class="_content_p4b0p_8 mx_MFileBody"
class="_content_1dw8a_8 mx_MFileBody"
>
<div
class="mx_MediaBody _mediaBody_14jys_8"
Expand Down Expand Up @@ -81,7 +81,7 @@ exports[`MBodyFactory > renderMBody fallback shows m.audio generic placeholder w
exports[`MBodyFactory > renderMBody fallback shows m.file generic placeholder when showFileInfo is true 1`] = `
<div>
<span
class="_content_p4b0p_8 mx_MFileBody"
class="_content_1dw8a_8 mx_MFileBody"
>
<div
class="mx_MediaBody _mediaBody_14jys_8"
Expand Down
214 changes: 214 additions & 0 deletions apps/web/src/components/views/right_panel/PdfViewer.module.css
Original file line number Diff line number Diff line change
@@ -0,0 +1,214 @@
/*
Copyright 2026 Element Creations Ltd.

SPDX-License-Identifier: AGPL-3.0-only OR GPL-3.0-only OR LicenseRef-Element-Commercial
Please see LICENSE files in the repository root for full details.
*/

/*
* Styles for the DOM that pdf.js's PDFViewer builds. This is the subset of the library's own
* `web/pdf_viewer.css` that a read-only viewer needs, scoped under our container rather than imported
* globally: the library's stylesheet declares `color-scheme` and its page metrics on `:root`, which
* would override Element's theming for the whole app.
*
* Annotation, XFA, and editor layers are deliberately absent — the viewer is constructed with those
* modes disabled, so none of that markup is ever created.
*/

.viewer {
display: flex;
flex-direction: column;
min-height: 0;
height: 100%;
overflow: hidden;
color: var(--cpd-color-text-primary);
background: var(--cpd-color-bg-subtle-secondary);
}

.toolbar {
flex: none;
display: flex;
align-items: center;
justify-content: center;
padding: var(--cpd-space-2x) var(--cpd-space-4x);
border-bottom: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary);
background: var(--cpd-color-bg-canvas-default);
}

.pageForm {
display: flex;
align-items: center;
gap: var(--cpd-space-2x);
}

.pageInput {
width: 4ch;
padding: var(--cpd-space-1x) var(--cpd-space-2x);
text-align: center;
color: var(--cpd-color-text-primary);
background: var(--cpd-color-bg-canvas-default);
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-primary);
border-radius: var(--cpd-radius-pill-effect);
font: var(--cpd-font-body-sm-medium);
}

.pageInput:focus-visible {
outline: var(--cpd-border-width-2) solid var(--cpd-color-border-focused);
outline-offset: 1px;
}

.pageSeparator,
.pageTotal {
color: var(--cpd-color-text-secondary);
font: var(--cpd-font-body-sm-medium);
}

/* Positioning context for the scroll area and the loading/error overlays. */
.body {
position: relative;
flex: 1;
min-height: 0;
}

/* pdf.js measures scrolling against this element and requires it to be absolutely positioned. */
.container {
position: absolute;
inset: 0;
overflow: auto;

--viewer-container-height: 0;
--pdfViewer-padding-bottom: var(--cpd-space-4x);
--page-margin: var(--cpd-space-4x) auto;
}

.container :global(.pdfViewer) {
--scale-factor: 1;

padding-bottom: var(--pdfViewer-padding-bottom);
}

.container :global(.pdfViewer .page) {
--user-unit: 1;
--total-scale-factor: calc(var(--scale-factor) * var(--user-unit));
--scale-round-x: 1px;
--scale-round-y: 1px;

position: relative;
direction: ltr;
width: 816px;
height: 1056px;
margin: var(--page-margin);
overflow: visible;
border: var(--cpd-border-width-1) solid var(--cpd-color-border-interactive-secondary);
background-color: white;
background-clip: content-box;
box-shadow: 0 2px 8px rgb(0 0 0 / 0.18);
}

.container :global(.pdfViewer .canvasWrapper) {
width: 100%;
height: 100%;
overflow: hidden;
}

.container :global(.pdfViewer .canvasWrapper canvas) {
position: absolute;
top: 0;
left: 0;
display: block;
width: 100%;
height: 100%;
margin: 0;
contain: content;
}

/* The detail canvas is a high-resolution tile of the visible region, drawn over the CSS-zoomed page. */
.container :global(.pdfViewer .canvasWrapper canvas.detailView) {
image-rendering: pixelated;
}

.container :global(.pdfViewer .dummyPage) {
position: relative;
width: 0;
height: var(--viewer-container-height);
}

/*
* Text layer: an invisible, selectable copy of the page text positioned over the canvas. The custom
* properties and the transform are the contract pdf.js's TextLayer writes against.
*/
.container :global(.textLayer) {
position: absolute;
inset: 0;
z-index: 1;
overflow: clip;
color-scheme: only light;
text-align: initial;
line-height: 1;
letter-spacing: normal;
word-spacing: normal;
opacity: 1;
-webkit-text-size-adjust: none;
-moz-text-size-adjust: none;
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));
}

.container :global(.textLayer span),
.container :global(.textLayer br) {
position: absolute;
color: transparent;
white-space: pre;
cursor: text;
transform-origin: 0% 0%;
user-select: text;
}

.container :global(.textLayer > :not([class~="markedContent"])),
.container :global(.textLayer [class~="markedContent"] span:not([class~="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));
}

.container :global(.textLayer [class~="markedContent"]) {
display: contents;
}

.container :global(.textLayer span[role="img"]) {
cursor: default;
user-select: none;
}

.container :global(.textLayer ::selection) {
color: transparent;
background: color-mix(in srgb, AccentColor, transparent 50%);
}

.message {
position: absolute;
inset: 0;
display: flex;
align-items: center;
justify-content: center;
padding: var(--cpd-space-6x) var(--cpd-space-4x);
text-align: center;
color: var(--cpd-color-text-secondary);
background: var(--cpd-color-bg-subtle-secondary);
font: var(--cpd-font-body-md-regular);
}

.error {
color: var(--cpd-color-text-critical-primary);
}
Loading
Loading