diff --git a/packages/frontend/apps/electron/src/main/windows-manager/tab-views-meta-schema.ts b/packages/frontend/apps/electron/src/main/windows-manager/tab-views-meta-schema.ts index 18a77dba12438..4fca530a475cf 100644 --- a/packages/frontend/apps/electron/src/main/windows-manager/tab-views-meta-schema.ts +++ b/packages/frontend/apps/electron/src/main/windows-manager/tab-views-meta-schema.ts @@ -9,6 +9,8 @@ export const workbenchViewIconNameSchema = z.enum([ 'page', 'edgeless', 'journal', + 'attachment', + 'pdf', ]); export const workbenchViewMetaSchema = z.object({ diff --git a/packages/frontend/component/package.json b/packages/frontend/component/package.json index b62eaee7d21c1..ea7db8e8c1086 100644 --- a/packages/frontend/component/package.json +++ b/packages/frontend/component/package.json @@ -14,7 +14,7 @@ }, "peerDependencies": { "@blocksuite/affine": "*", - "@blocksuite/icons": "2.1.67" + "@blocksuite/icons": "*" }, "dependencies": { "@affine/cli": "workspace:*", @@ -24,6 +24,7 @@ "@affine/i18n": "workspace:*", "@atlaskit/pragmatic-drag-and-drop": "^1.2.1", "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@blocksuite/icons": "2.1.69", "@emotion/react": "^11.11.4", "@emotion/styled": "^11.11.5", "@radix-ui/react-avatar": "^1.0.4", @@ -40,11 +41,14 @@ "@radix-ui/react-toast": "^1.1.5", "@radix-ui/react-tooltip": "^1.0.7", "@radix-ui/react-visually-hidden": "^1.1.0", + "@toeverything/pdf-viewer": "^0.1.0", "@toeverything/theme": "^1.0.16", "@vanilla-extract/dynamic": "^2.1.0", "check-password-strength": "^2.0.10", "clsx": "^2.1.0", "dayjs": "^1.11.10", + "file-type": "^19.1.0", + "filesize": "^10.1.6", "jotai": "^2.8.0", "lit": "^3.1.2", "lodash-es": "^4.17.21", @@ -57,6 +61,7 @@ "react-paginate": "^8.2.0", "react-router-dom": "^6.22.3", "react-transition-state": "^2.1.1", + "react-virtuoso": "^4.7.8", "sonner": "^1.4.41", "swr": "^2.2.5", "zod": "^3.22.4" diff --git a/packages/frontend/component/src/components/attachment-viewer/error.tsx b/packages/frontend/component/src/components/attachment-viewer/error.tsx new file mode 100644 index 0000000000000..2a9bf9550ce7d --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/error.tsx @@ -0,0 +1,28 @@ +import type { AttachmentBlockModel } from '@blocksuite/blocks'; +import { ArrowDownBigIcon, PageIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; + +import { Button } from '../../ui/button'; +import * as styles from './styles.css'; + +interface ErrorProps { + model: AttachmentBlockModel; + ext: string; + isPDF: boolean; +} + +export const Error = ({ ext }: ErrorProps) => { + return ( +
+ +

Unable to preview this file

+

.{ext} file type not supported.

+
+ + +
+
+ ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/index.tsx b/packages/frontend/component/src/components/attachment-viewer/index.tsx new file mode 100644 index 0000000000000..f2ee24c081895 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/index.tsx @@ -0,0 +1,30 @@ +import type { AttachmentBlockModel } from '@blocksuite/blocks'; +import { filesize } from 'filesize'; +import { useMemo } from 'react'; + +import { Error } from './error'; +import * as styles from './styles.css'; +import { Titlebar } from './titlebar'; +import { Viewer } from './viewer'; + +export type AttachmentViewerProps = { + model: AttachmentBlockModel; +}; + +export const AttachmentViewer = ({ model }: AttachmentViewerProps) => { + const props = useMemo(() => { + const pieces = model.name.split('.'); + const ext = pieces.pop() || ''; + const name = pieces.join('.'); + const isPDF = ext === 'pdf'; + const size = filesize(model.size); + return { model, name, ext, size, isPDF }; + }, [model]); + + return ( +
+ + {props.isPDF ? : } +
+ ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/styles.css.ts b/packages/frontend/component/src/components/attachment-viewer/styles.css.ts new file mode 100644 index 0000000000000..193fa68e99391 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/styles.css.ts @@ -0,0 +1,172 @@ +import { cssVarV2 } from '@toeverything/theme/v2'; +import { style } from '@vanilla-extract/css'; + +export const viewerContainer = style({ + position: 'relative', + display: 'flex', + flexDirection: 'column', + width: '100%', + height: '100%', +}); + +export const titlebar = style({ + display: 'flex', + justifyContent: 'space-between', + height: '52px', + padding: '10px 8px', + background: cssVarV2('layer/background/primary'), + fontSize: '12px', + fontWeight: 400, + color: cssVarV2('text/secondary'), + borderTopWidth: '0.5px', + borderTopStyle: 'solid', + borderTopColor: cssVarV2('layer/insideBorder/border'), +}); + +export const titlebarChild = style({ + selectors: { + [`${titlebar} > &`]: { + display: 'flex', + gap: '12px', + alignItems: 'center', + paddingLeft: '12px', + paddingRight: '12px', + }, + '&.zoom:not(.show)': { + display: 'none', + }, + }, +}); + +export const titlebarName = style({ + display: 'flex', +}); + +export const body = style({ + display: 'flex', + flex: 1, + position: 'relative', + selectors: { + '&:before': { + position: 'absolute', + // position: 'fixed', + content: '', + top: 0, + right: 0, + bottom: 0, + left: 0, + // width: '100%', + // height: '100%', + // zIndex: -1, + }, + '&:not(.gridding):before': { + backgroundColor: cssVarV2('layer/background/secondary'), + }, + '&.gridding:before': { + opacity: 0.25, + backgroundSize: '20px 20px', + backgroundImage: `linear-gradient(${cssVarV2('button/grabber/default')} 1px, transparent 1px), linear-gradient(to right, ${cssVarV2('button/grabber/default')} 1px, transparent 1px)`, + }, + }, +}); + +export const virtuoso = style({ + width: '100%', +}); + +export const error = style({ + flexDirection: 'column', + alignItems: 'center', + justifyContent: 'center', + gap: '4px', +}); + +export const errorTitle = style({ + fontSize: '15px', + fontWeight: 500, + lineHeight: '24px', + color: cssVarV2('text/primary'), + marginTop: '12px', +}); + +export const errorMessage = style({ + fontSize: '12px', + fontWeight: 500, + lineHeight: '20px', + color: cssVarV2('text/tertiary'), +}); + +export const errorBtns = style({ + display: 'flex', + flexDirection: 'column', + gap: '10px', + marginTop: '28px', +}); + +export const viewerPage = style({ + margin: '20px auto', + maxWidth: 'calc(100% - 40px)', + background: cssVarV2('layer/white'), + boxSizing: 'border-box', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: cssVarV2('layer/insideBorder/border'), + boxShadow: + '0px 4px 20px 0px var(--transparent-black-200, rgba(0, 0, 0, 0.10))', +}); + +export const thumbnails = style({ + position: 'absolute', + boxSizing: 'border-box', + width: '120px', + padding: '12px 0', + right: '30px', + bottom: '30px', + borderRadius: '8px', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: cssVarV2('layer/insideBorder/border'), + backgroundColor: cssVarV2('layer/background/primary'), + boxShadow: cssVarV2('shadow/overlayPanelShadow/2-color'), + fontSize: '12px', + fontWeight: 500, + lineHeight: '20px', + color: cssVarV2('text/secondary'), +}); + +export const thumbnailsPages = style({ + display: 'flex', + flexDirection: 'column', + // gap: '12px', + selectors: { + '&.collapsed': { + display: 'none', + }, + '&:not(.collapsed)': { + marginBottom: '8px', + }, + }, +}); + +export const thumbnailsPage = style({ + margin: '0px 12px 12px', + display: 'flex', + overflow: 'clip', + // width: '100%', + borderRadius: '4px', + borderWidth: '1px', + borderStyle: 'solid', + borderColor: cssVarV2('layer/insideBorder/border'), + selectors: { + '&.selected': { + borderColor: '#29A3FA', + }, + }, +}); + +export const thumbnailsIndicator = style({ + display: 'flex', + alignItems: 'center', + justifyContent: 'space-between', + padding: '0 12px', +}); diff --git a/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx b/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx new file mode 100644 index 0000000000000..0874a8e10ac8f --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/titlebar.tsx @@ -0,0 +1,99 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { + EditIcon, + LocalDataIcon, + MoreHorizontalIcon, + ZoomDownIcon, + ZoomUpIcon, +} from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import { useState } from 'react'; + +import { IconButton } from '../../ui/button'; +import { Menu, MenuItem } from '../../ui/menu'; +import * as styles from './styles.css'; +import { saveBufferToFile } from './utils'; + +const items = [ + { + name: 'Rename', + icon: , + action(_model: AttachmentBlockModel) {}, + }, + { + name: 'Download', + icon: , + action(model: AttachmentBlockModel) { + const { sourceId, name } = model; + if (!sourceId) return; + saveBufferToFile(sourceId, name).catch(console.error); + }, + }, +]; + +export const MenuItems = ({ model }: { model: AttachmentBlockModel }) => + items.map(({ name, icon, action }) => ( + action(model)} prefixIcon={icon}> + {name} + + )); + +export interface TitlebarProps { + model: AttachmentBlockModel; + name: string; + ext: string; + size: string; + isPDF: boolean; + zoom?: number; +} + +export const Titlebar = ({ + model, + name, + ext, + size, + zoom = 100, + isPDF = false, +}: TitlebarProps) => { + const [openMenu, setOpenMenu] = useState(false); + + return ( +
+
+
+
{name}
+ .{ext} +
+
{size}
+ }> + } + rootOptions={{ + open: openMenu, + onOpenChange: setOpenMenu, + }} + contentOptions={{ + side: 'bottom', + align: 'center', + avoidCollisions: false, + }} + > + }> + +
+
+ }> +
{zoom}%
+ }> +
+
+ ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/utils.ts b/packages/frontend/component/src/components/attachment-viewer/utils.ts new file mode 100644 index 0000000000000..2238b3eb09857 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/utils.ts @@ -0,0 +1,40 @@ +import { fileTypeFromBuffer } from 'file-type'; + +export async function saveBufferToFile(url: string, filename: string) { + // given input url may not have correct mime type + const blob = await attachmentUrlToBlob(url); + if (!blob) { + return; + } + + const blobUrl = URL.createObjectURL(blob); + const a = document.createElement('a'); + a.href = blobUrl; + a.download = filename; + document.body.append(a); + a.click(); + a.remove(); + URL.revokeObjectURL(blobUrl); +} + +export async function attachmentUrlToBlob( + url: string +): Promise { + const buffer = await fetch(url).then(response => response.arrayBuffer()); + + if (!buffer) { + console.warn('Could not get blob'); + return; + } + try { + const type = await fileTypeFromBuffer(buffer); + if (!type) { + return; + } + const blob = new Blob([buffer], { type: type.mime }); + return blob; + } catch (error) { + console.error('Error converting attachment to blob', error); + } + return; +} diff --git a/packages/frontend/component/src/components/attachment-viewer/viewer.tsx b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx new file mode 100644 index 0000000000000..78b19d60b0210 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/viewer.tsx @@ -0,0 +1,417 @@ +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { CollapseIcon, ExpandIcon } from '@blocksuite/icons/rc'; +import clsx from 'clsx'; +import { debounce } from 'lodash-es'; +import type { HTMLAttributes, PropsWithChildren, ReactElement } from 'react'; +import React, { + useCallback, + useEffect, + useMemo, + useRef, + useState, +} from 'react'; +import type { VirtuosoHandle } from 'react-virtuoso'; +import { Virtuoso } from 'react-virtuoso'; + +import { IconButton } from '../../ui/button'; +import { Scrollable } from '../../ui/scrollbar'; +import * as styles from './styles.css'; +// import { observeResize } from '../../utils'; +import type { MessageData, MessageDataType } from './worker/types'; +import { MessageOp, State } from './worker/types'; + +const Page = React.memo( + ({ + width, + height, + className, + }: { + index: number; + width: number; + height: number; + className: string; + }) => { + return ( +
+ +
+ ); + } +); + +Page.displayName = 'viewer-page'; + +const THUMBNAIL_WIDTH = 94; + +const Thumbnail = React.memo( + ({ + index, + width, + height, + className, + onSelect, + }: { + index: number; + width: number; + height: number; + className: string; + onSelect: (index: number) => void; + }) => { + return ( +
onSelect(index)}> + +
+ ); + } +); + +Thumbnail.displayName = 'viewer-thumbnail'; + +const Scroller = React.forwardRef< + HTMLDivElement, + PropsWithChildren> +>(({ style, ...props }, ref) => { + return ( + + + + + ); +}); + +Scroller.displayName = 'viewer-scroller'; + +interface ViewerProps { + model: AttachmentBlockModel; +} + +export const Viewer = ({ model }: ViewerProps): ReactElement => { + const [connected, setConnected] = useState(false); + const [loaded, setLoaded] = useState(false); + const [docInfo, setDocInfo] = useState({ + cursor: 0, + total: 0, + width: 1, + height: 1, + }); + const viewerRef = useRef(null); + const scrollerRef = useRef(null); + const scrollerHandleRef = useRef(null); + const workerRef = useRef(null); + + const [mainVisibleRange, setMainVisibleRange] = useState({ + startIndex: 0, + endIndex: 0, + }); + + const [collapsed, setCollapsed] = useState(true); + const thumbnailsScrollerHandleRef = useRef(null); + const thumbnailsScrollerRef = useRef(null); + const [thumbnailsVisibleRange, setThumbnailsVisibleRange] = useState({ + startIndex: 0, + endIndex: 0, + }); + + const post = useCallback( + ( + type: T, + data?: MessageDataType[T], + transfer = [] + ) => { + workerRef.current?.postMessage( + { + state: State.Poll, + type, + [type]: data, + }, + transfer + ); + }, + [workerRef] + ); + + const render = useCallback( + (id: number, imageData: ImageData) => { + const el = scrollerRef.current; + if (!el) return; + + const canvas: HTMLCanvasElement | null = el.querySelector( + `[data-index="${id}"] canvas` + ); + if (!canvas) return; + if (canvas.dataset.rendered) return; + + // TODO(@fundon): improve + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.putImageData(imageData, 0, 0); + canvas.dataset.rendered = 'true'; + } + }, + [scrollerRef] + ); + + const renderThumbnail = useCallback( + (id: number, imageData: ImageData) => { + const el = thumbnailsScrollerRef.current; + if (!el) return; + + const canvas: HTMLCanvasElement | null = el.querySelector( + `[data-index="${id}"] canvas` + ); + if (!canvas) return; + if (canvas.dataset.rendered) return; + + // TODO(@fundon): improve + const ctx = canvas.getContext('2d'); + if (ctx) { + ctx.putImageData(imageData, 0, 0); + canvas.dataset.rendered = 'true'; + } + }, + [thumbnailsScrollerRef] + ); + + const onScroll = useCallback(() => { + const el = scrollerRef.current; + if (!el) return; + + const { scrollTop, scrollHeight } = el; + + setDocInfo(info => { + const cursor = Math.ceil(scrollTop / (scrollHeight / info.total)); + // thumbnailsScrollerHandleRef.current?.scrollToIndex(cursor) + return { + ...info, + cursor, + }; + }); + // }, [scrollerRef, thumbnailsScrollerHandleRef]); + }, [scrollerRef]); + + const onSelect = useCallback( + (index: number) => { + scrollerHandleRef.current?.scrollToIndex(index); + setDocInfo(info => ({ ...info, cursor: index })); + }, + [scrollerHandleRef] + ); + + const updateMainVisibleRange = useMemo( + () => debounce(setMainVisibleRange, 233, { leading: true, trailing: true }), + [setMainVisibleRange] + ); + + const updateThumbnailsVisibleRange = useMemo( + () => + debounce(setThumbnailsVisibleRange, 233, { + leading: true, + trailing: true, + }), + [setThumbnailsVisibleRange] + ); + + // useEffect(() => { + // const el = viewerRef.current; + // if (!el) return; + + // return observeResize(el, entry => { + // console.log(entry); + // }); + // }, []); + + useEffect(() => { + const { startIndex, endIndex } = mainVisibleRange; + let index = startIndex; + for (; index < endIndex + 1; index++) { + post(MessageOp.Render, { index, kind: 'page' }); + } + }, [mainVisibleRange, post]); + + useEffect(() => { + const { startIndex, endIndex } = thumbnailsVisibleRange; + let index = startIndex; + for (; index < endIndex + 1; index++) { + post(MessageOp.Render, { index, kind: 'thumbnail' }); + } + }, [thumbnailsVisibleRange, post]); + + useEffect(() => { + workerRef.current = new Worker( + /* webpackChunkName: "pdf.worker" */ new URL( + './worker/worker.ts', + import.meta.url + ) + ); + + async function process({ data }: MessageEvent) { + const { type, state } = data; + + if (type === MessageOp.Init) { + setConnected(state === State.Ready); + return; + } + if (type === MessageOp.Open) { + setLoaded(state === State.Ready); + return; + } + + if (state === State.Poll) return; + + switch (type) { + case MessageOp.ReadInfo: { + const action = data[type]; + setDocInfo(info => ({ ...info, ...action })); + break; + } + case MessageOp.Rendered: { + const { index, imageData, kind } = data[type]; + if (kind === 'page') { + render(index, imageData); + } else { + renderThumbnail(index, imageData); + } + break; + } + } + } + + workerRef.current.addEventListener('message', event => { + process(event).catch(console.error); + }); + + return () => { + workerRef.current?.terminate(); + }; + }, [model, post, render, renderThumbnail]); + + useEffect(() => { + if (!connected) return; + if (!model.sourceId) return; + + model.doc.blobSync + .get(model.sourceId) + .then(blob => { + if (!blob) return; + post(MessageOp.Open, { blob, dpi: window.devicePixelRatio }); + }) + .catch(console.error); + }, [connected, model, post]); + + useEffect(() => { + if (!loaded) return; + post(MessageOp.ReadInfo); + }, [loaded, post]); + + const pageContent = (index: number) => { + return ( + + ); + }; + + const thumbnailContent = (index: number) => { + return ( + + ); + }; + + const components = useMemo(() => { + return { + Scroller, + }; + }, []); + + return ( +
+ { + if (scrollerRef.current) return; + scrollerRef.current = scroller as HTMLElement; + }} + className={styles.virtuoso} + rangeChanged={updateMainVisibleRange} + increaseViewportBy={{ + top: docInfo.height * Math.min(5, docInfo.total), + bottom: docInfo.height * Math.min(5, docInfo.total), + }} + totalCount={docInfo.total} + itemContent={pageContent} + components={components} + /> +
+ {collapsed ? null : ( +
+ { + if (thumbnailsScrollerRef.current) return; + thumbnailsScrollerRef.current = scroller as HTMLElement; + }} + rangeChanged={updateThumbnailsVisibleRange} + className={styles.virtuoso} + totalCount={docInfo.total} + itemContent={thumbnailContent} + components={components} + /> +
+ )} +
+
+ {docInfo.cursor + 1}/{docInfo.total} +
+ : } + onClick={() => setCollapsed(state => !state)} + /> +
+
+
+ ); +}; diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/types.ts b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts new file mode 100644 index 0000000000000..4ec6b2a064931 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/worker/types.ts @@ -0,0 +1,33 @@ +export enum State { + Poll, + Ready, +} + +export enum MessageOp { + Init, + Open, + ReadInfo, + Render, + Rendered, +} + +export type MessageDataMap = { + [MessageOp.Init]: undefined; + [MessageOp.Open]: { blob: Blob; dpi: number }; + [MessageOp.ReadInfo]: { total: number; width: number; height: number }; + [MessageOp.Render]: { index: number; kind: 'page' | 'thumbnail' }; + [MessageOp.Rendered]: { + index: number; + imageData: ImageData; + kind: 'page' | 'thumbnail'; + }; +}; + +export type MessageDataType = { + [P in keyof T]: T[P]; +}; + +export type MessageData = { + state: State; + type: T; +} & P; diff --git a/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts new file mode 100644 index 0000000000000..d6423a6083148 --- /dev/null +++ b/packages/frontend/component/src/components/attachment-viewer/worker/worker.ts @@ -0,0 +1,164 @@ +import { DebugLogger } from '@affine/debug'; +import type { Document } from '@toeverything/pdf-viewer'; +import { + createPDFium, + PageRenderingflags, + Runtime, + Viewer, +} from '@toeverything/pdf-viewer'; + +import type { MessageData, MessageDataType } from './types'; +import { MessageOp, State } from './types'; + +const logger = new DebugLogger('affine:pdf-worker'); + +let dpi = 2; +let inited = false; +let viewer: Viewer | null = null; +let doc: Document | undefined = undefined; + +const cached = new Map(); +const docInfo = { cursor: 0, total: 0, width: 1, height: 1 }; +const flags = PageRenderingflags.REVERSE_BYTE_ORDER | PageRenderingflags.ANNOT; + +function post(type: T, data?: MessageDataType[T]) { + self.postMessage({ state: State.Ready, type, [type]: data }); +} + +async function resizeImageData( + imageData: ImageData, + options: { + resizeWidth: number; + resizeHeight: number; + } +) { + const { resizeWidth: w, resizeHeight: h } = options; + const bitmap = await createImageBitmap( + imageData, + 0, + 0, + imageData.width, + imageData.height, + options + ); + const canvas = new OffscreenCanvas(w, h); + const ctx = canvas.getContext('2d'); + if (!ctx) return imageData; + ctx.drawImage(bitmap, 0, 0); + return ctx.getImageData(0, 0, w, h); +} + +async function start() { + logger.debug('pdf worker pending'); + self.postMessage({ state: State.Poll, type: MessageOp.Init }); + + const pdfium = await createPDFium(); + viewer = new Viewer(new Runtime(pdfium)); + inited = true; + + self.postMessage({ state: State.Ready, type: MessageOp.Init }); + logger.debug('pdf worker ready'); +} + +async function process({ data }: MessageEvent) { + if (!inited || !viewer) { + await start(); + } + + if (!viewer) return; + + const { type, state } = data; + + if (state !== State.Poll) return; + + switch (type) { + case MessageOp.Open: { + const action = data[type]; + if (!action?.blob) return; + + dpi = action.dpi; + doc = await viewer.openWithBlob(action.blob); + + if (!doc) return; + + post(MessageOp.Open); + break; + } + + case MessageOp.ReadInfo: { + if (!doc) return; + + const page = doc.page(0); + if (page) { + docInfo.cursor = 0; + docInfo.total = doc.pageCount(); + docInfo.height = page.height(); + docInfo.width = page.width(); + page.close(); + post(MessageOp.ReadInfo, docInfo); + } + break; + } + + case MessageOp.Render: { + if (!doc) return; + + const { index, kind } = data[type]; + + let imageData = cached.size > 0 ? cached.get(index) : undefined; + if (imageData) { + if (kind === 'thumbnail') { + const resizeWidth = (94 * dpi) >> 0; + const resizeHeight = + ((docInfo.height / docInfo.width) * resizeWidth) >> 0; + imageData = await resizeImageData(imageData, { + resizeWidth, + resizeHeight, + }); + } + + post(MessageOp.Rendered, { index, imageData, kind }); + return; + } + + const width = Math.ceil(docInfo.width * dpi); + const height = Math.ceil(docInfo.height * dpi); + const page = doc.page(index); + + if (page) { + const bitmap = viewer.createBitmap(width, height, 0); + bitmap.fill(0, 0, width, height); + page.render(bitmap, 0, 0, width, height, 0, flags); + + const data = bitmap.toBytes(); + + bitmap.close(); + page.close(); + + imageData = new ImageData(new Uint8ClampedArray(data), width, height); + + cached.set(index, imageData); + + if (kind === 'thumbnail') { + const resizeWidth = (94 * dpi) >> 0; + const resizeHeight = + ((docInfo.height / docInfo.width) * resizeWidth) >> 0; + imageData = await resizeImageData(imageData, { + resizeWidth, + resizeHeight, + }); + } + + post(MessageOp.Rendered, { index, imageData, kind }); + } + + break; + } + } +} + +self.addEventListener('message', (event: MessageEvent) => { + process(event).catch(console.error); +}); + +start().catch(console.error); diff --git a/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx new file mode 100644 index 0000000000000..4f95a5abca858 --- /dev/null +++ b/packages/frontend/core/src/desktop/pages/workspace/attachment/index.tsx @@ -0,0 +1,87 @@ +import { AttachmentViewer } from '@affine/component/attachment-viewer'; +import { + type AttachmentBlockModel, + matchFlavours, +} from '@blocksuite/affine/blocks'; +import { + type Doc, + DocsService, + FrameworkScope, + useService, +} from '@toeverything/infra'; +import { type ReactElement, useEffect, useLayoutEffect, useState } from 'react'; +import { useParams } from 'react-router-dom'; + +import { + ViewBody, + ViewHeader, + ViewIcon, + ViewTitle, +} from '../../../../modules/workbench'; +import { PageNotFound } from '../../404'; + +const useLoadAttachment = (pageId?: string, attachmentId?: string) => { + const docsService = useService(DocsService); + const [doc, setDoc] = useState(null); + const [model, setModel] = useState(null); + + useLayoutEffect(() => { + if (!pageId) return; + + const { doc, release } = docsService.open(pageId); + + if (!doc.blockSuiteDoc.ready) { + doc.blockSuiteDoc.load(); + } + + setDoc(doc); + + return () => { + release(); + }; + }, [docsService, pageId]); + + useEffect(() => { + if (!doc) return; + if (!attachmentId) return; + + const disposable = doc.blockSuiteDoc.slots.blockUpdated + .filter(({ type, id }) => type === 'add' && id === attachmentId) + // @ts-expect-error allow + .filter(({ model }) => matchFlavours(model, ['affine:attachment'])) + // @ts-expect-error allow + .once(({ model }) => setModel(model as AttachmentBlockModel)); + + return () => { + disposable.dispose(); + }; + }, [doc, attachmentId]); + + return { doc, model }; +}; + +export const AttachmentPage = (): ReactElement => { + const params = useParams(); + const { doc, model } = useLoadAttachment(params.pageId, params.attachmentId); + + if (!doc || !model) { + return ; + } + + return ( + <> + + + + + + + + + + ); +}; + +export const Component = () => { + return ; +}; diff --git a/packages/frontend/core/src/desktop/workbench-router.ts b/packages/frontend/core/src/desktop/workbench-router.ts index 0e810fa39b2d8..189ea393dd844 100644 --- a/packages/frontend/core/src/desktop/workbench-router.ts +++ b/packages/frontend/core/src/desktop/workbench-router.ts @@ -29,6 +29,10 @@ export const workbenchRoutes = [ path: '/:pageId', lazy: () => import('./pages/workspace/detail-page/detail-page'), }, + { + path: '/:pageId/:attachmentId', + lazy: () => import('./pages/workspace/attachment/index'), + }, { path: '*', lazy: () => import('./pages/404'), diff --git a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts index 9dfe4935d6280..e6f7a5b0635bb 100644 --- a/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts +++ b/packages/frontend/core/src/modules/peek-view/entities/peek-view.ts @@ -1,5 +1,6 @@ import type { BlockComponent, EditorHost } from '@blocksuite/affine/block-std'; import type { + AttachmentBlockModel, DocMode, EmbedLinkedDocModel, EmbedSyncedDocModel, @@ -51,6 +52,11 @@ export type ImagePeekViewInfo = { docRef: DocReferenceInfo; }; +export type AttachmentPeekViewInfo = { + type: 'attachment'; + docRef: DocReferenceInfo; +}; + export type AIChatBlockPeekViewInfo = { type: 'ai-chat-block'; docRef: DocReferenceInfo; @@ -68,6 +74,7 @@ export type ActivePeekView = { info: | DocPeekViewInfo | ImagePeekViewInfo + | AttachmentPeekViewInfo | CustomTemplatePeekViewInfo | AIChatBlockPeekViewInfo; }; @@ -90,6 +97,12 @@ const isImageBlockModel = ( return blockModel.flavour === 'affine:image'; }; +const isAttachmentBlockModel = ( + blockModel: BlockModel +): blockModel is AttachmentBlockModel => { + return blockModel.flavour === 'affine:attachment'; +}; + const isSurfaceRefModel = ( blockModel: BlockModel ): blockModel is SurfaceRefBlockModel => { @@ -162,6 +175,14 @@ function resolvePeekInfoFromPeekTarget( }, }; } + } else if (isAttachmentBlockModel(blockModel)) { + return { + type: 'attachment', + docRef: { + docId: blockModel.doc.id, + blockIds: [blockModel.id], + }, + }; } else if (isImageBlockModel(blockModel)) { return { type: 'image', diff --git a/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx b/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx new file mode 100644 index 0000000000000..1ea2cb240ab21 --- /dev/null +++ b/packages/frontend/core/src/modules/peek-view/view/attachment-preview/index.tsx @@ -0,0 +1,43 @@ +import { AttachmentViewer } from '@affine/component/attachment-viewer'; +import type { AttachmentBlockModel } from '@blocksuite/affine/blocks'; +import { type PropsWithChildren, Suspense, useMemo } from 'react'; +import { ErrorBoundary, type FallbackProps } from 'react-error-boundary'; + +import { useEditor } from '../utils'; + +const ErrorLogger = (props: FallbackProps) => { + console.error('image preview modal error', props.error); + return null; +}; + +export const AttachmentPreviewErrorBoundary = (props: PropsWithChildren) => { + return ( + {props.children} + ); +}; + +export type AttachmentPreviewModalProps = { + docId: string; + blockId: string; +}; + +export const AttachmentPreviewPeekView = ({ + docId, + blockId, +}: AttachmentPreviewModalProps) => { + const { doc } = useEditor(docId); + const blocksuiteDoc = doc?.blockSuiteDoc; + const model = useMemo(() => { + const block = blocksuiteDoc?.getBlock(blockId); + if (block?.model) { + return block.model as AttachmentBlockModel; + } + return null; + }, [blockId, blocksuiteDoc]); + + return ( + + {model ? : null} + + ); +}; diff --git a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx index f2fe712a6090c..ed9faa91e46c5 100644 --- a/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx +++ b/packages/frontend/core/src/modules/peek-view/view/peek-view-manager.tsx @@ -6,6 +6,7 @@ import { useEffect, useMemo } from 'react'; import type { ActivePeekView } from '../entities/peek-view'; import { PeekViewService } from '../services/peek-view'; +import { AttachmentPreviewPeekView } from './attachment-preview'; import { DocPeekPreview } from './doc-preview'; import { ImagePreviewPeekView } from './image-preview'; import { @@ -25,6 +26,15 @@ function renderPeekView({ info }: ActivePeekView) { return ; } + if (info.type === 'attachment' && info.docRef.blockIds?.[0]) { + return ( + + ); + } + if (info.type === 'image' && info.docRef.blockIds?.[0]) { return ( { return ; } + // TODO(@fundon): attachment's controls + if (info.type === 'image') { return null; // image controls are rendered in the image preview } diff --git a/packages/frontend/core/src/modules/workbench/constants.tsx b/packages/frontend/core/src/modules/workbench/constants.tsx index 719d816fd4701..7439354f5e8a8 100644 --- a/packages/frontend/core/src/modules/workbench/constants.tsx +++ b/packages/frontend/core/src/modules/workbench/constants.tsx @@ -1,7 +1,9 @@ import { AllDocsIcon, + AttachmentIcon, DeleteIcon, EdgelessIcon, + ExportToPdfIcon, PageIcon, TagIcon, TodayIcon, @@ -18,6 +20,8 @@ export const iconNameToIcon = { journal: , tag: , trash: , + attachment: , + pdf: , } satisfies Record; export type ViewIconName = keyof typeof iconNameToIcon; diff --git a/tools/cli/src/webpack/config.ts b/tools/cli/src/webpack/config.ts index e1bd39b768ea0..012d6b7cafd62 100644 --- a/tools/cli/src/webpack/config.ts +++ b/tools/cli/src/webpack/config.ts @@ -111,8 +111,8 @@ export const createConfiguration: ( : 'js/[name].js', // In some cases webpack will emit files starts with "_" which is reserved in web extension. chunkFilename: pathData => - pathData.chunk?.name === 'worker' - ? 'js/worker.[contenthash:8].js' + pathData.chunk?.name?.endsWith?.('worker') + ? 'js/[name].[contenthash:8].js' : buildFlags.mode === 'production' ? 'js/chunk.[name].[contenthash:8].js' : 'js/chunk.[name].js', diff --git a/yarn.lock b/yarn.lock index f663554b6a165..c65b7a1ab166a 100644 --- a/yarn.lock +++ b/yarn.lock @@ -352,6 +352,7 @@ __metadata: "@storybook/react": "npm:^8.2.9" "@storybook/react-vite": "npm:^8.2.9" "@testing-library/react": "npm:^16.0.0" + "@toeverything/pdf-viewer": "npm:^0.1.0" "@toeverything/theme": "npm:^1.0.16" "@types/react": "npm:^18.2.75" "@types/react-dom": "npm:^18.2.24" @@ -360,6 +361,8 @@ __metadata: check-password-strength: "npm:^2.0.10" clsx: "npm:^2.1.0" dayjs: "npm:^1.11.10" + file-type: "npm:^19.1.0" + filesize: "npm:^10.1.6" jotai: "npm:^2.8.0" lit: "npm:^3.1.2" lodash-es: "npm:^4.17.21" @@ -372,6 +375,7 @@ __metadata: react-paginate: "npm:^8.2.0" react-router-dom: "npm:^6.22.3" react-transition-state: "npm:^2.1.1" + react-virtuoso: "npm:^4.7.8" sonner: "npm:^1.4.41" storybook: "npm:^8.2.9" swr: "npm:^2.2.5" @@ -382,7 +386,7 @@ __metadata: zod: "npm:^3.22.4" peerDependencies: "@blocksuite/affine": "*" - "@blocksuite/icons": 2.1.67 + "@blocksuite/icons": "*" languageName: unknown linkType: soft @@ -13420,6 +13424,30 @@ __metadata: languageName: unknown linkType: soft +"@toeverything/pdf-viewer-types@npm:0.1.0": + version: 0.1.0 + resolution: "@toeverything/pdf-viewer-types@npm:0.1.0" + checksum: 10/aed8a2e9375e121a663e96147d6a726d4dd7b4917cfa35bac5c422c820125b2bc3e129281cad8c3983d1da12191ba4c6e0e45231123fce5d89091ce18dbc9560 + languageName: node + linkType: hard + +"@toeverything/pdf-viewer@npm:^0.1.0": + version: 0.1.0 + resolution: "@toeverything/pdf-viewer@npm:0.1.0" + dependencies: + "@toeverything/pdf-viewer-types": "npm:0.1.0" + "@toeverything/pdfium": "npm:0.1.0" + checksum: 10/75e1df49ecce97e667ac9ec1a630ba4492f1b712d4380a9c999f05535e305e4fd2a069d004449477520ea308ec5928f02b51268659f75788ae4715a7b6e14da7 + languageName: node + linkType: hard + +"@toeverything/pdfium@npm:0.1.0": + version: 0.1.0 + resolution: "@toeverything/pdfium@npm:0.1.0" + checksum: 10/5fee0f76608d27d747b9266096cdd153027e8f3e8774571524f86afd88757833a61472400df5dcf2a6ea2e16bcfe9864b090ef7e483c260998ba3fad418a9ba8 + languageName: node + linkType: hard + "@toeverything/theme@npm:^1.0.15, @toeverything/theme@npm:^1.0.16": version: 1.0.16 resolution: "@toeverything/theme@npm:1.0.16" @@ -20984,7 +21012,7 @@ __metadata: languageName: node linkType: hard -"filesize@npm:^10.0.12": +"filesize@npm:^10.0.12, filesize@npm:^10.1.6": version: 10.1.6 resolution: "filesize@npm:10.1.6" checksum: 10/e800837c4fc02303f1944d5a4c7b706df1c5cd95d745181852604fb00a1c2d55d2d3921252722bd2f0c86b59c94edaba23fa224776bbf977455d4034e7be1f45