feat(editor): float image with text wrap (#145, port from Forkmost)
Adds floatLeft / floatRight image alignment so text wraps beside the image, beyond the existing block left/center/right. Ported from Forkmost PR #7 / upstream Docmost PR #1132 (fuscodev), adapted to gitmost's imperative image node-view (the upstream uses a React styled component; ours styles the node-view container directly via applyAlignment). - editor-ext image.ts: `setImageAlign` accepts `floatLeft`/`floatRight`; `applyAlignment` resets float/padding then, for a float mode, sets `float:left|right` + side padding on the (shrink-to-fit) container so text flows beside it (the inner <img> already has max-width:100%). The resolved align is mirrored onto the container as `data-image-align` for the responsive rule. `data-align` already round-trips the value through parse/renderHTML, so float survives serialization / collab / history with no schema change. - image-menu.tsx: Float-left / Float-right bubble-menu buttons (IconFloatLeft/ Right) with active state. - image-resize.module.css: on narrow screens (<=600px) a floated image collapses to full width and drops the float (`!important`, keyed on data-image-align) — the upstream "100% width on small screen" follow-up. - i18n: en-US + ru-RU strings. editor-ext build + client tsc --noEmit clean. Visual wrap behavior is best confirmed in-browser (logic/serialization verified by build + types). Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
@@ -1287,5 +1287,7 @@
|
|||||||
"Analytics / tracker": "Analytics / tracker",
|
"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.",
|
"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",
|
"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}}",
|
"Create subpage of {{name}}": "Создать подстраницу для {{name}}",
|
||||||
"Dictation language": "Язык диктовки",
|
"Dictation language": "Язык диктовки",
|
||||||
"Auto-detect": "Автоопределение",
|
"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)": "Обтекание справа"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -13,6 +13,8 @@ import {
|
|||||||
IconLayoutAlignCenter,
|
IconLayoutAlignCenter,
|
||||||
IconLayoutAlignLeft,
|
IconLayoutAlignLeft,
|
||||||
IconLayoutAlignRight,
|
IconLayoutAlignRight,
|
||||||
|
IconFloatLeft,
|
||||||
|
IconFloatRight,
|
||||||
IconDownload,
|
IconDownload,
|
||||||
IconRefresh,
|
IconRefresh,
|
||||||
IconTrash,
|
IconTrash,
|
||||||
@@ -41,6 +43,8 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
isAlignLeft: ctx.editor.isActive("image", { align: "left" }),
|
||||||
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
isAlignCenter: ctx.editor.isActive("image", { align: "center" }),
|
||||||
isAlignRight: ctx.editor.isActive("image", { align: "right" }),
|
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,
|
src: imageAttrs?.src || null,
|
||||||
alt: imageAttrs?.alt || "",
|
alt: imageAttrs?.alt || "",
|
||||||
};
|
};
|
||||||
@@ -104,6 +108,22 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
.run();
|
.run();
|
||||||
}, [editor]);
|
}, [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(() => {
|
const handleDownload = useCallback(() => {
|
||||||
if (!editorState?.src) return;
|
if (!editorState?.src) return;
|
||||||
const url = getFileUrl(editorState.src);
|
const url = getFileUrl(editorState.src);
|
||||||
@@ -201,6 +221,30 @@ export function ImageMenu({ editor }: EditorMenuProps) {
|
|||||||
</ActionIcon>
|
</ActionIcon>
|
||||||
</Tooltip>
|
</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} />
|
<div className={classes.divider} />
|
||||||
|
|
||||||
{altTextButton}
|
{altTextButton}
|
||||||
|
|||||||
@@ -62,3 +62,17 @@
|
|||||||
.resizing .handleBar {
|
.resizing .handleBar {
|
||||||
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
background-color: light-dark(var(--mantine-color-blue-6), var(--mantine-color-blue-4));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* 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 node view mirrors onto the container. */
|
||||||
|
@media (max-width: 600px) {
|
||||||
|
:global([data-image-align="floatLeft"]),
|
||||||
|
:global([data-image-align="floatRight"]) {
|
||||||
|
float: none !important;
|
||||||
|
width: 100% !important;
|
||||||
|
padding: 0 !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -51,7 +51,9 @@ declare module "@tiptap/core" {
|
|||||||
setImageAt: (
|
setImageAt: (
|
||||||
attributes: ImageAttributes & { pos: number | Range },
|
attributes: ImageAttributes & { pos: number | Range },
|
||||||
) => ReturnType;
|
) => ReturnType;
|
||||||
setImageAlign: (align: "left" | "center" | "right") => ReturnType;
|
setImageAlign: (
|
||||||
|
align: "left" | "center" | "right" | "floatLeft" | "floatRight",
|
||||||
|
) => ReturnType;
|
||||||
setImageWidth: (width: number) => ReturnType;
|
setImageWidth: (width: number) => ReturnType;
|
||||||
setImageSize: (width: number, height: number) => ReturnType;
|
setImageSize: (width: number, height: number) => ReturnType;
|
||||||
};
|
};
|
||||||
@@ -375,7 +377,26 @@ export const TiptapImage = Image.extend<ImageOptions>({
|
|||||||
});
|
});
|
||||||
|
|
||||||
function applyAlignment(container: HTMLElement, align: string) {
|
function applyAlignment(container: HTMLElement, align: string) {
|
||||||
if (align === "left") {
|
// 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.float = "";
|
||||||
|
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.float = "left";
|
||||||
|
container.style.padding = "0 10px 0 0";
|
||||||
|
container.style.justifyContent = "flex-start";
|
||||||
|
} else if (align === "floatRight") {
|
||||||
|
container.style.float = "right";
|
||||||
|
container.style.padding = "0 0 0 10px";
|
||||||
|
container.style.justifyContent = "flex-end";
|
||||||
|
} else if (align === "left") {
|
||||||
container.style.justifyContent = "flex-start";
|
container.style.justifyContent = "flex-start";
|
||||||
} else if (align === "right") {
|
} else if (align === "right") {
|
||||||
container.style.justifyContent = "flex-end";
|
container.style.justifyContent = "flex-end";
|
||||||
|
|||||||
Reference in New Issue
Block a user