mirror of
https://github.com/docmost/docmost.git
synced 2026-09-11 07:56:54 +08:00
Merge branch 'main' into sandboxed
This commit is contained in:
@@ -85,6 +85,9 @@ export function AudioMenu({ editor }: EditorMenuProps) {
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`audio-menu`}
|
||||
ref={(element) => {
|
||||
if (element) element.style.zIndex = "99";
|
||||
}}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
|
||||
@@ -23,11 +23,21 @@ import {
|
||||
} from "@/features/comment/atoms/comment-atom";
|
||||
import { useAtom, useAtomValue } from "jotai";
|
||||
import { v7 as uuid7 } from "uuid";
|
||||
import { isCellSelection, isEditorReady, isTextSelected } from "@docmost/editor-ext";
|
||||
import {
|
||||
isCellSelection,
|
||||
isEditorReady,
|
||||
isTextSelected,
|
||||
} from "@docmost/editor-ext";
|
||||
import { LinkSelector } from "@/features/editor/components/bubble-menu/link-selector.tsx";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { showAiMenuAtom, showLinkMenuAtom } from "@/features/editor/atoms/editor-atoms";
|
||||
import { userAtom, workspaceAtom } from "@/features/user/atoms/current-user-atom";
|
||||
import {
|
||||
showAiMenuAtom,
|
||||
showLinkMenuAtom,
|
||||
} from "@/features/editor/atoms/editor-atoms";
|
||||
import {
|
||||
userAtom,
|
||||
workspaceAtom,
|
||||
} from "@/features/user/atoms/current-user-atom";
|
||||
|
||||
export interface BubbleMenuItem {
|
||||
name: string;
|
||||
@@ -217,7 +227,12 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
|
||||
<ActionIcon.Group>
|
||||
{items.map((item, index) => (
|
||||
<Tooltip key={index} label={t(item.name)} withArrow>
|
||||
<Tooltip
|
||||
key={index}
|
||||
label={t(item.name)}
|
||||
withArrow
|
||||
withinPortal={false}
|
||||
>
|
||||
<ActionIcon
|
||||
key={index}
|
||||
variant="default"
|
||||
@@ -226,7 +241,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
aria-label={t(item.name)}
|
||||
className={clsx({ [classes.active]: item.isActive() })}
|
||||
style={{ border: "none" }}
|
||||
onClick={() => isEditorReady(props.editor) && item.command()}
|
||||
onClick={() =>
|
||||
isEditorReady(props.editor) && item.command()
|
||||
}
|
||||
>
|
||||
<item.icon style={{ width: rem(16) }} stroke={2} />
|
||||
</ActionIcon>
|
||||
@@ -256,7 +273,9 @@ export const EditorBubbleMenu: FC<EditorBubbleMenuProps> = (props) => {
|
||||
radius="6px"
|
||||
aria-label={t(commentItem.name)}
|
||||
style={{ border: "none" }}
|
||||
onClick={() => isEditorReady(props.editor) && commentItem.command()}
|
||||
onClick={() =>
|
||||
isEditorReady(props.editor) && commentItem.command()
|
||||
}
|
||||
>
|
||||
<IconMessage size={16} stroke={2} />
|
||||
</ActionIcon>
|
||||
|
||||
@@ -129,8 +129,7 @@ function handleColorKeyNav(
|
||||
grid: "text" | "highlight",
|
||||
) {
|
||||
const cols = COLOR_GRID_COLS;
|
||||
const total =
|
||||
grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
|
||||
const total = grid === "text" ? TEXT_COLORS.length : HIGHLIGHT_COLORS.length;
|
||||
const col = index % cols;
|
||||
|
||||
if (e.key === "ArrowRight") {
|
||||
@@ -163,8 +162,7 @@ function handleColorKeyNav(
|
||||
if (prev >= 0) {
|
||||
focusSwatch(grid, prev);
|
||||
} else if (grid === "highlight") {
|
||||
const lastRowStart =
|
||||
Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
|
||||
const lastRowStart = Math.floor((TEXT_COLORS.length - 1) / cols) * cols;
|
||||
focusSwatch("text", Math.min(lastRowStart + col, TEXT_COLORS.length - 1));
|
||||
}
|
||||
return;
|
||||
@@ -222,7 +220,7 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
withArrow
|
||||
>
|
||||
<Popover.Target>
|
||||
<Tooltip label={t("Text color")} withArrow>
|
||||
<Tooltip label={t("Text color")} withArrow withinPortal={false}>
|
||||
<Button
|
||||
variant="default"
|
||||
radius="0"
|
||||
@@ -247,26 +245,26 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
|
||||
<Popover.Dropdown onMouseDown={(e) => e.preventDefault()}>
|
||||
<Stack gap="md" p="2px">
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("Text color")}
|
||||
</Text>
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{TEXT_COLORS.map(({ name, color }, index) => {
|
||||
const applyTextColor = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setColor(color || "")
|
||||
.run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
return (
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("Text color")}
|
||||
</Text>
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{TEXT_COLORS.map(({ name, color }, index) => {
|
||||
const applyTextColor = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetColor();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.setColor(color || "")
|
||||
.run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
return (
|
||||
<Tooltip key={index} label={t(name)} withArrow>
|
||||
<Box
|
||||
role="button"
|
||||
@@ -306,34 +304,34 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
A
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("Highlight color")}
|
||||
</Text>
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
|
||||
const applyHighlight = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetHighlight();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleMark("highlight", {
|
||||
color: color || "",
|
||||
colorName: name.toLowerCase() || "",
|
||||
})
|
||||
.run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
return (
|
||||
<Box>
|
||||
<Text size="sm" fw={600} mb="xs">
|
||||
{t("Highlight color")}
|
||||
</Text>
|
||||
<SimpleGrid cols={5} spacing="xs">
|
||||
{HIGHLIGHT_COLORS.map(({ name, color }, index) => {
|
||||
const applyHighlight = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
if (name === "Default") {
|
||||
editor.commands.unsetHighlight();
|
||||
} else {
|
||||
editor
|
||||
.chain()
|
||||
.focus()
|
||||
.toggleMark("highlight", {
|
||||
color: color || "",
|
||||
colorName: name.toLowerCase() || "",
|
||||
})
|
||||
.run();
|
||||
}
|
||||
setIsOpen(false);
|
||||
};
|
||||
return (
|
||||
<Tooltip key={index} label={t(name)} withArrow>
|
||||
<Box
|
||||
role="button"
|
||||
@@ -378,37 +376,36 @@ export const ColorSelector: FC<ColorSelectorProps> = ({
|
||||
)}
|
||||
</Box>
|
||||
</Tooltip>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
);
|
||||
})}
|
||||
</SimpleGrid>
|
||||
</Box>
|
||||
|
||||
<Button
|
||||
variant="default"
|
||||
fullWidth
|
||||
data-color-grid="remove"
|
||||
className={classes.removeColor}
|
||||
onClick={() => {
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.unsetColor();
|
||||
editor.commands.unsetHighlight();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const lastRowStart =
|
||||
Math.floor(
|
||||
(HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS,
|
||||
) * COLOR_GRID_COLS;
|
||||
focusSwatch("highlight", lastRowStart);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Remove color")}
|
||||
</Button>
|
||||
</Stack>
|
||||
<Button
|
||||
variant="default"
|
||||
fullWidth
|
||||
data-color-grid="remove"
|
||||
className={classes.removeColor}
|
||||
onClick={() => {
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.unsetColor();
|
||||
editor.commands.unsetHighlight();
|
||||
}
|
||||
setIsOpen(false);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
const lastRowStart =
|
||||
Math.floor((HIGHLIGHT_COLORS.length - 1) / COLOR_GRID_COLS) *
|
||||
COLOR_GRID_COLS;
|
||||
focusSwatch("highlight", lastRowStart);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{t("Remove color")}
|
||||
</Button>
|
||||
</Stack>
|
||||
</Popover.Dropdown>
|
||||
</Popover>
|
||||
);
|
||||
|
||||
@@ -10,7 +10,7 @@ export const LinkSelector: FC = () => {
|
||||
const setShowLinkMenu = useSetAtom(showLinkMenuAtom);
|
||||
|
||||
return (
|
||||
<Tooltip label={t("Add link")} withArrow>
|
||||
<Tooltip label={t("Add link")} withArrow withinPortal={false}>
|
||||
<ActionIcon
|
||||
variant="default"
|
||||
size="lg"
|
||||
|
||||
@@ -91,7 +91,12 @@ export const TextAlignmentSelector: FC<TextAlignmentProps> = ({
|
||||
onChange={setIsOpen}
|
||||
>
|
||||
<Menu.Target>
|
||||
<Tooltip label={t("Text align")} withArrow disabled={isOpen}>
|
||||
<Tooltip
|
||||
label={t("Text align")}
|
||||
withArrow
|
||||
disabled={isOpen}
|
||||
withinPortal={false}
|
||||
>
|
||||
<Button
|
||||
variant="default"
|
||||
style={{ border: "none", height: "34px" }}
|
||||
|
||||
@@ -121,12 +121,14 @@ export function CalloutMenu({ editor }: EditorMenuProps) {
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`callout-menu`}
|
||||
ref={(element) => {
|
||||
if (element) element.style.zIndex = "99";
|
||||
}}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
placement: "bottom",
|
||||
// offset: 233, // // offset: [0, 10],
|
||||
// zIndex: 99,
|
||||
flip: false,
|
||||
}}
|
||||
shouldShow={shouldShow}
|
||||
|
||||
@@ -257,6 +257,9 @@ export function ColumnsMenu({ editor }: EditorMenuProps) {
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey="columns-menu"
|
||||
ref={(element) => {
|
||||
if (element) element.style.zIndex = "99";
|
||||
}}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
|
||||
@@ -0,0 +1,188 @@
|
||||
import type { Editor } from "@tiptap/react";
|
||||
import type { Node as PMNode } from "@tiptap/pm/model";
|
||||
import Lightbox, { type Slide } from "yet-another-react-lightbox";
|
||||
import type { LightboxRequest } from "@/features/editor/atoms/editor-atoms";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
import "yet-another-react-lightbox/styles.css";
|
||||
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);
|
||||
|
||||
if (type === "video") {
|
||||
return {
|
||||
type: "video",
|
||||
sources: [{ src, type: getVideoMimeType(rawSrc) }],
|
||||
download: { url: src, filename },
|
||||
};
|
||||
} else {
|
||||
return {
|
||||
type: "image",
|
||||
src,
|
||||
alt: alt || undefined,
|
||||
download: { url: src, filename },
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const LIGHTBOX_NODE_TYPES: Record<string, "image" | "video"> = {
|
||||
image: "image",
|
||||
video: "video",
|
||||
drawio: "image",
|
||||
excalidraw: "image",
|
||||
};
|
||||
|
||||
// video is excluded: clicks there operate the native controls
|
||||
const CLICK_TO_EXPAND_NODE_TYPES = new Set(["image", "drawio", "excalidraw"]);
|
||||
|
||||
export function getLightboxClickRequest(node: PMNode): LightboxRequest {
|
||||
if (!CLICK_TO_EXPAND_NODE_TYPES.has(node.type.name)) return null;
|
||||
|
||||
const src = typeof node.attrs.src === "string" ? node.attrs.src : "";
|
||||
if (!src) return null;
|
||||
|
||||
return { src: getFileUrl(src), type: "image" };
|
||||
}
|
||||
|
||||
function getPageMedia(editor: Editor): Slide[] {
|
||||
const media: Slide[] = [];
|
||||
|
||||
editor.state.doc.descendants((node) => {
|
||||
const type = LIGHTBOX_NODE_TYPES[node.type.name];
|
||||
if (!type) return;
|
||||
|
||||
const rawSrc = typeof node.attrs.src === "string" ? node.attrs.src : "";
|
||||
if (!rawSrc) return;
|
||||
|
||||
media.push(getMedia(rawSrc, type, 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);
|
||||
const [isFullscreen, setIsFullscreen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) setIsFullscreen(false);
|
||||
}, [open]);
|
||||
|
||||
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={[Download, Fullscreen, Video, Zoom]}
|
||||
styles={{
|
||||
container: { backgroundColor: "rgba(0, 0, 0, 0.8)" },
|
||||
icon: { width: 24, height: 24 },
|
||||
toolbar: {
|
||||
margin: 8,
|
||||
borderRadius: 8,
|
||||
backgroundColor: "rgba(0, 0, 0, 0.5)",
|
||||
},
|
||||
}}
|
||||
controller={{ closeOnBackdropClick: !isFullscreen }}
|
||||
on={{
|
||||
enterFullscreen: () => setIsFullscreen(true),
|
||||
exitFullscreen: () => setIsFullscreen(false),
|
||||
}}
|
||||
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 { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import {
|
||||
@@ -24,6 +25,7 @@ import {
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconZoomIn,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getDrawioUrl, getFileUrl } from "@/lib/config.ts";
|
||||
@@ -39,10 +41,12 @@ import { decodeBase64ToSvgString, svgStringToFile } from "@/lib/utils";
|
||||
import { IAttachment } from "@/features/attachments/types/attachment.types";
|
||||
import { modals } from "@mantine/modals";
|
||||
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 DrawioMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [initialXML, setInitialXML] = useState<string>("");
|
||||
const drawioRef = useRef<DrawIoEmbedRef>(null);
|
||||
@@ -269,6 +273,9 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`drawio-menu`}
|
||||
ref={(element) => {
|
||||
if (element) element.style.zIndex = "99";
|
||||
}}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
@@ -328,6 +335,23 @@ export function DrawioMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
editorState?.src &&
|
||||
setLightboxRequest({
|
||||
src: getFileUrl(editorState.src),
|
||||
type: "image",
|
||||
})
|
||||
}
|
||||
size="lg"
|
||||
aria-label={t("Expand")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconZoomIn size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus";
|
||||
import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react";
|
||||
import { lazy, Suspense, useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useSetAtom } from "jotai";
|
||||
import { Node as PMNode } from "@tiptap/pm/model";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import {
|
||||
@@ -25,6 +26,7 @@ import {
|
||||
IconDownload,
|
||||
IconEdit,
|
||||
IconTrash,
|
||||
IconZoomIn,
|
||||
} from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { getFileUrl } from "@/lib/config.ts";
|
||||
@@ -37,6 +39,7 @@ import ReactClearModal from "react-clear-modal";
|
||||
import { useHandleLibrary } from "@excalidraw/excalidraw";
|
||||
import { localStorageLibraryAdapter } from "@/features/editor/components/excalidraw/excalidraw-utils.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";
|
||||
|
||||
const ExcalidrawComponent = lazy(() =>
|
||||
@@ -47,6 +50,7 @@ const ExcalidrawComponent = lazy(() =>
|
||||
|
||||
export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
const { t } = useTranslation();
|
||||
const setLightboxRequest = useSetAtom(lightboxRequestAtom);
|
||||
const [opened, { open, close }] = useDisclosure(false);
|
||||
const [excalidrawAPI, setExcalidrawAPI] =
|
||||
useState<ExcalidrawImperativeAPI>(null);
|
||||
@@ -359,6 +363,23 @@ export function ExcalidrawMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
editorState?.src &&
|
||||
setLightboxRequest({
|
||||
src: getFileUrl(editorState.src),
|
||||
type: "image",
|
||||
})
|
||||
}
|
||||
size="lg"
|
||||
aria-label={t("Expand")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconZoomIn size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Edit")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleOpen}
|
||||
|
||||
@@ -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 {
|
||||
@@ -16,16 +17,19 @@ import {
|
||||
IconDownload,
|
||||
IconRefresh,
|
||||
IconTrash,
|
||||
IconZoomIn,
|
||||
} from "@tabler/icons-react";
|
||||
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,
|
||||
@@ -152,6 +156,9 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`image-menu`}
|
||||
ref={(element) => {
|
||||
if (element) element.style.zIndex = "99";
|
||||
}}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
@@ -207,6 +214,23 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
editorState?.src &&
|
||||
setLightboxRequest({
|
||||
src: getFileUrl(editorState.src),
|
||||
type: "image",
|
||||
})
|
||||
}
|
||||
size="lg"
|
||||
aria-label={t("Expand")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconZoomIn size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
|
||||
@@ -26,7 +26,12 @@ import { INTERNAL_LINK_REGEX } from "@/lib/constants";
|
||||
import { LinkEditorPanel } from "@/features/editor/components/link/link-editor-panel.tsx";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import { buildSharedPageUrl } from "@/features/page/page.utils.ts";
|
||||
import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts";
|
||||
import {
|
||||
buildPageUrl,
|
||||
buildPublicSpaceUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
import { sanitizeUrl, copyToClipboard, isEditorReady } from "@docmost/editor-ext";
|
||||
import { normalizeUrl } from "@/lib/utils";
|
||||
@@ -60,9 +65,10 @@ export default function LinkView(props: MarkViewProps) {
|
||||
const href = mark.attrs.href as string;
|
||||
const navigate = useNavigate();
|
||||
const location = useLocation();
|
||||
const { shareId, pageSlug } = useParams();
|
||||
const { shareId, spaceSlug, pageSlug } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const isShareRoute = location.pathname.startsWith("/share");
|
||||
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
|
||||
|
||||
const [popoverState, setPopoverState] = useState<
|
||||
"closed" | "preview" | "edit"
|
||||
@@ -84,16 +90,33 @@ export default function LinkView(props: MarkViewProps) {
|
||||
const activeView = isPopoverVisible ? popoverState : lastOpenState.current;
|
||||
|
||||
const { data: linkedPage } = usePageQuery({
|
||||
pageId: isPopoverVisible && slugId && !isShareRoute ? slugId : null,
|
||||
pageId:
|
||||
isPopoverVisible && slugId && !isShareRoute && !isPublicSpaceRoute
|
||||
? slugId
|
||||
: null,
|
||||
});
|
||||
|
||||
const { data: sharedPageData } = useSharePageQuery({
|
||||
pageId: isPopoverVisible && slugId && isShareRoute ? slugId : null,
|
||||
});
|
||||
|
||||
const pageTitle = isShareRoute
|
||||
? sharedPageData?.page?.title
|
||||
: linkedPage?.title;
|
||||
// Resolved eagerly (not gated on the popover): an unresolvable target must
|
||||
// render as inert text rather than a link that dead-ends at /login.
|
||||
const { data: publicSpacePageData } = usePublicSpacePageQuery({
|
||||
spaceSlug: isPublicSpaceRoute && slugId ? spaceSlug : undefined,
|
||||
pageSlugId: slugId,
|
||||
contentless: true,
|
||||
});
|
||||
|
||||
const isUnresolvedPublicLink =
|
||||
isPublicSpaceRoute && isInternal && !publicSpacePageData?.page && !slugId;
|
||||
|
||||
let pageTitle = linkedPage?.title;
|
||||
if (isShareRoute) {
|
||||
pageTitle = sharedPageData?.page?.title;
|
||||
} else if (isPublicSpaceRoute) {
|
||||
pageTitle = publicSpacePageData?.page?.title;
|
||||
}
|
||||
|
||||
const pendingTitleRef = useRef<string | null>(null);
|
||||
const titleInputRef = useRef<HTMLInputElement>(null);
|
||||
@@ -270,6 +293,27 @@ export default function LinkView(props: MarkViewProps) {
|
||||
anchorId: anchor || undefined,
|
||||
});
|
||||
navigate(sharedUrl);
|
||||
} else if (isPublicSpaceRoute) {
|
||||
if (slugId && publicSpacePageData?.page) {
|
||||
navigate(
|
||||
buildPublicSpaceUrl({
|
||||
// cross-space targets resolve to their own space's public URL
|
||||
spaceSlug: publicSpacePageData.space?.slug ?? spaceSlug,
|
||||
pageSlugId: slugId,
|
||||
pageTitle: pageTitle,
|
||||
anchorId: anchor || undefined,
|
||||
}),
|
||||
);
|
||||
} else if (slugId) {
|
||||
// no public URL: the /p/ resolver redirects members straight to the
|
||||
// page and funnels anonymous visitors through login first; a new tab
|
||||
// keeps the docs tab's history intact through that redirect chain
|
||||
window.open(
|
||||
buildPageUrl(undefined, slugId, pageTitle, anchor || undefined),
|
||||
"_blank",
|
||||
"noopener,noreferrer",
|
||||
);
|
||||
}
|
||||
} else {
|
||||
navigate(anchor ? `${targetPath}#${anchor}` : targetPath);
|
||||
}
|
||||
@@ -286,8 +330,11 @@ export default function LinkView(props: MarkViewProps) {
|
||||
location.pathname,
|
||||
isInternal,
|
||||
isShareRoute,
|
||||
isPublicSpaceRoute,
|
||||
slugId,
|
||||
shareId,
|
||||
spaceSlug,
|
||||
publicSpacePageData,
|
||||
pageTitle,
|
||||
pageSlug,
|
||||
]);
|
||||
@@ -329,12 +376,18 @@ export default function LinkView(props: MarkViewProps) {
|
||||
setPopoverState("closed");
|
||||
}, [editor]);
|
||||
|
||||
const internalHref = () => {
|
||||
if (isShareRoute && slugId) {
|
||||
return buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle });
|
||||
}
|
||||
if (isPublicSpaceRoute && slugId && publicSpacePageData?.page) {
|
||||
return buildPublicSpaceUrl({ spaceSlug, pageSlugId: slugId, pageTitle });
|
||||
}
|
||||
return href;
|
||||
};
|
||||
|
||||
const displayHref = sanitizeUrl(
|
||||
isInternal
|
||||
? isShareRoute && slugId
|
||||
? buildSharedPageUrl({ shareId, pageSlugId: slugId, pageTitle })
|
||||
: href
|
||||
: normalizeUrl(href),
|
||||
isInternal ? internalHref() : normalizeUrl(href),
|
||||
);
|
||||
|
||||
const linkTitleInput = (
|
||||
@@ -384,6 +437,16 @@ export default function LinkView(props: MarkViewProps) {
|
||||
</>
|
||||
);
|
||||
|
||||
// Targets outside the published space have no public URL, so the label is
|
||||
// rendered as inert text instead of a link that dead-ends at /login.
|
||||
if (isUnresolvedPublicLink) {
|
||||
return (
|
||||
<span ref={wrapperRef} className={classes.linkWrapper}>
|
||||
<MarkViewContent />
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<Popover
|
||||
opened={isPopoverVisible}
|
||||
|
||||
@@ -4,8 +4,10 @@ import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { Link, useLocation, useNavigate, useParams } from "react-router-dom";
|
||||
import { usePageQuery } from "@/features/page/queries/page-query.ts";
|
||||
import { useSharePageQuery } from "@/features/share/queries/share-query.ts";
|
||||
import { usePublicSpacePageQuery } from "@/features/public-space/queries/public-space-query.ts";
|
||||
import {
|
||||
buildPageUrl,
|
||||
buildPublicSpaceUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
@@ -22,17 +24,30 @@ export default function MentionView(props: NodeViewProps) {
|
||||
|
||||
const location = useLocation();
|
||||
const isShareRoute = location.pathname.startsWith("/share");
|
||||
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
|
||||
|
||||
const {
|
||||
data: page,
|
||||
isLoading,
|
||||
isError,
|
||||
} = usePageQuery({ pageId: isPageMention && !isShareRoute ? slugId : null });
|
||||
} = usePageQuery({
|
||||
pageId:
|
||||
isPageMention && !isShareRoute && !isPublicSpaceRoute ? slugId : null,
|
||||
});
|
||||
|
||||
const { data: sharedPage } = useSharePageQuery({
|
||||
pageId: isPageMention && isShareRoute ? slugId : undefined,
|
||||
});
|
||||
|
||||
// Without the slugId guard the request resolves to the space home, which
|
||||
// would render a mention pointing at the wrong page.
|
||||
const { data: publicPageData } = usePublicSpacePageQuery({
|
||||
spaceSlug:
|
||||
isPageMention && isPublicSpaceRoute && slugId ? spaceSlug : undefined,
|
||||
pageSlugId: slugId,
|
||||
contentless: true,
|
||||
});
|
||||
|
||||
const currentPageSlugId = extractPageSlugId(pageSlug);
|
||||
const isSamePage = currentPageSlugId === slugId;
|
||||
|
||||
@@ -103,7 +118,61 @@ export default function MentionView(props: NodeViewProps) {
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
{hasTarget && !isShareRoute && isError && (
|
||||
{hasTarget && isPublicSpaceRoute && publicPageData?.page && (
|
||||
<Anchor
|
||||
component={Link}
|
||||
fw={500}
|
||||
to={buildPublicSpaceUrl({
|
||||
spaceSlug: publicPageData.space?.slug ?? spaceSlug,
|
||||
pageSlugId: slugId,
|
||||
pageTitle: publicPageData.page.title || label,
|
||||
anchorId,
|
||||
})}
|
||||
onClick={handleClick}
|
||||
underline="never"
|
||||
className={classes.pageMentionLink}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
component="span"
|
||||
size={18}
|
||||
style={{ verticalAlign: "text-bottom" }}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
<span className={classes.pageMentionText}>
|
||||
{publicPageData.page.title || label}
|
||||
</span>
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
{/* No public URL: the /p/ resolver redirects members to the page and
|
||||
funnels anonymous visitors through login first. New tab, so the
|
||||
redirect chain never rewrites the docs tab's history. */}
|
||||
{hasTarget && isPublicSpaceRoute && !publicPageData?.page && (
|
||||
<Anchor
|
||||
fw={500}
|
||||
href={buildPageUrl(undefined, slugId, label, anchorId)}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
underline="never"
|
||||
className={classes.pageMentionLink}
|
||||
>
|
||||
<ActionIcon
|
||||
variant="transparent"
|
||||
color="gray"
|
||||
component="span"
|
||||
size={18}
|
||||
style={{ verticalAlign: "text-bottom" }}
|
||||
>
|
||||
<IconFileDescription size={18} />
|
||||
</ActionIcon>
|
||||
<span className={classes.pageMentionText}>{label}</span>
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
{hasTarget && !isShareRoute && !isPublicSpaceRoute && isError && (
|
||||
<Anchor
|
||||
component={Link}
|
||||
fw={500}
|
||||
@@ -127,7 +196,7 @@ export default function MentionView(props: NodeViewProps) {
|
||||
</Anchor>
|
||||
)}
|
||||
|
||||
{hasTarget && !isShareRoute && !isError && (
|
||||
{hasTarget && !isShareRoute && !isPublicSpaceRoute && !isError && (
|
||||
<Anchor
|
||||
component={Link}
|
||||
fw={500}
|
||||
|
||||
@@ -106,6 +106,9 @@ export function PdfMenu({ editor }: EditorMenuProps) {
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`pdf-menu`}
|
||||
ref={(element) => {
|
||||
if (element) element.style.zIndex = "99";
|
||||
}}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
|
||||
+6
-4
@@ -66,7 +66,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
}
|
||||
// Clear search term in editor
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setSearchTerm("");
|
||||
editor.commands.setSearchTerms([""]);
|
||||
}
|
||||
};
|
||||
|
||||
@@ -117,7 +117,7 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
|
||||
useEffect(() => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.setSearchTerm(searchText);
|
||||
editor.commands.setSearchTerms([searchText]);
|
||||
editor.commands.resetIndex();
|
||||
editor.commands.selectCurrentItem();
|
||||
}, [searchText]);
|
||||
@@ -181,8 +181,10 @@ function SearchAndReplaceDialog({ editor, editable = true }: PageFindDialogDialo
|
||||
|
||||
const location = useLocation();
|
||||
useEffect(() => {
|
||||
closeDialog();
|
||||
}, [location]);
|
||||
if (pageFindState.isOpen) {
|
||||
closeDialog();
|
||||
}
|
||||
}, [location.pathname]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
|
||||
+205
@@ -0,0 +1,205 @@
|
||||
import { ActionIcon, Dialog, Flex, Text, Tooltip } from "@mantine/core";
|
||||
import {
|
||||
IconArrowNarrowDown,
|
||||
IconArrowNarrowUp,
|
||||
IconX,
|
||||
} from "@tabler/icons-react";
|
||||
import { useEditor } from "@tiptap/react";
|
||||
import { isEditorReady } from "@docmost/editor-ext";
|
||||
import React, { useCallback, useEffect, useRef, useState } from "react";
|
||||
import classes from "./search-replace.module.css";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { useLocation, useNavigate } from "react-router-dom";
|
||||
|
||||
interface SearchNavigationDialogProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
}
|
||||
|
||||
interface SearchNavigationEvent extends CustomEvent {
|
||||
detail: {
|
||||
searchTerms: string[];
|
||||
wholeWord?: boolean;
|
||||
};
|
||||
}
|
||||
|
||||
function SearchNavigationDialog({ editor }: SearchNavigationDialogProps) {
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const navigate = useNavigate();
|
||||
const [open, setOpen] = useState(false);
|
||||
const openRef = useRef(false);
|
||||
const [resultState, setResultState] = useState({
|
||||
resultIndex: 0,
|
||||
resultsLength: 0,
|
||||
});
|
||||
|
||||
const goToSelection = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
const position = results[resultIndex];
|
||||
|
||||
setResultState({
|
||||
resultsLength: results.length,
|
||||
resultIndex,
|
||||
});
|
||||
|
||||
if (!position) return;
|
||||
requestAnimationFrame(() => {
|
||||
document
|
||||
.querySelector(".search-result-current")
|
||||
?.scrollIntoView({ behavior: "smooth", block: "center" });
|
||||
});
|
||||
};
|
||||
|
||||
const next = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.nextSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const previous = () => {
|
||||
if (!isEditorReady(editor)) return;
|
||||
editor.commands.previousSearchResult();
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const close = useCallback(() => {
|
||||
if (!openRef.current) return;
|
||||
|
||||
openRef.current = false;
|
||||
setOpen(false);
|
||||
if (isEditorReady(editor)) {
|
||||
editor.commands.setSearchTerms([""]);
|
||||
}
|
||||
const nextParams = new URLSearchParams(location.search);
|
||||
nextParams.delete("q");
|
||||
nextParams.delete("m");
|
||||
const nextSearch = nextParams.toString();
|
||||
navigate(
|
||||
{
|
||||
pathname: location.pathname,
|
||||
search: nextSearch ? `?${nextSearch}` : "",
|
||||
hash: location.hash,
|
||||
},
|
||||
{ replace: true },
|
||||
);
|
||||
}, [editor, location.hash, location.pathname, location.search, navigate]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleOpen = (event: Event) => {
|
||||
const { searchTerms: terms, wholeWord = true } = (
|
||||
event as SearchNavigationEvent
|
||||
).detail;
|
||||
|
||||
if (!terms?.length || !isEditorReady(editor)) return;
|
||||
|
||||
openRef.current = false;
|
||||
editor.commands.setSearchTerms(terms);
|
||||
editor.commands.setWholeWord(wholeWord);
|
||||
editor.commands.resetIndex();
|
||||
|
||||
const { results, resultIndex } = editor.storage.searchAndReplace;
|
||||
openRef.current = true;
|
||||
if (results.length === 0) {
|
||||
close();
|
||||
return;
|
||||
}
|
||||
|
||||
setOpen(true);
|
||||
setResultState({
|
||||
resultIndex,
|
||||
resultsLength: results.length,
|
||||
});
|
||||
|
||||
goToSelection();
|
||||
};
|
||||
|
||||
const handleClose = () => {
|
||||
if (openRef.current) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
document.addEventListener("openSearchNavigationDialog", handleOpen);
|
||||
document.addEventListener("openFindDialogFromEditor", handleClose);
|
||||
document.addEventListener("closeFindDialogFromEditor", handleClose);
|
||||
|
||||
return () => {
|
||||
document.removeEventListener("openSearchNavigationDialog", handleOpen);
|
||||
document.removeEventListener("openFindDialogFromEditor", handleClose);
|
||||
document.removeEventListener("closeFindDialogFromEditor", handleClose);
|
||||
};
|
||||
}, [close, editor]);
|
||||
|
||||
useEffect(() => {
|
||||
const handleTransaction = () => {
|
||||
if (!openRef.current || editor.isDestroyed) return;
|
||||
|
||||
const { results } = editor.storage.searchAndReplace;
|
||||
if (results.length === 0) {
|
||||
close();
|
||||
}
|
||||
};
|
||||
|
||||
editor.on("transaction", handleTransaction);
|
||||
return () => {
|
||||
editor.off("transaction", handleTransaction);
|
||||
};
|
||||
}, [close, editor]);
|
||||
|
||||
return (
|
||||
<Dialog
|
||||
className={classes.findDialog}
|
||||
opened={open}
|
||||
size="xs"
|
||||
radius="md"
|
||||
w="auto"
|
||||
position={{ top: 90, right: 50 }}
|
||||
withBorder
|
||||
aria-label="Search navigation"
|
||||
>
|
||||
<Flex align="center" gap="xs">
|
||||
<Text size="xs" style={{ flex: 1 }}>
|
||||
{resultState.resultsLength > 0
|
||||
? `${resultState.resultIndex + 1}/${resultState.resultsLength}`
|
||||
: t("Not found")}
|
||||
</Text>
|
||||
<Tooltip label="Previous match">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={previous}
|
||||
aria-label="Previous match"
|
||||
disabled={resultState.resultsLength === 0}
|
||||
>
|
||||
<IconArrowNarrowUp size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Next match">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={next}
|
||||
aria-label="Next match"
|
||||
disabled={resultState.resultsLength === 0}
|
||||
>
|
||||
<IconArrowNarrowDown size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
<Tooltip label="Close">
|
||||
<ActionIcon
|
||||
variant="subtle"
|
||||
color="gray"
|
||||
onClick={close}
|
||||
aria-label="Close"
|
||||
>
|
||||
<IconX size={16} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
</Flex>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
export default SearchNavigationDialog;
|
||||
+47
@@ -0,0 +1,47 @@
|
||||
import { useEffect, useRef } from "react";
|
||||
import type { useEditor } from "@tiptap/react";
|
||||
|
||||
interface UseSearchNavigationParamsProps {
|
||||
editor: ReturnType<typeof useEditor>;
|
||||
isSynced: boolean;
|
||||
pageId: string;
|
||||
searchParams: URLSearchParams;
|
||||
showStatic: boolean;
|
||||
}
|
||||
|
||||
export function useSearchNavigationParams({
|
||||
editor,
|
||||
isSynced,
|
||||
pageId,
|
||||
searchParams,
|
||||
showStatic,
|
||||
}: UseSearchNavigationParamsProps) {
|
||||
const appliedSearchKeyRef = useRef<string | null>(null);
|
||||
const searchKey = `${pageId}:${searchParams.toString()}`;
|
||||
|
||||
useEffect(() => {
|
||||
const searchQueries = searchParams.getAll("q");
|
||||
if (!searchQueries.length) {
|
||||
appliedSearchKeyRef.current = null;
|
||||
return;
|
||||
}
|
||||
|
||||
if (
|
||||
!editor ||
|
||||
editor.isDestroyed ||
|
||||
!editor.view.dom.isConnected ||
|
||||
appliedSearchKeyRef.current === searchKey
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
const match = searchParams.get("m");
|
||||
appliedSearchKeyRef.current = searchKey;
|
||||
|
||||
document.dispatchEvent(
|
||||
new CustomEvent("openSearchNavigationDialog", {
|
||||
detail: { searchTerms: searchQueries, wholeWord: match === "whole" },
|
||||
}),
|
||||
);
|
||||
}, [editor, isSynced, searchKey, searchParams, showStatic]);
|
||||
}
|
||||
@@ -61,6 +61,9 @@ export const SubpagesMenu = React.memo(
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`subpages-menu`}
|
||||
ref={(element) => {
|
||||
if (element) element.style.zIndex = "99";
|
||||
}}
|
||||
updateDelay={0}
|
||||
shouldShow={shouldShow}
|
||||
>
|
||||
|
||||
@@ -3,30 +3,59 @@ import { Stack, Text, Anchor, ActionIcon } from "@mantine/core";
|
||||
import { IconFileDescription } from "@tabler/icons-react";
|
||||
import { useGetSidebarPagesQuery } from "@/features/page/queries/page-query";
|
||||
import { useMemo } from "react";
|
||||
import { Link, useParams } from "react-router-dom";
|
||||
import { Link, useLocation, useParams } from "react-router-dom";
|
||||
import classes from "./subpages.module.css";
|
||||
import styles from "../mention/mention.module.css";
|
||||
import {
|
||||
buildPageUrl,
|
||||
buildPublicSpaceUrl,
|
||||
buildSharedPageUrl,
|
||||
} from "@/features/page/page.utils.ts";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { sortPositionKeys } from "@/features/page/tree/utils/utils";
|
||||
import { useSharedPageSubpages } from "@/features/share/hooks/use-shared-page-subpages";
|
||||
import { useAtomValue } from "jotai";
|
||||
import { publicSpaceTreeDataAtom } from "@/features/public-space/atoms/public-space-atoms.ts";
|
||||
import { findSubpagesInTree } from "@/features/share/utils";
|
||||
import { extractPageSlugId } from "@/lib";
|
||||
|
||||
export default function SubpagesView(props: NodeViewProps) {
|
||||
const { editor } = props;
|
||||
const { spaceSlug, shareId } = useParams();
|
||||
const { spaceSlug, shareId, pageSlug } = useParams();
|
||||
const { t } = useTranslation();
|
||||
const location = useLocation();
|
||||
const isPublicSpaceRoute = location.pathname.startsWith("/docs/");
|
||||
|
||||
//@ts-ignore
|
||||
const currentPageId = editor.storage.pageId;
|
||||
const publicSpaceTreeData = useAtomValue(publicSpaceTreeDataAtom);
|
||||
|
||||
// @ts-ignore
|
||||
const storagePageId = editor.storage.pageId;
|
||||
const routePageId = extractPageSlugId(pageSlug);
|
||||
let currentPageId = storagePageId;
|
||||
|
||||
if (shareId){
|
||||
currentPageId = routePageId;
|
||||
}
|
||||
|
||||
// Public docs must resolve the page from the route, not editor storage:
|
||||
// storage.pageId is set after this view's first render and is not reactive,
|
||||
// which froze the list at "No subpages" until something re-rendered it. The
|
||||
// space home renders at the bare URL, so it falls back to the first root.
|
||||
if (isPublicSpaceRoute) {
|
||||
currentPageId = routePageId ?? publicSpaceTreeData?.[0]?.slugId;
|
||||
}
|
||||
|
||||
// Get subpages from shared tree if we're in a shared context
|
||||
const sharedSubpages = useSharedPageSubpages(currentPageId);
|
||||
const publicSpaceSubpages = useMemo(
|
||||
() => findSubpagesInTree(publicSpaceTreeData, currentPageId),
|
||||
[publicSpaceTreeData, currentPageId],
|
||||
);
|
||||
|
||||
const isPublicView = Boolean(shareId) || isPublicSpaceRoute;
|
||||
|
||||
const { data, isLoading, error } = useGetSidebarPagesQuery(
|
||||
shareId ? null : { pageId: currentPageId },
|
||||
isPublicView ? null : { pageId: currentPageId },
|
||||
);
|
||||
|
||||
const subpages = useMemo(() => {
|
||||
@@ -41,17 +70,33 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
}));
|
||||
}
|
||||
|
||||
if (isPublicSpaceRoute) {
|
||||
return publicSpaceSubpages.map((node) => ({
|
||||
id: node.value,
|
||||
slugId: node.slugId,
|
||||
title: node.name,
|
||||
icon: node.icon,
|
||||
position: node.position,
|
||||
}));
|
||||
}
|
||||
|
||||
// Otherwise use the API data
|
||||
if (!data?.pages) return [];
|
||||
const allPages = data.pages.flatMap((page) => page.items);
|
||||
return sortPositionKeys(allPages);
|
||||
}, [data, shareId, sharedSubpages]);
|
||||
}, [
|
||||
data,
|
||||
shareId,
|
||||
sharedSubpages,
|
||||
isPublicSpaceRoute,
|
||||
publicSpaceSubpages,
|
||||
]);
|
||||
|
||||
if (isLoading && !shareId) {
|
||||
if (isLoading && !isPublicView) {
|
||||
return null;
|
||||
}
|
||||
|
||||
if (error && !shareId) {
|
||||
if (error && !isPublicView) {
|
||||
return (
|
||||
<NodeViewWrapper data-drag-handle>
|
||||
<Text c="dimmed" size="md" py="md">
|
||||
@@ -89,7 +134,13 @@ export default function SubpagesView(props: NodeViewProps) {
|
||||
pageSlugId: page.slugId,
|
||||
pageTitle: page.title,
|
||||
})
|
||||
: buildPageUrl(spaceSlug, page.slugId, page.title)
|
||||
: isPublicSpaceRoute
|
||||
? buildPublicSpaceUrl({
|
||||
spaceSlug,
|
||||
pageSlugId: page.slugId,
|
||||
pageTitle: page.title,
|
||||
})
|
||||
: buildPageUrl(spaceSlug, page.slugId, page.title)
|
||||
}
|
||||
underline="never"
|
||||
className={styles.pageMentionLink}
|
||||
|
||||
@@ -18,7 +18,7 @@ export type HeadingLink = {
|
||||
position: number;
|
||||
};
|
||||
|
||||
const recalculateLinks = (nodePos: NodePos[]) => {
|
||||
export const recalculateLinks = (nodePos: NodePos[]) => {
|
||||
const nodes: HTMLElement[] = [];
|
||||
|
||||
const links: HeadingLink[] = Array.from(nodePos).reduce<HeadingLink[]>(
|
||||
|
||||
+16
-1
@@ -9,6 +9,7 @@ import React, {
|
||||
} from "react";
|
||||
import {
|
||||
lookupTransclusion,
|
||||
lookupTransclusionForPublicSpace,
|
||||
lookupTransclusionForShare,
|
||||
} from "@/features/transclusion/services/transclusion-api";
|
||||
import type { TransclusionLookup } from "@/features/transclusion/types/transclusion.types";
|
||||
@@ -38,6 +39,7 @@ const TransclusionLookupContext = createContext<ContextValue | null>(null);
|
||||
export function TransclusionLookupProvider({
|
||||
children,
|
||||
shareId,
|
||||
spaceSlug,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
/**
|
||||
@@ -47,6 +49,11 @@ export function TransclusionLookupProvider({
|
||||
* app, where personal permissions gate access.
|
||||
*/
|
||||
shareId?: string;
|
||||
/**
|
||||
* When set, lookups go through the public-space endpoint and are gated by
|
||||
* the published space. Used by the public docs viewer.
|
||||
*/
|
||||
spaceSlug?: string;
|
||||
}) {
|
||||
const subscribersRef = useRef(new Map<LookupKey, Subscriber[]>());
|
||||
const queueRef = useRef(new Set<LookupKey>());
|
||||
@@ -55,6 +62,8 @@ export function TransclusionLookupProvider({
|
||||
// memoized callbacks (and thus doesn't re-render every consumer).
|
||||
const shareIdRef = useRef<string | undefined>(shareId);
|
||||
shareIdRef.current = shareId;
|
||||
const spaceSlugRef = useRef<string | undefined>(spaceSlug);
|
||||
spaceSlugRef.current = spaceSlug;
|
||||
// Last looked-up value for each key. Re-subscribers (e.g. when the editor
|
||||
// remounts after switching from static to live) get this immediately
|
||||
// instead of triggering a duplicate fetch.
|
||||
@@ -91,12 +100,18 @@ export function TransclusionLookupProvider({
|
||||
|
||||
try {
|
||||
const activeShareId = shareIdRef.current;
|
||||
const activeSpaceSlug = spaceSlugRef.current;
|
||||
const { items } = activeShareId
|
||||
? await lookupTransclusionForShare({
|
||||
shareId: activeShareId,
|
||||
references,
|
||||
})
|
||||
: await lookupTransclusion({ references });
|
||||
: activeSpaceSlug
|
||||
? await lookupTransclusionForPublicSpace({
|
||||
spaceSlug: activeSpaceSlug,
|
||||
references,
|
||||
})
|
||||
: await lookupTransclusion({ references });
|
||||
for (const r of items) {
|
||||
const key = `${r.sourcePageId}::${r.transclusionId}`;
|
||||
resultCacheRef.current.set(key, r);
|
||||
|
||||
@@ -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 {
|
||||
@@ -15,14 +16,17 @@ import {
|
||||
IconLayoutAlignRight,
|
||||
IconDownload,
|
||||
IconTrash,
|
||||
IconZoomIn,
|
||||
} 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,
|
||||
@@ -128,6 +132,9 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
<BaseBubbleMenu
|
||||
editor={editor}
|
||||
pluginKey={`video-menu`}
|
||||
ref={(element) => {
|
||||
if (element) element.style.zIndex = "99";
|
||||
}}
|
||||
updateDelay={0}
|
||||
getReferencedVirtualElement={getReferencedVirtualElement}
|
||||
options={{
|
||||
@@ -183,6 +190,23 @@ export function VideoMenu({ editor }: EditorMenuProps) {
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
<Tooltip position="top" label={t("Expand")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={() =>
|
||||
editorState?.src &&
|
||||
setLightboxRequest({
|
||||
src: getFileUrl(editorState.src),
|
||||
type: "video",
|
||||
})
|
||||
}
|
||||
size="lg"
|
||||
aria-label={t("Expand")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconZoomIn size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Download")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={handleDownload}
|
||||
|
||||
Reference in New Issue
Block a user