feat: add image alignment options and enhance image handling in ArticleEditorCanvas
This commit is contained in:
@@ -21,7 +21,6 @@ import {
|
||||
UnorderedListOutlined,
|
||||
} from "@ant-design/icons-vue";
|
||||
import { message } from "ant-design-vue";
|
||||
import { imageBlockSchema } from "@milkdown/kit/component/image-block";
|
||||
import { commandsCtx, editorViewCtx } from "@milkdown/kit/core";
|
||||
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
|
||||
import { NodeSelection, type Selection } from "@milkdown/kit/prose/state";
|
||||
@@ -58,6 +57,11 @@ import { computed, nextTick, onBeforeUnmount, onMounted, ref, watch } from "vue"
|
||||
import { useI18n } from "vue-i18n";
|
||||
|
||||
import { formatError } from "@/lib/errors";
|
||||
import {
|
||||
richImageBlockFeature,
|
||||
richImageBlockSchema,
|
||||
type RichImageAlign,
|
||||
} from "@/lib/milkdown/richImageBlock";
|
||||
|
||||
const props = defineProps<{
|
||||
title: string;
|
||||
@@ -103,7 +107,14 @@ type TableContextMenuAction =
|
||||
|
||||
type ImageResizeHandle = "nw" | "ne" | "sw" | "se";
|
||||
|
||||
type ImageContextMenuAction = "replace-image" | "toggle-caption" | "reset-size" | "delete-image";
|
||||
type ImageContextMenuAction =
|
||||
| "replace-image"
|
||||
| "toggle-caption"
|
||||
| "reset-size"
|
||||
| "align-left"
|
||||
| "align-center"
|
||||
| "align-right"
|
||||
| "delete-image";
|
||||
|
||||
type TableContextMenuState = {
|
||||
open: boolean;
|
||||
@@ -125,6 +136,7 @@ type SelectedImageState = {
|
||||
nodePos: number;
|
||||
nodeSize: number;
|
||||
src: string;
|
||||
align: RichImageAlign;
|
||||
};
|
||||
|
||||
type ImageContextMenuState = {
|
||||
@@ -134,6 +146,7 @@ type ImageContextMenuState = {
|
||||
nodePos: number;
|
||||
nodeSize: number;
|
||||
src: string;
|
||||
align: RichImageAlign;
|
||||
};
|
||||
|
||||
type SelectedImageSnapshot = {
|
||||
@@ -144,6 +157,7 @@ type SelectedImageSnapshot = {
|
||||
nodePos: number;
|
||||
nodeSize: number;
|
||||
src: string;
|
||||
align: RichImageAlign;
|
||||
};
|
||||
|
||||
function createDefaultTableContextMenuState(): TableContextMenuState {
|
||||
@@ -169,6 +183,7 @@ function createDefaultSelectedImageState(): SelectedImageState {
|
||||
nodePos: 0,
|
||||
nodeSize: 0,
|
||||
src: "",
|
||||
align: "center",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -180,6 +195,7 @@ function createDefaultImageContextMenuState(): ImageContextMenuState {
|
||||
nodePos: 0,
|
||||
nodeSize: 0,
|
||||
src: "",
|
||||
align: "center",
|
||||
};
|
||||
}
|
||||
|
||||
@@ -191,18 +207,17 @@ const { loading } = useEditor((root) => {
|
||||
[CrepeFeature.TopBar]: false,
|
||||
[CrepeFeature.Toolbar]: true,
|
||||
[CrepeFeature.Latex]: false,
|
||||
[CrepeFeature.ImageBlock]: false,
|
||||
[CrepeFeature.BlockEdit]: true,
|
||||
[CrepeFeature.Placeholder]: false,
|
||||
},
|
||||
featureConfigs: {
|
||||
[CrepeFeature.ImageBlock]: {
|
||||
onUpload: props.uploadImage,
|
||||
proxyDomURL: (url) => url,
|
||||
maxWidth: 960,
|
||||
maxHeight: 720,
|
||||
blockCaptionPlaceholderText: "Write image caption",
|
||||
},
|
||||
},
|
||||
});
|
||||
crepe.addFeature(richImageBlockFeature, {
|
||||
onUpload: props.uploadImage,
|
||||
proxyDomURL: (url) => url,
|
||||
maxWidth: 960,
|
||||
maxHeight: 720,
|
||||
blockCaptionPlaceholderText: "Write image caption",
|
||||
});
|
||||
|
||||
crepe.setReadonly(!!props.disabled);
|
||||
@@ -390,11 +405,14 @@ function insertImageBlockAtCursor(src: string): void {
|
||||
runEditorAction((ctx) => {
|
||||
const commands = ctx.get(commandsCtx);
|
||||
commands.call(addBlockTypeCommand.key, {
|
||||
nodeType: imageBlockSchema.type(ctx),
|
||||
nodeType: richImageBlockSchema.type(ctx),
|
||||
attrs: {
|
||||
src: resolvedSrc,
|
||||
caption: "",
|
||||
ratio: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
align: "center",
|
||||
},
|
||||
});
|
||||
|
||||
@@ -442,7 +460,12 @@ async function handleImageFileChange(event: Event): Promise<void> {
|
||||
}
|
||||
|
||||
if (action === "replace" && selectedImage.value.open) {
|
||||
updateSelectedImageAttribute("src", src);
|
||||
updateSelectedImageAttributes({
|
||||
src,
|
||||
ratio: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
@@ -458,6 +481,14 @@ function clamp(value: number, min: number, max: number): number {
|
||||
return Math.min(Math.max(value, min), max);
|
||||
}
|
||||
|
||||
function resolveImageAlign(value: unknown): RichImageAlign {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (normalized === "left" || normalized === "right") {
|
||||
return normalized;
|
||||
}
|
||||
return "center";
|
||||
}
|
||||
|
||||
function resetTablePicker(): void {
|
||||
tablePickerRows.value = TABLE_PICKER_INITIAL_ROWS;
|
||||
tablePickerCols.value = TABLE_PICKER_INITIAL_COLS;
|
||||
@@ -590,6 +621,7 @@ function resolveSelectedImageSnapshot(targetSelection?: Selection | null): Selec
|
||||
nodePos: selection.from,
|
||||
nodeSize: selection.node.nodeSize,
|
||||
src: String(selection.node.attrs.src ?? ""),
|
||||
align: resolveImageAlign(selection.node.attrs.align),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -622,6 +654,7 @@ function applySelectedImageSnapshot(snapshot: SelectedImageSnapshot | null): voi
|
||||
nodePos: snapshot.nodePos,
|
||||
nodeSize: snapshot.nodeSize,
|
||||
src: snapshot.src,
|
||||
align: snapshot.align,
|
||||
};
|
||||
attachSelectedImageDomSync();
|
||||
|
||||
@@ -966,6 +999,7 @@ function resolveImageContextFromTarget(target: EventTarget | null): SelectedImag
|
||||
nodePos,
|
||||
nodeSize: node.nodeSize,
|
||||
src: String(node.attrs.src ?? ""),
|
||||
align: resolveImageAlign(node.attrs.align),
|
||||
};
|
||||
});
|
||||
|
||||
@@ -989,6 +1023,7 @@ function openImageContextMenu(event: MouseEvent): void {
|
||||
nodePos: snapshot.nodePos,
|
||||
nodeSize: snapshot.nodeSize,
|
||||
src: snapshot.src,
|
||||
align: snapshot.align,
|
||||
};
|
||||
|
||||
void nextTick(() => {
|
||||
@@ -1028,7 +1063,9 @@ function handleEditorDrop(): void {
|
||||
finishEditorImageDrag();
|
||||
}
|
||||
|
||||
function updateSelectedImageAttribute(attr: "src" | "ratio", value: string | number): void {
|
||||
function updateSelectedImageAttributes(
|
||||
attrs: Partial<Record<"src" | "ratio" | "width" | "height" | "align", string | number>>,
|
||||
): void {
|
||||
if (!selectedImage.value.open) {
|
||||
return;
|
||||
}
|
||||
@@ -1036,7 +1073,11 @@ function updateSelectedImageAttribute(attr: "src" | "ratio", value: string | num
|
||||
const { nodePos } = selectedImage.value;
|
||||
runEditorAction((ctx) => {
|
||||
const view = ctx.get(editorViewCtx);
|
||||
view.dispatch(view.state.tr.setNodeAttribute(nodePos, attr, value));
|
||||
let transaction = view.state.tr;
|
||||
for (const [attr, value] of Object.entries(attrs)) {
|
||||
transaction = transaction.setNodeAttribute(nodePos, attr, value);
|
||||
}
|
||||
view.dispatch(transaction);
|
||||
requestAnimationFrame(() => {
|
||||
view.focus();
|
||||
scheduleSelectedImageSync();
|
||||
@@ -1044,6 +1085,13 @@ function updateSelectedImageAttribute(attr: "src" | "ratio", value: string | num
|
||||
});
|
||||
}
|
||||
|
||||
function updateSelectedImageAttribute(
|
||||
attr: "src" | "ratio" | "width" | "height" | "align",
|
||||
value: string | number,
|
||||
): void {
|
||||
updateSelectedImageAttributes({ [attr]: value });
|
||||
}
|
||||
|
||||
function deleteSelectedImage(): void {
|
||||
if (!selectedImage.value.open) {
|
||||
return;
|
||||
@@ -1091,7 +1139,12 @@ function replaceSelectedImage(): void {
|
||||
return;
|
||||
}
|
||||
|
||||
updateSelectedImageAttribute("src", src.trim());
|
||||
updateSelectedImageAttributes({
|
||||
src: src.trim(),
|
||||
ratio: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
}
|
||||
|
||||
function runImageContextAction(action: ImageContextMenuAction): void {
|
||||
@@ -1104,7 +1157,23 @@ function runImageContextAction(action: ImageContextMenuAction): void {
|
||||
break;
|
||||
case "reset-size":
|
||||
closeImageContextMenu();
|
||||
updateSelectedImageAttribute("ratio", 1);
|
||||
updateSelectedImageAttributes({
|
||||
ratio: 1,
|
||||
width: 0,
|
||||
height: 0,
|
||||
});
|
||||
break;
|
||||
case "align-left":
|
||||
closeImageContextMenu();
|
||||
updateSelectedImageAttribute("align", "left");
|
||||
break;
|
||||
case "align-center":
|
||||
closeImageContextMenu();
|
||||
updateSelectedImageAttribute("align", "center");
|
||||
break;
|
||||
case "align-right":
|
||||
closeImageContextMenu();
|
||||
updateSelectedImageAttribute("align", "right");
|
||||
break;
|
||||
case "delete-image":
|
||||
deleteSelectedImage();
|
||||
@@ -1130,7 +1199,6 @@ function beginSelectedImageResize(event: PointerEvent, handle: ImageResizeHandle
|
||||
return;
|
||||
}
|
||||
|
||||
const originHeight = Number(image.dataset.origin || startHeight);
|
||||
const minScale = Math.max(100 / Math.max(startWidth, 1), 100 / Math.max(startHeight, 1));
|
||||
const maxScale = Math.min(960 / Math.max(startWidth, 1), 720 / Math.max(startHeight, 1));
|
||||
const anchorX = handle.includes("w") ? startRect.right : startRect.left;
|
||||
@@ -1142,9 +1210,12 @@ function beginSelectedImageResize(event: PointerEvent, handle: ImageResizeHandle
|
||||
|
||||
const currentDistance = Math.hypot(moveEvent.clientX - anchorX, moveEvent.clientY - anchorY);
|
||||
const scale = clamp(currentDistance / startDiagonal, minScale, maxScale);
|
||||
const nextWidth = Number((startWidth * scale).toFixed(2));
|
||||
const nextHeight = Number((startHeight * scale).toFixed(2));
|
||||
|
||||
image.dataset.width = String(nextWidth);
|
||||
image.dataset.height = String(nextHeight);
|
||||
image.style.width = `${nextWidth}px`;
|
||||
image.style.height = `${nextHeight}px`;
|
||||
syncSelectedImageBoundsFromDom();
|
||||
};
|
||||
@@ -1154,14 +1225,18 @@ function beginSelectedImageResize(event: PointerEvent, handle: ImageResizeHandle
|
||||
window.removeEventListener("pointerup", onPointerUp);
|
||||
body.style.cursor = "";
|
||||
|
||||
const currentWidth = Number(image.dataset.width || image.getBoundingClientRect().width);
|
||||
const currentHeight = Number(image.dataset.height || image.getBoundingClientRect().height);
|
||||
const ratio = Number.parseFloat((currentHeight / Math.max(originHeight, 1)).toFixed(2));
|
||||
if (Number.isNaN(ratio) || ratio <= 0) {
|
||||
if (Number.isNaN(currentWidth) || currentWidth <= 0 || Number.isNaN(currentHeight) || currentHeight <= 0) {
|
||||
scheduleSelectedImageSync();
|
||||
return;
|
||||
}
|
||||
|
||||
updateSelectedImageAttribute("ratio", ratio);
|
||||
updateSelectedImageAttributes({
|
||||
ratio: 1,
|
||||
width: currentWidth,
|
||||
height: currentHeight,
|
||||
});
|
||||
};
|
||||
|
||||
body.style.cursor = handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize";
|
||||
@@ -1576,6 +1651,33 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
||||
<CodeOutlined />
|
||||
<span>{{ t("article.editor.imageMenu.caption") }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="article-editor-canvas__image-context-action"
|
||||
:class="{ 'article-editor-canvas__image-context-action--active': selectedImage.align === 'left' }"
|
||||
@click="runImageContextAction('align-left')"
|
||||
>
|
||||
<AlignLeftOutlined />
|
||||
<span>{{ t("article.editor.imageMenu.alignLeft") }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="article-editor-canvas__image-context-action"
|
||||
:class="{ 'article-editor-canvas__image-context-action--active': selectedImage.align === 'center' }"
|
||||
@click="runImageContextAction('align-center')"
|
||||
>
|
||||
<AlignCenterOutlined />
|
||||
<span>{{ t("article.editor.imageMenu.alignCenter") }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="article-editor-canvas__image-context-action"
|
||||
:class="{ 'article-editor-canvas__image-context-action--active': selectedImage.align === 'right' }"
|
||||
@click="runImageContextAction('align-right')"
|
||||
>
|
||||
<AlignRightOutlined />
|
||||
<span>{{ t("article.editor.imageMenu.alignRight") }}</span>
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="article-editor-canvas__image-context-action"
|
||||
@@ -1852,10 +1954,10 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
||||
position: fixed;
|
||||
z-index: 1200;
|
||||
display: grid;
|
||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
||||
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||
gap: 10px;
|
||||
min-width: 300px;
|
||||
max-width: min(360px, calc(100vw - 32px));
|
||||
min-width: 440px;
|
||||
max-width: min(520px, calc(100vw - 32px));
|
||||
padding: 12px;
|
||||
border: 1px solid #e8eef8;
|
||||
border-radius: 16px;
|
||||
@@ -1904,6 +2006,16 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
||||
color: #245bdb;
|
||||
}
|
||||
|
||||
.article-editor-canvas__image-context-action--active {
|
||||
border-color: #c8d8ff;
|
||||
background: #edf4ff;
|
||||
color: #245bdb;
|
||||
}
|
||||
|
||||
.article-editor-canvas__image-context-action--active :deep(svg) {
|
||||
color: #245bdb;
|
||||
}
|
||||
|
||||
.article-editor-canvas__image-context-action--danger:hover {
|
||||
border-color: #ffd2cf;
|
||||
background: #fff1f0;
|
||||
@@ -1964,8 +2076,23 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.article-editor-canvas__surface :deep(.milkdown-image-block) {
|
||||
margin: 18px 0;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.article-editor-canvas__surface :deep(.milkdown-image-block[data-align="left"]) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.article-editor-canvas__surface :deep(.milkdown-image-block[data-align="right"]) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.article-editor-canvas__surface :deep(.milkdown-image-block > .image-wrapper) {
|
||||
margin: 0 auto;
|
||||
display: inline-flex;
|
||||
flex-direction: column;
|
||||
max-width: 100%;
|
||||
}
|
||||
|
||||
.article-editor-canvas__surface :deep(.milkdown-image-block.selected > .image-wrapper::before) {
|
||||
@@ -1974,8 +2101,14 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
||||
}
|
||||
|
||||
.article-editor-canvas__surface :deep(.milkdown-image-block img) {
|
||||
display: block;
|
||||
max-width: min(100%, 960px);
|
||||
margin: 0 auto;
|
||||
border-radius: 18px;
|
||||
}
|
||||
|
||||
.article-editor-canvas__surface :deep(.milkdown-image-block .caption-input) {
|
||||
width: min(100%, 960px);
|
||||
margin-top: 12px;
|
||||
}
|
||||
|
||||
.article-editor-canvas__surface :deep(.milkdown-image-block > .image-wrapper .operation) {
|
||||
|
||||
@@ -7,7 +7,7 @@ const props = defineProps<{
|
||||
}>();
|
||||
|
||||
const renderer = new MarkdownIt({
|
||||
html: false,
|
||||
html: true,
|
||||
linkify: true,
|
||||
breaks: true,
|
||||
});
|
||||
@@ -84,6 +84,28 @@ const html = computed(() => renderer.render(props.content || ""));
|
||||
color: inherit;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(p.article-editor-image) {
|
||||
margin: 1.25em 0;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(p.article-editor-image[align="left"]) {
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(p.article-editor-image[align="center"]) {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(p.article-editor-image[align="right"]) {
|
||||
text-align: right;
|
||||
}
|
||||
|
||||
.markdown-preview :deep(p.article-editor-image img) {
|
||||
max-width: min(100%, 960px);
|
||||
border-radius: 16px;
|
||||
box-shadow: 0 10px 28px rgba(15, 23, 42, 0.08);
|
||||
}
|
||||
|
||||
.markdown-preview :deep(table) {
|
||||
width: 100%;
|
||||
margin: 0 0 1em;
|
||||
|
||||
Reference in New Issue
Block a user