From a5bda92e14e8dd0b6808364b0b60783efe9b4fcb Mon Sep 17 00:00:00 2001 From: Andy Jarosz Date: Fri, 23 Oct 2026 05:37:59 -0500 Subject: [PATCH] feat(editor): add per-image left, center, and right alignment (#654) Select a standalone image to align it left, center, or right. The choice stays with that image through save, Markdown edits, and reload. Existing images keep their previous layout until an alignment is chosen. Galleries keep their own layout. Native editors apply a saved alignment and do not add a picker in this change. --- apps/ios/EditorSource/src/main.ts | 13 +- .../src/components/LocalTiptapEditor.tsx | 2 + .../src/components/editor/ResizableImage.tsx | 65 +++++++-- apps/web/src/styles/globals.css | 37 +++++- docs/changes/2026-10-22-image-alignment.md | 7 + .../2026-10-22-image-alignment.zh-CN.md | 7 + packages/shared/src/details.ts | 14 +- packages/shared/src/document-extensions.ts | 3 +- packages/shared/src/i18n/en-US.ts | 2 + packages/shared/src/i18n/ja.ts | 2 + packages/shared/src/i18n/pl.ts | 2 + packages/shared/src/i18n/zh-CN.ts | 2 + packages/shared/src/image-alignment.test.mjs | 90 +++++++++++++ packages/shared/src/image-alignment.ts | 123 ++++++++++++++++++ packages/shared/src/image-gallery.ts | 7 + packages/shared/src/index.ts | 1 + tests/e2e/image-alignment.spec.ts | 119 +++++++++++++++++ 17 files changed, 475 insertions(+), 21 deletions(-) create mode 100644 docs/changes/2026-10-22-image-alignment.md create mode 100644 docs/changes/2026-10-22-image-alignment.zh-CN.md create mode 100644 packages/shared/src/image-alignment.test.mjs create mode 100644 packages/shared/src/image-alignment.ts create mode 100644 tests/e2e/image-alignment.spec.ts 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 ? (