@@ -15,19 +15,24 @@ const TOAST_ID = 'cam-toast';
1515const STYLE_ID = 'cam-styles' ;
1616const DISMISS_ID = 'cam-dismiss-btn' ;
1717const WRAPPER_ATTR = 'data-cam-anchor-wrapper' ;
18+ const UI_INSTANCE_ATTR = 'data-cam-instance' ;
19+ const ACTIVE_INSTANCE_ATTR = 'data-cam-active-instance' ;
1820
1921/** Max time (ms) to keep observing for the anchor element. */
2022const ANCHOR_OBSERVE_TIMEOUT = 8000 ;
2123
24+ let activeAnchorObserver : MutationObserver | null = null ;
25+ let activeAnchorTimeout : number | null = null ;
26+
2227function injectStyles ( ) : void {
2328 if ( document . getElementById ( STYLE_ID ) ) return ;
2429
2530 const css = `
2631 /* ---- Floating wrapper (positions the copy button + dismiss X) ---- */
2732 .cam-floating-wrapper {
2833 position: fixed;
29- bottom: 24px ;
30- right: 24px ;
34+ bottom: 96px ;
35+ right: 18px ;
3136 z-index: 999999;
3237 display: flex;
3338 align-items: flex-start;
@@ -36,21 +41,21 @@ function injectStyles(): void {
3641 /* ---- Floating icon button (bottom-right fallback) ---- */
3742 #${ BUTTON_ID } .cam-floating {
3843 position: relative;
39- width: 44px ;
40- height: 44px ;
44+ width: 38px ;
45+ height: 38px ;
4146 padding: 0;
4247 border: none;
4348 background: none;
44- border-radius: 12px ;
45- box-shadow: 0 4px 14px rgba(0,0,0,0.15 );
49+ border-radius: 10px ;
50+ box-shadow: 0 3px 12px rgba(0,0,0,0.16 );
4651 cursor: pointer;
47- opacity: 0.8 ;
52+ opacity: 0.78 ;
4853 transition: all 0.2s cubic-bezier(0.175, 0.885, 0.32, 1.275);
4954 }
5055 #${ BUTTON_ID } .cam-floating:hover {
51- transform: scale(1.1 );
56+ transform: scale(1.06 );
5257 opacity: 1;
53- box-shadow: 0 6px 20px rgba(0,0,0,0.25 );
58+ box-shadow: 0 5px 18px rgba(0,0,0,0.24 );
5459 }
5560 #${ BUTTON_ID } .cam-floating:active {
5661 transform: scale(0.95);
@@ -64,19 +69,19 @@ function injectStyles(): void {
6469 /* ---- Dismiss (X) button on the floating icon ---- */
6570 #${ DISMISS_ID } {
6671 position: absolute;
67- top: -6px ;
68- right: -6px ;
69- width: 18px ;
70- height: 18px ;
72+ top: -5px ;
73+ right: -5px ;
74+ width: 16px ;
75+ height: 16px ;
7176 padding: 0;
7277 border: none;
7378 border-radius: 50%;
7479 background: rgba(0, 0, 0, 0.65);
7580 color: #fff;
76- font-size: 11px ;
81+ font-size: 10px ;
7782 font-weight: 700;
7883 font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
79- line-height: 18px ;
84+ line-height: 16px ;
8085 text-align: center;
8186 cursor: pointer;
8287 opacity: 0;
@@ -185,13 +190,17 @@ function injectStyles(): void {
185190
186191 /* ---- Shared ---- */
187192 #${ BUTTON_ID } .cam-icon {
188- width: 18px ;
189- height: 18px ;
193+ width: 16px ;
194+ height: 16px ;
190195 flex-shrink: 0;
191196 }
192197 #${ BUTTON_ID } .cam-icon-btn .cam-icon {
193- width: 20px;
194- height: 20px;
198+ width: 18px;
199+ height: 18px;
200+ }
201+ #${ BUTTON_ID } .cam-floating .cam-icon {
202+ width: 100%;
203+ height: 100%;
195204 }
196205
197206 #${ BUTTON_ID } .cam-success {
@@ -232,17 +241,17 @@ function injectStyles(): void {
232241
233242 @media (max-width: 600px) {
234243 .cam-floating-wrapper {
235- bottom: 24px ;
236- right: 16px ;
244+ bottom: 92px ;
245+ right: 14px ;
237246 }
238247 #${ BUTTON_ID } .cam-floating {
239- padding: 8px 14px ;
240- font-size: 13px ;
241- border-radius: 10px ;
248+ width: 36px ;
249+ height: 36px ;
250+ border-radius: 9px ;
242251 }
243252 #${ TOAST_ID } {
244253 top: auto;
245- bottom: 80px ;
254+ bottom: 144px ;
246255 right: 16px;
247256 }
248257 }
@@ -329,16 +338,44 @@ function applyInlineCss(el: HTMLElement, css?: Record<string, string>): void {
329338 }
330339}
331340
341+ function createInstanceId ( ) : string {
342+ return `${ Date . now ( ) . toString ( 36 ) } -${ Math . random ( ) . toString ( 36 ) . slice ( 2 ) } ` ;
343+ }
344+
345+ function setActiveInstance ( instanceId : string ) : void {
346+ document . documentElement . setAttribute ( ACTIVE_INSTANCE_ATTR , instanceId ) ;
347+ }
348+
349+ function isActiveInstance ( instanceId : string ) : boolean {
350+ return document . documentElement . getAttribute ( ACTIVE_INSTANCE_ATTR ) === instanceId ;
351+ }
352+
353+ function markInjected ( el : HTMLElement , instanceId : string ) : void {
354+ el . setAttribute ( UI_INSTANCE_ATTR , instanceId ) ;
355+ }
356+
357+ function cancelAnchorObserver ( ) : void {
358+ activeAnchorObserver ?. disconnect ( ) ;
359+ activeAnchorObserver = null ;
360+
361+ if ( activeAnchorTimeout !== null ) {
362+ window . clearTimeout ( activeAnchorTimeout ) ;
363+ activeAnchorTimeout = null ;
364+ }
365+ }
366+
332367function buildAnchorNode (
333368 btn : HTMLButtonElement ,
334369 anchor : AnchorConfig ,
370+ instanceId : string ,
335371) : HTMLElement {
336372 applyInlineCss ( btn , anchor . css ) ;
337373
338374 if ( ! anchor . wrapperTag ) return btn ;
339375
340376 const wrapper = document . createElement ( anchor . wrapperTag ) ;
341377 wrapper . setAttribute ( WRAPPER_ATTR , 'true' ) ;
378+ markInjected ( wrapper , instanceId ) ;
342379
343380 if ( anchor . wrapperClass ) {
344381 wrapper . className = anchor . wrapperClass ;
@@ -349,10 +386,21 @@ function buildAnchorNode(
349386 return wrapper ;
350387}
351388
389+ function removeInjectedUi ( except ?: HTMLElement ) : void {
390+ const nodes = new Set < Element > ( ) ;
391+ document
392+ . querySelectorAll ( `[${ WRAPPER_ATTR } ], .cam-floating-wrapper, #${ BUTTON_ID } , #${ DISMISS_ID } ` )
393+ . forEach ( ( el ) => {
394+ if ( except && ( el === except || el . contains ( except ) ) ) return ;
395+ nodes . add ( el ) ;
396+ } ) ;
397+
398+ nodes . forEach ( ( el ) => el . remove ( ) ) ;
399+ }
400+
352401function clearInjectedUi ( ) : void {
353- document . querySelectorAll ( `[${ WRAPPER_ATTR } ]` ) . forEach ( ( el ) => el . remove ( ) ) ;
354- document . querySelector ( '.cam-floating-wrapper' ) ?. remove ( ) ;
355- document . getElementById ( BUTTON_ID ) ?. remove ( ) ;
402+ cancelAnchorObserver ( ) ;
403+ removeInjectedUi ( ) ;
356404}
357405
358406/**
@@ -362,19 +410,25 @@ function clearInjectedUi(): void {
362410function attachToAnchor (
363411 btn : HTMLButtonElement ,
364412 anchor : AnchorConfig ,
413+ instanceId : string ,
365414) : boolean {
415+ if ( ! isActiveInstance ( instanceId ) ) return false ;
416+
366417 const target = findAnchorTarget ( anchor . selector ) ;
367418 if ( ! target ) return false ;
368419
420+ removeInjectedUi ( btn ) ;
421+
369422 // Use the extractor's preferred style, defaulting to icon-only
370423 const styleKey = anchor . style || 'icon' ;
371424 btn . className = STYLE_CLASS [ styleKey ] || 'cam-icon-btn' ;
425+ markInjected ( btn , instanceId ) ;
372426
373427 // If a label is provided (or the style is not icon), show text alongside the icon
374428 const label = anchor . label ?? ( styleKey === 'icon' ? '' : 'Copy as Markdown' ) ;
375429 safeSetHtml ( btn , `${ getIcon ( ) } ${ label ? `<span>${ label } </span>` : '' } ` ) ;
376430
377- const insertionNode = buildAnchorNode ( btn , anchor ) ;
431+ const insertionNode = buildAnchorNode ( btn , anchor , instanceId ) ;
378432
379433 const position = anchor . position || 'append' ;
380434 switch ( position ) {
@@ -418,17 +472,21 @@ function dismissForCurrentPage(): void {
418472 * Show the button as a floating FAB at the bottom-right,
419473 * wrapped with a dismiss (X) button.
420474 */
421- function showFloating ( btn : HTMLButtonElement ) : void {
475+ function showFloating ( btn : HTMLButtonElement , instanceId : string ) : void {
476+ if ( ! isActiveInstance ( instanceId ) ) return ;
422477 if ( isDismissedForCurrentPage ( ) ) return ;
423478
424479 btn . className = 'cam-floating' ;
480+ markInjected ( btn , instanceId ) ;
425481 safeSetHtml ( btn , getIcon ( ) ) ;
426482
427483 const wrapper = document . createElement ( 'div' ) ;
428484 wrapper . className = 'cam-floating-wrapper' ;
485+ markInjected ( wrapper , instanceId ) ;
429486
430487 const dismiss = document . createElement ( 'button' ) ;
431488 dismiss . id = DISMISS_ID ;
489+ markInjected ( dismiss , instanceId ) ;
432490 dismiss . title = 'Hide for this page' ;
433491 dismiss . setAttribute ( 'aria-label' , 'Dismiss Copy as Markdown button' ) ;
434492 dismiss . textContent = '✕' ;
@@ -465,10 +523,15 @@ export function showButton(
465523
466524 clearInjectedUi ( ) ;
467525
526+ const instanceId = createInstanceId ( ) ;
527+ setActiveInstance ( instanceId ) ;
528+
468529 const btn = document . createElement ( 'button' ) ;
469530 btn . id = BUTTON_ID ;
531+ btn . type = 'button' ;
470532 btn . title = 'Copy this page as Markdown' ;
471533 btn . setAttribute ( 'aria-label' , 'Copy this page as Markdown' ) ;
534+ markInjected ( btn , instanceId ) ;
472535
473536 // Wire up click handler
474537 btn . addEventListener ( 'click' , async ( e ) => {
@@ -489,18 +552,18 @@ export function showButton(
489552
490553 // Attempt anchor placement
491554 if ( anchor ) {
492- if ( attachToAnchor ( btn , anchor ) ) {
555+ if ( attachToAnchor ( btn , anchor , instanceId ) ) {
493556 console . log ( '[Copy as Markdown] Anchored inline' ) ;
494557 return btn ;
495558 }
496559
497560 // Anchor not found yet — show floating immediately, observe for the anchor
498- showFloating ( btn ) ;
561+ showFloating ( btn , instanceId ) ;
499562 console . log ( '[Copy as Markdown] Anchor not found yet, floating while observing…' ) ;
500563
501- observeForAnchor ( btn , anchor ) ;
564+ observeForAnchor ( btn , anchor , instanceId ) ;
502565 } else {
503- showFloating ( btn ) ;
566+ showFloating ( btn , instanceId ) ;
504567 }
505568
506569 return btn ;
@@ -513,14 +576,27 @@ export function showButton(
513576function observeForAnchor (
514577 btn : HTMLButtonElement ,
515578 anchor : AnchorConfig ,
579+ instanceId : string ,
516580) : void {
517581 let settled = false ;
518582
519583 const observer = new MutationObserver ( ( ) => {
520584 if ( settled ) return ;
585+ if ( ! isActiveInstance ( instanceId ) ) {
586+ settled = true ;
587+ observer . disconnect ( ) ;
588+ if ( activeAnchorObserver === observer ) activeAnchorObserver = null ;
589+ return ;
590+ }
591+
521592 if ( findAnchorTarget ( anchor . selector ) ) {
522593 settled = true ;
523594 observer . disconnect ( ) ;
595+ if ( activeAnchorObserver === observer ) activeAnchorObserver = null ;
596+ if ( activeAnchorTimeout !== null ) {
597+ window . clearTimeout ( activeAnchorTimeout ) ;
598+ activeAnchorTimeout = null ;
599+ }
524600
525601 // Detach from floating position (remove the wrapper if present)
526602 btn . closest ( '.cam-floating-wrapper' ) ?. remove ( ) ;
@@ -529,22 +605,25 @@ function observeForAnchor(
529605 btn . className = '' ;
530606 btn . removeAttribute ( 'style' ) ;
531607
532- if ( attachToAnchor ( btn , anchor ) ) {
608+ if ( attachToAnchor ( btn , anchor , instanceId ) ) {
533609 console . log ( '[Copy as Markdown] Late-anchored inline' ) ;
534- } else {
610+ } else if ( isActiveInstance ( instanceId ) ) {
535611 // Shouldn't happen, but be safe
536- showFloating ( btn ) ;
612+ showFloating ( btn , instanceId ) ;
537613 }
538614 }
539615 } ) ;
540616
541617 observer . observe ( document . body , { childList : true , subtree : true } ) ;
618+ activeAnchorObserver = observer ;
542619
543620 // Stop observing after timeout
544- setTimeout ( ( ) => {
621+ activeAnchorTimeout = window . setTimeout ( ( ) => {
545622 if ( ! settled ) {
546623 settled = true ;
547624 observer . disconnect ( ) ;
625+ if ( activeAnchorObserver === observer ) activeAnchorObserver = null ;
626+ activeAnchorTimeout = null ;
548627 console . log ( '[Copy as Markdown] Anchor not found after timeout, staying floating' ) ;
549628 }
550629 } , ANCHOR_OBSERVE_TIMEOUT ) ;
0 commit comments