feat: add image alignment options and enhance image handling in ArticleEditorCanvas
This commit is contained in:
@@ -21,6 +21,7 @@
|
|||||||
"@vueuse/core": "^14.2.1",
|
"@vueuse/core": "^14.2.1",
|
||||||
"ant-design-vue": "^4.2.6",
|
"ant-design-vue": "^4.2.6",
|
||||||
"dayjs": "^1.11.20",
|
"dayjs": "^1.11.20",
|
||||||
|
"dompurify": "^3.3.3",
|
||||||
"markdown-it": "^14.1.1",
|
"markdown-it": "^14.1.1",
|
||||||
"pinia": "^3.0.4",
|
"pinia": "^3.0.4",
|
||||||
"vue": "^3.5.31",
|
"vue": "^3.5.31",
|
||||||
|
|||||||
@@ -21,7 +21,6 @@ import {
|
|||||||
UnorderedListOutlined,
|
UnorderedListOutlined,
|
||||||
} from "@ant-design/icons-vue";
|
} from "@ant-design/icons-vue";
|
||||||
import { message } from "ant-design-vue";
|
import { message } from "ant-design-vue";
|
||||||
import { imageBlockSchema } from "@milkdown/kit/component/image-block";
|
|
||||||
import { commandsCtx, editorViewCtx } from "@milkdown/kit/core";
|
import { commandsCtx, editorViewCtx } from "@milkdown/kit/core";
|
||||||
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
|
import { redoCommand, undoCommand } from "@milkdown/kit/plugin/history";
|
||||||
import { NodeSelection, type Selection } from "@milkdown/kit/prose/state";
|
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 { useI18n } from "vue-i18n";
|
||||||
|
|
||||||
import { formatError } from "@/lib/errors";
|
import { formatError } from "@/lib/errors";
|
||||||
|
import {
|
||||||
|
richImageBlockFeature,
|
||||||
|
richImageBlockSchema,
|
||||||
|
type RichImageAlign,
|
||||||
|
} from "@/lib/milkdown/richImageBlock";
|
||||||
|
|
||||||
const props = defineProps<{
|
const props = defineProps<{
|
||||||
title: string;
|
title: string;
|
||||||
@@ -103,7 +107,14 @@ type TableContextMenuAction =
|
|||||||
|
|
||||||
type ImageResizeHandle = "nw" | "ne" | "sw" | "se";
|
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 = {
|
type TableContextMenuState = {
|
||||||
open: boolean;
|
open: boolean;
|
||||||
@@ -125,6 +136,7 @@ type SelectedImageState = {
|
|||||||
nodePos: number;
|
nodePos: number;
|
||||||
nodeSize: number;
|
nodeSize: number;
|
||||||
src: string;
|
src: string;
|
||||||
|
align: RichImageAlign;
|
||||||
};
|
};
|
||||||
|
|
||||||
type ImageContextMenuState = {
|
type ImageContextMenuState = {
|
||||||
@@ -134,6 +146,7 @@ type ImageContextMenuState = {
|
|||||||
nodePos: number;
|
nodePos: number;
|
||||||
nodeSize: number;
|
nodeSize: number;
|
||||||
src: string;
|
src: string;
|
||||||
|
align: RichImageAlign;
|
||||||
};
|
};
|
||||||
|
|
||||||
type SelectedImageSnapshot = {
|
type SelectedImageSnapshot = {
|
||||||
@@ -144,6 +157,7 @@ type SelectedImageSnapshot = {
|
|||||||
nodePos: number;
|
nodePos: number;
|
||||||
nodeSize: number;
|
nodeSize: number;
|
||||||
src: string;
|
src: string;
|
||||||
|
align: RichImageAlign;
|
||||||
};
|
};
|
||||||
|
|
||||||
function createDefaultTableContextMenuState(): TableContextMenuState {
|
function createDefaultTableContextMenuState(): TableContextMenuState {
|
||||||
@@ -169,6 +183,7 @@ function createDefaultSelectedImageState(): SelectedImageState {
|
|||||||
nodePos: 0,
|
nodePos: 0,
|
||||||
nodeSize: 0,
|
nodeSize: 0,
|
||||||
src: "",
|
src: "",
|
||||||
|
align: "center",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -180,6 +195,7 @@ function createDefaultImageContextMenuState(): ImageContextMenuState {
|
|||||||
nodePos: 0,
|
nodePos: 0,
|
||||||
nodeSize: 0,
|
nodeSize: 0,
|
||||||
src: "",
|
src: "",
|
||||||
|
align: "center",
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -191,18 +207,17 @@ const { loading } = useEditor((root) => {
|
|||||||
[CrepeFeature.TopBar]: false,
|
[CrepeFeature.TopBar]: false,
|
||||||
[CrepeFeature.Toolbar]: true,
|
[CrepeFeature.Toolbar]: true,
|
||||||
[CrepeFeature.Latex]: false,
|
[CrepeFeature.Latex]: false,
|
||||||
|
[CrepeFeature.ImageBlock]: false,
|
||||||
[CrepeFeature.BlockEdit]: true,
|
[CrepeFeature.BlockEdit]: true,
|
||||||
[CrepeFeature.Placeholder]: false,
|
[CrepeFeature.Placeholder]: false,
|
||||||
},
|
},
|
||||||
featureConfigs: {
|
});
|
||||||
[CrepeFeature.ImageBlock]: {
|
crepe.addFeature(richImageBlockFeature, {
|
||||||
onUpload: props.uploadImage,
|
onUpload: props.uploadImage,
|
||||||
proxyDomURL: (url) => url,
|
proxyDomURL: (url) => url,
|
||||||
maxWidth: 960,
|
maxWidth: 960,
|
||||||
maxHeight: 720,
|
maxHeight: 720,
|
||||||
blockCaptionPlaceholderText: "Write image caption",
|
blockCaptionPlaceholderText: "Write image caption",
|
||||||
},
|
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
crepe.setReadonly(!!props.disabled);
|
crepe.setReadonly(!!props.disabled);
|
||||||
@@ -390,11 +405,14 @@ function insertImageBlockAtCursor(src: string): void {
|
|||||||
runEditorAction((ctx) => {
|
runEditorAction((ctx) => {
|
||||||
const commands = ctx.get(commandsCtx);
|
const commands = ctx.get(commandsCtx);
|
||||||
commands.call(addBlockTypeCommand.key, {
|
commands.call(addBlockTypeCommand.key, {
|
||||||
nodeType: imageBlockSchema.type(ctx),
|
nodeType: richImageBlockSchema.type(ctx),
|
||||||
attrs: {
|
attrs: {
|
||||||
src: resolvedSrc,
|
src: resolvedSrc,
|
||||||
caption: "",
|
caption: "",
|
||||||
ratio: 1,
|
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) {
|
if (action === "replace" && selectedImage.value.open) {
|
||||||
updateSelectedImageAttribute("src", src);
|
updateSelectedImageAttributes({
|
||||||
|
src,
|
||||||
|
ratio: 1,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -458,6 +481,14 @@ function clamp(value: number, min: number, max: number): number {
|
|||||||
return Math.min(Math.max(value, min), max);
|
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 {
|
function resetTablePicker(): void {
|
||||||
tablePickerRows.value = TABLE_PICKER_INITIAL_ROWS;
|
tablePickerRows.value = TABLE_PICKER_INITIAL_ROWS;
|
||||||
tablePickerCols.value = TABLE_PICKER_INITIAL_COLS;
|
tablePickerCols.value = TABLE_PICKER_INITIAL_COLS;
|
||||||
@@ -590,6 +621,7 @@ function resolveSelectedImageSnapshot(targetSelection?: Selection | null): Selec
|
|||||||
nodePos: selection.from,
|
nodePos: selection.from,
|
||||||
nodeSize: selection.node.nodeSize,
|
nodeSize: selection.node.nodeSize,
|
||||||
src: String(selection.node.attrs.src ?? ""),
|
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,
|
nodePos: snapshot.nodePos,
|
||||||
nodeSize: snapshot.nodeSize,
|
nodeSize: snapshot.nodeSize,
|
||||||
src: snapshot.src,
|
src: snapshot.src,
|
||||||
|
align: snapshot.align,
|
||||||
};
|
};
|
||||||
attachSelectedImageDomSync();
|
attachSelectedImageDomSync();
|
||||||
|
|
||||||
@@ -966,6 +999,7 @@ function resolveImageContextFromTarget(target: EventTarget | null): SelectedImag
|
|||||||
nodePos,
|
nodePos,
|
||||||
nodeSize: node.nodeSize,
|
nodeSize: node.nodeSize,
|
||||||
src: String(node.attrs.src ?? ""),
|
src: String(node.attrs.src ?? ""),
|
||||||
|
align: resolveImageAlign(node.attrs.align),
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -989,6 +1023,7 @@ function openImageContextMenu(event: MouseEvent): void {
|
|||||||
nodePos: snapshot.nodePos,
|
nodePos: snapshot.nodePos,
|
||||||
nodeSize: snapshot.nodeSize,
|
nodeSize: snapshot.nodeSize,
|
||||||
src: snapshot.src,
|
src: snapshot.src,
|
||||||
|
align: snapshot.align,
|
||||||
};
|
};
|
||||||
|
|
||||||
void nextTick(() => {
|
void nextTick(() => {
|
||||||
@@ -1028,7 +1063,9 @@ function handleEditorDrop(): void {
|
|||||||
finishEditorImageDrag();
|
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) {
|
if (!selectedImage.value.open) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
@@ -1036,7 +1073,11 @@ function updateSelectedImageAttribute(attr: "src" | "ratio", value: string | num
|
|||||||
const { nodePos } = selectedImage.value;
|
const { nodePos } = selectedImage.value;
|
||||||
runEditorAction((ctx) => {
|
runEditorAction((ctx) => {
|
||||||
const view = ctx.get(editorViewCtx);
|
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(() => {
|
requestAnimationFrame(() => {
|
||||||
view.focus();
|
view.focus();
|
||||||
scheduleSelectedImageSync();
|
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 {
|
function deleteSelectedImage(): void {
|
||||||
if (!selectedImage.value.open) {
|
if (!selectedImage.value.open) {
|
||||||
return;
|
return;
|
||||||
@@ -1091,7 +1139,12 @@ function replaceSelectedImage(): void {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedImageAttribute("src", src.trim());
|
updateSelectedImageAttributes({
|
||||||
|
src: src.trim(),
|
||||||
|
ratio: 1,
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
function runImageContextAction(action: ImageContextMenuAction): void {
|
function runImageContextAction(action: ImageContextMenuAction): void {
|
||||||
@@ -1104,7 +1157,23 @@ function runImageContextAction(action: ImageContextMenuAction): void {
|
|||||||
break;
|
break;
|
||||||
case "reset-size":
|
case "reset-size":
|
||||||
closeImageContextMenu();
|
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;
|
break;
|
||||||
case "delete-image":
|
case "delete-image":
|
||||||
deleteSelectedImage();
|
deleteSelectedImage();
|
||||||
@@ -1130,7 +1199,6 @@ function beginSelectedImageResize(event: PointerEvent, handle: ImageResizeHandle
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const originHeight = Number(image.dataset.origin || startHeight);
|
|
||||||
const minScale = Math.max(100 / Math.max(startWidth, 1), 100 / Math.max(startHeight, 1));
|
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 maxScale = Math.min(960 / Math.max(startWidth, 1), 720 / Math.max(startHeight, 1));
|
||||||
const anchorX = handle.includes("w") ? startRect.right : startRect.left;
|
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 currentDistance = Math.hypot(moveEvent.clientX - anchorX, moveEvent.clientY - anchorY);
|
||||||
const scale = clamp(currentDistance / startDiagonal, minScale, maxScale);
|
const scale = clamp(currentDistance / startDiagonal, minScale, maxScale);
|
||||||
|
const nextWidth = Number((startWidth * scale).toFixed(2));
|
||||||
const nextHeight = Number((startHeight * scale).toFixed(2));
|
const nextHeight = Number((startHeight * scale).toFixed(2));
|
||||||
|
|
||||||
|
image.dataset.width = String(nextWidth);
|
||||||
image.dataset.height = String(nextHeight);
|
image.dataset.height = String(nextHeight);
|
||||||
|
image.style.width = `${nextWidth}px`;
|
||||||
image.style.height = `${nextHeight}px`;
|
image.style.height = `${nextHeight}px`;
|
||||||
syncSelectedImageBoundsFromDom();
|
syncSelectedImageBoundsFromDom();
|
||||||
};
|
};
|
||||||
@@ -1154,14 +1225,18 @@ function beginSelectedImageResize(event: PointerEvent, handle: ImageResizeHandle
|
|||||||
window.removeEventListener("pointerup", onPointerUp);
|
window.removeEventListener("pointerup", onPointerUp);
|
||||||
body.style.cursor = "";
|
body.style.cursor = "";
|
||||||
|
|
||||||
|
const currentWidth = Number(image.dataset.width || image.getBoundingClientRect().width);
|
||||||
const currentHeight = Number(image.dataset.height || image.getBoundingClientRect().height);
|
const currentHeight = Number(image.dataset.height || image.getBoundingClientRect().height);
|
||||||
const ratio = Number.parseFloat((currentHeight / Math.max(originHeight, 1)).toFixed(2));
|
if (Number.isNaN(currentWidth) || currentWidth <= 0 || Number.isNaN(currentHeight) || currentHeight <= 0) {
|
||||||
if (Number.isNaN(ratio) || ratio <= 0) {
|
|
||||||
scheduleSelectedImageSync();
|
scheduleSelectedImageSync();
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
updateSelectedImageAttribute("ratio", ratio);
|
updateSelectedImageAttributes({
|
||||||
|
ratio: 1,
|
||||||
|
width: currentWidth,
|
||||||
|
height: currentHeight,
|
||||||
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
body.style.cursor = handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize";
|
body.style.cursor = handle === "nw" || handle === "se" ? "nwse-resize" : "nesw-resize";
|
||||||
@@ -1576,6 +1651,33 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
|||||||
<CodeOutlined />
|
<CodeOutlined />
|
||||||
<span>{{ t("article.editor.imageMenu.caption") }}</span>
|
<span>{{ t("article.editor.imageMenu.caption") }}</span>
|
||||||
</button>
|
</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
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
class="article-editor-canvas__image-context-action"
|
class="article-editor-canvas__image-context-action"
|
||||||
@@ -1852,10 +1954,10 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
|||||||
position: fixed;
|
position: fixed;
|
||||||
z-index: 1200;
|
z-index: 1200;
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: repeat(2, minmax(0, 1fr));
|
grid-template-columns: repeat(3, minmax(0, 1fr));
|
||||||
gap: 10px;
|
gap: 10px;
|
||||||
min-width: 300px;
|
min-width: 440px;
|
||||||
max-width: min(360px, calc(100vw - 32px));
|
max-width: min(520px, calc(100vw - 32px));
|
||||||
padding: 12px;
|
padding: 12px;
|
||||||
border: 1px solid #e8eef8;
|
border: 1px solid #e8eef8;
|
||||||
border-radius: 16px;
|
border-radius: 16px;
|
||||||
@@ -1904,6 +2006,16 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
|||||||
color: #245bdb;
|
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 {
|
.article-editor-canvas__image-context-action--danger:hover {
|
||||||
border-color: #ffd2cf;
|
border-color: #ffd2cf;
|
||||||
background: #fff1f0;
|
background: #fff1f0;
|
||||||
@@ -1964,8 +2076,23 @@ function runTableContextAction(action: TableContextMenuAction): void {
|
|||||||
outline: none;
|
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) {
|
.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) {
|
.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) {
|
.article-editor-canvas__surface :deep(.milkdown-image-block img) {
|
||||||
|
display: block;
|
||||||
max-width: min(100%, 960px);
|
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) {
|
.article-editor-canvas__surface :deep(.milkdown-image-block > .image-wrapper .operation) {
|
||||||
|
|||||||
@@ -7,7 +7,7 @@ const props = defineProps<{
|
|||||||
}>();
|
}>();
|
||||||
|
|
||||||
const renderer = new MarkdownIt({
|
const renderer = new MarkdownIt({
|
||||||
html: false,
|
html: true,
|
||||||
linkify: true,
|
linkify: true,
|
||||||
breaks: true,
|
breaks: true,
|
||||||
});
|
});
|
||||||
@@ -84,6 +84,28 @@ const html = computed(() => renderer.render(props.content || ""));
|
|||||||
color: inherit;
|
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) {
|
.markdown-preview :deep(table) {
|
||||||
width: 100%;
|
width: 100%;
|
||||||
margin: 0 0 1em;
|
margin: 0 0 1em;
|
||||||
|
|||||||
@@ -425,6 +425,9 @@ const enUS = {
|
|||||||
imageMenu: {
|
imageMenu: {
|
||||||
replace: "Replace image",
|
replace: "Replace image",
|
||||||
caption: "Toggle caption",
|
caption: "Toggle caption",
|
||||||
|
alignLeft: "Align left",
|
||||||
|
alignCenter: "Align center",
|
||||||
|
alignRight: "Align right",
|
||||||
resetSize: "Reset size",
|
resetSize: "Reset size",
|
||||||
delete: "Delete image",
|
delete: "Delete image",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -432,6 +432,9 @@ const zhCN = {
|
|||||||
imageMenu: {
|
imageMenu: {
|
||||||
replace: "替换图片",
|
replace: "替换图片",
|
||||||
caption: "切换图注",
|
caption: "切换图注",
|
||||||
|
alignLeft: "居左",
|
||||||
|
alignCenter: "居中",
|
||||||
|
alignRight: "居右",
|
||||||
resetSize: "重置大小",
|
resetSize: "重置大小",
|
||||||
delete: "删除图片",
|
delete: "删除图片",
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -0,0 +1,599 @@
|
|||||||
|
import type { Editor } from "@milkdown/kit/core";
|
||||||
|
|
||||||
|
import { imageBlockConfig } from "@milkdown/kit/component/image-block";
|
||||||
|
import { inlineImageConfig, imageInlineComponent } from "@milkdown/kit/component/image-inline";
|
||||||
|
import { $nodeSchema, $remark, $view } from "@milkdown/kit/utils";
|
||||||
|
import { CrepeFeature, useCrepeFeatures } from "@milkdown/crepe";
|
||||||
|
import DOMPurify from "dompurify";
|
||||||
|
|
||||||
|
export type RichImageAlign = "left" | "center" | "right";
|
||||||
|
|
||||||
|
type RichImageBlockFeatureConfig = {
|
||||||
|
inlineUploadButton?: string;
|
||||||
|
inlineImageIcon?: string;
|
||||||
|
inlineConfirmButton?: string;
|
||||||
|
inlineUploadPlaceholderText?: string;
|
||||||
|
inlineOnUpload?: (file: File) => Promise<string>;
|
||||||
|
blockUploadButton?: string;
|
||||||
|
blockImageIcon?: string;
|
||||||
|
blockCaptionIcon?: string;
|
||||||
|
blockConfirmButton?: string;
|
||||||
|
blockCaptionPlaceholderText?: string;
|
||||||
|
blockUploadPlaceholderText?: string;
|
||||||
|
blockOnUpload?: (file: File) => Promise<string>;
|
||||||
|
onUpload?: (file: File) => Promise<string>;
|
||||||
|
proxyDomURL?: (url: string) => Promise<string> | string;
|
||||||
|
onImageLoadError?: (event: Event) => void | Promise<void>;
|
||||||
|
maxWidth?: number;
|
||||||
|
maxHeight?: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
type ParsedImageMarkup = {
|
||||||
|
src: string;
|
||||||
|
caption: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
ratio: number;
|
||||||
|
align: RichImageAlign;
|
||||||
|
};
|
||||||
|
|
||||||
|
type RichImageBlockNode = {
|
||||||
|
type: "image-block";
|
||||||
|
url: string;
|
||||||
|
title: string;
|
||||||
|
alt: string;
|
||||||
|
width: number;
|
||||||
|
height: number;
|
||||||
|
ratio: number;
|
||||||
|
align: RichImageAlign;
|
||||||
|
};
|
||||||
|
|
||||||
|
const IMAGE_DATA_TYPE = "image-block";
|
||||||
|
|
||||||
|
function clampImageAlign(value: unknown): RichImageAlign {
|
||||||
|
const normalized = String(value ?? "").trim().toLowerCase();
|
||||||
|
if (normalized === "left" || normalized === "right") {
|
||||||
|
return normalized;
|
||||||
|
}
|
||||||
|
return "center";
|
||||||
|
}
|
||||||
|
|
||||||
|
function parsePositiveNumber(value: unknown): number {
|
||||||
|
const parsed = Number.parseFloat(String(value ?? "").trim());
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
return Number(parsed.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseRatio(value: unknown): number {
|
||||||
|
const parsed = Number.parseFloat(String(value ?? "").trim());
|
||||||
|
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
return Number(parsed.toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
function escapeHtmlAttribute(value: string): string {
|
||||||
|
return value
|
||||||
|
.replaceAll("&", "&")
|
||||||
|
.replaceAll("\"", """)
|
||||||
|
.replaceAll("<", "<")
|
||||||
|
.replaceAll(">", ">");
|
||||||
|
}
|
||||||
|
|
||||||
|
function resolveAlignFromClassName(value: string): RichImageAlign | null {
|
||||||
|
if (value.includes("article-editor-image--left")) {
|
||||||
|
return "left";
|
||||||
|
}
|
||||||
|
if (value.includes("article-editor-image--right")) {
|
||||||
|
return "right";
|
||||||
|
}
|
||||||
|
if (value.includes("article-editor-image--center")) {
|
||||||
|
return "center";
|
||||||
|
}
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function parseImageMarkup(markup: string): ParsedImageMarkup | null {
|
||||||
|
if (typeof DOMParser === "undefined") {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(markup, "text/html");
|
||||||
|
const image = doc.body.querySelector("img");
|
||||||
|
if (!(image instanceof HTMLImageElement)) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const src = image.getAttribute("src")?.trim() ?? "";
|
||||||
|
if (!src) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const container = image.closest("p, figure, div");
|
||||||
|
const align =
|
||||||
|
resolveAlignFromClassName(`${container?.className ?? ""} ${image.className ?? ""}`) ??
|
||||||
|
clampImageAlign(container?.getAttribute("align") || image.getAttribute("align"));
|
||||||
|
|
||||||
|
return {
|
||||||
|
src,
|
||||||
|
caption: (image.getAttribute("title") || image.getAttribute("alt") || "").trim(),
|
||||||
|
width: parsePositiveNumber(image.getAttribute("width")),
|
||||||
|
height: parsePositiveNumber(image.getAttribute("height")),
|
||||||
|
ratio: parseRatio(image.getAttribute("data-ratio")),
|
||||||
|
align,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function serializeImageHtml(attrs: Record<string, unknown>): string {
|
||||||
|
const src = String(attrs.src ?? "").trim();
|
||||||
|
const caption = String(attrs.caption ?? "").trim();
|
||||||
|
const width = parsePositiveNumber(attrs.width);
|
||||||
|
const height = parsePositiveNumber(attrs.height);
|
||||||
|
const ratio = parseRatio(attrs.ratio);
|
||||||
|
const align = clampImageAlign(attrs.align);
|
||||||
|
const encodedCaption = escapeHtmlAttribute(caption);
|
||||||
|
|
||||||
|
const widthAttribute = width > 0 ? ` width="${Math.round(width)}"` : "";
|
||||||
|
const heightAttribute = height > 0 ? ` height="${Math.round(height)}"` : "";
|
||||||
|
const ratioAttribute = width === 0 && height === 0 && ratio !== 1 ? ` data-ratio="${ratio.toFixed(2)}"` : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
`<p class="article-editor-image article-editor-image--${align}" align="${align}">` +
|
||||||
|
`<img src="${escapeHtmlAttribute(src)}"` +
|
||||||
|
` alt="${encodedCaption}"` +
|
||||||
|
(caption ? ` title="${encodedCaption}"` : "") +
|
||||||
|
widthAttribute +
|
||||||
|
heightAttribute +
|
||||||
|
ratioAttribute +
|
||||||
|
" />" +
|
||||||
|
"</p>"
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function needsHtmlSerialization(node: { attrs: Record<string, unknown> }): boolean {
|
||||||
|
const width = parsePositiveNumber(node.attrs.width);
|
||||||
|
const height = parsePositiveNumber(node.attrs.height);
|
||||||
|
const align = clampImageAlign(node.attrs.align);
|
||||||
|
return width > 0 || height > 0 || align !== "center";
|
||||||
|
}
|
||||||
|
|
||||||
|
function toRichImageBlockNode(parsed: ParsedImageMarkup): RichImageBlockNode {
|
||||||
|
return {
|
||||||
|
type: "image-block",
|
||||||
|
url: parsed.src,
|
||||||
|
title: parsed.caption,
|
||||||
|
alt: parsed.ratio === 1 ? "" : parsed.ratio.toFixed(2),
|
||||||
|
width: parsed.width,
|
||||||
|
height: parsed.height,
|
||||||
|
ratio: parsed.ratio,
|
||||||
|
align: parsed.align,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
function transformRichImageNode(node: Record<string, unknown>): Record<string, unknown> {
|
||||||
|
if (node.type === "paragraph" && Array.isArray(node.children) && node.children.length === 1) {
|
||||||
|
const [firstChild] = node.children as Array<Record<string, unknown>>;
|
||||||
|
if (firstChild?.type === "image") {
|
||||||
|
return {
|
||||||
|
type: "image-block",
|
||||||
|
url: String(firstChild.url ?? ""),
|
||||||
|
title: String(firstChild.title ?? ""),
|
||||||
|
alt: String(firstChild.alt ?? ""),
|
||||||
|
width: 0,
|
||||||
|
height: 0,
|
||||||
|
ratio: parseRatio(firstChild.alt),
|
||||||
|
align: "center",
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
if (firstChild?.type === "html") {
|
||||||
|
const parsed = parseImageMarkup(String(firstChild.value ?? ""));
|
||||||
|
if (parsed) {
|
||||||
|
return toRichImageBlockNode(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (node.type === "html") {
|
||||||
|
const parsed = parseImageMarkup(String(node.value ?? ""));
|
||||||
|
if (parsed) {
|
||||||
|
return toRichImageBlockNode(parsed);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Array.isArray(node.children)) {
|
||||||
|
node.children = (node.children as Array<Record<string, unknown>>).map((child) => transformRichImageNode(child));
|
||||||
|
}
|
||||||
|
|
||||||
|
return node;
|
||||||
|
}
|
||||||
|
|
||||||
|
export const richImageBlockRemarkPlugin = $remark("remark-rich-image-block", () => () => (tree) => {
|
||||||
|
transformRichImageNode(tree as unknown as Record<string, unknown>);
|
||||||
|
});
|
||||||
|
|
||||||
|
export const richImageBlockSchema = $nodeSchema("image-block", () => ({
|
||||||
|
inline: false,
|
||||||
|
group: "block",
|
||||||
|
selectable: true,
|
||||||
|
draggable: true,
|
||||||
|
isolating: true,
|
||||||
|
marks: "",
|
||||||
|
atom: true,
|
||||||
|
priority: 100,
|
||||||
|
attrs: {
|
||||||
|
src: { default: "", validate: "string" },
|
||||||
|
caption: { default: "", validate: "string" },
|
||||||
|
ratio: { default: 1, validate: "number" },
|
||||||
|
width: { default: 0, validate: "number" },
|
||||||
|
height: { default: 0, validate: "number" },
|
||||||
|
align: { default: "center", validate: "string" },
|
||||||
|
},
|
||||||
|
parseDOM: [
|
||||||
|
{
|
||||||
|
tag: `img[data-type="${IMAGE_DATA_TYPE}"]`,
|
||||||
|
getAttrs: (dom) => {
|
||||||
|
if (!(dom instanceof HTMLElement)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
src: dom.getAttribute("src") || "",
|
||||||
|
caption: dom.getAttribute("title") || dom.getAttribute("alt") || "",
|
||||||
|
ratio: parseRatio(dom.getAttribute("data-ratio") || dom.getAttribute("ratio")),
|
||||||
|
width: parsePositiveNumber(dom.getAttribute("width")),
|
||||||
|
height: parsePositiveNumber(dom.getAttribute("height")),
|
||||||
|
align: clampImageAlign(dom.getAttribute("align")),
|
||||||
|
};
|
||||||
|
},
|
||||||
|
},
|
||||||
|
],
|
||||||
|
toDOM: (node) => [
|
||||||
|
"img",
|
||||||
|
{
|
||||||
|
"data-type": IMAGE_DATA_TYPE,
|
||||||
|
src: node.attrs.src,
|
||||||
|
alt: node.attrs.caption,
|
||||||
|
title: node.attrs.caption,
|
||||||
|
align: node.attrs.align,
|
||||||
|
width: node.attrs.width || undefined,
|
||||||
|
height: node.attrs.height || undefined,
|
||||||
|
"data-ratio": node.attrs.ratio,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
parseMarkdown: {
|
||||||
|
match: ({ type }) => type === "image-block",
|
||||||
|
runner: (state, node, type) => {
|
||||||
|
state.addNode(type, {
|
||||||
|
src: String((node as RichImageBlockNode).url ?? ""),
|
||||||
|
caption: String((node as RichImageBlockNode).title ?? ""),
|
||||||
|
ratio: parseRatio((node as RichImageBlockNode).ratio || (node as RichImageBlockNode).alt),
|
||||||
|
width: parsePositiveNumber((node as RichImageBlockNode).width),
|
||||||
|
height: parsePositiveNumber((node as RichImageBlockNode).height),
|
||||||
|
align: clampImageAlign((node as RichImageBlockNode).align),
|
||||||
|
});
|
||||||
|
},
|
||||||
|
},
|
||||||
|
toMarkdown: {
|
||||||
|
match: (node) => node.type.name === "image-block",
|
||||||
|
runner: (state, node) => {
|
||||||
|
if (needsHtmlSerialization(node)) {
|
||||||
|
state.addNode("html", undefined, serializeImageHtml(node.attrs));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
state.openNode("paragraph");
|
||||||
|
state.addNode("image", undefined, undefined, {
|
||||||
|
title: node.attrs.caption,
|
||||||
|
url: node.attrs.src,
|
||||||
|
alt: parseRatio(node.attrs.ratio) === 1 ? "" : parseRatio(node.attrs.ratio).toFixed(2),
|
||||||
|
});
|
||||||
|
state.closeNode();
|
||||||
|
},
|
||||||
|
},
|
||||||
|
}));
|
||||||
|
|
||||||
|
export const richImageBlockView = $view(richImageBlockSchema.node, (ctx) => {
|
||||||
|
const config = ctx.get(imageBlockConfig.key);
|
||||||
|
|
||||||
|
return (initialNode, view, getPos) => {
|
||||||
|
let activeNode = initialNode;
|
||||||
|
let renderToken = 0;
|
||||||
|
let captionTimer = 0;
|
||||||
|
let showCaption = Boolean(initialNode.attrs.caption);
|
||||||
|
|
||||||
|
const dom = document.createElement("div");
|
||||||
|
dom.className = "milkdown-image-block";
|
||||||
|
|
||||||
|
const wrapper = document.createElement("div");
|
||||||
|
wrapper.className = "image-wrapper";
|
||||||
|
|
||||||
|
const operation = document.createElement("div");
|
||||||
|
operation.className = "operation";
|
||||||
|
|
||||||
|
const operationItem = document.createElement("div");
|
||||||
|
operationItem.className = "operation-item";
|
||||||
|
operationItem.innerHTML = DOMPurify.sanitize((config.captionIcon || "C").trim());
|
||||||
|
operation.appendChild(operationItem);
|
||||||
|
|
||||||
|
const image = document.createElement("img");
|
||||||
|
image.setAttribute("data-type", IMAGE_DATA_TYPE);
|
||||||
|
|
||||||
|
const resizeHandle = document.createElement("div");
|
||||||
|
resizeHandle.className = "image-resize-handle";
|
||||||
|
|
||||||
|
const captionInput = document.createElement("input");
|
||||||
|
captionInput.className = "caption-input";
|
||||||
|
captionInput.placeholder = config.captionPlaceholderText;
|
||||||
|
captionInput.draggable = true;
|
||||||
|
|
||||||
|
wrapper.append(operation, image, resizeHandle);
|
||||||
|
dom.appendChild(wrapper);
|
||||||
|
|
||||||
|
const setAttr = (attr: string, value: unknown) => {
|
||||||
|
if (!view.editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const pos = getPos();
|
||||||
|
if (pos == null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
view.dispatch(
|
||||||
|
view.state.tr.setNodeAttribute(
|
||||||
|
pos,
|
||||||
|
attr,
|
||||||
|
attr === "src" ? DOMPurify.sanitize(String(value ?? "")) : value,
|
||||||
|
),
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncCaptionInput = () => {
|
||||||
|
captionInput.value = String(activeNode.attrs.caption ?? "");
|
||||||
|
if (!showCaption) {
|
||||||
|
captionInput.remove();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!captionInput.isConnected) {
|
||||||
|
dom.appendChild(captionInput);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const calculateBaseSize = (): { width: number; height: number } | null => {
|
||||||
|
if (!image.naturalWidth || !image.naturalHeight) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
let width = image.naturalWidth;
|
||||||
|
let height = image.naturalHeight;
|
||||||
|
let maxWidth = dom.getBoundingClientRect().width || width;
|
||||||
|
|
||||||
|
if (config.maxWidth) {
|
||||||
|
maxWidth = Math.min(maxWidth, config.maxWidth);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (width > maxWidth) {
|
||||||
|
const scale = maxWidth / width;
|
||||||
|
width *= scale;
|
||||||
|
height *= scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (config.maxHeight && height > config.maxHeight) {
|
||||||
|
const scale = config.maxHeight / height;
|
||||||
|
width *= scale;
|
||||||
|
height *= scale;
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
width: Number(width.toFixed(2)),
|
||||||
|
height: Number(height.toFixed(2)),
|
||||||
|
};
|
||||||
|
};
|
||||||
|
|
||||||
|
const syncImageLayout = () => {
|
||||||
|
dom.dataset.align = clampImageAlign(activeNode.attrs.align);
|
||||||
|
wrapper.dataset.align = clampImageAlign(activeNode.attrs.align);
|
||||||
|
|
||||||
|
const baseSize = calculateBaseSize();
|
||||||
|
const width = parsePositiveNumber(activeNode.attrs.width);
|
||||||
|
const height = parsePositiveNumber(activeNode.attrs.height);
|
||||||
|
const ratio = parseRatio(activeNode.attrs.ratio);
|
||||||
|
|
||||||
|
image.alt = String(activeNode.attrs.caption ?? "");
|
||||||
|
image.title = String(activeNode.attrs.caption ?? "");
|
||||||
|
image.dataset.ratio = ratio.toFixed(2);
|
||||||
|
|
||||||
|
if (!baseSize) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let displayWidth = width;
|
||||||
|
let displayHeight = height;
|
||||||
|
|
||||||
|
if (displayWidth <= 0 && displayHeight <= 0) {
|
||||||
|
displayWidth = Number((baseSize.width * ratio).toFixed(2));
|
||||||
|
displayHeight = Number((baseSize.height * ratio).toFixed(2));
|
||||||
|
} else if (displayWidth > 0 && displayHeight <= 0) {
|
||||||
|
displayHeight = Number((displayWidth * baseSize.height / baseSize.width).toFixed(2));
|
||||||
|
} else if (displayWidth <= 0 && displayHeight > 0) {
|
||||||
|
displayWidth = Number((displayHeight * baseSize.width / baseSize.height).toFixed(2));
|
||||||
|
}
|
||||||
|
|
||||||
|
image.dataset.originWidth = baseSize.width.toFixed(2);
|
||||||
|
image.dataset.originHeight = baseSize.height.toFixed(2);
|
||||||
|
image.dataset.origin = baseSize.height.toFixed(2);
|
||||||
|
image.dataset.width = displayWidth.toFixed(2);
|
||||||
|
image.dataset.height = displayHeight.toFixed(2);
|
||||||
|
image.style.width = `${displayWidth}px`;
|
||||||
|
image.style.height = `${displayHeight}px`;
|
||||||
|
|
||||||
|
if (config.maxWidth) {
|
||||||
|
image.style.maxWidth = `${config.maxWidth}px`;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindImageSource = (node: typeof initialNode) => {
|
||||||
|
renderToken += 1;
|
||||||
|
const currentToken = renderToken;
|
||||||
|
const src = String(node.attrs.src ?? "");
|
||||||
|
const proxied = config.proxyDomURL?.(src) ?? src;
|
||||||
|
|
||||||
|
const applySource = (value: string) => {
|
||||||
|
if (currentToken !== renderToken) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
image.src = value;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (typeof proxied === "string") {
|
||||||
|
applySource(proxied);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
proxied.then(applySource).catch(console.error);
|
||||||
|
};
|
||||||
|
|
||||||
|
const bindAttrs = (node: typeof initialNode) => {
|
||||||
|
activeNode = node;
|
||||||
|
bindImageSource(node);
|
||||||
|
syncCaptionInput();
|
||||||
|
syncImageLayout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageLoad = () => {
|
||||||
|
syncImageLayout();
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleImageError = (event: Event) => {
|
||||||
|
void Promise.resolve(config.onImageLoadError?.(event)).catch(() => {});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleToggleCaption = (event: PointerEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
if (!view.editable) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
showCaption = !showCaption;
|
||||||
|
syncCaptionInput();
|
||||||
|
if (showCaption) {
|
||||||
|
requestAnimationFrame(() => {
|
||||||
|
captionInput.focus();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCaptionInput = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (captionTimer) {
|
||||||
|
window.clearTimeout(captionTimer);
|
||||||
|
}
|
||||||
|
|
||||||
|
captionTimer = window.setTimeout(() => {
|
||||||
|
setAttr("caption", target.value);
|
||||||
|
}, 300);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleCaptionBlur = (event: Event) => {
|
||||||
|
const target = event.target as HTMLInputElement;
|
||||||
|
if (captionTimer) {
|
||||||
|
window.clearTimeout(captionTimer);
|
||||||
|
captionTimer = 0;
|
||||||
|
}
|
||||||
|
setAttr("caption", target.value);
|
||||||
|
};
|
||||||
|
|
||||||
|
const stopCaptionDrag = (event: DragEvent) => {
|
||||||
|
event.preventDefault();
|
||||||
|
event.stopPropagation();
|
||||||
|
};
|
||||||
|
|
||||||
|
image.addEventListener("load", handleImageLoad);
|
||||||
|
image.addEventListener("error", handleImageError);
|
||||||
|
operationItem.addEventListener("pointerdown", handleToggleCaption);
|
||||||
|
captionInput.addEventListener("input", handleCaptionInput);
|
||||||
|
captionInput.addEventListener("blur", handleCaptionBlur);
|
||||||
|
captionInput.addEventListener("dragstart", stopCaptionDrag);
|
||||||
|
|
||||||
|
bindAttrs(initialNode);
|
||||||
|
|
||||||
|
return {
|
||||||
|
dom,
|
||||||
|
update: (updatedNode) => {
|
||||||
|
if (updatedNode.type !== initialNode.type) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
bindAttrs(updatedNode);
|
||||||
|
return true;
|
||||||
|
},
|
||||||
|
stopEvent: (event) => {
|
||||||
|
if (event.target instanceof HTMLInputElement) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
|
||||||
|
return event.target instanceof HTMLElement && Boolean(event.target.closest(".operation"));
|
||||||
|
},
|
||||||
|
selectNode: () => {
|
||||||
|
dom.classList.add("selected");
|
||||||
|
},
|
||||||
|
deselectNode: () => {
|
||||||
|
dom.classList.remove("selected");
|
||||||
|
},
|
||||||
|
destroy: () => {
|
||||||
|
if (captionTimer) {
|
||||||
|
window.clearTimeout(captionTimer);
|
||||||
|
}
|
||||||
|
image.removeEventListener("load", handleImageLoad);
|
||||||
|
image.removeEventListener("error", handleImageError);
|
||||||
|
operationItem.removeEventListener("pointerdown", handleToggleCaption);
|
||||||
|
captionInput.removeEventListener("input", handleCaptionInput);
|
||||||
|
captionInput.removeEventListener("blur", handleCaptionBlur);
|
||||||
|
captionInput.removeEventListener("dragstart", stopCaptionDrag);
|
||||||
|
dom.remove();
|
||||||
|
},
|
||||||
|
};
|
||||||
|
};
|
||||||
|
});
|
||||||
|
|
||||||
|
export const richImageBlockFeature = (editor: Editor, config?: RichImageBlockFeatureConfig) => {
|
||||||
|
editor
|
||||||
|
.config((ctx) => {
|
||||||
|
useCrepeFeatures(ctx).update((features: CrepeFeature[]) => {
|
||||||
|
if (features.includes(CrepeFeature.ImageBlock)) {
|
||||||
|
return features;
|
||||||
|
}
|
||||||
|
return [...features, CrepeFeature.ImageBlock];
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.config((ctx) => {
|
||||||
|
ctx.update(inlineImageConfig.key, (value) => ({
|
||||||
|
uploadButton: config?.inlineUploadButton ?? "Upload",
|
||||||
|
imageIcon: config?.inlineImageIcon ?? value.imageIcon,
|
||||||
|
confirmButton: config?.inlineConfirmButton ?? value.confirmButton,
|
||||||
|
uploadPlaceholderText: config?.inlineUploadPlaceholderText ?? value.uploadPlaceholderText,
|
||||||
|
onUpload: config?.inlineOnUpload ?? config?.onUpload ?? value.onUpload,
|
||||||
|
proxyDomURL: config?.proxyDomURL,
|
||||||
|
}));
|
||||||
|
ctx.update(imageBlockConfig.key, (value) => ({
|
||||||
|
uploadButton: config?.blockUploadButton ?? value.uploadButton,
|
||||||
|
imageIcon: config?.blockImageIcon ?? value.imageIcon,
|
||||||
|
captionIcon: config?.blockCaptionIcon ?? value.captionIcon,
|
||||||
|
confirmButton: config?.blockConfirmButton ?? value.confirmButton,
|
||||||
|
captionPlaceholderText: config?.blockCaptionPlaceholderText ?? value.captionPlaceholderText,
|
||||||
|
uploadPlaceholderText: config?.blockUploadPlaceholderText ?? value.uploadPlaceholderText,
|
||||||
|
onUpload: config?.blockOnUpload ?? config?.onUpload ?? value.onUpload,
|
||||||
|
proxyDomURL: config?.proxyDomURL,
|
||||||
|
onImageLoadError: config?.onImageLoadError ?? value.onImageLoadError,
|
||||||
|
maxWidth: config?.maxWidth,
|
||||||
|
maxHeight: config?.maxHeight,
|
||||||
|
}));
|
||||||
|
})
|
||||||
|
.use([richImageBlockRemarkPlugin, richImageBlockSchema, richImageBlockView, imageBlockConfig].flat())
|
||||||
|
.use(imageInlineComponent);
|
||||||
|
};
|
||||||
@@ -90,6 +90,35 @@ function baseContent(article: AdapterContext["article"]): string {
|
|||||||
return markdownToHtml(article.markdown_content ?? "");
|
return markdownToHtml(article.markdown_content ?? "");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function wrapStandaloneImages(html: string): string {
|
||||||
|
if (typeof DOMParser === "undefined") {
|
||||||
|
return html.replace(/<img([^>]+src="[^"]+"[^>]*)>/gi, "<figure><img$1></figure>");
|
||||||
|
}
|
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(`<div data-root="zhihu-content">${html}</div>`, "text/html");
|
||||||
|
const root = doc.body.querySelector('[data-root="zhihu-content"]');
|
||||||
|
if (!(root instanceof HTMLElement)) {
|
||||||
|
return html;
|
||||||
|
}
|
||||||
|
|
||||||
|
for (const image of [...root.querySelectorAll("img")]) {
|
||||||
|
if (image.closest("figure")) {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const parent = image.parentElement;
|
||||||
|
if (parent?.tagName === "P") {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
|
||||||
|
const figure = doc.createElement("figure");
|
||||||
|
image.parentNode?.insertBefore(figure, image);
|
||||||
|
figure.appendChild(image);
|
||||||
|
}
|
||||||
|
|
||||||
|
return root.innerHTML.trim();
|
||||||
|
}
|
||||||
|
|
||||||
function transformContent(html: string): string {
|
function transformContent(html: string): string {
|
||||||
let next = html.trim();
|
let next = html.trim();
|
||||||
|
|
||||||
@@ -99,7 +128,7 @@ function transformContent(html: string): string {
|
|||||||
next = next.replace(/\sdata-(?!draft)[a-z-]+="[^"]*"/gi, "");
|
next = next.replace(/\sdata-(?!draft)[a-z-]+="[^"]*"/gi, "");
|
||||||
next = next.replace(/<figure[^>]*>\s*(<img[\s\S]*?>)\s*<\/figure>/gi, "$1");
|
next = next.replace(/<figure[^>]*>\s*(<img[\s\S]*?>)\s*<\/figure>/gi, "$1");
|
||||||
next = next.replace(/<pre><code class="language-([^"]+)">/gi, '<pre lang="$1"><code>');
|
next = next.replace(/<pre><code class="language-([^"]+)">/gi, '<pre lang="$1"><code>');
|
||||||
next = next.replace(/<img([^>]+src="[^"]+"[^>]*)>/gi, "<figure><img$1></figure>");
|
next = wrapStandaloneImages(next);
|
||||||
|
|
||||||
return next;
|
return next;
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+3
@@ -43,6 +43,9 @@ importers:
|
|||||||
dayjs:
|
dayjs:
|
||||||
specifier: ^1.11.20
|
specifier: ^1.11.20
|
||||||
version: 1.11.20
|
version: 1.11.20
|
||||||
|
dompurify:
|
||||||
|
specifier: ^3.3.3
|
||||||
|
version: 3.3.3
|
||||||
markdown-it:
|
markdown-it:
|
||||||
specifier: ^14.1.1
|
specifier: ^14.1.1
|
||||||
version: 14.1.1
|
version: 14.1.1
|
||||||
|
|||||||
Reference in New Issue
Block a user