Skip to content

Commit 75dfbd1

Browse files
committed
✨ 支持粘贴图片
1 parent 9bf8981 commit 75dfbd1

File tree

12 files changed

+256
-497
lines changed

12 files changed

+256
-497
lines changed

app/src/core/Project.tsx

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -119,6 +119,7 @@ export class Project {
119119
private _uri: URI;
120120
public state: ProjectState = ProjectState.Unsaved;
121121
public stage: StageObject[] = [];
122+
public attachments = new Map<string, Blob>();
122123
/**
123124
* 创建Encoder对象比直接用encode()快
124125
* @see https://github.com/msgpack/msgpack-javascript#reusing-encoder-and-decoder-instances
@@ -314,6 +315,12 @@ export class Project {
314315
get fs(): FileSystemProvider {
315316
return this.fileSystemProviders.get(this.uri.scheme)!;
316317
}
318+
319+
addAttachment(data: Blob) {
320+
const uuid = crypto.randomUUID();
321+
this.attachments.set(uuid, data);
322+
return uuid;
323+
}
317324
}
318325

319326
declare module "./Project" {

app/src/core/render/canvas2d/basicRenderer/ImageRenderer.tsx

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
1-
import { Vector } from "@graphif/data-structures";
21
import { Project, service } from "@/core/Project";
2+
import { Vector } from "@graphif/data-structures";
33

44
/**
55
* 图片渲染器
@@ -16,16 +16,16 @@ export class ImageRenderer {
1616
* @param scale 1 表示正常,0.5 表示缩小一半,2 表示放大两倍
1717
*/
1818
renderImageElement(
19-
imageElement: HTMLImageElement,
19+
source: Exclude<CanvasImageSource, VideoFrame | SVGElement>,
2020
location: Vector,
2121
scale: number = 1 / (window.devicePixelRatio || 1),
2222
) {
2323
this.project.canvas.ctx.drawImage(
24-
imageElement,
24+
source,
2525
location.x,
2626
location.y,
27-
imageElement.width * scale * this.project.camera.currentScale,
28-
imageElement.height * scale * this.project.camera.currentScale,
27+
source.width * scale * this.project.camera.currentScale,
28+
source.height * scale * this.project.camera.currentScale,
2929
);
3030
}
3131
}

app/src/core/render/canvas2d/entityRenderer/EntityRenderer.tsx

Lines changed: 10 additions & 46 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,5 @@
1-
import { Color, Vector } from "@graphif/data-structures";
2-
import { Rectangle } from "@graphif/shapes";
31
import { Project, service } from "@/core/Project";
2+
import { Renderer } from "@/core/render/canvas2d/renderer";
43
import { Settings } from "@/core/service/Settings";
54
import { Entity } from "@/core/stage/stageObject/abstract/StageEntity";
65
import { ConnectPoint } from "@/core/stage/stageObject/entity/ConnectPoint";
@@ -11,7 +10,8 @@ import { Section } from "@/core/stage/stageObject/entity/Section";
1110
import { SvgNode } from "@/core/stage/stageObject/entity/SvgNode";
1211
import { TextNode } from "@/core/stage/stageObject/entity/TextNode";
1312
import { UrlNode } from "@/core/stage/stageObject/entity/UrlNode";
14-
import { Renderer } from "@/core/render/canvas2d/renderer";
13+
import { Color, Vector } from "@graphif/data-structures";
14+
import { Rectangle } from "@graphif/shapes";
1515

