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" ;
51import { Project , service } from "@/core/Project" ;
62import { SerializedDataAdder } from "@/core/stage/stageManager/concreteMethods/StageSerializedAdder" ;
73import { Entity } from "@/core/stage/stageObject/abstract/StageEntity" ;
4+ import { CollisionBox } from "@/core/stage/stageObject/collisionBox/collisionBox" ;
85import { ImageNode } from "@/core/stage/stageObject/entity/ImageNode" ;
6+ import { SvgNode } from "@/core/stage/stageObject/entity/SvgNode" ;
97import { 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
185316export 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 ( / < s v g \s + ( [ ^ > ] * ) > / i) ;
353+ if ( ! openTagMatch ) return false ; // 无有效属性则直接失败
354+
355+ // 检查是否存在 xmlns 命名空间声明
356+ const xmlnsRegex = / x m l n s \s * = \s * [ " ' ] h t t p : \/ \/ w w w \. w 3 \. o r g \/ 2 0 0 0 \/ s v g [ " ' ] / 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