From 20032be9211cdebf18ab7574c5a98e38579b76d5 Mon Sep 17 00:00:00 2001 From: claude_code Date: Thu, 2 Jul 2026 04:22:25 +0300 Subject: [PATCH] =?UTF-8?q?feat(editor):=20inline=20image=20alignment=20?= =?UTF-8?q?=E2=80=94=20place=20several=20images=20side=20by=20side?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Add a new value "inline" to the image align attribute (alongside left/center/right/floatLeft/floatRight). Inline images render as inline-block containers, so consecutive ones form a row that wraps naturally on narrow viewports; unlike the float modes, text does not wrap around them. - applyAlignment: reset-then-apply extended to display/vertical-align; the reset restores the constructor's inline display:flex so non-inline modes keep byte-identical styles and editor-ext stays independent of the client CSS class - image bubble menu: new "Inline (side by side)" button (IconLayoutColumns) with active state, mirroring the float buttons - i18n: key registered in en-US and ru-RU ("В ряд"), like the float labels - tests: 3 new applyAlignment specs (apply, reset on switch-away, float->inline) - no schema/MCP/markdown changes needed: align round-trips as data-align --- CHANGELOG.md | 7 ++++ .../public/locales/en-US/translation.json | 1 + .../public/locales/ru-RU/translation.json | 1 + .../editor/components/image/image-menu.tsx | 22 +++++++++++++ .../editor-ext/src/lib/image/image.spec.ts | 32 +++++++++++++++++++ packages/editor-ext/src/lib/image/image.ts | 25 ++++++++++++++- 6 files changed, 87 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ae01ac01..62c10b17 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -12,6 +12,13 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- **Place several images side by side in a row.** A new "Inline (side by + side)" alignment mode in the image bubble menu renders consecutive inline + images as a row that wraps onto the next line on narrow screens. Unlike the + float modes, text does not wrap around inline images. The mode round-trips + losslessly through markdown as `data-align`, like the other alignment + values. + - **Editable captions for images.** Images gain an optional caption shown below them, edited inline from the image bubble menu and stored as a `caption` attribute. Captions round-trip losslessly through markdown as a `data-caption` attribute on the image, so diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json index 08fae9a7..938a9ea1 100644 --- a/apps/client/public/locales/en-US/translation.json +++ b/apps/client/public/locales/en-US/translation.json @@ -1322,6 +1322,7 @@ "Move to space": "Move to space", "Float left (wrap text)": "Float left (wrap text)", "Float right (wrap text)": "Float right (wrap text)", + "Inline (side by side)": "Inline (side by side)", "Switch to tree": "Switch to tree", "Switch to flat list": "Switch to flat list", "Toggle subpages display mode": "Toggle subpages display mode", diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json index 88629662..c6226d7c 100644 --- a/apps/client/public/locales/ru-RU/translation.json +++ b/apps/client/public/locales/ru-RU/translation.json @@ -1175,6 +1175,7 @@ "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.", "Float left (wrap text)": "Обтекание слева", "Float right (wrap text)": "Обтекание справа", + "Inline (side by side)": "В ряд", "Switch to tree": "Переключить на дерево", "Switch to flat list": "Переключить на плоский список", "Toggle subpages display mode": "Переключить режим отображения подстраниц", 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 13834405..e91a1868 100644 --- a/apps/client/src/features/editor/components/image/image-menu.tsx +++ b/apps/client/src/features/editor/components/image/image-menu.tsx @@ -15,6 +15,7 @@ import { IconLayoutAlignRight, IconFloatLeft, IconFloatRight, + IconLayoutColumns, IconDownload, IconRefresh, IconTrash, @@ -46,6 +47,7 @@ export function ImageMenu({ editor }: EditorMenuProps) { isAlignRight: ctx.editor.isActive("image", { align: "right" }), isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }), isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }), + isInline: ctx.editor.isActive("image", { align: "inline" }), src: imageAttrs?.src || null, alt: imageAttrs?.alt || "", caption: imageAttrs?.caption || "", @@ -126,6 +128,14 @@ export function ImageMenu({ editor }: EditorMenuProps) { .run(); }, [editor]); + const alignImageInline = useCallback(() => { + editor + .chain() + .focus(undefined, { scrollIntoView: false }) + .setImageAlign("inline") + .run(); + }, [editor]); + const handleDownload = useCallback(() => { if (!editorState?.src) return; const url = getFileUrl(editorState.src); @@ -259,6 +269,18 @@ export function ImageMenu({ editor }: EditorMenuProps) { + + + + + +
{altTextButton} diff --git a/packages/editor-ext/src/lib/image/image.spec.ts b/packages/editor-ext/src/lib/image/image.spec.ts index 3f1f56ef..007d62b8 100644 --- a/packages/editor-ext/src/lib/image/image.spec.ts +++ b/packages/editor-ext/src/lib/image/image.spec.ts @@ -63,6 +63,38 @@ describe("applyAlignment", () => { expect(el.dataset.imageAlign).toBe("center"); }); + it("inline -> inline-block + top alignment + gap padding, no float", () => { + applyAlignment(el, "inline"); + expect(el.style.display).toBe("inline-block"); + expect(el.style.verticalAlign).toBe("top"); + expect(el.style.padding).toBe("0px 10px 10px 0px"); + expect(el.dataset.imageAlign).toBe("inline"); + expect(el.style.cssFloat).toBe(""); + }); + + it("clears inline-block when switching inline -> center (reset-then-apply)", () => { + applyAlignment(el, "inline"); + expect(el.style.display).toBe("inline-block"); + // Switching back to a flex alignment must replace the inline-block + // override with the constructor-style flex, not just clear it. + applyAlignment(el, "center"); + expect(el.style.display).toBe("flex"); + expect(el.style.verticalAlign).toBe(""); + expect(el.style.padding).toBe(""); + expect(el.dataset.imageAlign).toBe("center"); + expect(el.style.justifyContent).toBe("center"); + }); + + it("clears a previous float when switching floatLeft -> inline", () => { + applyAlignment(el, "floatLeft"); + expect(el.style.cssFloat).toBe("left"); + applyAlignment(el, "inline"); + expect(el.style.cssFloat).toBe(""); + expect(el.style.display).toBe("inline-block"); + expect(el.style.verticalAlign).toBe("top"); + expect(el.dataset.imageAlign).toBe("inline"); + }); + it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => { applyAlignment(el, "floatLeft"); expect(el.style.cssFloat).toBe("left"); diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts index 9fd597d7..7e6e48ae 100644 --- a/packages/editor-ext/src/lib/image/image.ts +++ b/packages/editor-ext/src/lib/image/image.ts @@ -53,7 +53,13 @@ declare module "@tiptap/core" { attributes: ImageAttributes & { pos: number | Range }, ) => ReturnType; setImageAlign: ( - align: "left" | "center" | "right" | "floatLeft" | "floatRight", + align: + | "left" + | "center" + | "right" + | "floatLeft" + | "floatRight" + | "inline", ) => ReturnType; setImageWidth: (width: number) => ReturnType; setImageSize: (width: number, height: number) => ReturnType; @@ -415,6 +421,14 @@ export function applyAlignment(container: HTMLElement, align: string) { // (a previous float must not leak into a later left/center/right). container.style.cssFloat = ""; container.style.padding = ""; + // The ResizableNodeView constructor sets an inline `display: flex` on the + // container; the inline mode overrides it with `inline-block`, so the reset + // restores the constructor's flex here. This keeps the container's layout + // independent of any app-level CSS class (which also happens to set flex) + // and makes non-inline modes carry exactly the same inline styles as before + // the inline mode existed. + container.style.display = "flex"; + container.style.verticalAlign = ""; // Mirror the resolved alignment onto the CONTAINER as a data attribute so the // responsive stylesheet can neutralize the float on small screens (an inline // `float` can only be overridden by `!important`, which keys off this attr). @@ -430,6 +444,15 @@ export function applyAlignment(container: HTMLElement, align: string) { container.style.cssFloat = "right"; container.style.padding = "0 0 0 10px"; container.style.justifyContent = "flex-end"; + } else if (align === "inline") { + // Consecutive inline images sit side by side on one line box and wrap to + // the next line when the viewport is narrow. The right/bottom padding + // provides the gap between images in a row and between wrapped rows; + // vertical-align: top keeps rows of different-height images aligned by + // their top edge. + container.style.display = "inline-block"; + container.style.verticalAlign = "top"; + container.style.padding = "0 10px 10px 0"; } else if (align === "left") { container.style.justifyContent = "flex-start"; } else if (align === "right") {