Skip to content

Commit dd9913f

Browse files
committed
Highlight selected text only, not the whole element
- Plugin sends per-line rects from range.getClientRects() and pre-adds the iframe offset at mouseup time, so shell-layout shifts between mouseup and message receipt don't misplace the overlay. - TextHighlightOverlay renders one div per rect; falls back to the bounding rect for restored highlights. - useAnnotationRects shifts rects by the element's scroll delta via an _anchorRect pattern; rejects stale id matches after async resolution. - Clear inspector selection when entering highlight mode so the old element-wide .highlight.select box doesn't linger. - Persist and restore visual.rects on highlight tasks.
1 parent 099d3d1 commit dd9913f

8 files changed

Lines changed: 88 additions & 17 deletions

File tree

src/plugin/bridge/events.ts

Lines changed: 26 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -134,11 +134,34 @@ export function bridgeEvents(): string {
134134
var source = findSourceElement(anchorEl);
135135
var data = getSourceData(source.sourceEl);
136136
137+
// Measure the iframe's position in the shell at mouseup time so the rects
138+
// we send are in shell-viewport coords. The shell can't do this after the
139+
// fact — by the time the message arrives, panel shifts from task creation
140+
// may have moved the iframe, and the selection would land in the wrong spot.
141+
var frameOffX = 0, frameOffY = 0;
142+
try {
143+
var frame = window.frameElement;
144+
if (frame) {
145+
var fr = frame.getBoundingClientRect();
146+
frameOffX = fr.left; frameOffY = fr.top;
147+
}
148+
} catch (_e) {}
149+
137150
var selRect = null;
151+
var selRects = null;
138152
try {
139153
var range = sel.getRangeAt(0);
140154
var br = range.getBoundingClientRect();
141-
if (br.width > 0 && br.height > 0) selRect = { x: br.x, y: br.y, width: br.width, height: br.height };
155+
if (br.width > 0 && br.height > 0) selRect = { x: br.x + frameOffX, y: br.y + frameOffY, width: br.width, height: br.height };
156+
var crs = range.getClientRects();
157+
if (crs && crs.length > 0) {
158+
selRects = [];
159+
for (var i = 0; i < crs.length; i++) {
160+
var cr = crs[i];
161+
if (cr.width > 0 && cr.height > 0) selRects.push({ x: cr.x + frameOffX, y: cr.y + frameOffY, width: cr.width, height: cr.height });
162+
}
163+
if (selRects.length === 0) selRects = null;
164+
}
142165
} catch (_e) {}
143166
sendToShell('selection:text', {
144167
text: text,
@@ -148,7 +171,8 @@ export function bridgeEvents(): string {
148171
component: data.component,
149172
mfe: data.mfe,
150173
tag: anchorEl.tagName.toLowerCase(),
151-
rect: selRect
174+
rect: selRect,
175+
rects: selRects
152176
});
153177
}
154178

src/shared/bridge-types.ts

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,8 @@ export interface SelectionTextEvent {
196196
component: string
197197
mfe?: string
198198
tag: string
199+
rect?: { x: number; y: number; width: number; height: number }
200+
rects?: { x: number; y: number; width: number; height: number }[]
199201
}
200202

201203
export interface KeyDownEvent {

src/shell/components/TextHighlightOverlay.vue

Lines changed: 19 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -19,8 +19,26 @@ const emit = defineEmits<{
1919
v-for="h in highlights"
2020
:key="'vis-' + h.id"
2121
>
22+
<template v-if="h.rects && h.rects.length">
23+
<div
24+
v-for="(r, i) in h.rects"
25+
:key="'vis-' + h.id + '-' + i"
26+
class="text-highlight-visual"
27+
:class="{ selected: h.id === selectedId }"
28+
:style="{
29+
left: r.x + 'px', top: r.y + 'px',
30+
width: r.width + 'px', height: r.height + 'px',
31+
background: h.color + '30',
32+
borderBottom: '2px solid ' + h.color,
33+
boxShadow: h.id === selectedId ? '0 0 0 1px ' + h.color : 'none',
34+
}"
35+
@click.stop="emit('select', h.id)"
36+
>
37+
<span v-if="i === 0" class="highlight-badge" :style="{ background: h.color }">#{{ h.number }}</span>
38+
</div>
39+
</template>
2240
<div
23-
v-if="h.rect"
41+
v-else-if="h.rect"
2442
class="text-highlight-visual"
2543
:class="{ selected: h.id === selectedId }"
2644
:style="{

src/shell/composables/useAnnotationRects.ts

Lines changed: 25 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -86,7 +86,31 @@ export function useAnnotationRects(deps: {
8686
}
8787
} else if (entry.type === 'highlight') {
8888
const hl = annotations.highlights.value.find(h => h.id === entry.id)
89-
if (hl) hl.rect = rect
89+
if (!hl) continue
90+
// Reject stale matches: the highlight we found may have been replaced
91+
// mid-await with a new one that happens to share the same id (ids are
92+
// derived from a counter that gets reclaimed when pending annotations
93+
// are discarded, so a rapid swap produces id collisions).
94+
if (hl.eid !== eids[i]) continue
95+
if (hl.rects && hl.rects.length) {
96+
// Range-based highlight: track the element's movement and shift the
97+
// per-line rects (which describe text runs, not the element box) by
98+
// the same delta. Don't overwrite hl.rect with the element rect.
99+
const prev = (hl as any)._anchorRect as typeof rect | undefined
100+
if (prev) {
101+
const dx = rect.x - prev.x
102+
const dy = rect.y - prev.y
103+
if (Math.abs(dx) > 0.5 || Math.abs(dy) > 0.5) {
104+
hl.rects = hl.rects.map(r => ({ x: r.x + dx, y: r.y + dy, width: r.width, height: r.height }))
105+
if (hl.rect) hl.rect = { x: hl.rect.x + dx, y: hl.rect.y + dy, width: hl.rect.width, height: hl.rect.height }
106+
;(hl as any)._anchorRect = rect
107+
}
108+
} else {
109+
;(hl as any)._anchorRect = rect
110+
}
111+
} else {
112+
hl.rect = rect
113+
}
90114
} else if (entry.type === 'section') {
91115
const section = annotations.drawnSections.value.find(s => s.id === entry.id)
92116
if (!section) continue

src/shell/composables/useAnnotations.ts

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -77,6 +77,7 @@ export interface TextHighlight {
7777
prompt: string
7878
color: string
7979
rect?: ElementRect
80+
rects?: ElementRect[]
8081
eid?: string
8182
file: string
8283
line: number
@@ -234,13 +235,14 @@ export function useAnnotations() {
234235
// ── Text Highlights ──
235236
const selectedHighlightId = ref<string | null>(null)
236237

237-
function addHighlight(text: string, source: { file: string; line: number; component: string; elementTag: string }, color?: string, rect?: ElementRect, eid?: string): TextHighlight {
238+
function addHighlight(text: string, source: { file: string; line: number; component: string; elementTag: string }, color?: string, rect?: ElementRect, eid?: string, rects?: ElementRect[]): TextHighlight {
238239
counter++
239240
const h: TextHighlight = {
240241
id: `hl-${counter}`, number: counter,
241242
selectedText: text, prompt: '',
242243
color: color || readVar('--mode-highlight', '#f59e0b'),
243244
...(rect ? { rect } : {}),
245+
...(rects && rects.length ? { rects } : {}),
244246
...(eid ? { eid } : {}),
245247
file: source.file, line: source.line,
246248
component: source.component, elementTag: source.elementTag,

src/shell/composables/useBridgeEventHandlers.ts

Lines changed: 10 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -203,22 +203,23 @@ export function useBridgeEventHandlers(deps: BridgeEventHandlerDeps) {
203203
async function onSelectionText(data: {
204204
text: string; eid: string; file: string; line: number; component: string; tag: string
205205
rect?: { x: number; y: number; width: number; height: number }
206+
rects?: { x: number; y: number; width: number; height: number }[]
206207
}) {
207208
discardUncommittedAnnotations()
208-
// Convert iframe-local rect to viewport coords
209-
const iframeEl = iframeRef.value
210-
const offsetX = iframeEl?.getBoundingClientRect().left || 0
211-
const offsetY = iframeEl?.getBoundingClientRect().top || 0
212-
let viewportRect: { x: number; y: number; width: number; height: number } | undefined
213-
if (data.rect) {
214-
viewportRect = { x: data.rect.x + offsetX, y: data.rect.y + offsetY, width: data.rect.width, height: data.rect.height }
215-
}
209+
// The plugin converts to shell-viewport coords at mouseup time (see
210+
// bridge/events.ts). Don't add the iframe offset here — by the time this
211+
// handler runs, the iframe may have shifted (panel layout changes trigger
212+
// when we commit the previous highlight), and re-measuring would misplace
213+
// the selection.
214+
const viewportRect = data.rect ? { ...data.rect } : undefined
215+
const viewportRects = data.rects?.map(r => ({ x: r.x, y: r.y, width: r.width, height: r.height }))
216216
const hl = annotations.addHighlight(
217217
data.text,
218218
{ file: data.file, line: data.line, component: data.component, elementTag: data.tag },
219219
highlightColor.value,
220220
viewportRect,
221-
data.eid
221+
data.eid,
222+
viewportRects
222223
)
223224
// Fallback: resolve element rect by eid if bridge didn't send selection rect
224225
if (!viewportRect && data.eid) {

src/shell/composables/useInteractionModeSync.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -48,7 +48,7 @@ export function useInteractionModeSync(opts: UseInteractionModeSyncOptions) {
4848
if (mode !== 'arrow') {
4949
arrowHoverElement.value = null
5050
}
51-
if (mode === 'interact') {
51+
if (mode === 'interact' || mode === 'highlight') {
5252
clearSelection()
5353
hoverRect.value = null
5454
hoverInfo.value = null

src/shell/composables/useTaskWorkflows.ts

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -375,7 +375,7 @@ export function useTaskWorkflows(deps: {
375375
createRouteTask({
376376
type: 'annotation', description: intent, file: ctx.file, line: parseInt(String(ctx.line)) || 0,
377377
component: ctx.component, action: 'text_edit',
378-
visual: { kind: 'highlight', annotationId: ctx.annotationId, eid: hl?.eid, rect: hl?.rect, color: hl?.color },
378+
visual: { kind: 'highlight', annotationId: ctx.annotationId, eid: hl?.eid, rect: hl?.rect, rects: hl?.rects, color: hl?.color },
379379
context: { element_tag: meta.elementTag, selected_text: meta.selectedText },
380380
})
381381
} else if (ctx.kind === 'select') {
@@ -533,7 +533,7 @@ export function useTaskWorkflows(deps: {
533533
const hl = deps.annotations.addHighlight(
534534
ctx.selected_text || '',
535535
{ file: task.file, line: task.line, component: task.component || '', elementTag: ctx.element_tag || '' },
536-
v.color, v.rect,
536+
v.color, v.rect, undefined, v.rects,
537537
)
538538
hl.route = taskRoute
539539
v.annotationId = hl.id

0 commit comments

Comments
 (0)