feat(editor): float image with text wrap (#145) #157
@@ -1287,5 +1287,7 @@
|
||||
"Analytics / tracker": "Analytics / tracker",
|
||||
"Injected verbatim into the <head> of PUBLIC SHARE pages only (same-origin). For analytics snippets (Google Analytics, Yandex.Metrika, etc.). Admin only.": "Injected verbatim into the <head> 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)"
|
||||
}
|
||||
|
||||
@@ -1137,5 +1137,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)": "Обтекание справа"
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Float left (wrap text)")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageFloatLeft}
|
||||
size="lg"
|
||||
aria-label={t("Float left (wrap text)")}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isFloatLeft })}
|
||||
>
|
||||
<IconFloatLeft size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<Tooltip position="top" label={t("Float right (wrap text)")} withinPortal={false}>
|
||||
<ActionIcon
|
||||
onClick={alignImageFloatRight}
|
||||
size="lg"
|
||||
aria-label={t("Float right (wrap text)")}
|
||||
variant="subtle"
|
||||
className={clsx({ [classes.active]: editorState?.isFloatRight })}
|
||||
>
|
||||
<IconFloatRight size={18} />
|
||||
</ActionIcon>
|
||||
</Tooltip>
|
||||
|
||||
<div className={classes.divider} />
|
||||
|
||||
{altTextButton}
|
||||
|
||||
67
packages/editor-ext/src/lib/image/image.spec.ts
Normal file
67
packages/editor-ext/src/lib/image/image.spec.ts
Normal file
@@ -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");
|
||||
});
|
||||
});
|
||||
@@ -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<ImageOptions>({
|
||||
},
|
||||
});
|
||||
|
||||
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 <img> 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";
|
||||
|
||||
Reference in New Issue
Block a user