Skip to content

Commit c13acb2

Browse files
committed
fix: harden copy button placement
1 parent 6550ed9 commit c13acb2

4 files changed

Lines changed: 284 additions & 42 deletions

File tree

package.json

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
{
22
"name": "copy-as-markdown",
3-
"version": "1.1.4",
3+
"version": "1.1.5",
44
"description": "Context-aware Copy as Markdown buttons — the fastest way to share web content with LLMs",
55
"type": "module",
66
"scripts": {

src/core/ui.ts

Lines changed: 117 additions & 38 deletions
Original file line numberDiff line numberDiff line change
@@ -15,19 +15,24 @@ const TOAST_ID = 'cam-toast';
1515
const STYLE_ID = 'cam-styles';
1616
const DISMISS_ID = 'cam-dismiss-btn';
1717
const 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. */
2022
const ANCHOR_OBSERVE_TIMEOUT = 8000;
2123

24+
let activeAnchorObserver: MutationObserver | null = null;
25+
let activeAnchorTimeout: number | null = null;
26+
2227
function 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+
332367
function 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+
352401
function 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 {
362410
function 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(
513576
function 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);

src/main.ts

Lines changed: 11 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -39,6 +39,9 @@ import './extractors/pypi';
3939
if ((window as any).__copyAsMarkdownInit) return;
4040
(window as any).__copyAsMarkdownInit = true;
4141

42+
let extractCurrentPage: (() => Promise<string>) | null = null;
43+
let toolbarListenerAttached = false;
44+
4245
function init(): void {
4346
let extractor = findExtractor(window.location.href);
4447

@@ -70,20 +73,25 @@ import './extractors/pypi';
7073
console.log(`[Copy as Markdown] Active extractor: ${extractor.name}`);
7174
}
7275

76+
extractCurrentPage = () => extractor!.extract();
77+
7378
const anchor = extractor!.buttonPlacement === 'anchor'
7479
? extractor!.anchor
7580
: null;
7681

7782
showButton(
78-
() => extractor!.extract(),
83+
() => extractCurrentPage!(),
7984
anchor,
8085
);
8186

8287
// Listen for Extension Toolbar Icon clicks
83-
if (typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
88+
if (!toolbarListenerAttached && typeof chrome !== 'undefined' && chrome.runtime && chrome.runtime.onMessage) {
89+
toolbarListenerAttached = true;
8490
chrome.runtime.onMessage.addListener((request) => {
8591
if (request.action === 'copy-as-markdown') {
86-
Promise.resolve(extractor!.extract())
92+
if (!extractCurrentPage) return;
93+
94+
Promise.resolve(extractCurrentPage())
8795
.then(md => {
8896
copyToClipboard(md).then(() => {
8997
showToast('✅ Copied as Markdown!');

0 commit comments

Comments
 (0)