1616
/**
1717
* 处理节点相关的绘制
@@ -240,52 +240,16 @@ export class EntityRenderer {
240240
);
241241
} else if (imageNode.state === "success") {
242242
this.project.imageRenderer.renderImageElement(
243-
imageNode.imageElement,
243+
imageNode.bitmap!,
244244
this.project.renderer.transformWorld2View(imageNode.rectangle.location),
245-
imageNode.scaleNumber,
246-
);
247-
} else if (imageNode.state === "encodingError" || imageNode.state === "unknownError") {
248-
this.project.textRenderer.renderTextFromCenter(
249-
imageNode.uuid,
250-
this.project.renderer.transformWorld2View(imageNode.rectangle.topCenter),
251-
10 * this.project.camera.currentScale,
252-
Color.Red,
245+
imageNode.scale,
253246
);
247+
} else if (imageNode.state === "notFound") {
254248
this.project.textRenderer.renderTextFromCenter(
255-
imageNode.errorDetails,
256-
this.project.renderer.transformWorld2View(imageNode.rectangle.bottomCenter),
257-
10 * this.project.camera.currentScale,
258-
Color.Red,
259-
);
260-
if (imageNode.state === "unknownError") {
261-
this.project.textRenderer.renderTextFromCenter(
262-
"未知错误,建议反馈",
263-
this.project.renderer.transformWorld2View(imageNode.rectangle.center),
264-
20 * this.project.camera.currentScale,
265-
Color.Red,
266-
);
267-
} else if (imageNode.state === "encodingError") {
268-
this.project.textRenderer.renderTextFromCenter(
269-
"图片base64编码错误",
270-
this.project.renderer.transformWorld2View(imageNode.rectangle.center),
271-
20 * this.project.camera.currentScale,
272-
Color.Red,
273-
);
274-
}
275-
}
276-
// 调试,缩放信息和位置信息
277-
if (Settings.sync.showDebug) {
278-
this.project.textRenderer.renderText(
279-
"scale: " + imageNode.scaleNumber.toString(),
280-
this.project.renderer.transformWorld2View(imageNode.rectangle.location.subtract(new Vector(0, 6))),
281-
3 * this.project.camera.currentScale,
282-
Color.Gray,
283-
);
284-
this.project.textRenderer.renderText(
285-
"origin size: " + imageNode.originImageSize.toString(),
286-
this.project.renderer.transformWorld2View(imageNode.rectangle.location.subtract(new Vector(0, 3 + 6))),
287-
3 * this.project.camera.currentScale,
288-
Color.Gray,
249+
"not found",
250+
this.project.renderer.transformWorld2View(imageNode.rectangle.center),
251+
20 * this.project.camera.currentScale,
252+
this.project.stageStyleManager.currentStyle.StageObjectBorder,
289253
);
290254
}
291255
this.renderEntityDetails(imageNode);

app/src/core/service/Settings.tsx

Lines changed: 0 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -78,8 +78,6 @@ export namespace Settings {
7878
autoBackupDraftPath: string;
7979
autoBackupLimitCount: number;
8080
generateTextNodeByStringTabCount: number; // 仅在生成节点面板中使用
81-
compressPastedImages: boolean;
82-
maxPastedImageSize: number;
8381
autoLayoutWhenTreeGenerate: boolean;
8482
// 控制相关
8583
enableCollision: boolean; // 暂无
@@ -186,8 +184,6 @@ export namespace Settings {
186184
ignoreTextNodeTextRenderLessThanCameraScale: 0.065,
187185
showTextNodeBorder: true,
188186
autoRefreshStageByMouseAction: true,
189-
compressPastedImages: true,
190-
maxPastedImageSize: 1920,
191187
textCacheSize: 100,
192188
textScalingBehavior: "temp",
193189
antialiasing: "low",

app/src/core/service/dataManageService/copyEngine/copyEngine.tsx

Lines changed: 182 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,20 @@
1-
import { Vector } from "@graphif/data-structures";
2-
import { Rectangle } from "@graphif/shapes";
3-
import { Serialized } from "@/types/node";
4-
import { isMac } from "@/utils/platform";
51
import { Project, service } from "@/core/Project";
62
import { SerializedDataAdder } from "@/core/stage/stageManager/concreteMethods/StageSerializedAdder";
73
import { Entity } from "@/core/stage/stageObject/abstract/StageEntity";
4+
import { CollisionBox } from "@/core/stage/stageObject/collisionBox/collisionBox";
85
import { ImageNode } from "@/core/stage/stageObject/entity/ImageNode";
6+
import { SvgNode } from "@/core/stage/stageObject/entity/SvgNode";
97
import { TextNode } from "@/core/stage/stageObject/entity/TextNode";
10-
import { copyEnginePasteImage } from "@/core/service/dataManageService/copyEngine/pasteImage";
11-
import { copyEnginePastePlainText } from "@/core/service/dataManageService/copyEngine/pastePlainText";
8+
import { UrlNode } from "@/core/stage/stageObject/entity/UrlNode";
9+
import { Serialized } from "@/types/node";
10+
import { PathString } from "@/utils/pathString";
11+
import { isMac } from "@/utils/platform";
12+
import { Vector } from "@graphif/data-structures";
13+
import { Rectangle } from "@graphif/shapes";
14+
import { readImage, readText } from "@tauri-apps/plugin-clipboard-manager";
15+
import { toast } from "sonner";
16+
import { MouseLocation } from "../../controlService/MouseLocation";
17+
import { RectanglePushInEffect } from "../../feedbackService/effectEngine/concrete/RectanglePushInEffect";
1218

1319
/**
1420
* 专门用来管理节点复制的引擎
@@ -146,7 +152,7 @@ export class CopyEngine {
146152
paste() {
147153
// 如果有虚拟粘贴板数据,则优先粘贴虚拟粘贴板上的东西
148154
if (this.isVirtualClipboardEmpty()) {
149-
readClipboardItems(this.project.renderer.transformView2World(MouseLocation.vector()));
155+
this.readClipboard();
150156
} else {
151157
SerializedDataAdder.addSerializedData(this.copyBoardData, this.copyBoardMouseVector);
152158
}
@@ -180,6 +186,131 @@ export class CopyEngine {
180186
const clipboardRect = Rectangle.getBoundingRectangle(rectangles);
181187
this.copyBoardDataRectangle = clipboardRect;
182188
}
189+
190+
async readClipboard() {
191+
try {
192+
const text = await readText();
193+
this.copyEnginePastePlainText(text);
194+
} catch (err) {
195+
console.warn("文本剪贴板是空的", err);
196+
}
197+
try {
198+
// https://github.com/HuLaSpark/HuLa/blob/fe37c246777cde3325555ed2ba2fcf860888a4a8/src/utils/ImageUtils.ts#L121
199+
const image = await readImage();
200+
const imageData = await image.rgba();
201+
const { width, height } = await image.size();
202+
const canvas = document.createElement("canvas");
203+
canvas.width = width;
204+
canvas.height = height;
205+
const ctx = canvas.getContext("2d")!;
206+
const canvasImageData = ctx.createImageData(width, height);
207+
let uint8Array: Uint8Array;
208+
if (imageData.buffer instanceof ArrayBuffer) {
209+
uint8Array = new Uint8Array(imageData.buffer, imageData.byteOffset, imageData.byteLength);
210+
} else {
211+
uint8Array = new Uint8Array(imageData);
212+
}
213+
canvasImageData.data.set(uint8Array);
214+
ctx.putImageData(canvasImageData, 0, 0);
215+
const blob = await new Promise<Blob>((resolve, reject) => {
216+
canvas.toBlob((blob) => {
217+
if (!blob) {
218+
reject();
219+
} else {
220+
resolve(blob);
221+
}
222+
}, "image/avif");
223+
});
224+
this.copyEnginePasteImage(blob);
225+
} catch (err) {
226+
console.error("图片剪贴板是空的", err);
227+
}
228+
}
229+
230+
async copyEnginePastePlainText(item: string) {
231+
let entity: Entity | null = null;
232+
const collisionBox = new CollisionBox([
233+
new Rectangle(this.project.renderer.transformView2World(MouseLocation.vector()), Vector.getZero()),
234+
]);
235+
236+
if (isSvgString(item)) {
237+
// 是SVG类型
238+
entity = new SvgNode(this.project, {
239+
uuid: crypto.randomUUID(),
240+
content: item,
241+
location: [MouseLocation.x, MouseLocation.y],
242+
size: [400, 100],
243+
color: [0, 0, 0, 0],
244+
});
245+
} else if (PathString.isValidURL(item)) {
246+
// 是URL类型
247+
entity = new UrlNode(this.project, {
248+
title: "链接",
249+
uuid: crypto.randomUUID(),
250+
url: item,
251+
location: [MouseLocation.x, MouseLocation.y],
252+
});
253+
} else if (isMermaidGraphString(item)) {
254+
// 是Mermaid图表类型
255+
entity = new TextNode(this.project, {
256+
text: "mermaid图表",
257+
details: "```mermaid\n" + item + "\n```",
258+
collisionBox,
259+
});
260+
} else {
261+
const { valid, text, url } = PathString.isMarkdownUrl(item);
262+
if (valid) {
263+
// 是Markdown链接类型
264+
entity = new UrlNode(this.project, {
265+
title: text,
266+
uuid: crypto.randomUUID(),
267+
url: url,
268+
location: [MouseLocation.x, MouseLocation.y],
269+
});
270+
} else {
271+
// 只是普通的文本
272+
if (item.length > 3000) {
273+
entity = new TextNode(this.project, {
274+
text: "粘贴板文字过长",
275+
collisionBox,
276+
details: item,
277+
});
278+
} else {
279+
entity = new TextNode(this.project, {
280+
text: item,
281+
collisionBox,
282+
});
283+
// entity.move(
284+
// new Vector(-entity.collisionBox.getRectangle().width / 2, -entity.collisionBox.getRectangle().height / 2),
285+
// );
286+
}
287+
}
288+
}
289+
290+
if (entity !== null) {
291+
this.project.stageManager.add(entity);
292+
// 添加到section
293+
const mouseSections = this.project.sectionMethods.getSectionsByInnerLocation(MouseLocation);
294+
if (mouseSections.length > 0) {
295+
this.project.stageManager.goInSection([entity], mouseSections[0]);
296+
this.project.effects.addEffect(
297+
RectanglePushInEffect.sectionGoInGoOut(
298+
entity.collisionBox.getRectangle(),
299+
mouseSections[0].collisionBox.getRectangle(),
300+
),
301+
);
302+
}
303+
}
304+
}
305+
306+
async copyEnginePasteImage(item: Blob) {
307+
const attachmentId = this.project.addAttachment(item);
308+
309+
const imageNode = new ImageNode(this.project, {
310+
attachmentId,
311+
});
312+
this.project.stageManager.add(imageNode);
313+
}
183314
}
184315

185316
export function getRectangleFromSerializedEntities(serializedEntities: Serialized.Entity[]): Rectangle {
@@ -206,20 +337,49 @@ export function getRectangleFromSerializedEntities(serializedEntities: Serialize
206337
return Rectangle.getBoundingRectangle(rectangles);
207338
}
208339

209-
async function readClipboardItems(mouseLocation: Vector) {
210-
// test
211-
try {
212-
navigator.clipboard.read().then(async (items) => {
213-
for (const item of items) {
214-
if (item.types.includes("image/png")) {
215-
copyEnginePasteImage(item, mouseLocation);
216-
}
217-
if (item.types.includes("text/plain")) {
218-
copyEnginePastePlainText(item, mouseLocation);
219-
}
220-
}
221-
});
222-
} catch (err) {
223-
console.error("Failed to read clipboard contents: ", err);
340+
function isSvgString(str: string): boolean {
341+
const trimmed = str.trim();
342+
343+
// 基础结构检查
344+
if (
345+
!trimmed.startsWith("<svg") || // 是否以 <svg 开头
346+
!trimmed.endsWith("</svg>") // 是否以 </svg> 结尾
347+
) {
348+
return false;
349+
}
350+
351+
// 提取 <svg> 标签的属性部分
352+
const openTagMatch = trimmed.match(/<svg\s+([^>]*)>/i);
353+
if (!openTagMatch) return false; // 无有效属性则直接失败
354+
355+
// 检查是否存在 xmlns 命名空间声明
356+
const xmlnsRegex = /xmlns\s*=\s*["']http:\/\/www\.w3\.org\/2000\/svg["']/i;
357+
if (!xmlnsRegex.test(openTagMatch[1])) {
358+
return false;
359+
}
360+
361+
// 可选:通过 DOM 解析进一步验证(仅限浏览器环境)
362+
// 若在 Node.js 等无 DOM 环境,可注释此部分
363+
if (typeof DOMParser !== "undefined") {
364+
try {
365+
const parser = new DOMParser();
366+
const doc = parser.parseFromString(trimmed, "image/svg+xml");
367+
const svgElement = doc.documentElement;
368+
return svgElement.tagName.toLowerCase() === "svg" && svgElement.namespaceURI === "http://www.w3.org/2000/svg";
369+
} catch {
370+
// 解析失败则直接失败
371+
toast.error("SVG 解析失败");
372+
return false;
373+
}
374+
}
375+
376+
return true;
377+
}
378+
379+
function isMermaidGraphString(str: string): boolean {
380+
str = str.trim();
381+
if (str.startsWith("graph TD;") && str.endsWith(";")) {
382+
return true;
224383
}
384+
return false;
225385
}

0 commit comments

Comments
 (0)