@@ -4,13 +4,17 @@ export interface ClipboardPayload {
44 kind : "timeline-clip" | "dom-element" ;
55 html : string ;
66 sourceFile : string ;
7+ originSelector ?: string ;
8+ originSelectorIndex ?: number ;
79}
810
911interface SerializedPayload {
1012 _marker : string ;
1113 kind : "timeline-clip" | "dom-element" ;
1214 html : string ;
1315 sourceFile : string ;
16+ originSelector ?: string ;
17+ originSelectorIndex ?: number ;
1418}
1519
1620export function serializeClipboardPayload ( payload : ClipboardPayload ) : string {
@@ -19,6 +23,8 @@ export function serializeClipboardPayload(payload: ClipboardPayload): string {
1923 kind : payload . kind ,
2024 html : payload . html ,
2125 sourceFile : payload . sourceFile ,
26+ originSelector : payload . originSelector ,
27+ originSelectorIndex : payload . originSelectorIndex ,
2228 } ;
2329 return JSON . stringify ( data ) ;
2430}
@@ -35,7 +41,118 @@ export function deserializeClipboardPayload(json: string): ClipboardPayload | nu
3541 if ( obj . _marker !== CLIPBOARD_MARKER ) return null ;
3642 if ( obj . kind !== "timeline-clip" && obj . kind !== "dom-element" ) return null ;
3743 if ( typeof obj . html !== "string" || typeof obj . sourceFile !== "string" ) return null ;
38- return { kind : obj . kind , html : obj . html , sourceFile : obj . sourceFile } ;
44+ return {
45+ kind : obj . kind ,
46+ html : obj . html ,
47+ sourceFile : obj . sourceFile ,
48+ originSelector : typeof obj . originSelector === "string" ? obj . originSelector : undefined ,
49+ originSelectorIndex :
50+ typeof obj . originSelectorIndex === "number" ? obj . originSelectorIndex : undefined ,
51+ } ;
52+ }
53+
54+ /**
55+ * Insert `newHtml` as a sibling immediately after the element matched by
56+ * `selector` (at `selectorIndex`) in `source`. Falls back to inserting after
57+ * the composition root if the selector doesn't match — so paste never silently
58+ * drops the content.
59+ */
60+ export function insertAsSibling (
61+ source : string ,
62+ newHtml : string ,
63+ selector : string | undefined ,
64+ selectorIndex : number | undefined ,
65+ ) : string {
66+ if ( selector ) {
67+ const idx = selectorIndex ?? 0 ;
68+ let matchCount = 0 ;
69+
70+ // Find the element by searching for its opening tag pattern.
71+ // For id selectors like #foo, search for id="foo".
72+ // For class selectors like .name-text, search for class="...name-text...".
73+ // For attribute selectors like [data-composition-id="x"], search literally.
74+
75+ let searchPattern : RegExp | null = null ;
76+ if ( selector . startsWith ( "#" ) ) {
77+ const id = selector . slice ( 1 ) ;
78+ searchPattern = new RegExp ( `<[a-z][^>]*\\bid="${ id } "[^>]*>` , "gi" ) ;
79+ } else if ( selector . startsWith ( "." ) ) {
80+ const cls = selector . slice ( 1 ) ;
81+ searchPattern = new RegExp ( `<[a-z][^>]*\\bclass="[^"]*\\b${ cls } \\b[^"]*"[^>]*>` , "gi" ) ;
82+ } else if ( selector . startsWith ( "[" ) ) {
83+ const inner = selector . slice ( 1 , - 1 ) ;
84+ searchPattern = new RegExp ( `<[a-z][^>]*\\b${ inner . replace ( / " / g, '"' ) } [^>]*>` , "gi" ) ;
85+ }
86+
87+ if ( searchPattern ) {
88+ let match : RegExpExecArray | null ;
89+ while ( ( match = searchPattern . exec ( source ) ) !== null ) {
90+ if ( matchCount === idx ) {
91+ const insertPos = findClosingTagPosition ( source , match . index ) ;
92+ if ( insertPos > 0 ) {
93+ return source . slice ( 0 , insertPos ) + "\n" + newHtml + source . slice ( insertPos ) ;
94+ }
95+ }
96+ matchCount ++ ;
97+ }
98+ }
99+ }
100+
101+ // Fallback: insert after composition root opening tag (same as timeline clips)
102+ const rootOpenTag = / < [ ^ > ] * d a t a - c o m p o s i t i o n - i d = " [ ^ " ] + " [ ^ > ] * > / i;
103+ const rootMatch = rootOpenTag . exec ( source ) ;
104+ if ( rootMatch && rootMatch . index != null ) {
105+ const insertAt = rootMatch . index + rootMatch [ 0 ] . length ;
106+ return source . slice ( 0 , insertAt ) + newHtml + source . slice ( insertAt ) ;
107+ }
108+
109+ return source + newHtml ;
110+ }
111+
112+ function findClosingTagPosition ( html : string , openTagStart : number ) : number {
113+ // Find the end of the opening tag
114+ const openTagEnd = html . indexOf ( ">" , openTagStart ) ;
115+ if ( openTagEnd < 0 ) return - 1 ;
116+
117+ // Self-closing tag?
118+ if ( html [ openTagEnd - 1 ] === "/" ) return openTagEnd + 1 ;
119+
120+ // Extract the tag name
121+ const tagNameMatch = html . slice ( openTagStart ) . match ( / ^ < ( [ a - z ] [ a - z 0 - 9 ] * ) / i) ;
122+ if ( ! tagNameMatch ) return - 1 ;
123+ const tagName = tagNameMatch [ 1 ] ! ;
124+
125+ // Walk forward counting open/close tags of the same name
126+ let depth = 1 ;
127+ let pos = openTagEnd + 1 ;
128+ const openRe = new RegExp ( `<${ tagName } (?:\\s|>|/>)` , "gi" ) ;
129+ const closeRe = new RegExp ( `</${ tagName } \\s*>` , "gi" ) ;
130+
131+ while ( depth > 0 && pos < html . length ) {
132+ openRe . lastIndex = pos ;
133+ closeRe . lastIndex = pos ;
134+
135+ const nextOpen = openRe . exec ( html ) ;
136+ const nextClose = closeRe . exec ( html ) ;
137+
138+ if ( ! nextClose ) return - 1 ;
139+
140+ if ( nextOpen && nextOpen . index < nextClose . index ) {
141+ // Check if it's self-closing
142+ const selfCloseCheck = html . lastIndexOf ( "/" , html . indexOf ( ">" , nextOpen . index ) ) ;
143+ if ( selfCloseCheck > nextOpen . index ) {
144+ pos = html . indexOf ( ">" , nextOpen . index ) + 1 ;
145+ } else {
146+ depth ++ ;
147+ pos = html . indexOf ( ">" , nextOpen . index ) + 1 ;
148+ }
149+ } else {
150+ depth -- ;
151+ if ( depth === 0 ) return nextClose . index + nextClose [ 0 ] . length ;
152+ pos = nextClose . index + nextClose [ 0 ] . length ;
153+ }
154+ }
155+ return - 1 ;
39156}
40157
41158export function deduplicateIds ( html : string , existingIds : string [ ] ) : string {
0 commit comments