diff --git a/apps/client/public/locales/en-US/translation.json b/apps/client/public/locales/en-US/translation.json
index fc653fc0..0a6a8d44 100644
--- a/apps/client/public/locales/en-US/translation.json
+++ b/apps/client/public/locales/en-US/translation.json
@@ -1298,5 +1298,7 @@
"Analytics / tracker": "Analytics / tracker",
"Injected verbatim into the
of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.",
"Go to login page": "Go to login page",
- "Move to space": "Move to space"
+ "Move to space": "Move to space",
+ "Float left (wrap text)": "Float left (wrap text)",
+ "Float right (wrap text)": "Float right (wrap text)"
}
diff --git a/apps/client/public/locales/ru-RU/translation.json b/apps/client/public/locales/ru-RU/translation.json
index 221fc229..3659a1a9 100644
--- a/apps/client/public/locales/ru-RU/translation.json
+++ b/apps/client/public/locales/ru-RU/translation.json
@@ -1150,5 +1150,7 @@
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
"Dictation language": "Язык диктовки",
"Auto-detect": "Автоопределение",
- "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью."
+ "Spoken language hint sent to the transcription model. Auto-detect lets the model decide.": "Подсказка языка речи для модели транскрипции. «Автоопределение» оставляет выбор за моделью.",
+ "Float left (wrap text)": "Обтекание слева",
+ "Float right (wrap text)": "Обтекание справа"
}
diff --git a/apps/client/src/features/editor/components/common/node-resize.module.css b/apps/client/src/features/editor/components/common/node-resize.module.css
index 4159e44e..d2d7d4fe 100644
--- a/apps/client/src/features/editor/components/common/node-resize.module.css
+++ b/apps/client/src/features/editor/components/common/node-resize.module.css
@@ -73,3 +73,18 @@
display: none !important;
}
}
+
+/* Float image (#145): on narrow screens a floated image would crowd the text to
+ an unreadable column, so collapse it to full width and drop the float.
+ `!important` is required because applyAlignment sets `float`/`padding` inline,
+ which a normal rule cannot override. Keys off the `data-image-align` attribute
+ the image node view mirrors onto its container. This module is the one actually
+ imported by the resize node views (node-resize-handles.ts), so the rule loads. */
+@media (max-width: 600px) {
+ .container:global([data-image-align="floatLeft"]),
+ .container:global([data-image-align="floatRight"]) {
+ float: none !important;
+ width: 100% !important;
+ padding: 0 !important;
+ }
+}
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 179deda1..e328090b 100644
--- a/apps/client/src/features/editor/components/image/image-menu.tsx
+++ b/apps/client/src/features/editor/components/image/image-menu.tsx
@@ -13,6 +13,8 @@ import {
IconLayoutAlignCenter,
IconLayoutAlignLeft,
IconLayoutAlignRight,
+ IconFloatLeft,
+ IconFloatRight,
IconDownload,
IconRefresh,
IconTrash,
@@ -41,6 +43,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
+ isFloatLeft: ctx.editor.isActive("image", { align: "floatLeft" }),
+ isFloatRight: ctx.editor.isActive("image", { align: "floatRight" }),
src: imageAttrs?.src || null,
alt: imageAttrs?.alt || "",
};
@@ -104,6 +108,22 @@ export function ImageMenu({ editor }: EditorMenuProps) {
.run();
}, [editor]);
+ const alignImageFloatLeft = useCallback(() => {
+ editor
+ .chain()
+ .focus(undefined, { scrollIntoView: false })
+ .setImageAlign("floatLeft")
+ .run();
+ }, [editor]);
+
+ const alignImageFloatRight = useCallback(() => {
+ editor
+ .chain()
+ .focus(undefined, { scrollIntoView: false })
+ .setImageAlign("floatRight")
+ .run();
+ }, [editor]);
+
const handleDownload = useCallback(() => {
if (!editorState?.src) return;
const url = getFileUrl(editorState.src);
@@ -201,6 +221,30 @@ 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
new file mode 100644
index 00000000..2a1b7f8c
--- /dev/null
+++ b/packages/editor-ext/src/lib/image/image.spec.ts
@@ -0,0 +1,67 @@
+import { describe, it, expect, beforeEach } from "vitest";
+import { applyAlignment } from "./image";
+
+// applyAlignment is a pure DOM mutation: it sets the float / padding /
+// justify-content / data-image-align on an image node-view container per the
+// resolved `align`. Tested directly (issue #145 review) since the five-way
+// branch, the reset-then-apply guard, and the data-image-align mirror (which the
+// responsive @media rule keys off) are otherwise uncovered.
+
+describe("applyAlignment", () => {
+ let el: HTMLElement;
+ beforeEach(() => {
+ el = document.createElement("div");
+ });
+
+ it("floatLeft -> float:left + right padding, mirrored on data-image-align", () => {
+ applyAlignment(el, "floatLeft");
+ expect(el.style.cssFloat).toBe("left");
+ expect(el.style.padding).toBe("0px 10px 0px 0px");
+ expect(el.dataset.imageAlign).toBe("floatLeft");
+ expect(el.style.justifyContent).toBe("flex-start");
+ });
+
+ it("floatRight -> float:right + left padding", () => {
+ applyAlignment(el, "floatRight");
+ expect(el.style.cssFloat).toBe("right");
+ expect(el.style.padding).toBe("0px 0px 0px 10px");
+ expect(el.dataset.imageAlign).toBe("floatRight");
+ expect(el.style.justifyContent).toBe("flex-end");
+ });
+
+ it("left -> justify flex-start, no float", () => {
+ applyAlignment(el, "left");
+ expect(el.style.justifyContent).toBe("flex-start");
+ expect(el.style.cssFloat).toBe("");
+ expect(el.style.padding).toBe("");
+ expect(el.dataset.imageAlign).toBe("left");
+ });
+
+ it("right -> justify flex-end, no float", () => {
+ applyAlignment(el, "right");
+ expect(el.style.justifyContent).toBe("flex-end");
+ expect(el.style.cssFloat).toBe("");
+ expect(el.dataset.imageAlign).toBe("right");
+ });
+
+ it("center (default) -> justify center, no float", () => {
+ applyAlignment(el, "center");
+ expect(el.style.justifyContent).toBe("center");
+ expect(el.style.cssFloat).toBe("");
+ expect(el.style.padding).toBe("");
+ expect(el.dataset.imageAlign).toBe("center");
+ });
+
+ it("clears a previous float when switching floatLeft -> left (reset-then-apply)", () => {
+ applyAlignment(el, "floatLeft");
+ expect(el.style.cssFloat).toBe("left");
+ expect(el.style.padding).toBe("0px 10px 0px 0px");
+ // Switching to a block alignment must drop the float and its padding, not
+ // leak them (the bug the reset guard prevents).
+ applyAlignment(el, "left");
+ expect(el.style.cssFloat).toBe("");
+ expect(el.style.padding).toBe("");
+ expect(el.dataset.imageAlign).toBe("left");
+ expect(el.style.justifyContent).toBe("flex-start");
+ });
+});
diff --git a/packages/editor-ext/src/lib/image/image.ts b/packages/editor-ext/src/lib/image/image.ts
index 6a921926..7856ecb6 100644
--- a/packages/editor-ext/src/lib/image/image.ts
+++ b/packages/editor-ext/src/lib/image/image.ts
@@ -51,7 +51,9 @@ declare module "@tiptap/core" {
setImageAt: (
attributes: ImageAttributes & { pos: number | Range },
) => ReturnType;
- setImageAlign: (align: "left" | "center" | "right") => ReturnType;
+ setImageAlign: (
+ align: "left" | "center" | "right" | "floatLeft" | "floatRight",
+ ) => ReturnType;
setImageWidth: (width: number) => ReturnType;
setImageSize: (width: number, height: number) => ReturnType;
};
@@ -374,8 +376,27 @@ export const TiptapImage = Image.extend({
},
});
-function applyAlignment(container: HTMLElement, align: string) {
- if (align === "left") {
+export function applyAlignment(container: HTMLElement, align: string) {
+ // Reset the float-mode styles first so toggling between any two modes is clean
+ // (a previous float must not leak into a later left/center/right).
+ container.style.cssFloat = "";
+ container.style.padding = "";
+ // 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).
+ container.dataset.imageAlign = align;
+
+ if (align === "floatLeft") {
+ // Real text wrap: the (shrink-to-fit) container floats left, text flows on
+ // its right. The inner
already carries max-width:100%.
+ container.style.cssFloat = "left";
+ container.style.padding = "0 10px 0 0";
+ container.style.justifyContent = "flex-start";
+ } else if (align === "floatRight") {
+ container.style.cssFloat = "right";
+ container.style.padding = "0 0 0 10px";
+ container.style.justifyContent = "flex-end";
+ } else if (align === "left") {
container.style.justifyContent = "flex-start";
} else if (align === "right") {
container.style.justifyContent = "flex-end";