Skip to content

Commit 6254f4b

Browse files
committed
Enhance caret interactions
1 parent 521215b commit 6254f4b

7 files changed

Lines changed: 386 additions & 3 deletions

File tree

index.html

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,9 +10,12 @@
1010

1111
<script type="module">
1212
import wavearea from './dist/wavearea.js'
13+
import { smoothCaret, loopHighlight } from './src/layers/index.js'
14+
1315
wavearea(document.getElementById('wavearea'), {
1416
engine: window.__playerEngine || undefined,
1517
store: window.__store || undefined,
18+
layers: [smoothCaret(), loopHighlight()],
1619
})
1720
</script>
1821
</body>

src/layers/index.js

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,2 @@
1+
export { default as smoothCaret } from './smooth-caret.js'
2+
export { default as loopHighlight } from './loop-highlight.js'

src/layers/loop-highlight.js

Lines changed: 95 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,95 @@
1+
// Loop highlight layer — highlights the active loop region
2+
// Uses CSS Custom Highlight API when available, falls back to overlay div
3+
4+
// convert clean offset to raw offset (including combining marks)
5+
function cleanToRaw(content, cleanPos) {
6+
let clean = 0, raw = 0
7+
while (clean < cleanPos && raw < content.length) {
8+
if (content[raw] < '\u0300') clean++
9+
raw++
10+
while (raw < content.length && content[raw] >= '\u0300') raw++
11+
}
12+
return raw
13+
}
14+
15+
export default function loopHighlight(opts = {}) {
16+
let { color = 'rgba(100, 160, 255, 0.2)', name = 'loop' } = opts
17+
18+
return (state, root) => {
19+
let hasAPI = typeof CSS !== 'undefined' && CSS.highlights
20+
21+
let style = document.createElement('style')
22+
style.textContent = hasAPI
23+
? `::highlight(${name}) { background: ${color}; }`
24+
: `.loop-highlight-overlay { position: absolute; background: ${color}; pointer-events: none; z-index: 1; }`
25+
root.appendChild(style)
26+
27+
let highlight = hasAPI ? new Highlight() : null
28+
if (hasAPI) CSS.highlights.set(name, highlight)
29+
30+
let overlay = null
31+
let raf = null
32+
let prevStart = -1, prevEnd = -1, prevLoop = false
33+
34+
let update = () => {
35+
let ea = state.refs?.editarea
36+
let textNode = ea?.firstChild
37+
38+
// skip if unchanged
39+
if (state.loop === prevLoop && state.clipStart === prevStart && state.clipEnd === prevEnd) return
40+
prevStart = state.clipStart; prevEnd = state.clipEnd; prevLoop = state.loop
41+
42+
if (!ea || !textNode || !state.loop || state.clipStart == null || state.clipEnd == null || state.clipStart >= state.clipEnd) {
43+
if (hasAPI) highlight.clear()
44+
if (overlay) overlay.style.display = 'none'
45+
return
46+
}
47+
48+
let content = textNode.textContent
49+
let rawStart = cleanToRaw(content, state.clipStart)
50+
let rawEnd = cleanToRaw(content, state.clipEnd)
51+
52+
if (hasAPI) {
53+
highlight.clear()
54+
let range = new Range()
55+
range.setStart(textNode, Math.min(rawStart, content.length))
56+
range.setEnd(textNode, Math.min(rawEnd, content.length))
57+
highlight.add(range)
58+
} else {
59+
if (!overlay) {
60+
overlay = document.createElement('div')
61+
overlay.className = 'loop-highlight-overlay'
62+
let container = root.querySelector('.container')
63+
if (container) container.appendChild(overlay)
64+
}
65+
let range = new Range()
66+
range.setStart(textNode, Math.min(rawStart, content.length))
67+
range.setEnd(textNode, Math.min(rawEnd, content.length))
68+
let rects = range.getClientRects()
69+
if (rects.length) {
70+
let eaRect = ea.getBoundingClientRect()
71+
let r = rects[0]
72+
overlay.style.display = ''
73+
overlay.style.left = (r.left - eaRect.left) + 'px'
74+
overlay.style.top = (r.top - eaRect.top) + 'px'
75+
overlay.style.width = r.width + 'px'
76+
overlay.style.height = r.height + 'px'
77+
}
78+
}
79+
}
80+
81+
// poll on rAF
82+
let poll = () => {
83+
update()
84+
raf = requestAnimationFrame(poll)
85+
}
86+
raf = requestAnimationFrame(poll)
87+
88+
return () => {
89+
if (raf) cancelAnimationFrame(raf)
90+
if (hasAPI) CSS.highlights.delete(name)
91+
if (overlay) overlay.remove()
92+
style.remove()
93+
}
94+
}
95+
}

src/layers/smooth-caret.js

