diff --git a/packages/tiptap/src/shared/extensions/index.ts b/packages/tiptap/src/shared/extensions/index.ts index ae2ced871e..67902f51ca 100644 --- a/packages/tiptap/src/shared/extensions/index.ts +++ b/packages/tiptap/src/shared/extensions/index.ts @@ -1,3 +1,4 @@ +import { ResizableNodeView } from "@tiptap/core"; import FileHandler from "@tiptap/extension-file-handler"; import Highlight from "@tiptap/extension-highlight"; import Image from "@tiptap/extension-image"; @@ -38,6 +39,14 @@ export type FileHandlerConfig = { onImageUpload?: (file: File) => Promise; }; +function extractAttachmentIdFromSrc(src: string): string | null { + const filename = src.split("/").pop() || ""; + const match = filename.match( + /^([0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12})\./i, + ); + return match ? match[1] : null; +} + export type ExtensionOptions = { imageExtension?: any; onLinkOpen?: (url: string) => void; @@ -57,6 +66,119 @@ const AttachmentImage = Image.extend({ return { "data-attachment-id": attributes.attachmentId }; }, }, + width: { + default: null, + parseHTML: (element) => element.getAttribute("width"), + renderHTML: (attributes) => { + if (!attributes.width) { + return {}; + } + return { width: attributes.width }; + }, + }, + height: { + default: null, + parseHTML: (element) => element.getAttribute("height"), + renderHTML: (attributes) => { + if (!attributes.height) { + return {}; + } + return { height: attributes.height }; + }, + }, + }; + }, + + addNodeView() { + const resize = this.options.resize; + if (!resize || !resize.enabled) { + return null; + } + + return ({ node, getPos, HTMLAttributes, editor }) => { + const img = document.createElement("img"); + + Object.entries(HTMLAttributes).forEach(([key, value]) => { + if (value == null) { + return; + } + img.setAttribute(key, String(value)); + }); + + const width = node.attrs.width; + const height = node.attrs.height; + if (width != null) { + img.style.width = + typeof width === "number" ? `${width}px` : String(width); + } + if (height != null) { + img.style.height = + typeof height === "number" ? `${height}px` : String(height); + } + + const min = + resize.minWidth || resize.minHeight + ? { + width: resize.minWidth, + height: resize.minHeight, + } + : undefined; + + return new ResizableNodeView({ + editor, + element: img, + node, + getPos, + onResize: (nextWidth, nextHeight) => { + img.style.width = `${nextWidth}px`; + img.style.height = `${nextHeight}px`; + }, + onCommit: (nextWidth, nextHeight) => { + const pos = getPos(); + if (pos === undefined) { + return; + } + editor.commands.updateAttributes("image", { + width: nextWidth, + height: nextHeight, + }); + }, + onUpdate: (updatedNode) => { + if (updatedNode.type !== node.type) { + return false; + } + + const nextWidth = updatedNode.attrs.width; + const nextHeight = updatedNode.attrs.height; + if (nextWidth == null) { + img.style.removeProperty("width"); + } else { + img.style.width = + typeof nextWidth === "number" + ? `${nextWidth}px` + : String(nextWidth); + } + if (nextHeight == null) { + img.style.removeProperty("height"); + } else { + img.style.height = + typeof nextHeight === "number" + ? `${nextHeight}px` + : String(nextHeight); + } + + if (updatedNode.attrs.src) { + img.setAttribute("src", String(updatedNode.attrs.src)); + } + + return true; + }, + options: { + directions: resize.directions, + min, + preserveAspectRatio: resize.alwaysPreserveAspectRatio, + }, + }); }; }, @@ -68,7 +190,7 @@ const AttachmentImage = Image.extend({ src, alt: token.text || "", title: token.title || null, - attachmentId: null, + attachmentId: extractAttachmentIdFromSrc(src), }, }; }, @@ -115,6 +237,19 @@ export const getExtensions = ( inline: false, allowBase64: true, HTMLAttributes: { class: "tiptap-image" }, + resize: { + enabled: true, + directions: [ + "top", + "bottom", + "left", + "right", + "top-left", + "top-right", + "bottom-left", + "bottom-right", + ], + }, }), Underline, Placeholder.configure({ diff --git a/packages/tiptap/src/styles/base.css b/packages/tiptap/src/styles/base.css index c01a07c054..c8be26bdbc 100644 --- a/packages/tiptap/src/styles/base.css +++ b/packages/tiptap/src/styles/base.css @@ -34,6 +34,7 @@ } .node-image { + position: relative; padding-top: 0.5rem; padding-bottom: 0.5rem; } @@ -50,6 +51,88 @@ outline: none; } + img { + display: block; + } + + [data-resize-handle] { + position: absolute; + background: rgba(0, 0, 0, 0.5); + border: 1px solid rgba(255, 255, 255, 0.8); + border-radius: 2px; + z-index: 10; + + &:hover { + background: rgba(0, 0, 0, 0.8); + } + } + + [data-resize-handle="top-left"], + [data-resize-handle="top-right"], + [data-resize-handle="bottom-left"], + [data-resize-handle="bottom-right"] { + width: 8px; + height: 8px; + } + + [data-resize-handle="top-left"] { + top: -4px; + left: -4px; + cursor: nwse-resize; + } + + [data-resize-handle="top-right"] { + top: -4px; + right: -4px; + cursor: nesw-resize; + } + + [data-resize-handle="bottom-left"] { + bottom: -4px; + left: -4px; + cursor: nesw-resize; + } + + [data-resize-handle="bottom-right"] { + bottom: -4px; + right: -4px; + cursor: nwse-resize; + } + + [data-resize-handle="top"], + [data-resize-handle="bottom"] { + height: 6px; + left: 8px; + right: 8px; + } + + [data-resize-handle="top"] { + top: -3px; + cursor: ns-resize; + } + + [data-resize-handle="bottom"] { + bottom: -3px; + cursor: ns-resize; + } + + [data-resize-handle="left"], + [data-resize-handle="right"] { + width: 6px; + top: 8px; + bottom: 8px; + } + + [data-resize-handle="left"] { + left: -3px; + cursor: ew-resize; + } + + [data-resize-handle="right"] { + right: -3px; + cursor: ew-resize; + } + .tiptap-image { max-width: 100%; height: auto;