mirror of
https://github.com/docmost/docmost.git
synced 2026-08-24 13:01:07 +08:00
Compare commits
5
Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
6cd6db106d | ||
|
|
aa20901559 | ||
|
|
89c22944ee | ||
|
|
b4da604512 | ||
|
|
549cf7c005 |
@@ -64,6 +64,7 @@
|
||||
"react-router-dom": "7.18.2",
|
||||
"semver": "7.7.4",
|
||||
"socket.io-client": "4.8.3",
|
||||
"yet-another-react-lightbox": "^3.32.2",
|
||||
"zod": "4.3.6"
|
||||
},
|
||||
"devDependencies": {
|
||||
|
||||
@@ -294,6 +294,9 @@
|
||||
"Export space": "Export space",
|
||||
"Export {{type}}": "Export {{type}}",
|
||||
"File exceeds the {{limit}} attachment limit": "File exceeds the {{limit}} attachment limit",
|
||||
"Media": "Media",
|
||||
"Open image": "Open image",
|
||||
"Open video": "Open video",
|
||||
"Align left": "Align left",
|
||||
"Align right": "Align right",
|
||||
"Align center": "Align center",
|
||||
|
||||
@@ -16,6 +16,14 @@ export const showAiMenuAtom = atom(false);
|
||||
|
||||
export const showLinkMenuAtom = atom(false);
|
||||
|
||||
export type LightboxRequest = {
|
||||
src: string;
|
||||
type: "image" | "video";
|
||||
} | null;
|
||||
|
||||
const initialLightboxRequest: LightboxRequest = null;
|
||||
export const lightboxRequestAtom = atom(initialLightboxRequest);
|
||||
|
||||
// Current page's edit mode — initialized from the user's saved preference on
|
||||
// first load, can be toggled locally without persisting to the server.
|
||||
export const currentPageEditModeAtom = atom<PageEditMode>(PageEditMode.Edit);
|
||||
|
||||
@@ -0,0 +1,154 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import Lightbox, { type Slide } from "yet-another-react-lightbox";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
import "yet-another-react-lightbox/plugins/captions.css";
|
||||
import Captions from "yet-another-react-lightbox/plugins/captions";
|
||||
import Download from "yet-another-react-lightbox/plugins/download";
|
||||
import Fullscreen from "yet-another-react-lightbox/plugins/fullscreen";
|
||||
import Video from "yet-another-react-lightbox/plugins/video";
|
||||
import Zoom from "yet-another-react-lightbox/plugins/zoom";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import i18n from "@/i18n.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
type LightboxViewProps = {
|
||||
editor: Editor;
|
||||
open: boolean;
|
||||
src: string;
|
||||
type: "image" | "video";
|
||||
onClose: () => void;
|
||||
};
|
||||
|
||||
function getVideoMimeType(src: string) {
|
||||
const extension = src.split(/[?#]/, 1)[0].split(".").pop()?.toLowerCase();
|
||||
|
||||
switch (extension) {
|
||||
case "webm":
|
||||
return "video/webm";
|
||||
case "ogv":
|
||||
return "video/ogg";
|
||||
case "mov":
|
||||
return "video/quicktime";
|
||||
case "m4v":
|
||||
return "video/x-m4v";
|
||||
default:
|
||||
return "video/mp4";
|
||||
}
|
||||
}
|
||||
|
||||
function getFilename(src: string) {
|
||||
const filename = src.split(/[?#]/, 1)[0].split("/").pop();
|
||||
if (!filename) return i18n.t("Media");
|
||||
|
||||
try {
|
||||
return decodeURIComponent(filename);
|
||||
} catch {
|
||||
return filename;
|
||||
}
|
||||
}
|
||||
|
||||
function getMedia(rawSrc: string, type?: string, alt?: string): Slide {
|
||||
const src = getFileUrl(rawSrc);
|
||||
const filename = getFilename(rawSrc);
|
||||
const caption = alt || filename;
|
||||
|
||||
if (type === "video") {
|
||||
return {
|
||||
type: "video",
|
||||
sources: [{ src, type: getVideoMimeType(rawSrc) }],
|
||||
title: caption,
|
||||
download: { url: src, filename },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "image",
|
||||
src,
|
||||
alt: alt || undefined,
|
||||
title: caption,
|
||||
download: { url: src, filename },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
function getPageMedia(editor: Editor): Slide[] {
|
||||
const media: Slide[] = [];
|
||||
|
||||
editor.state.doc.descendants((node) => {
|
||||
if (node.type.name !== "image" && node.type.name !== "video") return;
|
||||
|
||||
const rawSrc = typeof node.attrs.src === "string" ? node.attrs.src : "";
|
||||
if (!rawSrc) return;
|
||||
|
||||
media.push(getMedia(rawSrc, node.type.name, node.attrs.alt));
|
||||
});
|
||||
|
||||
return media;
|
||||
}
|
||||
|
||||
export default function LightboxView({
|
||||
editor,
|
||||
open,
|
||||
src,
|
||||
type,
|
||||
onClose,
|
||||
}: LightboxViewProps) {
|
||||
const { i18n: i18nInstance } = useTranslation();
|
||||
|
||||
const selectedSlide = useMemo(
|
||||
() => getMedia(src, type),
|
||||
[src, type, i18nInstance.language]
|
||||
);
|
||||
|
||||
const [pageSlides, setPageSlides] = useState<Slide[]>([]);
|
||||
const [loadedMediaKey, setLoadedMediaKey] = useState<string | null>(null);
|
||||
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
|
||||
setLoadedMediaKey(null);
|
||||
|
||||
const frame = requestAnimationFrame(() => {
|
||||
const slides = getPageMedia(editor);
|
||||
|
||||
setPageSlides(slides);
|
||||
setLoadedMediaKey(`${type}:${src}`);
|
||||
});
|
||||
|
||||
return () => cancelAnimationFrame(frame);
|
||||
}, [editor, open, type, src]);
|
||||
|
||||
const slides = loadedMediaKey === `${type}:${src}` ? pageSlides : [selectedSlide];
|
||||
|
||||
const index = useMemo(() => {
|
||||
if (!(pageSlides.length > 0)) {
|
||||
return 0;
|
||||
}
|
||||
|
||||
const idx = slides.findIndex((slide) =>
|
||||
type === "video"
|
||||
? "sources" in slide && slide.sources.some((s) => s.src === src)
|
||||
: "src" in slide && slide.src === src
|
||||
);
|
||||
return idx >= 0 ? idx : 0;
|
||||
}, [slides, src, type]);
|
||||
|
||||
return (
|
||||
<Lightbox
|
||||
open={open}
|
||||
close={onClose}
|
||||
index={index}
|
||||
slides={slides}
|
||||
plugins={[Captions, Download, Fullscreen, Video, Zoom]}
|
||||
captions={{ descriptionTextAlign: "center" }}
|
||||
video={{ controls: true, playsInline: true }}
|
||||
zoom={{
|
||||
scrollToZoom: true,
|
||||
maxZoomPixelRatio: 4,
|
||||
maxZoom: 4,
|
||||
supports: ["video"],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import React, { useCallback, useRef } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import {
|
||||
@@ -14,6 +15,7 @@ import {
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconDownload,
|
||||
IconMaximize,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
@@ -21,11 +23,13 @@ import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
|
||||
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -165,6 +169,25 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
altTextPanel
|
||||
) : (
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Open image")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
editorState?.src &&
|
||||
setLightboxRequest({
|
||||
src: getFileUrl(editorState.src),
|
||||
type: "image",
|
||||
})
|
||||
}
|
||||
size="lg"
|
||||
aria-label={t("Open image")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconMaximize size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageLeft}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { useCallback } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import {
|
||||
@@ -14,15 +15,18 @@ import {
|
||||
IconLayoutAlignLeft,
|
||||
IconLayoutAlignRight,
|
||||
IconDownload,
|
||||
IconMaximize,
|
||||
IconTrash,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import { useAltTextControl } from "@/features/editor/components/common/use-alt-text-control.tsx";
|
||||
import { lightboxRequestAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import classes from "../common/toolbar-menu.module.css";
|
||||
|
||||
export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
|
||||
|
||||
const editorState = useEditorState({
|
||||
editor,
|
||||
@@ -141,6 +145,25 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
altTextPanel
|
||||
) : (
|
||||
<div className={classes.toolbar}>
|
||||
<Tooltip position="top" label={t("Open video")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
editorState?.src &&
|
||||
setLightboxRequest({
|
||||
src: getFileUrl(editorState.src),
|
||||
type: "video",
|
||||
})
|
||||
}
|
||||
size="lg"
|
||||
aria-label={t("Open video")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconMaximize size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Align left")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignLeft}
|
||||
|
||||
@@ -33,6 +33,7 @@ import { useAtom, useAtomValue } from "jotai";
|
||||
import { currentUserAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import {
|
||||
currentPageEditModeAtom,
|
||||
lightboxRequestAtom,
|
||||
pageEditorAtom,
|
||||
yjsConnectionStatusAtom,
|
||||
yjsSyncedAtom,
|
||||
@@ -53,6 +54,7 @@ import CalloutMenu from "@/features/editor/components/callout/callout-menu.tsx";
|
||||
import VideoMenu from "@/features/editor/components/video/video-menu.tsx";
|
||||
import PdfMenu from "@/features/editor/components/pdf/pdf-menu.tsx";
|
||||
import SubpagesMenu from "@/features/editor/components/subpages/subpages-menu.tsx";
|
||||
import LightboxView from "@/features/editor/components/common/lightbox-view";
|
||||
import {
|
||||
handleFileDrop,
|
||||
handlePaste,
|
||||
@@ -184,6 +186,7 @@ function CollabPageEditor({
|
||||
const [, setActiveCommentId] = useAtom(activeCommentIdAtom);
|
||||
const [showCommentPopup, setShowCommentPopup] = useAtom(showCommentPopupAtom);
|
||||
const [showReadOnlyCommentPopup] = useAtom(showReadOnlyCommentPopupAtom);
|
||||
const [lightboxRequest, setLightboxRequest] = useAtom(lightboxRequestAtom);
|
||||
const [isLocalSynced, setIsLocalSynced] = useState(false);
|
||||
const [isRemoteSynced, setIsRemoteSynced] = useState(false);
|
||||
const [yjsConnectionStatus, setYjsConnectionStatus] = useAtom(
|
||||
@@ -459,6 +462,15 @@ function CollabPageEditor({
|
||||
{editor && !editorIsEditable && (editable || canComment) && (
|
||||
<ReadonlyBubbleMenu editor={editor} />
|
||||
)}
|
||||
{editor && (
|
||||
<LightboxView
|
||||
editor={editor}
|
||||
open={!!lightboxRequest}
|
||||
src={lightboxRequest?.src ?? ""}
|
||||
type={lightboxRequest?.type ?? "image"}
|
||||
onClose={() => setLightboxRequest(null)}
|
||||
/>
|
||||
)}
|
||||
{showCommentPopup && <CommentDialog editor={editor} pageId={pageId} />}
|
||||
{showReadOnlyCommentPopup && (
|
||||
<CommentDialog editor={editor} pageId={pageId} readOnly />
|
||||
|
||||
Generated
+2
@@ -4991,6 +4991,7 @@ packages:
|
||||
'@xmldom/xmldom@0.8.13':
|
||||
resolution: {integrity: sha512-KRYzxepc14G/CEpEGc3Yn+JKaAeT63smlDr+vjB8jRfgTBBI9wRj/nkQEO+ucV8p8I9bfKLWp37uHgFrbntPvw==}
|
||||
engines: {node: '>=10.0.0'}
|
||||
deprecated: this version has critical issues, please update to the latest version
|
||||
|
||||
'@xtuc/ieee754@1.2.0':
|
||||
resolution: {integrity: sha512-DX8nKgqcGwsc0eJSqYt5lwP4DH5FlHnmuWWBRy7X0NcaGR0ZtuyeESgMwTYVEtxmsNGY+qit4QYT/MIYTOTPeA==}
|
||||
@@ -6319,6 +6320,7 @@ packages:
|
||||
eslint@9.28.0:
|
||||
resolution: {integrity: sha512-ocgh41VhRlf9+fVpe7QKzwLj9c92fDiqOj8Y3Sd4/ZmVA4Btx4PlUYPq4pp9JDyupkf1upbEXecxL2mwNV7jPQ==}
|
||||
engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0}
|
||||
deprecated: This version is no longer supported. Please see https://eslint.org/version-support for other options.
|
||||
hasBin: true
|
||||
peerDependencies:
|
||||
jiti: '*'
|
||||
|
||||
Reference in New Issue
Block a user