diff --git a/apps/ios/EditorSource/src/main.ts b/apps/ios/EditorSource/src/main.ts index 3df2d6bf..2ad49fa0 100644 --- a/apps/ios/EditorSource/src/main.ts +++ b/apps/ios/EditorSource/src/main.ts @@ -13,6 +13,7 @@ import mermaid from "mermaid"; import { toCanvas } from "html-to-image"; import { createEdgeEverDocumentExtensions, + applyImageAlignment, createNativeUnsupportedContentExtensions, DETAILS_EDITOR_CSS, diagramDocumentToX6Cells, @@ -407,7 +408,7 @@ function clampImageWidth(n: number): number { * with the picture — otherwise WKWebView keeps a full-width slot and paints * a pale selection wash in the empty half. */ -function applyFigureWidth(el: HTMLElement, width: number | null) { +function applyFigureWidth(el: HTMLElement, width: number | null, alignment?: unknown) { const w = width == null ? null : clampImageWidth(width); if (w == null) { el.style.removeProperty("width"); @@ -415,6 +416,7 @@ function applyFigureWidth(el: HTMLElement, width: number | null) { el.style.removeProperty("--ee-image-width"); delete el.dataset.width; el.classList.remove("has-width"); + applyImageAlignment(el, alignment); return; } const pct = `${w}%`; @@ -428,6 +430,7 @@ function applyFigureWidth(el: HTMLElement, width: number | null) { el.style.setProperty("margin-right", "auto"); el.style.setProperty("box-sizing", "border-box"); el.style.setProperty("display", "block"); + applyImageAlignment(el, alignment); } /** @@ -533,7 +536,7 @@ function createEdgeEverImageExtension() { /* keep markup update even if selection fails */ } ed.view.dispatch(tr); - applyFigureWidth(wrapper, nextWidth); + applyFigureWidth(wrapper, nextWidth, currentNode.attrs.alignment); setActiveWidth(nextWidth); }; @@ -660,7 +663,7 @@ function createEdgeEverImageExtension() { const alt = String(attrs.alt ?? ""); image.alt = alt; const width = parseImageWidth(attrs.width); - applyFigureWidth(wrapper, width); + applyFigureWidth(wrapper, width, attrs.alignment); setActiveWidth(width); const src = String(attrs.src ?? ""); const rid = getResourceIdFromHref(src); @@ -728,7 +731,7 @@ function createEdgeEverImageExtension() { loadImage(updated.attrs as Record); } else { setActiveWidth(parseImageWidth(updated.attrs.width)); - applyFigureWidth(wrapper, parseImageWidth(updated.attrs.width)); + applyFigureWidth(wrapper, parseImageWidth(updated.attrs.width), updated.attrs.alignment); } return true; }, @@ -740,7 +743,7 @@ function createEdgeEverImageExtension() { parseImageWidth( (ed.state.selection as { node?: { attrs?: { width?: unknown } } }).node?.attrs?.width ) ?? parseImageWidth(wrapper.dataset.width) ?? parseImageWidth(currentNode.attrs.width); - applyFigureWidth(wrapper, selWidth); + applyFigureWidth(wrapper, selWidth, currentNode.attrs.alignment); // Width bar only in editor when the image is the active node selection. if (ed.isEditable && mode === "editor" && !image.hidden) { sizeControls.hidden = false; diff --git a/apps/mobile/src/components/LocalTiptapEditor.tsx b/apps/mobile/src/components/LocalTiptapEditor.tsx index 275ba05a..e5f33b17 100644 --- a/apps/mobile/src/components/LocalTiptapEditor.tsx +++ b/apps/mobile/src/components/LocalTiptapEditor.tsx @@ -31,6 +31,7 @@ import { markdownToDoc, MEMO_CONTENT_STYLE, createEdgeEverDocumentExtensions, + applyImageAlignment, DETAILS_EDITOR_CSS, wrapDetailsContentHtml, NativeAttachmentMetadata, @@ -2299,6 +2300,7 @@ const applyImageWidth = ( const width = parseImageWidth(attributes.width) ?? NEW_IMAGE_WIDTH_PERCENT; element.style.width = `${width}%`; element.dataset.width = String(width); + applyImageAlignment(element, attributes.alignment); return width; }; diff --git a/apps/web/src/components/editor/ResizableImage.tsx b/apps/web/src/components/editor/ResizableImage.tsx index fc114a74..243b97dc 100644 --- a/apps/web/src/components/editor/ResizableImage.tsx +++ b/apps/web/src/components/editor/ResizableImage.tsx @@ -2,9 +2,9 @@ import { useCallback, useEffect, useRef, useState, type MouseEvent, type Pointer import { mergeAttributes } from "@tiptap/core"; import Image from "@tiptap/extension-image"; import { NodeViewWrapper, ReactNodeViewRenderer, useEditorState, type NodeViewProps } from "@tiptap/react"; -import { Maximize2 } from "lucide-react"; +import { AlignLeft, AlignCenter, AlignRight, Maximize2 } from "lucide-react"; import { useTranslation } from "react-i18next"; -import { getImageReferrerPolicy, IMAGE_GALLERY_NODE_TYPE } from "@edgeever/shared"; +import { getImageReferrerPolicy, IMAGE_GALLERY_NODE_TYPE, imageAlignmentMargins, parseImageAlignment, type ImageAlignment } from "@edgeever/shared"; import { IMAGE_WIDTH_PRESETS, NEW_IMAGE_WIDTH_PERCENT, @@ -49,6 +49,8 @@ const ResizableImageNodeView = ({ const [previewWidth, setPreviewWidth] = useState(null); const nodeWidth = parseImageWidth(node.attrs.width) ?? NEW_IMAGE_WIDTH_PERCENT; const width = previewWidth ?? nodeWidth; + const explicitAlignment = parseImageAlignment(node.attrs.alignment); + const alignment = explicitAlignment ?? "left"; const { editable, inGallery } = useEditorState({ editor, selector: ({ editor: activeEditor }) => ({ @@ -144,6 +146,10 @@ const ResizableImageNodeView = ({ window.dispatchEvent(new CustomEvent(IMAGE_MENU_HIDE_EVENT)); }, []); + useEffect(() => { + if (showSizeControls) hideImageMenu(); + }, [hideImageMenu, showSizeControls]); + const requestImagePreview = useCallback(() => { if (!src) return; hideImageMenu(); @@ -160,6 +166,11 @@ const ResizableImageNodeView = ({ updateAttributes({ width: clampImageWidth(nextWidth) }); }, [editor, getPos, updateAttributes]); + const updateAlignment = (next: ImageAlignment) => { + if (!editor.isEditable || isImageInGallery(editor.state.doc, getPos())) return; + updateAttributes({ alignment: next }); + }; + const startResize = useCallback((event: ReactPointerEvent) => { if (!editable || isImageInGallery(editor.state.doc, getPos())) return; @@ -169,13 +180,16 @@ const ResizableImageNodeView = ({ event.preventDefault(); event.currentTarget.setPointerCapture(event.pointerId); - const parentWidth = parent.getBoundingClientRect().width; + const parentBounds = parent.getBoundingClientRect(); + const parentWidth = parentBounds.width; if (parentWidth <= 0) return; let pendingWidth = nodeWidth; + const startX = event.clientX; const previewFromPointer = (clientX: number) => { - const wrapperLeft = wrapper.getBoundingClientRect().left; - pendingWidth = clampImageWidth(((clientX - wrapperLeft) / parentWidth) * 100); + // Resizing is relative to the initial width: centered/right-aligned boxes + // move their left edge as they resize, so measuring that edge causes jumps. + pendingWidth = clampImageWidth(nodeWidth + ((clientX - startX) / parentWidth) * 100); setPreviewWidth(pendingWidth); }; const handlePointerMove = (moveEvent: PointerEvent) => previewFromPointer(moveEvent.clientX); @@ -222,9 +236,10 @@ const ResizableImageNodeView = ({ ref={wrapperRef} as="figure" className={cn("edgeever-image-node", selected && "is-selected")} - style={{ width: `${width}%` }} + style={{ width: `${width}%`, ...(explicitAlignment ? imageAlignmentMargins(explicitAlignment) : {}) }} data-width={width} - onMouseEnter={requestImageMenu} + data-image-align={alignment} + onMouseEnter={showSizeControls ? undefined : requestImageMenu} onMouseLeave={hideImageMenu} onContextMenu={(event: MouseEvent) => { event.preventDefault(); @@ -238,6 +253,15 @@ const ResizableImageNodeView = ({ title={title || undefined} draggable={false} referrerPolicy={getImageReferrerPolicy(src)} + onClick={(event) => { + if (!editor.isEditable || isImageInGallery(editor.state.doc, getPos())) return; + const position = getPos(); + if (typeof position !== "number") return; + // A positioned figure does not occupy the full block wrapper. Select + // its node explicitly instead of relying on coordinate hit testing. + event.preventDefault(); + editor.chain().focus().setNodeSelection(position).run(); + }} onDoubleClick={(event) => { event.preventDefault(); event.stopPropagation(); @@ -245,7 +269,7 @@ const ResizableImageNodeView = ({ }} /> {showSizeControls && ( -
+
{previewButton}
{IMAGE_WIDTH_PRESETS.map((preset) => ( @@ -262,6 +286,31 @@ const ResizableImageNodeView = ({ ))}
+ +
+ {([ + { value: "left", Icon: AlignLeft }, + { value: "center", Icon: AlignCenter }, + { value: "right", Icon: AlignRight }, + ] as const).map(({ value, Icon }) => ( + + + + + {t(`editor.imageAlignments.${value}`)} + + ))} +
+
{canGroupAdjacentImages ? (