diff --git a/apps/client/src/ee/comment/queries/comment-query.ts b/apps/client/src/ee/comment/queries/comment-query.ts index ecafe029..a7a5788a 100644 --- a/apps/client/src/ee/comment/queries/comment-query.ts +++ b/apps/client/src/ee/comment/queries/comment-query.ts @@ -48,7 +48,7 @@ export function useResolveCommentMutation() { resolvedAt: variables.resolved ? new Date() : null, resolvedById: variables.resolved ? "optimistic" : null, resolvedBy: variables.resolved - ? { id: "optimistic", name: "", avatarUrl: null } + ? ({ id: "optimistic", name: "", avatarUrl: null } as IComment["resolvedBy"]) : null, })), ); diff --git a/apps/client/src/features/editor/components/common/node-resize.module.css b/apps/client/src/features/editor/components/common/node-resize.module.css index 48e9702e..4159e44e 100644 --- a/apps/client/src/features/editor/components/common/node-resize.module.css +++ b/apps/client/src/features/editor/components/common/node-resize.module.css @@ -67,3 +67,9 @@ .resizing .handleBar { background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4)); } + +@media print { + .handle { + display: none !important; + } +} diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.module.css b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css index 02791e86..0d0a7688 100644 --- a/apps/client/src/features/editor/components/common/resizable-wrapper.module.css +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.module.css @@ -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: 36px; + height: 36px; + 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: 28px; + height: 3px; + } + + &::after { + width: 3px; + height: 28px; + } + + &: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; - - @mixin light { - background-color: var(--mantine-color-gray-5); - } - - @mixin dark { - background-color: var(--mantine-color-gray-6); - } + transition: background-color 0.15s ease; + background-color: light-dark( + var(--mantine-color-blue-4), + var(--mantine-color-blue-5) + ); +} + +.resizeHandleBottom:hover .resizeBar { + background-color: light-dark( + var(--mantine-color-blue-6), + var(--mantine-color-blue-4) + ); +} + +.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; } } diff --git a/apps/client/src/features/editor/components/common/resizable-wrapper.tsx b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx index c3cd1b62..ebb9cd78 100644 --- a/apps/client/src/features/editor/components/common/resizable-wrapper.tsx +++ b/apps/client/src/features/editor/components/common/resizable-wrapper.tsx @@ -2,111 +2,163 @@ 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 = { + 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 = { + br: "nwse-resize", + tl: "nwse-resize", + bl: "nesw-resize", + tr: "nesw-resize", + bottom: "ns-resize", +}; + +const CORNER_CLASSES: Record = { + 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 = ({ 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(null); - useEffect(() => { - if (!resizeParams) return; + const dragRef = useRef(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 }; - 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 (
setIsHovered(true)} onMouseLeave={() => setIsHovered(false)} > {children} - {!!resizeParams &&
} - {shouldShowHandles && direction === "vertical" && ( -
-
-
+ {isResizing &&
} + {shouldShowHandles && ( + <> + {(["tl", "tr", "bl", "br"] as const).map((corner) => ( +
handleResizeStart(e, corner)} + /> + ))} +
handleResizeStart(e, "bottom")} + > +
+
+ )}
); -}; \ No newline at end of file +}; diff --git a/apps/client/src/features/editor/components/embed/embed-view.module.css b/apps/client/src/features/editor/components/embed/embed-view.module.css index c58f3965..0ecb0d61 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.module.css +++ b/apps/client/src/features/editor/components/embed/embed-view.module.css @@ -1,3 +1,12 @@ +:global(.ProseMirror .node-embed.ProseMirror-selectednode) { + outline: none; +} + +.embedContainer { + display: flex; + justify-content: center; +} + .embedWrapper { @mixin light { background-color: var(--mantine-color-gray-0); @@ -13,4 +22,4 @@ height: 100%; border: none; border-radius: 8px; -} \ No newline at end of file +} diff --git a/apps/client/src/features/editor/components/embed/embed-view.tsx b/apps/client/src/features/editor/components/embed/embed-view.tsx index 52db19b2..021f4f3a 100644 --- a/apps/client/src/features/editor/components/embed/embed-view.tsx +++ b/apps/client/src/features/editor/components/embed/embed-view.tsx @@ -27,16 +27,13 @@ import { ResizableWrapper } from "../common/resizable-wrapper"; import classes from "./embed-view.module.css"; const schema = z.object({ - url: z - .string() - .trim() - .url({ message: i18n.t("Please enter a valid url") }), + url: z.url({ message: i18n.t("Please enter a valid url") }).trim(), }); export default function EmbedView(props: NodeViewProps) { const { t } = useTranslation(); const { node, selected, updateAttributes, editor } = props; - const { src, provider, height: nodeHeight } = node.attrs; + const { src, provider, width: nodeWidth, height: nodeHeight } = node.attrs; const embedUrl = useMemo(() => { if (src) { @@ -53,8 +50,8 @@ export default function EmbedView(props: NodeViewProps) { }); const handleResize = useCallback( - (newHeight: number) => { - updateAttributes({ height: newHeight }); + (newWidth: number, newHeight: number) => { + updateAttributes({ width: newWidth, height: newHeight }); }, [updateAttributes], ); @@ -85,27 +82,33 @@ export default function EmbedView(props: NodeViewProps) { } return ( - + {embedUrl ? ( - -