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") {