Lines changed: 96 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,96 @@
1+
// Smooth caret layer — replaces native caret with animated one
2+
// Blinks in editing mode, smooth transition during playback,
3+
// follows selection endpoint during drag
4+
5+
export default function smoothCaret(opts = {}) {
6+
let { width = 1, color = 'currentColor', hideNative = true, transition = '20ms linear' } = opts
7+
8+
return (state, root) => {
9+
// inject styles: blink animation + hide native caret
10+
let css = document.createElement('style')
11+
css.textContent = `
12+
@keyframes caret-blink { 0%, 100% { opacity: 1 } 50% { opacity: 0 } }
13+
@keyframes caret-pulse { 0%, 100% { opacity: 1 } 50% { opacity: .2 } }
14+
${hideNative ? '#editarea { caret-color: transparent; }' : ''}
15+
`
16+
root.appendChild(css)
17+
18+
let caret = document.createElement('div')
19+
caret.className = 'smooth-caret'
20+
caret.style.cssText = `
21+
position: fixed;
22+
width: ${width}px;
23+
background: ${color};
24+
pointer-events: none;
25+
z-index: 10;
26+
opacity: 0;
27+
will-change: transform;
28+
top: 0; left: 0;
29+
`
30+
document.body.appendChild(caret)
31+
32+
let raf = null
33+
let lineH = 20
34+
let prevX = 0, prevY = 0
35+
36+
let poll = () => {
37+
let ea = state.refs?.editarea
38+
if (ea) lineH = parseFloat(getComputedStyle(ea).lineHeight) || 20
39+
40+
let x = state.caretX, y = state.caretY
41+
42+
// during mouse drag, read live selection endpoint (state doesn't update until mouseup)
43+
if (state.isMouseDown && ea) {
44+
let s = window.getSelection()
45+
if (s.rangeCount) {
46+
// use focus end of selection (where the mouse is)
47+
let r = document.createRange()
48+
r.setStart(s.focusNode, s.focusOffset)
49+
r.collapse(true)
50+
let rects = r.getClientRects()
51+
let rect = rects[0]
52+
if (rect) { x = rect.right; y = rect.top }
53+
}
54+
}
55+
56+
if (x > 0 || y > 0) {
57+
let yChanged = Math.abs(y - prevY) > 2
58+
let moved = x !== prevX || yChanged
59+
caret.style.transition = state.playing && !yChanged ? `transform ${transition}` : 'none'
60+
caret.style.height = lineH + 'px'
61+
caret.style.transform = `translate(${x}px, ${y}px)`
62+
caret.style.opacity = '1'
63+
64+
if (state.playing) {
65+
caret.style.animation = 'none'
66+
} else if (moved) {
67+
// restart blink cycle on move — visible immediately at new position
68+
caret.style.animation = 'none'
69+
caret.offsetHeight // force reflow to reset animation
70+
caret.style.animation = 'caret-blink 1024ms step-end infinite'
71+
}
72+
73+
prevX = x; prevY = y
74+
} else {
75+
caret.style.opacity = '0'
76+
}
77+
78+
// sync CSS vars for #caret-line
79+
let container = state.refs?.container
80+
if (container && ea) {
81+
let eaRect = ea.getBoundingClientRect()
82+
container.style.setProperty('--caretx', (x - eaRect.left) + 'px')
83+
container.style.setProperty('--carety', (y - eaRect.top) + 'px')
84+
}
85+
86+
raf = requestAnimationFrame(poll)
87+
}
88+
raf = requestAnimationFrame(poll)
89+
90+
return () => {
91+
if (raf) cancelAnimationFrame(raf)
92+
caret.remove()
93+
css.remove()
94+
}
95+
}
96+
}

src/wavearea.html

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,6 @@
11
<div class="container"
22
:ref="refs.container"
3-
:onmousedown.document..onmouseup.document="e=> (isMouseDown = true, e=> isMouseDown = false)"
3+
:onmousedown.document..onmouseup.document="e=> (isMouseDown = true, loop = false, clipEnd = null, e.target.closest?.('#editarea') && window.getSelection().removeAllRanges(), e=> isMouseDown = false)"
44
:style="{
55
'--cols': cols,
66
'--carety': (caretY - (refs.editarea?.getBoundingClientRect().top ?? 0)) + 'px',

src/wavearea.js

Lines changed: 11 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -9,13 +9,14 @@ import createPlayer from './player.js';
99

1010
const BLOCK_SIZE = 1024
1111

12-
export default function wavearea(el, { store, engine } = {}) {
12+
export default function wavearea(el, { store, engine, layers } = {}) {
1313
el.innerHTML = template
1414

1515
let api = createApi({ store })
1616
let player = null
17+
let _cleanups = []
1718

18-
return sprae(el, {
19+
let state = sprae(el, {
1920
// deps
2021
selection,
2122
cleanText,
@@ -214,4 +215,12 @@ export default function wavearea(el, { store, engine } = {}) {
214215
return count
215216
}
216217
})
218+
219+
// initialize visual layers
220+
if (layers) for (let layer of layers) {
221+
let cleanup = layer(state, el)
222+
if (cleanup) _cleanups.push(cleanup)
223+
}
224+
225+
return state
217226
}

0 commit comments

Comments
 (0)