mirror of
https://github.com/docmost/docmost.git
synced 2026-08-28 09:17:06 +08:00
merge main
This commit is contained in:
@@ -1,10 +1,27 @@
|
||||
import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx";
|
||||
import { uploadVideoAction } from "@/features/editor/components/video/upload-video-action.tsx";
|
||||
import { uploadAttachmentAction } from "../attachment/upload-attachment-action";
|
||||
import { uploadPdfAction } from "../pdf/upload-pdf-action";
|
||||
import { createMentionAction } from "@/features/editor/components/link/internal-link-paste.ts";
|
||||
import { INTERNAL_LINK_REGEX } from "@/lib/constants.ts";
|
||||
import { Editor } from "@tiptap/core";
|
||||
import { matchIntegrationLink } from "@docmost/editor-ext";
|
||||
import {
|
||||
getAttachmentInfo,
|
||||
uploadFile,
|
||||
} from "@/features/page/services/page-service.ts";
|
||||
|
||||
const ATTACHMENT_NODE_TYPES = [
|
||||
"image",
|
||||
"video",
|
||||
"audio",
|
||||
"pdf",
|
||||
"attachment",
|
||||
"excalidraw",
|
||||
"drawio",
|
||||
];
|
||||
|
||||
const ATTACHMENT_URL_RE = /\/api\/files\/([0-9a-f-]+)\//;
|
||||
|
||||
export const handlePaste = (
|
||||
editor: Editor,
|
||||
@@ -35,7 +52,6 @@ export const handlePaste = (
|
||||
const url = clipboardData.trim();
|
||||
const { from: pos, empty } = editor.state.selection;
|
||||
const match = INTERNAL_LINK_REGEX.exec(url);
|
||||
const currentPageMatch = INTERNAL_LINK_REGEX.exec(window.location.href);
|
||||
|
||||
// pasted link must be from the same workspace/domain and must not be on a selection
|
||||
if (!empty || match[2] !== window.location.host) {
|
||||
@@ -43,12 +59,6 @@ export const handlePaste = (
|
||||
return false;
|
||||
}
|
||||
|
||||
// for now, we only support internal links from the same space
|
||||
// compare space name
|
||||
if (currentPageMatch[4].toLowerCase() !== match[4].toLowerCase()) {
|
||||
return false;
|
||||
}
|
||||
|
||||
const anchorId = match[6] ? match[6].split("#")[0] : undefined;
|
||||
const urlWithoutAnchor = anchorId
|
||||
? url.substring(0, url.indexOf("#"))
|
||||
@@ -63,19 +73,165 @@ export const handlePaste = (
|
||||
return true;
|
||||
}
|
||||
|
||||
if (event.clipboardData?.files.length) {
|
||||
const htmlData = event.clipboardData?.getData("text/html");
|
||||
const hasHtmlTable = htmlData && /<table[\s>]/i.test(htmlData);
|
||||
|
||||
if (event.clipboardData?.files.length && !hasHtmlTable) {
|
||||
event.preventDefault();
|
||||
for (const file of event.clipboardData.files) {
|
||||
const pos = editor.state.selection.from;
|
||||
uploadImageAction(file, editor, pos, pageId);
|
||||
uploadVideoAction(file, editor, pos, pageId);
|
||||
uploadPdfAction(file, editor, pos, pageId);
|
||||
uploadAttachmentAction(file, editor, pos, pageId);
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
if (htmlData && ATTACHMENT_URL_RE.test(htmlData)) {
|
||||
const pasteFrom = editor.state.selection.from;
|
||||
setTimeout(() => {
|
||||
reuploadPastedAttachments(editor, pageId, pasteFrom);
|
||||
}, 0);
|
||||
}
|
||||
|
||||
return false;
|
||||
};
|
||||
|
||||
async function reuploadPastedAttachments(
|
||||
editor: Editor,
|
||||
pageId: string,
|
||||
pasteFrom: number,
|
||||
) {
|
||||
const pasteEnd = editor.state.selection.from;
|
||||
if (pasteEnd <= pasteFrom) return;
|
||||
|
||||
type PastedNode = {
|
||||
pos: number;
|
||||
attachmentId: string;
|
||||
nodeTypeName: string;
|
||||
src?: string;
|
||||
url?: string;
|
||||
fileName?: string;
|
||||
};
|
||||
|
||||
const pastedNodes: PastedNode[] = [];
|
||||
const seenAttachmentIds = new Set<string>();
|
||||
|
||||
editor.state.doc.nodesBetween(pasteFrom, pasteEnd, (node, pos) => {
|
||||
if (!ATTACHMENT_NODE_TYPES.includes(node.type.name)) return;
|
||||
const attachmentId = node.attrs.attachmentId;
|
||||
if (!attachmentId) return;
|
||||
|
||||
const src = node.attrs.src || node.attrs.url || "";
|
||||
const match = ATTACHMENT_URL_RE.exec(src);
|
||||
if (!match) return;
|
||||
|
||||
const fileName =
|
||||
node.attrs.name || src.split("/").pop() || "file";
|
||||
|
||||
pastedNodes.push({
|
||||
pos,
|
||||
attachmentId,
|
||||
nodeTypeName: node.type.name,
|
||||
src: node.attrs.src,
|
||||
url: node.attrs.url,
|
||||
fileName,
|
||||
});
|
||||
seenAttachmentIds.add(attachmentId);
|
||||
});
|
||||
|
||||
if (pastedNodes.length === 0) return;
|
||||
|
||||
const attachmentPageMap = new Map<string, string | null>();
|
||||
await Promise.all(
|
||||
[...seenAttachmentIds].map(async (id) => {
|
||||
try {
|
||||
const info = await getAttachmentInfo(id);
|
||||
attachmentPageMap.set(id, info.pageId);
|
||||
} catch {
|
||||
attachmentPageMap.set(id, null);
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
const nodesToReupload = pastedNodes.filter((n) => {
|
||||
const ownerPageId = attachmentPageMap.get(n.attachmentId);
|
||||
return ownerPageId !== null && ownerPageId !== pageId;
|
||||
});
|
||||
|
||||
if (nodesToReupload.length === 0) return;
|
||||
|
||||
const uniqueNodes = new Map<string, (typeof nodesToReupload)[0]>();
|
||||
for (const node of nodesToReupload) {
|
||||
if (!uniqueNodes.has(node.attachmentId)) {
|
||||
uniqueNodes.set(node.attachmentId, node);
|
||||
}
|
||||
}
|
||||
|
||||
const reuploadResults = new Map<
|
||||
string,
|
||||
{ id: string; fileName: string; fileSize: number; mimeType: string }
|
||||
>();
|
||||
|
||||
await Promise.all(
|
||||
[...uniqueNodes.values()].map(async (node) => {
|
||||
const fileUrl = node.src || node.url;
|
||||
if (!fileUrl) return;
|
||||
|
||||
try {
|
||||
const response = await fetch(fileUrl, { credentials: "include" });
|
||||
if (!response.ok) return;
|
||||
const blob = await response.blob();
|
||||
const file = new File([blob], node.fileName, { type: blob.type });
|
||||
const newAttachment = await uploadFile(file, pageId);
|
||||
reuploadResults.set(node.attachmentId, {
|
||||
id: newAttachment.id,
|
||||
fileName: newAttachment.fileName,
|
||||
fileSize: newAttachment.fileSize,
|
||||
mimeType: newAttachment.mimeType,
|
||||
});
|
||||
} catch {
|
||||
// keep original reference on failure
|
||||
}
|
||||
}),
|
||||
);
|
||||
|
||||
if (reuploadResults.size === 0) return;
|
||||
|
||||
editor.chain().command(({ tr }) => {
|
||||
const sorted = [...nodesToReupload].sort((a, b) => b.pos - a.pos);
|
||||
|
||||
for (const pastedNode of sorted) {
|
||||
const result = reuploadResults.get(pastedNode.attachmentId);
|
||||
if (!result) continue;
|
||||
|
||||
const node = tr.doc.nodeAt(pastedNode.pos);
|
||||
if (!node || node.attrs.attachmentId !== pastedNode.attachmentId)
|
||||
continue;
|
||||
|
||||
const newAttrs = { ...node.attrs };
|
||||
newAttrs.attachmentId = result.id;
|
||||
|
||||
if (newAttrs.src) {
|
||||
newAttrs.src = `/api/files/${result.id}/${result.fileName}`;
|
||||
}
|
||||
if (newAttrs.url) {
|
||||
newAttrs.url = `/api/files/${result.id}/${result.fileName}`;
|
||||
}
|
||||
if (pastedNode.nodeTypeName === "attachment") {
|
||||
newAttrs.name = result.fileName;
|
||||
newAttrs.mime = result.mimeType;
|
||||
newAttrs.size = result.fileSize;
|
||||
}
|
||||
|
||||
tr.setNodeMarkup(pastedNode.pos, undefined, newAttrs);
|
||||
}
|
||||
|
||||
return true;
|
||||
}).run();
|
||||
}
|
||||
|
||||
export const handleFileDrop = (
|
||||
editor: Editor,
|
||||
event: DragEvent,
|
||||
@@ -93,6 +249,7 @@ export const handleFileDrop = (
|
||||
|
||||
uploadImageAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadVideoAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadPdfAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
uploadAttachmentAction(file, editor, coordinates?.pos ?? 0 - 1, pageId);
|
||||
}
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import classes from "./node-resize.module.css";
|
||||
import { ResizableNodeViewDirection } from "@docmost/editor-ext";
|
||||
|
||||
export function createResizeHandle(
|
||||
direction: ResizableNodeViewDirection,
|
||||
): HTMLElement {
|
||||
const handle = document.createElement("div");
|
||||
handle.dataset.resizeHandle = direction;
|
||||
handle.style.position = "absolute";
|
||||
handle.className = classes.handle;
|
||||
|
||||
if (direction === "left") {
|
||||
handle.style.left = "-8px";
|
||||
handle.style.top = "0";
|
||||
handle.style.bottom = "0";
|
||||
} else if (direction === "right") {
|
||||
handle.style.right = "-8px";
|
||||
handle.style.top = "0";
|
||||
handle.style.bottom = "0";
|
||||
}
|
||||
|
||||
const bar = document.createElement("div");
|
||||
bar.className = classes.handleBar;
|
||||
handle.appendChild(bar);
|
||||
|
||||
return handle;
|
||||
}
|
||||
|
||||
export function buildResizeClasses(nodeClass: string) {
|
||||
return {
|
||||
container: `${classes.container} ${nodeClass}`,
|
||||
wrapper: classes.wrapper,
|
||||
resizing: classes.resizing,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
.container {
|
||||
display: flex;
|
||||
}
|
||||
|
||||
.wrapper {
|
||||
position: relative;
|
||||
border-radius: 8px;
|
||||
overflow: visible;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.wrapper img,
|
||||
.wrapper video {
|
||||
height: auto !important;
|
||||
}
|
||||
|
||||
.resizing {
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.handle {
|
||||
position: absolute;
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
width: 16px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
cursor: ew-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
z-index: 2;
|
||||
}
|
||||
|
||||
.handle[data-resize-handle="left"] {
|
||||
left: -8px;
|
||||
}
|
||||
|
||||
.handle[data-resize-handle="right"] {
|
||||
right: -8px;
|
||||
}
|
||||
|
||||
.wrapper:hover .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.container:global(.ProseMirror-selectednode) .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resizing .handle {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.handleBar {
|
||||
width: 4px;
|
||||
height: 48px;
|
||||
border-radius: 4px;
|
||||
transition: background-color 0.15s ease;
|
||||
background-color: light-dark(var(--mantine-color-blue-4), var(--mantine-color-blue-5));
|
||||
}
|
||||
|
||||
.handle:hover .handleBar {
|
||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||
}
|
||||
|
||||
.resizing .handleBar {
|
||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||
}
|
||||
|
||||
@media print {
|
||||
.handle {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@@ -1,13 +1,11 @@
|
||||
.wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
overflow: visible;
|
||||
border-radius: 8px;
|
||||
}
|
||||
|
||||
.resizing {
|
||||
user-select: none;
|
||||
cursor: ns-resize;
|
||||
}
|
||||
|
||||
.overlay {
|
||||
@@ -20,12 +18,118 @@
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.cornerHandle {
|
||||
position: absolute;
|
||||
width: 24px;
|
||||
height: 24px;
|
||||
z-index: 2;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
&::before,
|
||||
&::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
border-radius: 1px;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-4),
|
||||
var(--mantine-color-blue-5)
|
||||
);
|
||||
transition: background-color 0.15s ease;
|
||||
}
|
||||
|
||||
&::before {
|
||||
width: 20px;
|
||||
height: 3px;
|
||||
}
|
||||
|
||||
&::after {
|
||||
width: 3px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
&:hover::before,
|
||||
&:hover::after {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-6),
|
||||
var(--mantine-color-blue-4)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
.cornerHandleTL {
|
||||
top: -2px;
|
||||
left: -2px;
|
||||
cursor: nwse-resize;
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cornerHandleTR {
|
||||
top: -2px;
|
||||
right: -2px;
|
||||
cursor: nesw-resize;
|
||||
|
||||
&::before {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
top: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cornerHandleBL {
|
||||
bottom: -2px;
|
||||
left: -2px;
|
||||
cursor: nesw-resize;
|
||||
|
||||
&::before {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.cornerHandleBR {
|
||||
bottom: -2px;
|
||||
right: -2px;
|
||||
cursor: nwse-resize;
|
||||
|
||||
&::before {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
|
||||
&::after {
|
||||
bottom: 0;
|
||||
right: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.resizeHandleBottom {
|
||||
position: absolute;
|
||||
bottom: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
height: 24px;
|
||||
bottom: -4px;
|
||||
left: 20px;
|
||||
right: 20px;
|
||||
height: 12px;
|
||||
cursor: ns-resize;
|
||||
opacity: 0;
|
||||
transition: opacity 0.2s ease;
|
||||
@@ -36,61 +140,53 @@
|
||||
touch-action: none;
|
||||
-webkit-user-select: none;
|
||||
user-select: none;
|
||||
|
||||
@mixin light {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.05));
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.05)
|
||||
);
|
||||
}
|
||||
|
||||
&:hover {
|
||||
@mixin light {
|
||||
background: linear-gradient(to bottom, transparent, rgba(0, 0, 0, 0.1));
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background: linear-gradient(
|
||||
to bottom,
|
||||
transparent,
|
||||
rgba(255, 255, 255, 0.1)
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
.wrapper:hover .resizeHandleBottom,
|
||||
.resizing .resizeHandleBottom {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resizeBar {
|
||||
width: 50px;
|
||||
height: 4px;
|
||||
height: 3px;
|
||||
border-radius: 2px;
|
||||
transition: background-color 0.2s ease;
|
||||
transition: background-color 0.15s ease;
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-4),
|
||||
var(--mantine-color-blue-5)
|
||||
);
|
||||
}
|
||||
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-5);
|
||||
}
|
||||
.resizeHandleBottom:hover .resizeBar {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-6),
|
||||
var(--mantine-color-blue-4)
|
||||
);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-6);
|
||||
}
|
||||
.wrapper:hover .cornerHandle,
|
||||
.wrapper:hover .resizeHandleBottom,
|
||||
.wrapper:global(.ProseMirror-selectednode) .cornerHandle,
|
||||
.wrapper:global(.ProseMirror-selectednode) .resizeHandleBottom,
|
||||
.resizing .cornerHandle,
|
||||
.resizing .resizeHandleBottom {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.resizing .cornerHandle::before,
|
||||
.resizing .cornerHandle::after {
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-6),
|
||||
var(--mantine-color-blue-4)
|
||||
);
|
||||
}
|
||||
|
||||
.resizeHandleBottom:hover .resizeBar,
|
||||
.resizing .resizeBar {
|
||||
@mixin light {
|
||||
background-color: var(--mantine-color-gray-7);
|
||||
}
|
||||
background-color: light-dark(
|
||||
var(--mantine-color-blue-6),
|
||||
var(--mantine-color-blue-4)
|
||||
);
|
||||
}
|
||||
|
||||
@mixin dark {
|
||||
background-color: var(--mantine-color-gray-4);
|
||||
@media print {
|
||||
.cornerHandle,
|
||||
.resizeHandleBottom {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -2,111 +2,172 @@ import React, { ReactNode, useCallback, useEffect, useRef, useState } from "reac
|
||||
import clsx from "clsx";
|
||||
import classes from "./resizable-wrapper.module.css";
|
||||
|
||||
type Handle = "tl" | "tr" | "bl" | "br" | "bottom";
|
||||
|
||||
const HANDLE_SIGN: Record<Handle, { x: number; y: number }> = {
|
||||
br: { x: 1, y: 1 },
|
||||
bl: { x: -1, y: 1 },
|
||||
tr: { x: 1, y: -1 },
|
||||
tl: { x: -1, y: -1 },
|
||||
bottom: { x: 0, y: 1 },
|
||||
};
|
||||
|
||||
const HANDLE_CURSOR: Record<Handle, string> = {
|
||||
br: "nwse-resize",
|
||||
tl: "nwse-resize",
|
||||
bl: "nesw-resize",
|
||||
tr: "nesw-resize",
|
||||
bottom: "ns-resize",
|
||||
};
|
||||
|
||||
const CORNER_CLASSES: Record<string, string> = {
|
||||
tl: classes.cornerHandleTL,
|
||||
tr: classes.cornerHandleTR,
|
||||
bl: classes.cornerHandleBL,
|
||||
br: classes.cornerHandleBR,
|
||||
};
|
||||
|
||||
interface ResizableWrapperProps {
|
||||
children: ReactNode;
|
||||
initialWidth?: number;
|
||||
initialHeight?: number;
|
||||
minWidth?: number;
|
||||
maxWidth?: number;
|
||||
minHeight?: number;
|
||||
maxHeight?: number;
|
||||
onResize?: (height: number) => void;
|
||||
onResize?: (width: number, height: number) => void;
|
||||
isEditable?: boolean;
|
||||
className?: string;
|
||||
showHandles?: "always" | "hover";
|
||||
direction?: "vertical" | "horizontal" | "both";
|
||||
selected?: boolean;
|
||||
}
|
||||
|
||||
type DragState = {
|
||||
handle: Handle;
|
||||
startX: number;
|
||||
startY: number;
|
||||
startWidth: number;
|
||||
startHeight: number;
|
||||
};
|
||||
|
||||
export const ResizableWrapper: React.FC<ResizableWrapperProps> = ({
|
||||
children,
|
||||
initialWidth = 640,
|
||||
initialHeight = 480,
|
||||
minWidth = 200,
|
||||
maxWidth = 1200,
|
||||
minHeight = 200,
|
||||
maxHeight = 1200,
|
||||
onResize,
|
||||
isEditable = true,
|
||||
className,
|
||||
showHandles = "hover",
|
||||
direction = "vertical",
|
||||
selected = false,
|
||||
}) => {
|
||||
const [resizeParams, setResizeParams] = useState<{
|
||||
initialSize: number;
|
||||
initialClientY: number;
|
||||
initialClientX: number;
|
||||
} | null>(null);
|
||||
const [currentHeight, setCurrentHeight] = useState(initialHeight);
|
||||
const [isResizing, setIsResizing] = useState(false);
|
||||
const [isHovered, setIsHovered] = useState(false);
|
||||
const wrapperRef = useRef<HTMLDivElement>(null);
|
||||
|
||||
const dragRef = useRef<DragState | null>(null);
|
||||
const widthRef = useRef(initialWidth);
|
||||
const heightRef = useRef(initialHeight);
|
||||
const onResizeRef = useRef(onResize);
|
||||
onResizeRef.current = onResize;
|
||||
const constraintsRef = useRef({ minWidth, maxWidth, minHeight, maxHeight });
|
||||
constraintsRef.current = { minWidth, maxWidth, minHeight, maxHeight };
|
||||
|
||||
useEffect(() => {
|
||||
if (!resizeParams) return;
|
||||
if (!dragRef.current && wrapperRef.current) {
|
||||
widthRef.current = initialWidth;
|
||||
heightRef.current = initialHeight;
|
||||
wrapperRef.current.style.width = `${initialWidth}px`;
|
||||
wrapperRef.current.style.height = `${initialHeight}px`;
|
||||
}
|
||||
}, [initialWidth, initialHeight]);
|
||||
|
||||
const handleMouseMove = (e: MouseEvent) => {
|
||||
if (!wrapperRef.current) return;
|
||||
const handleMouseMove = useRef((e: MouseEvent) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || !wrapperRef.current) return;
|
||||
|
||||
if (direction === "vertical" || direction === "both") {
|
||||
const deltaY = e.clientY - resizeParams.initialClientY;
|
||||
const newHeight = Math.min(
|
||||
Math.max(resizeParams.initialSize + deltaY, minHeight),
|
||||
maxHeight
|
||||
);
|
||||
setCurrentHeight(newHeight);
|
||||
wrapperRef.current.style.height = `${newHeight}px`;
|
||||
}
|
||||
const sign = HANDLE_SIGN[drag.handle];
|
||||
const { minWidth, maxWidth, minHeight, maxHeight } = constraintsRef.current;
|
||||
|
||||
const deltaY = e.clientY - drag.startY;
|
||||
const newHeight = Math.min(Math.max(drag.startHeight + deltaY * sign.y, minHeight), maxHeight);
|
||||
heightRef.current = newHeight;
|
||||
wrapperRef.current.style.height = `${newHeight}px`;
|
||||
|
||||
if (sign.x !== 0) {
|
||||
const deltaX = e.clientX - drag.startX;
|
||||
const newWidth = Math.min(Math.max(drag.startWidth + deltaX * sign.x, minWidth), maxWidth);
|
||||
widthRef.current = newWidth;
|
||||
wrapperRef.current.style.width = `${newWidth}px`;
|
||||
}
|
||||
}).current;
|
||||
|
||||
const handleMouseUp = useRef(() => {
|
||||
dragRef.current = null;
|
||||
setIsResizing(false);
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
onResizeRef.current?.(widthRef.current, heightRef.current);
|
||||
}).current;
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent, handle: Handle) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
dragRef.current = {
|
||||
handle,
|
||||
startX: e.clientX,
|
||||
startY: e.clientY,
|
||||
startWidth: widthRef.current,
|
||||
startHeight: heightRef.current,
|
||||
};
|
||||
|
||||
const handleMouseUp = () => {
|
||||
setResizeParams(null);
|
||||
if (onResize && currentHeight !== initialHeight) {
|
||||
onResize(currentHeight);
|
||||
}
|
||||
document.body.style.cursor = "";
|
||||
document.body.style.userSelect = "";
|
||||
};
|
||||
|
||||
setIsResizing(true);
|
||||
document.body.style.cursor = HANDLE_CURSOR[handle];
|
||||
document.body.style.userSelect = "none";
|
||||
document.addEventListener("mousemove", handleMouseMove);
|
||||
document.addEventListener("mouseup", handleMouseUp);
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
document.removeEventListener("mousemove", handleMouseMove);
|
||||
document.removeEventListener("mouseup", handleMouseUp);
|
||||
};
|
||||
}, [resizeParams, currentHeight, initialHeight, onResize, minHeight, maxHeight, direction]);
|
||||
}, [handleMouseMove, handleMouseUp]);
|
||||
|
||||
const handleResizeStart = useCallback((e: React.MouseEvent) => {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
|
||||
setResizeParams({
|
||||
initialSize: currentHeight,
|
||||
initialClientY: e.clientY,
|
||||
initialClientX: e.clientX,
|
||||
});
|
||||
|
||||
document.body.style.cursor = "ns-resize";
|
||||
document.body.style.userSelect = "none";
|
||||
}, [currentHeight]);
|
||||
|
||||
const shouldShowHandles =
|
||||
isEditable &&
|
||||
(showHandles === "always" || (showHandles === "hover" && (isHovered || resizeParams)));
|
||||
const shouldShowHandles = isEditable && (isHovered || isResizing || selected);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={wrapperRef}
|
||||
className={clsx(classes.wrapper, className, {
|
||||
[classes.resizing]: !!resizeParams,
|
||||
[classes.resizing]: isResizing,
|
||||
})}
|
||||
style={{ height: currentHeight }}
|
||||
style={{ width: widthRef.current, height: heightRef.current }}
|
||||
onMouseEnter={() => setIsHovered(true)}
|
||||
onMouseLeave={() => setIsHovered(false)}
|
||||
>
|
||||
{children}
|
||||
{!!resizeParams && <div className={classes.overlay} />}
|
||||
{shouldShowHandles && direction === "vertical" && (
|
||||
<div
|
||||
className={classes.resizeHandleBottom}
|
||||
onMouseDown={handleResizeStart}
|
||||
>
|
||||
<div className={classes.resizeBar} />
|
||||
</div>
|
||||
{isResizing && <div className={classes.overlay} />}
|
||||
{shouldShowHandles && (
|
||||
<>
|
||||
{(["tl", "tr", "bl", "br"] as const).map((corner) => (
|
||||
<div
|
||||
key={corner}
|
||||
className={clsx(classes.cornerHandle, CORNER_CLASSES[corner])}
|
||||
onMouseDown={(e) => handleResizeStart(e, corner)}
|
||||
/>
|
||||
))}
|
||||
<div
|
||||
className={classes.resizeHandleBottom}
|
||||
onMouseDown={(e) => handleResizeStart(e, "bottom")}
|
||||
>
|
||||
<div className={classes.resizeBar} />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
};
|
||||
};
|
||||
|
||||
@@ -0,0 +1,29 @@
|
||||
.toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 2px;
|
||||
padding: 3px;
|
||||
border-radius: 8px;
|
||||
border: 1px solid light-dark(var(--mantine-color-gray-2), var(--mantine-color-dark-4));
|
||||
background-color: light-dark(var(--mantine-color-white), var(--mantine-color-dark-6));
|
||||
box-shadow: 0 2px 12px light-dark(rgba(0, 0, 0, 0.08), rgba(0, 0, 0, 0.35));
|
||||
}
|
||||
|
||||
.toolbar :global(.mantine-ActionIcon-root) {
|
||||
--ai-color: light-dark(var(--mantine-color-dark-7), var(--mantine-color-gray-4)) !important;
|
||||
--ai-hover: light-dark(var(--mantine-color-gray-0), var(--mantine-color-dark-5)) !important;
|
||||
}
|
||||
|
||||
.toolbar .active {
|
||||
--ai-color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-3)) !important;
|
||||
--ai-hover: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)) !important;
|
||||
background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5));
|
||||
}
|
||||
|
||||
.divider {
|
||||
width: 1px;
|
||||
height: 16px;
|
||||
align-self: center;
|
||||
margin: 0 2px;
|
||||
background-color: light-dark(var(--mantine-color-gray-3), var(--mantine-color-dark-3));
|
||||
}
|
||||
@@ -0,0 +1,139 @@
|
||||
import React, { useCallback, useEffect, useState } from "react";
|
||||
import { Editor } from "@tiptap/react";
|
||||
import {
|
||||
ActionIcon,
|
||||
Button,
|
||||
Group,
|
||||
Paper,
|
||||
Text,
|
||||
Textarea,
|
||||
Tooltip,
|
||||
} from "@mantine/core";
|
||||
import { IconAlt } from "@tabler/icons-react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
const ALT_MAX_LENGTH = 300;
|
||||
|
||||
function sanitizeAlt(value: string): string {
|
||||
return value
|
||||
.replace(/[\\\[\]!]/g, "")
|
||||
.replace(/\s+/g, " ")
|
||||
.trim();
|
||||
}
|
||||
|
||||
type UseAltTextControlArgs = {
|
||||
editor: Editor;
|
||||
nodeName: string;
|
||||
currentAlt: string;
|
||||
};
|
||||
|
||||
export function useAltTextControl({
|
||||
editor,
|
||||
nodeName,
|
||||
currentAlt,
|
||||
}: UseAltTextControlArgs) {
|
||||
const { t } = useTranslation();
|
||||
const [showInput, setShowInput] = useState(false);
|
||||
const [draft, setDraft] = useState("");
|
||||
|
||||
const open = useCallback(() => {
|
||||
setDraft(currentAlt || "");
|
||||
setShowInput(true);
|
||||
}, [currentAlt]);
|
||||
|
||||
useEffect(() => {
|
||||
const handler = () => {
|
||||
if (!editor.isActive(nodeName)) {
|
||||
setShowInput(false);
|
||||
}
|
||||
};
|
||||
editor.on("selectionUpdate", handler);
|
||||
return () => {
|
||||
editor.off("selectionUpdate", handler);
|
||||
};
|
||||
}, [editor, nodeName]);
|
||||
|
||||
const cancel = useCallback(() => {
|
||||
setShowInput(false);
|
||||
}, []);
|
||||
|
||||
const save = useCallback(() => {
|
||||
editor
|
||||
.chain()
|
||||
.focus(undefined, { scrollIntoView: false })
|
||||
.updateAttributes(nodeName, { alt: sanitizeAlt(draft) || undefined })
|
||||
.run();
|
||||
setShowInput(false);
|
||||
}, [editor, nodeName, draft]);
|
||||
|
||||
const onKeyDown = useCallback(
|
||||
(e: React.KeyboardEvent) => {
|
||||
if (e.key === "Enter" && (e.metaKey || e.ctrlKey)) {
|
||||
e.preventDefault();
|
||||
save();
|
||||
} else if (e.key === "Escape") {
|
||||
e.preventDefault();
|
||||
cancel();
|
||||
}
|
||||
},
|
||||
[save, cancel],
|
||||
);
|
||||
|
||||
const button = (
|
||||
<Tooltip position="top" label={t("Alt text")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={open}
|
||||
size="lg"
|
||||
aria-label={t("Alt text")}
|
||||
variant="subtle"
|
||||
>
|
||||
<IconAlt size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
);
|
||||
|
||||
const panel = showInput ? (
|
||||
<Paper
|
||||
withBorder
|
||||
shadow="md"
|
||||
radius={6}
|
||||
p="sm"
|
||||
w={320}
|
||||
style={{ position: "relative", zIndex: 100 }}
|
||||
>
|
||||
<Text size="sm" fw={600} mb={2}>
|
||||
{t("Alt text")}
|
||||
</Text>
|
||||
<Text size="xs" c="dimmed" mb="xs">
|
||||
{t("Describe this for accessibility.")}
|
||||
</Text>
|
||||
<Textarea
|
||||
size="xs"
|
||||
placeholder={t("Add a description")}
|
||||
value={draft}
|
||||
onChange={(e) => setDraft(e.currentTarget.value)}
|
||||
onKeyDown={onKeyDown}
|
||||
autoFocus
|
||||
autosize
|
||||
minRows={2}
|
||||
maxRows={5}
|
||||
maxLength={ALT_MAX_LENGTH}
|
||||
/>
|
||||
<Group justify="space-between" align="center" mt="xs" wrap="nowrap">
|
||||
<Text size="xs" c="dimmed">
|
||||
{draft.length}/{ALT_MAX_LENGTH}
|
||||
</Text>
|
||||
<Group gap="xs">
|
||||
<Button size="compact-xs" variant="default" onClick={cancel}>
|
||||
{t("Cancel")}
|
||||
</Button>
|
||||
<Button size="compact-xs" onClick={save}>
|
||||
{t("Save")}
|
||||
</Button>
|
||||
</Group>
|
||||
</Group>
|
||||
</Paper>
|
||||
) : null;
|
||||
|
||||
return { button, panel, isEditing: showInput };
|
||||
}
|
||||
Reference in New Issue
Block a user