diff --git a/apps/client/src/features/editor/components/image/image-menu.module.css b/apps/client/src/features/editor/components/image/image-menu.module.css new file mode 100644 index 00000000..f8fe4ae0 --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-menu.module.css @@ -0,0 +1,23 @@ +.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)); +} + +.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)); +} + +.active { + background-color: light-dark(var(--mantine-color-blue-0), var(--mantine-color-dark-5)); + color: light-dark(var(--mantine-color-blue-7), var(--mantine-color-blue-4)); +} diff --git a/apps/client/src/features/editor/components/image/image-menu.tsx b/apps/client/src/features/editor/components/image/image-menu.tsx index a1699f93..e59792d5 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -1,22 +1,29 @@ import { BubbleMenu as BaseBubbleMenu } from "@tiptap/react/menus"; import { findParentNode, posToDOMRect, useEditorState } from "@tiptap/react"; -import React, { useCallback } from "react"; +import React, { useCallback, useRef } from "react"; import { Node as PMNode } from "prosemirror-model"; import { EditorMenuProps, ShouldShowProps, } from "@/features/editor/components/table/types/types.ts"; import { ActionIcon, Tooltip } from "@mantine/core"; +import clsx from "clsx"; import { IconLayoutAlignCenter, IconLayoutAlignLeft, IconLayoutAlignRight, + IconDownload, + IconRefresh, + IconTrash, } from "@tabler/icons-react"; -import { NodeWidthResize } from "@/features/editor/components/common/node-width-resize.tsx"; import { useTranslation } from "react-i18next"; +import { getFileUrl } from "@/lib/config.ts"; +import { uploadImageAction } from "@/features/editor/components/image/upload-image-action.tsx"; +import classes from "./image-menu.module.css"; export function ImageMenu({ editor }: EditorMenuProps) { const { t } = useTranslation(); + const fileInputRef = useRef(null); const editorState = useEditorState({ editor, @@ -32,7 +39,7 @@ export function ImageMenu({ editor }: EditorMenuProps) { isAlignLeft: ctx.editor.isActive("image", { align: "left" }), isAlignCenter: ctx.editor.isActive("image", { align: "center" }), isAlignRight: ctx.editor.isActive("image", { align: "right" }), - width: imageAttrs?.width ? parseInt(imageAttrs.width) : null, + src: imageAttrs?.src || null, }; }, }); @@ -94,17 +101,39 @@ export function ImageMenu({ editor }: EditorMenuProps) { .run(); }, [editor]); - const onWidthChange = useCallback( - (value: number) => { - editor - .chain() - .focus(undefined, { scrollIntoView: false }) - .setImageWidth(value) - .run(); + const handleDownload = useCallback(() => { + if (!editorState?.src) return; + const url = getFileUrl(editorState.src); + const a = document.createElement("a"); + a.href = url; + a.download = ""; + a.click(); + }, [editorState?.src]); + + const handleReplace = useCallback(() => { + fileInputRef.current?.click(); + }, []); + + const handleFileChange = useCallback( + (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + + // @ts-ignore + const pageId = editor.storage?.pageId; + if (pageId) { + uploadImageAction(file, editor, pageId); + } + // Reset so the same file can be selected again + e.target.value = ""; }, [editor], ); + const handleDelete = useCallback(() => { + editor.commands.deleteSelection(); + }, [editor]); + return ( - +
@@ -135,7 +166,9 @@ export function ImageMenu({ editor }: EditorMenuProps) { onClick={alignImageCenter} size="lg" aria-label={t("Align center")} - variant={editorState?.isAlignCenter ? "light" : "default"} + variant="subtle" + c="dark" + className={clsx({ [classes.active]: editorState?.isAlignCenter })} > @@ -146,16 +179,60 @@ export function ImageMenu({ editor }: EditorMenuProps) { onClick={alignImageRight} size="lg" aria-label={t("Align right")} - variant={editorState?.isAlignRight ? "light" : "default"} + variant="subtle" + c="dark" + className={clsx({ [classes.active]: editorState?.isAlignRight })} > - - {editorState?.width && ( - - )} +
+ + + + + + + + + + + + + + + + + + +
+ + ); } diff --git a/apps/client/src/features/editor/components/image/image-resize-handles.ts b/apps/client/src/features/editor/components/image/image-resize-handles.ts new file mode 100644 index 00000000..467c5f03 --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-resize-handles.ts @@ -0,0 +1,33 @@ +import type { ResizableNodeViewDirection } from "@tiptap/core"; +import classes from "./image-resize.module.css"; + +export function createImageHandle( + 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 const imageResizeClasses = { + container: `${classes.container} node-image`, + wrapper: classes.wrapper, + resizing: classes.resizing, +}; diff --git a/apps/client/src/features/editor/components/image/image-resize.module.css b/apps/client/src/features/editor/components/image/image-resize.module.css new file mode 100644 index 00000000..24414171 --- /dev/null +++ b/apps/client/src/features/editor/components/image/image-resize.module.css @@ -0,0 +1,64 @@ +.container { + display: flex; +} + +.wrapper { + position: relative; + border-radius: 8px; + overflow: visible; + max-width: 100%; +} + +.wrapper img { + 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; +} + +.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)); +} diff --git a/apps/client/src/features/editor/extensions/extensions.ts b/apps/client/src/features/editor/extensions/extensions.ts index ef03108b..93a7e9a1 100644 --- a/apps/client/src/features/editor/extensions/extensions.ts +++ b/apps/client/src/features/editor/extensions/extensions.ts @@ -52,6 +52,10 @@ import { IUser } from "@/features/user/types/user.types.ts"; import MathInlineView from "@/features/editor/components/math/math-inline.tsx"; import MathBlockView from "@/features/editor/components/math/math-block.tsx"; import ImageView from "@/features/editor/components/image/image-view.tsx"; +import { + createImageHandle, + imageResizeClasses, +} from "@/features/editor/components/image/image-resize-handles.ts"; import CalloutView from "@/features/editor/components/callout/callout-view.tsx"; import VideoView from "@/features/editor/components/video/video-view.tsx"; import AttachmentView from "@/features/editor/components/attachment/attachment-view.tsx"; @@ -91,6 +95,7 @@ lowlight.register("fortran", fortran); lowlight.register("haskell", haskell); lowlight.register("scala", scala); +// @ts-ignore export const mainExtensions = [ StarterKit.configure({ heading: false, @@ -200,6 +205,16 @@ export const mainExtensions = [ TiptapImage.configure({ view: ImageView, allowBase64: false, + resize: { + enabled: true, + directions: ["left", "right"], + minWidth: 80, + minHeight: 40, + alwaysPreserveAspectRatio: true, + //@ts-ignore + createCustomHandle: createImageHandle, + className: imageResizeClasses, + }, }), TiptapVideo.configure({ view: VideoView, diff --git a/apps/client/src/main.tsx b/apps/client/src/main.tsx index 63a775de..0e4c3314 100644 --- a/apps/client/src/main.tsx +++ b/apps/client/src/main.tsx @@ -42,9 +42,9 @@ if (isCloud() && isPostHogEnabled) { }); } -const root = ReactDOM.createRoot( - document.getElementById("root") as HTMLElement, -); + +const container = document.getElementById("root") as HTMLElement; +const root = (container as any).__reactRoot ??= ReactDOM.createRoot(container); root.render( diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index e0f5053d..51d9006d 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -1,18 +1,41 @@ import Image from "@tiptap/extension-image"; import { ImageOptions as DefaultImageOptions } from "@tiptap/extension-image"; import { ReactNodeViewRenderer } from "@tiptap/react"; -import { mergeAttributes, Range } from "@tiptap/core"; +import { + mergeAttributes, + Range, + ResizableNodeView, +} from "@tiptap/core"; +import type { ResizableNodeViewDirection } from "@tiptap/core"; + +export type ImageResizeOptions = { + enabled: boolean; + directions?: ResizableNodeViewDirection[]; + minWidth?: number; + minHeight?: number; + alwaysPreserveAspectRatio?: boolean; + createCustomHandle?: (direction: ResizableNodeViewDirection) => HTMLElement; + className?: { + container?: string; + wrapper?: string; + handle?: string; + resizing?: string; + }; +}; export interface ImageOptions extends DefaultImageOptions { view: any; + resize: ImageResizeOptions | false; } + export interface ImageAttributes { src?: string; alt?: string; align?: string; attachmentId?: string; size?: number; - width?: number; + width?: number | string; + height?: number; aspectRatio?: number; placeholder?: { id: string; @@ -25,10 +48,11 @@ declare module "@tiptap/core" { imageBlock: { setImage: (attributes: ImageAttributes) => ReturnType; setImageAt: ( - attributes: ImageAttributes & { pos: number | Range } + attributes: ImageAttributes & { pos: number | Range }, ) => ReturnType; setImageAlign: (align: "left" | "center" | "right") => ReturnType; setImageWidth: (width: number) => ReturnType; + setImageSize: (width: number, height: number) => ReturnType; }; } } @@ -46,6 +70,7 @@ export const TiptapImage = Image.extend({ return { ...this.parent?.(), view: null, + resize: false, }; }, @@ -59,12 +84,30 @@ export const TiptapImage = Image.extend({ }), }, width: { - default: "100%", - parseHTML: (element) => element.getAttribute("width"), + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("width"); + if (!raw) return null; + if (raw.endsWith("%")) return raw; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, renderHTML: (attributes: ImageAttributes) => ({ width: attributes.width, }), }, + height: { + default: null, + parseHTML: (element) => { + const raw = element.getAttribute("height"); + if (!raw) return null; + const num = parseFloat(raw); + return isNaN(num) ? null : num; + }, + renderHTML: (attributes: ImageAttributes) => ({ + height: attributes.height, + }), + }, align: { default: "center", parseHTML: (element) => element.getAttribute("data-align"), @@ -142,16 +185,189 @@ export const TiptapImage = Image.extend({ setImageWidth: (width) => ({ commands }) => - commands.updateAttributes("image", { - width: `${Math.max(0, Math.min(100, width))}%`, - }), + commands.updateAttributes("image", { width }), + + setImageSize: + (width, height) => + ({ commands }) => + commands.updateAttributes("image", { width, height }), }; }, addNodeView() { - // Force the react node view to render immediately using flush sync (https://github.com/ueberdosis/tiptap/blob/b4db352f839e1d82f9add6ee7fb45561336286d8/packages/react/src/ReactRenderer.tsx#L183-L191) - this.editor.isInitialized = true; + const resize = this.options.resize; - return ReactNodeViewRenderer(this.options.view); + if (!resize || !resize.enabled) { + // Fallback to React node view (existing behavior) + this.editor.isInitialized = true; + return ReactNodeViewRenderer(this.options.view); + } + + const { + directions, + minWidth, + minHeight, + alwaysPreserveAspectRatio, + createCustomHandle, + className, + } = resize; + + return ({ node, getPos, HTMLAttributes, editor }) => { + // If no src yet (placeholder/uploading), use React view for loading UI + if (!HTMLAttributes.src) { + editor.isInitialized = true; + const reactView = ReactNodeViewRenderer(this.options.view); + const view = reactView({ + node, + getPos, + HTMLAttributes, + editor, + extension: this, + decorations: [] as any, + innerDecorations: {} as any, + }); + + // When the node gets a src, return false from update to force rebuild + const originalUpdate = view.update?.bind(view); + view.update = (updatedNode, decorations, innerDecorations) => { + if (updatedNode.attrs.src && !node.attrs.src) { + return false; + } + if (originalUpdate) { + return originalUpdate(updatedNode, decorations, innerDecorations); + } + return true; + }; + + return view; + } + + // Has src — use ResizableNodeView + const el = document.createElement("img"); + + Object.entries(HTMLAttributes).forEach(([key, value]) => { + if (value != null) { + switch (key) { + case "width": + case "height": + break; + default: + el.setAttribute(key, String(value)); + break; + } + } + }); + + el.src = HTMLAttributes.src; + el.style.display = "block"; + el.style.maxWidth = "100%"; + el.style.borderRadius = "8px"; + + let currentNode = node; + + const nodeView = new ResizableNodeView({ + element: el, + editor, + node, + getPos, + onResize: (w, h) => { + el.style.width = `${w}px`; + el.style.height = `${h}px`; + }, + onCommit: () => { + const pos = getPos(); + if (pos === undefined) return; + + this.editor + .chain() + .setNodeSelection(pos) + .updateAttributes(this.name, { + width: Math.round(el.offsetWidth), + height: Math.round(el.offsetHeight), + }) + .run(); + }, + onUpdate: (updatedNode, _decorations, _innerDecorations) => { + if (updatedNode.type !== currentNode.type) { + return false; + } + + if (updatedNode.attrs.src !== currentNode.attrs.src) { + el.src = updatedNode.attrs.src || ""; + } + + if (updatedNode.attrs.alt !== currentNode.attrs.alt) { + el.alt = updatedNode.attrs.alt || ""; + } + + // Update alignment on container + const align = updatedNode.attrs.align || "center"; + const container = nodeView.dom as HTMLElement; + applyAlignment(container, align); + + currentNode = updatedNode; + return true; + }, + options: { + directions, + min: { + width: minWidth, + height: minHeight, + }, + preserveAspectRatio: alwaysPreserveAspectRatio === true, + createCustomHandle, + className, + }, + }); + + const dom = nodeView.dom as HTMLElement; + + // Apply initial alignment + applyAlignment(dom, node.attrs.align || "center"); + + // Handle percentage width backward compat + const widthAttr = node.attrs.width; + if (typeof widthAttr === "string" && widthAttr.endsWith("%")) { + // Defer conversion until we can measure the container + requestAnimationFrame(() => { + const parentEl = dom.parentElement; + if (parentEl) { + const containerWidth = parentEl.clientWidth; + const pctValue = parseInt(widthAttr, 10); + if (!isNaN(pctValue) && containerWidth > 0) { + const pxWidth = Math.round( + containerWidth * (pctValue / 100), + ); + el.style.width = `${pxWidth}px`; + if (node.attrs.aspectRatio) { + el.style.height = `${Math.round(pxWidth / node.attrs.aspectRatio)}px`; + } + } + } + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }); + } + + // Hide until image loads (official TipTap pattern) + dom.style.visibility = "hidden"; + dom.style.pointerEvents = "none"; + el.onload = () => { + dom.style.visibility = ""; + dom.style.pointerEvents = ""; + }; + + return nodeView; + }; }, }); + +function applyAlignment(container: HTMLElement, align: string) { + if (align === "left") { + container.style.justifyContent = "flex-start"; + } else if (align === "right") { + container.style.justifyContent = "flex-end"; + } else { + container.style.justifyContent = "center"; + } +}