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