Created
June 17, 2026 13:51
-
-
Save Sharktheone/f05c85670025d18bbcbd63bf525d6925 to your computer and use it in GitHub Desktop.
Webkit svg trails
This file contains hidden or bidirectional Unicode text that may be interpreted or compiled differently than what appears below. To review, open the file in an editor that reveals hidden Unicode characters.
Learn more about bidirectional Unicode characters
| <!doctype html> | |
| <html lang="en"> | |
| <head> | |
| <meta charset="utf-8" /> | |
| <meta name="viewport" content="width=device-width, initial-scale=1" /> | |
| <title>WebKit note-drag trail repro</title> | |
| <style> | |
| html, body { | |
| margin: 0; | |
| height: 100%; | |
| font-family: system-ui, sans-serif; | |
| background: #ffffff; | |
| color: #eee; | |
| overflow: hidden; | |
| } | |
| /* --- the "infinite canvas" viewport -------------------------------- */ | |
| /* Mirrors styles.module.scss .noteAreaWrapper { overflow:hidden } */ | |
| .viewport { | |
| position: absolute; | |
| inset: 0; | |
| overflow: hidden; | |
| } | |
| /* Mirrors .notearea — the scaled+translated canvas. | |
| transform: scale() creates a new containing block + compositing | |
| layer; this is what makes WebKit's incremental repaint of children | |
| go wrong at fractional device-pixel scales. */ | |
| .canvas { | |
| position: relative; | |
| width: 100%; | |
| height: 100%; | |
| transform: scale(1.09) translate(0px, 0px); /* odd scale on purpose */ | |
| transform-origin: center center; | |
| } | |
| /* Mirrors the wrapper div in NoteArea.tsx that gets left/top set. | |
| position:absolute + animating left/top is the trigger. */ | |
| .note { | |
| position: absolute; | |
| width: 233px; | |
| user-select: none; | |
| cursor: grab; | |
| } | |
| .note:active { cursor: grabbing; } | |
| /* --bordercolor is bright on purpose: the border is what WebKit | |
| fails to clear, so it's the color you see smeared in the trail. */ | |
| .note-body { | |
| background: var(--notecolor); | |
| border: solid var(--bordercolor); | |
| border-width: 4px 4px 0 4px; | |
| padding: 12px; | |
| color: #111; | |
| min-height: 70px; | |
| font-size: 14px; | |
| } | |
| /* Dogear with gradient + pseudo-element, same shape as the real app. | |
| Gradients / pseudo-elements give WebKit more to (fail to) repaint. */ | |
| .dogears { | |
| height: 25px; | |
| background: var(--notecolor); | |
| width: calc(100% - 25px); | |
| position: relative; | |
| border: solid var(--bordercolor); | |
| border-width: 0 0 4px 4px; | |
| } | |
| .dogears::before { | |
| content: ''; | |
| position: absolute; | |
| top: 0; right: -25px; | |
| width: 25px; height: 25px; | |
| background: linear-gradient(135deg, | |
| color-mix(in srgb, var(--notecolor) 70%, var(--bordercolor) 30%) 0%, | |
| color-mix(in srgb, var(--notecolor) 70%, var(--bordercolor) 30%) calc(50% - 4px), | |
| var(--bordercolor) calc(50% - 4px), var(--bordercolor) 50%, | |
| transparent 50%, transparent 100%); | |
| border: solid var(--bordercolor); | |
| border-width: 4px 0 0 4px; | |
| } | |
| /* The SVG context the bug report calls out. Tabler icons render as | |
| inline SVG all over the canvas, so each note carries one. */ | |
| .note svg { display: block; margin-bottom: 6px; color: #111; } | |
| /* --- HUD ----------------------------------------------------------- */ | |
| .hud { | |
| position: absolute; | |
| top: 12px; left: 50%; | |
| transform: translateX(-50%); | |
| z-index: 10; | |
| display: flex; | |
| gap: 6px; | |
| align-items: center; | |
| background: rgba(20,20,20,.85); | |
| border: 1px solid #555; | |
| border-radius: 8px; | |
| padding: 8px 10px; | |
| font-size: 12px; | |
| } | |
| .hud button { | |
| font: inherit; | |
| background: #444; | |
| color: #eee; | |
| border: 1px solid #666; | |
| border-radius: 5px; | |
| padding: 4px 8px; | |
| cursor: pointer; | |
| } | |
| .hud button.on { background: #2f7; color: #111; border-color: #2f7; } | |
| .hud .val { font-variant-numeric: tabular-nums; min-width: 3ch; text-align: right; } | |
| .hint { | |
| position: absolute; | |
| bottom: 12px; left: 12px; | |
| z-index: 10; | |
| max-width: 520px; | |
| background: rgba(20,20,20,.85); | |
| border: 1px solid #555; | |
| border-radius: 8px; | |
| padding: 10px 12px; | |
| font-size: 12px; | |
| line-height: 1.5; | |
| } | |
| kbd { | |
| background:#333;border:1px solid #666;border-bottom-width:2px; | |
| border-radius:4px;padding:0 5px;font-family:inherit; | |
| } | |
| </style> | |
| </head> | |
| <body> | |
| <div class="viewport" id="viewport"> | |
| <div class="canvas" id="canvas"></div> | |
| </div> | |
| <div class="hud"> | |
| <span>scale</span> | |
| <button id="scaleDown">−</button> | |
| <span class="val" id="scaleVal">1.09</span> | |
| <button id="scaleUp">+</button> | |
| <span style="opacity:.4">|</span> | |
| <button id="toggleScale" class="on">scaled parent</button> | |
| <button id="toggleSvg" class="on">SVG in notes</button> | |
| <button id="toggleMove" class="on">move via left/top</button> | |
| </div> | |
| <div class="hint"> | |
| <b>Repro:</b> set the browser zoom to <b>109%</b> (<kbd>⌘</kbd>+ a few times), | |
| then drag a note around in Safari/WebKit. The note smears a trail of stale | |
| copies instead of repainting cleanly.<br /> | |
| Toggle the buttons to isolate the trigger. The trail needs all three on: | |
| a <i>scaled parent</i> + a <i>fractional effective scale</i> (page-zoom × | |
| canvas-scale) + moving the child by <i>left/top</i>. Flip “move via left/top” | |
| to off (uses <code>transform: translate</code> instead) and the trail | |
| disappears — that’s the fix for the real app. | |
| </div> | |
| <script> | |
| const NOTE_COLOR = '#ffffff'; | |
| const BORDER_COLOR = '#000000'; | |
| const ICON = `<svg width="20" height="20" viewBox="0 0 24 24" fill="none" | |
| stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"> | |
| <path d="M14 3v4a1 1 0 0 0 1 1h4" /> | |
| <path d="M17 21H7a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h7l5 5v11a2 2 0 0 1-2 2z" /> | |
| <line x1="9" y1="9" x2="10" y2="9" /> | |
| <line x1="9" y1="13" x2="15" y2="13" /> | |
| <line x1="9" y1="17" x2="15" y2="17" /> | |
| </svg>`; | |
| const canvas = document.getElementById('canvas'); | |
| const scaleVal = document.getElementById('scaleVal'); | |
| let scale = 1.09; | |
| let useSvg = true; | |
| let moveViaLeftTop = true; | |
| // A single note, centered-ish, so the trail is the only thing on screen. | |
| const notes = []; | |
| function buildNotes() { | |
| canvas.innerHTML = ''; | |
| notes.length = 0; | |
| const x = 120, y = 120; | |
| const note = document.createElement('div'); | |
| note.className = 'note'; | |
| note.style.setProperty('--notecolor', NOTE_COLOR); | |
| note.style.setProperty('--bordercolor', BORDER_COLOR); | |
| note.style.left = x + 'px'; | |
| note.style.top = y + 'px'; | |
| note.dataset.x = x; | |
| note.dataset.y = y; | |
| note.innerHTML = | |
| `<div class="note-body">${useSvg ? ICON : ''}drag me around</div>` + | |
| `<div class="dogears"></div>`; | |
| attachDrag(note); | |
| canvas.appendChild(note); | |
| notes.push(note); | |
| } | |
| function place(note, x, y) { | |
| note.dataset.x = x; | |
| note.dataset.y = y; | |
| if (moveViaLeftTop) { | |
| note.style.transform = ''; | |
| note.style.left = x + 'px'; | |
| note.style.top = y + 'px'; | |
| } else { | |
| // The fix: keep left/top fixed, move with a compositor-friendly transform. | |
| note.style.left = '0px'; | |
| note.style.top = '0px'; | |
| note.style.transform = `translate(${x}px, ${y}px)`; | |
| } | |
| } | |
| function attachDrag(note) { | |
| note.addEventListener('mousedown', (e) => { | |
| if (e.button !== 0) return; | |
| e.preventDefault(); | |
| const startX = e.clientX / scale; | |
| const startY = e.clientY / scale; | |
| const orig = { x: +note.dataset.x, y: +note.dataset.y }; | |
| function onMove(ev) { | |
| const dx = ev.clientX / scale - startX; | |
| const dy = ev.clientY / scale - startY; | |
| place(note, orig.x + dx, orig.y + dy); | |
| } | |
| function onUp() { | |
| document.removeEventListener('mousemove', onMove); | |
| document.removeEventListener('mouseup', onUp); | |
| } | |
| document.addEventListener('mousemove', onMove); | |
| document.addEventListener('mouseup', onUp); | |
| }); | |
| } | |
| function applyCanvasTransform(scaleOn) { | |
| canvas.style.transform = scaleOn | |
| ? `scale(${scale}) translate(0px, 0px)` | |
| : 'none'; | |
| } | |
| // --- HUD wiring ------------------------------------------------------ | |
| const toggleScale = document.getElementById('toggleScale'); | |
| const toggleSvg = document.getElementById('toggleSvg'); | |
| const toggleMove = document.getElementById('toggleMove'); | |
| document.getElementById('scaleUp').onclick = () => setScale(scale + 0.01); | |
| document.getElementById('scaleDown').onclick = () => setScale(scale - 0.01); | |
| function setScale(s) { | |
| scale = Math.round(Math.max(0.2, Math.min(3, s)) * 100) / 100; | |
| scaleVal.textContent = scale.toFixed(2); | |
| applyCanvasTransform(toggleScale.classList.contains('on')); | |
| } | |
| toggleScale.onclick = () => { | |
| toggleScale.classList.toggle('on'); | |
| applyCanvasTransform(toggleScale.classList.contains('on')); | |
| }; | |
| toggleSvg.onclick = () => { | |
| toggleSvg.classList.toggle('on'); | |
| useSvg = toggleSvg.classList.contains('on'); | |
| buildNotes(); | |
| }; | |
| toggleMove.onclick = () => { | |
| toggleMove.classList.toggle('on'); | |
| moveViaLeftTop = toggleMove.classList.contains('on'); | |
| // re-place every note with the new strategy | |
| notes.forEach(n => place(n, +n.dataset.x, +n.dataset.y)); | |
| }; | |
| buildNotes(); | |
| applyCanvasTransform(true); | |
| </script> | |
| </body> | |
| </html> |
Sign up for free
to join this conversation on GitHub.
Already have an account?
Sign in to comment