From 4000b3a9f0e7bf654d38d15d1ecdbf3409234341 Mon Sep 17 00:00:00 2001 From: yuetsh <517252939@qq.com> Date: Tue, 20 Oct 2026 20:35:56 +0800 Subject: [PATCH] =?UTF-8?q?feat(=E5=90=8E=E5=8F=B0=E5=AF=8C=E6=96=87?= =?UTF-8?q?=E6=9C=AC):=20=E5=9B=BE=E7=89=87=E5=8F=AF=E4=BB=A5=E6=8B=96?= =?UTF-8?q?=E5=9B=9B=E4=B8=AA=E8=A7=92=E7=BC=A9=E6=94=BE=EF=BC=8C=E6=8B=96?= =?UTF-8?q?=E5=AE=8C=E7=9A=84=E5=AE=BD=E5=BA=A6=E5=AD=A6=E7=94=9F=E7=AB=AF?= =?UTF-8?q?=E7=85=A7=E6=A0=B7=E7=94=9F=E6=95=88?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 点中或悬停图片四角出把手,按原比例缩放。宽写回 style、去掉高(学生端高一律 auto): 老题面的图片尺寸都在 style 里,Tiptap 自带的 resize 写的是 width / height 属性, 被 style 盖住,拖完存下来还是原尺寸,所以自己接了 ResizableNodeView。 解析和输出没动,不拖的图片打开再保存和原来一样。 Co-Authored-By: Claude Opus 5.5 --- apps/web/src/shared/components/TextEditor.vue | 37 ++++++++++++ apps/web/src/shared/richText.ts | 57 ++++++++++++++++++- 2 files changed, 93 insertions(+), 1 deletion(-) diff --git a/apps/web/src/shared/components/TextEditor.vue b/apps/web/src/shared/components/TextEditor.vue index b32563e..71557a9 100644 --- a/apps/web/src/shared/components/TextEditor.vue +++ b/apps/web/src/shared/components/TextEditor.vue @@ -692,6 +692,43 @@ const inTable = computed(() => isActive("table")) vertical-align: middle; } +/* 图片外面那层可缩放的容器(richText.ts 的 StyledImage):选中或悬停时四个角出把手 */ +.content :deep([data-resize-container]) { + max-width: 100%; + vertical-align: middle; +} + +.content :deep([data-resize-wrapper]) { + min-width: 0; + max-width: 100%; +} + +.content :deep([data-resize-handle]) { + display: none; + width: 9px; + height: 9px; + margin: -4px; + border: 1px solid #fff; + background: v-bind("theme.primaryColor"); + z-index: 1; +} + +.content :deep([data-resize-container]:hover [data-resize-handle]), +.content :deep(.ProseMirror-selectednode [data-resize-handle]), +.content :deep([data-resize-state="true"] [data-resize-handle]) { + display: block; +} + +.content :deep([data-resize-handle="top-left"]), +.content :deep([data-resize-handle="bottom-right"]) { + cursor: nwse-resize; +} + +.content :deep([data-resize-handle="top-right"]), +.content :deep([data-resize-handle="bottom-left"]) { + cursor: nesw-resize; +} + .content :deep(.ProseMirror-selectednode) { outline: 2px solid v-bind("theme.primaryColor"); } diff --git a/apps/web/src/shared/richText.ts b/apps/web/src/shared/richText.ts index 7d21dc5..0662139 100644 --- a/apps/web/src/shared/richText.ts +++ b/apps/web/src/shared/richText.ts @@ -1,4 +1,4 @@ -import { Extension } from "@tiptap/core" +import { Extension, getRenderedAttributes, ResizableNodeView } from "@tiptap/core" import { Image } from "@tiptap/extension-image" import { TableKit } from "@tiptap/extension-table" import { TextAlign } from "@tiptap/extension-text-align" @@ -27,6 +27,61 @@ const StyledImage = Image.extend({ }, } }, + + // 拖四个角缩放。Tiptap 自带的 resize 把尺寸写进 width / height 属性,可老图的尺寸在 style 里、 + // 盖过属性,拖完存下来还是原来的大小 —— 所以自己接:宽写回 style,高去掉(学生端高一律 auto) + addNodeView() { + return ({ node, getPos, editor }) => { + const img = document.createElement("img") + const extensionAttributes = editor.extensionManager.attributes + + /** 节点属性原样落到 上,编辑区和存下来的 HTML 是同一个样子 */ + const render = (node: Parameters[0]) => { + const attributes = getRenderedAttributes(node, extensionAttributes) + for (const name of img.getAttributeNames()) { + if (attributes[name] == null) img.removeAttribute(name) + } + for (const [name, value] of Object.entries(attributes)) { + if (value != null) img.setAttribute(name, value) + } + // 拖动的是外面那层容器,图片自己不能被浏览器当成原生拖拽 + img.draggable = false + } + + render(node) + const view = new ResizableNodeView({ + element: img, + editor, + node, + getPos, + onCommit: (width) => { + const pos = getPos() + if (pos === undefined) return + const { style } = document.createElement("img") + style.cssText = view.node.attrs.style ?? "" + style.removeProperty("height") + style.width = `${Math.round(width)}px` + editor + .chain() + .setNodeSelection(pos) + .updateAttributes(this.name, { style: style.cssText, width: null, height: null }) + .run() + }, + onUpdate: (node) => { + render(node) + return true + }, + options: { + directions: ["top-left", "top-right", "bottom-left", "bottom-right"], + min: { width: 24, height: 24 }, + preserveAspectRatio: true, + }, + }) + // 它会拿 width 属性往 style 里写一份,和 style 里原有的宽打架;以节点属性为准 + render(node) + return view + } + }, }) /** 段落的首行缩进、左边距。老题面里有 `text-indent: 28pt`、`padding-left: 40px` 这种 */