chore(frontend): introduce prettier + eslint and prune unused code
- Add Prettier 3 with prettier-plugin-organize-imports (sorts/removes unused imports) - Add ESLint 10 flat config with typescript-eslint + eslint-plugin-vue + eslint-config-prettier - Add root scripts: format, format:check, lint, lint:fix - Reformat 257 files across admin-web, ops-web, desktop-client, packages - Remove unused locals/exports flagged by --noUnusedLocals/--noUnusedParameters - Fix duplicate localTabLabel key, surrogate-pair regex u-flag, NBSP literal in regex - Skip server/ (Go) and apps/browser-extension/ (deprecated per ADR) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,606 +1,611 @@
|
||||
import type { Editor } from "@milkdown/kit/core";
|
||||
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";
|
||||
import { CrepeFeature, useCrepeFeatures } from '@milkdown/crepe'
|
||||
import { imageBlockConfig } from '@milkdown/kit/component/image-block'
|
||||
import { imageInlineComponent, inlineImageConfig } from '@milkdown/kit/component/image-inline'
|
||||
import { $nodeSchema, $remark, $view } from '@milkdown/kit/utils'
|
||||
import DOMPurify from 'dompurify'
|
||||
|
||||
export type RichImageAlign = "left" | "center" | "right";
|
||||
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;
|
||||
};
|
||||
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;
|
||||
assetId: number;
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: number;
|
||||
align: RichImageAlign;
|
||||
};
|
||||
src: string
|
||||
caption: string
|
||||
assetId: number
|
||||
width: number
|
||||
height: number
|
||||
ratio: number
|
||||
align: RichImageAlign
|
||||
}
|
||||
|
||||
type RichImageBlockNode = {
|
||||
type: "image-block";
|
||||
url: string;
|
||||
title: string;
|
||||
alt: string;
|
||||
assetId: number;
|
||||
width: number;
|
||||
height: number;
|
||||
ratio: number;
|
||||
align: RichImageAlign;
|
||||
};
|
||||
type: 'image-block'
|
||||
url: string
|
||||
title: string
|
||||
alt: string
|
||||
assetId: number
|
||||
width: number
|
||||
height: number
|
||||
ratio: number
|
||||
align: RichImageAlign
|
||||
}
|
||||
|
||||
const IMAGE_DATA_TYPE = "image-block";
|
||||
const IMAGE_DATA_TYPE = 'image-block'
|
||||
|
||||
function clampImageAlign(value: unknown): RichImageAlign {
|
||||
const normalized = String(value ?? "").trim().toLowerCase();
|
||||
if (normalized === "left" || normalized === "right") {
|
||||
return normalized;
|
||||
const normalized = String(value ?? '')
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
if (normalized === 'left' || normalized === 'right') {
|
||||
return normalized
|
||||
}
|
||||
return "center";
|
||||
return 'center'
|
||||
}
|
||||
|
||||
function parsePositiveNumber(value: unknown): number {
|
||||
const parsed = Number.parseFloat(String(value ?? "").trim());
|
||||
const parsed = Number.parseFloat(String(value ?? '').trim())
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
return Number(parsed.toFixed(2));
|
||||
return Number(parsed.toFixed(2))
|
||||
}
|
||||
|
||||
function parseRatio(value: unknown): number {
|
||||
const parsed = Number.parseFloat(String(value ?? "").trim());
|
||||
const parsed = Number.parseFloat(String(value ?? '').trim())
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 1;
|
||||
return 1
|
||||
}
|
||||
return Number(parsed.toFixed(2));
|
||||
return Number(parsed.toFixed(2))
|
||||
}
|
||||
|
||||
function parsePositiveInteger(value: unknown): number {
|
||||
const parsed = Number.parseInt(String(value ?? "").trim(), 10);
|
||||
const parsed = Number.parseInt(String(value ?? '').trim(), 10)
|
||||
if (!Number.isFinite(parsed) || parsed <= 0) {
|
||||
return 0;
|
||||
return 0
|
||||
}
|
||||
return parsed;
|
||||
return parsed
|
||||
}
|
||||
|
||||
function escapeHtmlAttribute(value: string): string {
|
||||
return value
|
||||
.replaceAll("&", "&")
|
||||
.replaceAll("\"", """)
|
||||
.replaceAll("<", "<")
|
||||
.replaceAll(">", ">");
|
||||
.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--left')) {
|
||||
return 'left'
|
||||
}
|
||||
if (value.includes("article-editor-image--right")) {
|
||||
return "right";
|
||||
if (value.includes('article-editor-image--right')) {
|
||||
return 'right'
|
||||
}
|
||||
if (value.includes("article-editor-image--center")) {
|
||||
return "center";
|
||||
if (value.includes('article-editor-image--center')) {
|
||||
return 'center'
|
||||
}
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
function parseImageMarkup(markup: string): ParsedImageMarkup | null {
|
||||
if (typeof DOMParser === "undefined") {
|
||||
return null;
|
||||
if (typeof DOMParser === 'undefined') {
|
||||
return null
|
||||
}
|
||||
|
||||
const doc = new DOMParser().parseFromString(markup, "text/html");
|
||||
const image = doc.body.querySelector("img");
|
||||
const doc = new DOMParser().parseFromString(markup, 'text/html')
|
||||
const image = doc.body.querySelector('img')
|
||||
if (!(image instanceof HTMLImageElement)) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const src = image.getAttribute("src")?.trim() ?? "";
|
||||
const src = image.getAttribute('src')?.trim() ?? ''
|
||||
if (!src) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
const container = image.closest("p, figure, div");
|
||||
const container = image.closest('p, figure, div')
|
||||
const align =
|
||||
resolveAlignFromClassName(`${container?.className ?? ""} ${image.className ?? ""}`) ??
|
||||
clampImageAlign(container?.getAttribute("align") || image.getAttribute("align"));
|
||||
resolveAlignFromClassName(`${container?.className ?? ''} ${image.className ?? ''}`) ??
|
||||
clampImageAlign(container?.getAttribute('align') || image.getAttribute('align'))
|
||||
|
||||
return {
|
||||
src,
|
||||
caption: (image.getAttribute("title") || image.getAttribute("alt") || "").trim(),
|
||||
assetId: parsePositiveInteger(image.getAttribute("data-asset-id")),
|
||||
width: parsePositiveNumber(image.getAttribute("width")),
|
||||
height: parsePositiveNumber(image.getAttribute("height")),
|
||||
ratio: parseRatio(image.getAttribute("data-ratio")),
|
||||
caption: (image.getAttribute('title') || image.getAttribute('alt') || '').trim(),
|
||||
assetId: parsePositiveInteger(image.getAttribute('data-asset-id')),
|
||||
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 assetId = parsePositiveInteger(attrs.assetId);
|
||||
const align = clampImageAlign(attrs.align);
|
||||
const encodedCaption = escapeHtmlAttribute(caption);
|
||||
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 assetId = parsePositiveInteger(attrs.assetId)
|
||||
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)}"` : "";
|
||||
const assetIdAttribute = assetId > 0 ? ` data-asset-id="${assetId}"` : "";
|
||||
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)}"` : ''
|
||||
const assetIdAttribute = assetId > 0 ? ` data-asset-id="${assetId}"` : ''
|
||||
|
||||
return (
|
||||
`<p class="article-editor-image article-editor-image--${align}" align="${align}">` +
|
||||
`<img src="${escapeHtmlAttribute(src)}"` +
|
||||
` alt="${encodedCaption}"` +
|
||||
(caption ? ` title="${encodedCaption}"` : "") +
|
||||
(caption ? ` title="${encodedCaption}"` : '') +
|
||||
widthAttribute +
|
||||
heightAttribute +
|
||||
ratioAttribute +
|
||||
assetIdAttribute +
|
||||
" />" +
|
||||
"</p>"
|
||||
);
|
||||
' />' +
|
||||
'</p>'
|
||||
)
|
||||
}
|
||||
|
||||
function needsHtmlSerialization(node: { attrs: Record<string, unknown> }): boolean {
|
||||
const width = parsePositiveNumber(node.attrs.width);
|
||||
const height = parsePositiveNumber(node.attrs.height);
|
||||
const assetId = parsePositiveInteger(node.attrs.assetId);
|
||||
const align = clampImageAlign(node.attrs.align);
|
||||
return width > 0 || height > 0 || align !== "center" || assetId > 0;
|
||||
const width = parsePositiveNumber(node.attrs.width)
|
||||
const height = parsePositiveNumber(node.attrs.height)
|
||||
const assetId = parsePositiveInteger(node.attrs.assetId)
|
||||
const align = clampImageAlign(node.attrs.align)
|
||||
return width > 0 || height > 0 || align !== 'center' || assetId > 0
|
||||
}
|
||||
|
||||
function toRichImageBlockNode(parsed: ParsedImageMarkup): RichImageBlockNode {
|
||||
return {
|
||||
type: "image-block",
|
||||
type: 'image-block',
|
||||
url: parsed.src,
|
||||
title: parsed.caption,
|
||||
alt: parsed.ratio === 1 ? "" : parsed.ratio.toFixed(2),
|
||||
alt: parsed.ratio === 1 ? '' : parsed.ratio.toFixed(2),
|
||||
assetId: parsed.assetId,
|
||||
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") {
|
||||
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 ?? ""),
|
||||
type: 'image-block',
|
||||
url: String(firstChild.url ?? ''),
|
||||
title: String(firstChild.title ?? ''),
|
||||
alt: String(firstChild.alt ?? ''),
|
||||
assetId: 0,
|
||||
width: 0,
|
||||
height: 0,
|
||||
ratio: parseRatio(firstChild.alt),
|
||||
align: "center",
|
||||
};
|
||||
align: 'center',
|
||||
}
|
||||
}
|
||||
|
||||
if (firstChild?.type === "html") {
|
||||
const parsed = parseImageMarkup(String(firstChild.value ?? ""));
|
||||
if (firstChild?.type === 'html') {
|
||||
const parsed = parseImageMarkup(String(firstChild.value ?? ''))
|
||||
if (parsed) {
|
||||
return toRichImageBlockNode(parsed);
|
||||
return toRichImageBlockNode(parsed)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (node.type === "html") {
|
||||
const parsed = parseImageMarkup(String(node.value ?? ""));
|
||||
if (node.type === 'html') {
|
||||
const parsed = parseImageMarkup(String(node.value ?? ''))
|
||||
if (parsed) {
|
||||
return toRichImageBlockNode(parsed);
|
||||
return toRichImageBlockNode(parsed)
|
||||
}
|
||||
}
|
||||
|
||||
if (Array.isArray(node.children)) {
|
||||
node.children = (node.children as Array<Record<string, unknown>>).map((child) => transformRichImageNode(child));
|
||||
node.children = (node.children as Array<Record<string, unknown>>).map((child) =>
|
||||
transformRichImageNode(child),
|
||||
)
|
||||
}
|
||||
|
||||
return node;
|
||||
return node
|
||||
}
|
||||
|
||||
export const richImageBlockRemarkPlugin = $remark("remark-rich-image-block", () => () => (tree) => {
|
||||
transformRichImageNode(tree as unknown as Record<string, unknown>);
|
||||
});
|
||||
export const richImageBlockRemarkPlugin = $remark('remark-rich-image-block', () => () => (tree) => {
|
||||
transformRichImageNode(tree as unknown as Record<string, unknown>)
|
||||
})
|
||||
|
||||
export const richImageBlockSchema = $nodeSchema("image-block", () => ({
|
||||
export const richImageBlockSchema = $nodeSchema('image-block', () => ({
|
||||
inline: false,
|
||||
group: "block",
|
||||
group: 'block',
|
||||
selectable: true,
|
||||
draggable: true,
|
||||
isolating: true,
|
||||
marks: "",
|
||||
marks: '',
|
||||
atom: true,
|
||||
priority: 100,
|
||||
attrs: {
|
||||
src: { default: "", validate: "string" },
|
||||
caption: { default: "", validate: "string" },
|
||||
assetId: { default: 0, validate: "number" },
|
||||
ratio: { default: 1, validate: "number" },
|
||||
width: { default: 0, validate: "number" },
|
||||
height: { default: 0, validate: "number" },
|
||||
align: { default: "center", validate: "string" },
|
||||
src: { default: '', validate: 'string' },
|
||||
caption: { default: '', validate: 'string' },
|
||||
assetId: { default: 0, validate: 'number' },
|
||||
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 false
|
||||
}
|
||||
|
||||
return {
|
||||
src: dom.getAttribute("src") || "",
|
||||
caption: dom.getAttribute("title") || dom.getAttribute("alt") || "",
|
||||
assetId: parsePositiveInteger(dom.getAttribute("data-asset-id")),
|
||||
ratio: parseRatio(dom.getAttribute("data-ratio") || dom.getAttribute("ratio")),
|
||||
width: parsePositiveNumber(dom.getAttribute("width")),
|
||||
height: parsePositiveNumber(dom.getAttribute("height")),
|
||||
align: clampImageAlign(dom.getAttribute("align")),
|
||||
};
|
||||
src: dom.getAttribute('src') || '',
|
||||
caption: dom.getAttribute('title') || dom.getAttribute('alt') || '',
|
||||
assetId: parsePositiveInteger(dom.getAttribute('data-asset-id')),
|
||||
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",
|
||||
'img',
|
||||
{
|
||||
"data-type": IMAGE_DATA_TYPE,
|
||||
'data-type': IMAGE_DATA_TYPE,
|
||||
src: node.attrs.src,
|
||||
alt: node.attrs.caption,
|
||||
title: node.attrs.caption,
|
||||
align: node.attrs.align,
|
||||
"data-asset-id": parsePositiveInteger(node.attrs.assetId) || undefined,
|
||||
'data-asset-id': parsePositiveInteger(node.attrs.assetId) || undefined,
|
||||
width: node.attrs.width || undefined,
|
||||
height: node.attrs.height || undefined,
|
||||
"data-ratio": node.attrs.ratio,
|
||||
'data-ratio': node.attrs.ratio,
|
||||
},
|
||||
],
|
||||
parseMarkdown: {
|
||||
match: ({ type }) => type === "image-block",
|
||||
match: ({ type }) => type === 'image-block',
|
||||
runner: (state, node, type) => {
|
||||
state.addNode(type, {
|
||||
src: String((node as RichImageBlockNode).url ?? ""),
|
||||
caption: String((node as RichImageBlockNode).title ?? ""),
|
||||
src: String((node as RichImageBlockNode).url ?? ''),
|
||||
caption: String((node as RichImageBlockNode).title ?? ''),
|
||||
assetId: parsePositiveInteger((node as RichImageBlockNode).assetId),
|
||||
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",
|
||||
match: (node) => node.type.name === 'image-block',
|
||||
runner: (state, node) => {
|
||||
if (needsHtmlSerialization(node)) {
|
||||
state.addNode("html", undefined, serializeImageHtml(node.attrs));
|
||||
return;
|
||||
state.addNode('html', undefined, serializeImageHtml(node.attrs))
|
||||
return
|
||||
}
|
||||
|
||||
state.openNode("paragraph");
|
||||
state.addNode("image", undefined, undefined, {
|
||||
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();
|
||||
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);
|
||||
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);
|
||||
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 dom = document.createElement('div')
|
||||
dom.className = 'milkdown-image-block'
|
||||
|
||||
const wrapper = document.createElement("div");
|
||||
wrapper.className = "image-wrapper";
|
||||
const wrapper = document.createElement('div')
|
||||
wrapper.className = 'image-wrapper'
|
||||
|
||||
const operation = document.createElement("div");
|
||||
operation.className = "operation";
|
||||
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 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 image = document.createElement('img')
|
||||
image.setAttribute('data-type', IMAGE_DATA_TYPE)
|
||||
|
||||
const resizeHandle = document.createElement("div");
|
||||
resizeHandle.className = "image-resize-handle";
|
||||
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;
|
||||
const captionInput = document.createElement('input')
|
||||
captionInput.className = 'caption-input'
|
||||
captionInput.placeholder = config.captionPlaceholderText
|
||||
captionInput.draggable = true
|
||||
|
||||
wrapper.append(operation, image, resizeHandle);
|
||||
dom.appendChild(wrapper);
|
||||
wrapper.append(operation, image, resizeHandle)
|
||||
dom.appendChild(wrapper)
|
||||
|
||||
const setAttr = (attr: string, value: unknown) => {
|
||||
if (!view.editable) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
const pos = getPos();
|
||||
const pos = getPos()
|
||||
if (pos == null) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
view.dispatch(
|
||||
view.state.tr.setNodeAttribute(
|
||||
pos,
|
||||
attr,
|
||||
attr === "src" ? DOMPurify.sanitize(String(value ?? "")) : value,
|
||||
attr === 'src' ? DOMPurify.sanitize(String(value ?? '')) : value,
|
||||
),
|
||||
);
|
||||
};
|
||||
)
|
||||
}
|
||||
|
||||
const syncCaptionInput = () => {
|
||||
captionInput.value = String(activeNode.attrs.caption ?? "");
|
||||
captionInput.value = String(activeNode.attrs.caption ?? '')
|
||||
if (!showCaption) {
|
||||
captionInput.remove();
|
||||
return;
|
||||
captionInput.remove()
|
||||
return
|
||||
}
|
||||
|
||||
if (!captionInput.isConnected) {
|
||||
dom.appendChild(captionInput);
|
||||
dom.appendChild(captionInput)
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const calculateBaseSize = (): { width: number; height: number } | null => {
|
||||
if (!image.naturalWidth || !image.naturalHeight) {
|
||||
return null;
|
||||
return null
|
||||
}
|
||||
|
||||
let width = image.naturalWidth;
|
||||
let height = image.naturalHeight;
|
||||
let maxWidth = dom.getBoundingClientRect().width || width;
|
||||
let width = image.naturalWidth
|
||||
let height = image.naturalHeight
|
||||
let maxWidth = dom.getBoundingClientRect().width || width
|
||||
|
||||
if (config.maxWidth) {
|
||||
maxWidth = Math.min(maxWidth, config.maxWidth);
|
||||
maxWidth = Math.min(maxWidth, config.maxWidth)
|
||||
}
|
||||
|
||||
if (width > maxWidth) {
|
||||
const scale = maxWidth / width;
|
||||
width *= scale;
|
||||
height *= scale;
|
||||
const scale = maxWidth / width
|
||||
width *= scale
|
||||
height *= scale
|
||||
}
|
||||
|
||||
if (config.maxHeight && height > config.maxHeight) {
|
||||
const scale = config.maxHeight / height;
|
||||
width *= scale;
|
||||
height *= scale;
|
||||
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);
|
||||
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);
|
||||
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);
|
||||
image.alt = String(activeNode.attrs.caption ?? '')
|
||||
image.title = String(activeNode.attrs.caption ?? '')
|
||||
image.dataset.ratio = ratio.toFixed(2)
|
||||
|
||||
if (!baseSize) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
let displayWidth = width;
|
||||
let displayHeight = height;
|
||||
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));
|
||||
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));
|
||||
displayHeight = Number(((displayWidth * baseSize.height) / baseSize.width).toFixed(2))
|
||||
} else if (displayWidth <= 0 && displayHeight > 0) {
|
||||
displayWidth = Number((displayHeight * baseSize.width / baseSize.height).toFixed(2));
|
||||
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`;
|
||||
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`;
|
||||
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;
|
||||
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;
|
||||
return
|
||||
}
|
||||
image.src = value;
|
||||
};
|
||||
|
||||
if (typeof proxied === "string") {
|
||||
applySource(proxied);
|
||||
return;
|
||||
image.src = value
|
||||
}
|
||||
|
||||
proxied.then(applySource).catch(console.error);
|
||||
};
|
||||
if (typeof proxied === 'string') {
|
||||
applySource(proxied)
|
||||
return
|
||||
}
|
||||
|
||||
proxied.then(applySource).catch(console.error)
|
||||
}
|
||||
|
||||
const bindAttrs = (node: typeof initialNode) => {
|
||||
activeNode = node;
|
||||
bindImageSource(node);
|
||||
syncCaptionInput();
|
||||
syncImageLayout();
|
||||
};
|
||||
activeNode = node
|
||||
bindImageSource(node)
|
||||
syncCaptionInput()
|
||||
syncImageLayout()
|
||||
}
|
||||
|
||||
const handleImageLoad = () => {
|
||||
syncImageLayout();
|
||||
};
|
||||
syncImageLayout()
|
||||
}
|
||||
|
||||
const handleImageError = (event: Event) => {
|
||||
void Promise.resolve(config.onImageLoadError?.(event)).catch(() => {});
|
||||
};
|
||||
void Promise.resolve(config.onImageLoadError?.(event)).catch(() => {})
|
||||
}
|
||||
|
||||
const handleToggleCaption = (event: PointerEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
event.preventDefault()
|
||||
event.stopPropagation()
|
||||
if (!view.editable) {
|
||||
return;
|
||||
return
|
||||
}
|
||||
|
||||
showCaption = !showCaption;
|
||||
syncCaptionInput();
|
||||
showCaption = !showCaption
|
||||
syncCaptionInput()
|
||||
if (showCaption) {
|
||||
requestAnimationFrame(() => {
|
||||
captionInput.focus();
|
||||
});
|
||||
captionInput.focus()
|
||||
})
|
||||
}
|
||||
};
|
||||
}
|
||||
|
||||
const handleCaptionInput = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const target = event.target as HTMLInputElement
|
||||
if (captionTimer) {
|
||||
window.clearTimeout(captionTimer);
|
||||
window.clearTimeout(captionTimer)
|
||||
}
|
||||
|
||||
captionTimer = window.setTimeout(() => {
|
||||
setAttr("caption", target.value);
|
||||
}, 300);
|
||||
};
|
||||
setAttr('caption', target.value)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
const handleCaptionBlur = (event: Event) => {
|
||||
const target = event.target as HTMLInputElement;
|
||||
const target = event.target as HTMLInputElement
|
||||
if (captionTimer) {
|
||||
window.clearTimeout(captionTimer);
|
||||
captionTimer = 0;
|
||||
window.clearTimeout(captionTimer)
|
||||
captionTimer = 0
|
||||
}
|
||||
setAttr("caption", target.value);
|
||||
};
|
||||
setAttr('caption', target.value)
|
||||
}
|
||||
|
||||
const stopCaptionDrag = (event: DragEvent) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
};
|
||||
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);
|
||||
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);
|
||||
bindAttrs(initialNode)
|
||||
|
||||
return {
|
||||
dom,
|
||||
update: (updatedNode) => {
|
||||
if (updatedNode.type !== initialNode.type) {
|
||||
return false;
|
||||
return false
|
||||
}
|
||||
|
||||
bindAttrs(updatedNode);
|
||||
return true;
|
||||
bindAttrs(updatedNode)
|
||||
return true
|
||||
},
|
||||
stopEvent: (event) => {
|
||||
if (event.target instanceof HTMLInputElement) {
|
||||
return true;
|
||||
return true
|
||||
}
|
||||
|
||||
return event.target instanceof HTMLElement && Boolean(event.target.closest(".operation"));
|
||||
return event.target instanceof HTMLElement && Boolean(event.target.closest('.operation'))
|
||||
},
|
||||
selectNode: () => {
|
||||
dom.classList.add("selected");
|
||||
dom.classList.add('selected')
|
||||
},
|
||||
deselectNode: () => {
|
||||
dom.classList.remove("selected");
|
||||
dom.classList.remove('selected')
|
||||
},
|
||||
destroy: () => {
|
||||
if (captionTimer) {
|
||||
window.clearTimeout(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();
|
||||
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
|
||||
}
|
||||
return [...features, CrepeFeature.ImageBlock];
|
||||
});
|
||||
return [...features, CrepeFeature.ImageBlock]
|
||||
})
|
||||
})
|
||||
.config((ctx) => {
|
||||
ctx.update(inlineImageConfig.key, (value) => ({
|
||||
uploadButton: config?.inlineUploadButton ?? "Upload",
|
||||
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,
|
||||
@@ -613,8 +618,15 @@ export const richImageBlockFeature = (editor: Editor, config?: RichImageBlockFea
|
||||
onImageLoadError: config?.onImageLoadError ?? value.onImageLoadError,
|
||||
maxWidth: config?.maxWidth,
|
||||
maxHeight: config?.maxHeight,
|
||||
}));
|
||||
}))
|
||||
})
|
||||
.use([richImageBlockRemarkPlugin, richImageBlockSchema, richImageBlockView, imageBlockConfig].flat())
|
||||
.use(imageInlineComponent);
|
||||
};
|
||||
.use(
|
||||
[
|
||||
richImageBlockRemarkPlugin,
|
||||
richImageBlockSchema,
|
||||
richImageBlockView,
|
||||
imageBlockConfig,
|
||||
].flat(),
|
||||
)
|
||||
.use(imageInlineComponent)
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user