Skip to content

[Component] Feature: Demo sprite hover and ruler highlights - #3763

Open
wenjiangping wants to merge 6 commits into
goplus:devfrom
wenjiangping:issue-3754
Open

wenjiangping wants to merge 6 commits into
goplus:devfrom
wenjiangping:issue-3754

Conversation

@wenjiangping

@wenjiangping wenjiangping commented Nov 11, 2026 •

Copy link
Copy Markdown
Collaborator

Related

Related to #3754 (part of #3403).

Background

Learners need to connect sprite references in code with the corresponding sprites on the stage, especially when several small sprites are present. This Course Simple Mode demo adds temporary sprite highlighting for code hover and Ruler measurement, using a smooth white silhouette outline and the existing sprite name label.

Experience changes

  • Hovering a resolved sprite reference, a sprite selector card, or a stage sprite in Course Simple Mode shows the corresponding sprite's silhouette outline and name label. Selector cards also support preview on keyboard focus.
  • All highlight sources share a static 2 px translucent white silhouette outline at 70% opacity, with no gray band. Smooth the silhouette edges so irregular shapes and fine transparency details produce a cleaner outline. Outline thickness stays fixed in screen pixels as the stage zoom changes.
  • Position sprite name labels 8 screen pixels below the sprite's bounding box to give the outline more breathing room.
  • Highlight the Ruler's current snapped sprite with the same outline and use white endpoint markers for the actual measurement positions. Preserve distance and angle feedback, and keep fixed endpoints visible after the full highlight ends.
  • Give the current Ruler snap target priority over hover previews, with at most one full sprite outline visible at a time. Exclude hidden sprites from Ruler snap targets in both editor modes.
  • Clear temporary previews when leaving a target, typing, changing documents, closing the selector, losing window focus, or starting a run. A short grace period allows moving onto a name label or hover card. Previews leave selection, the active code file, project data, camera position, and undo history unchanged.
  • Hide outlines during runtime display and project thumbnail capture. Keep sprite size and stacking order unchanged, without dimming the stage or adding a repeating animation.

Scope

  • Pages / components: Course Simple Mode code editor, resource selector, StageViewer, sprite outline and name label components, and the shared Ruler.
  • Main user journeys: locate a sprite from a code reference, preview a sprite in the selector or on the stage, and measure between stage positions.
  • Code-hover and stage-hover highlighting apply only in Simple Mode. Ruler snapping remains available in both editor modes.
  • Reuse recent outline bitmaps across hover targets, transform updates, and component remounts with a bounded cache; avoid repeated hover updates for the same code position.
  • Update the English and Chinese Tutorial v2 product documentation with the demo decision and interaction rules, and add regression coverage for resource hover, Ruler target reporting, and bitmap reuse.
  • Frontend UI, interaction, tests, and documentation only. No backend data, API endpoints, or backend business logic changes; Tutorial public contracts remain unchanged.

Validation

  • Checked the local course preview for code hover, sprite selector hover, stage hover, and Ruler measurement.
  • Visually compared the final outline on white, green, and dark backgrounds, including an irregular silhouette and enlarged edge details.
  • Checked preview cleanup, unresolved references, editor/document changes, window blur, and Ruler deactivation.
  • Latest targeted run passed 32 tests across 7 files, covering bitmap reuse, StageViewer, Ruler behavior, resource hover, hover cards, EditorPreview, and CodeEditorUI.
  • Passed targeted ESLint and Prettier checks, plus git diff --check.

@codecov

codecov Bot commented Nov 11, 2026 •

Copy link
Copy Markdown

Codecov Report

❌ Patch coverage is 71.82320% with 51 lines in your changes missing coverage. Please review.
✅ Project coverage is 59.87%. Comparing base (acfcce7) to head (a9b7c5b).
⚠️ Report is 7 commits behind head on dev.

Files with missing lines Patch % Lines
...onents/editor/preview/stage-viewer/StageViewer.vue 51.35% 16 Missing and 2 partials ⚠️
...omponents/editor/common/viewer/SpriteNameLabel.vue 0.00% 8 Missing ⚠️
...src/components/editor/common/viewer/SpriteNode.vue 0.00% 7 Missing ⚠️
...omponents/editor/common/viewer/SpriteHighlight.vue 90.62% 0 Missing and 6 partials ⚠️
.../xgo-code-editor/ui/input-helper/ResourceInput.vue 0.00% 5 Missing ⚠️
...rc/components/xgo-code-editor/ui/resource/index.ts 91.11% 2 Missing and 2 partials ⚠️
gui/src/components/editor/ProjectEditor.vue 33.33% 2 Missing ⚠️
...rc/components/xgo-code-editor/ui/code-editor-ui.ts 0.00% 1 Missing ⚠️
Additional details and impacted files
@@            Coverage Diff             @@
##              dev    #3763      +/-   ##
==========================================
+ Coverage   58.60%   59.87%   +1.27%     
==========================================
  Files         771      773       +2     
  Lines       27592    27897     +305     
  Branches     5195     5269      +74     
==========================================
+ Hits        16169    16703     +534     
+ Misses       9964     9678     -286     
- Partials     1459     1516      +57     
Flag Coverage Δ
gui 59.34% <71.82%> (+1.31%) ⬆️
tools-ai 79.61% <ø> (ø)

Flags with carried forward coverage won't be shown. Click here to find out more.

Files with missing lines Coverage Δ
...ui/src/components/editor/preview/EditorPreview.vue 66.46% <100.00%> (+0.20%) ⬆️
...s/editor/preview/stage-viewer/ruler/StageRuler.vue 87.41% <100.00%> (+0.17%) ⬆️
...src/components/xgo-code-editor/ui/CodeEditorUI.vue 54.47% <100.00%> (+1.76%) ⬆️
...rc/components/xgo-code-editor/ui/code-editor-ui.ts 17.56% <0.00%> (-0.06%) ⬇️
gui/src/components/editor/ProjectEditor.vue 76.00% <33.33%> (-5.82%) ⬇️
...rc/components/xgo-code-editor/ui/resource/index.ts 91.37% <91.11%> (+68.30%) ⬆️
.../xgo-code-editor/ui/input-helper/ResourceInput.vue 0.00% <0.00%> (ø)
...omponents/editor/common/viewer/SpriteHighlight.vue 90.62% <90.62%> (ø)
...src/components/editor/common/viewer/SpriteNode.vue 0.00% <0.00%> (ø)
...omponents/editor/common/viewer/SpriteNameLabel.vue 0.00% <0.00%> (ø)
... and 1 more

... and 69 files with indirect coverage changes

🚀 New features to boost your workflow:
  • ❄️ Test Analytics: Detect flaky tests, report on failures, and find test suite problems.
  • 📦 JS Bundle Analysis: Save yourself from yourself by tracking and limiting bundle sizes in JS merges.

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review summary

The hover and Ruler highlight wiring looks sound overall. Previews are cleared on leave, typing, document change, blur and unmount, and thumbnails hide the outline. I found no security issues. The inline comments below cover a few performance and documentation points.

One point that can't be placed inline: docs/product/tutorial-v2.zh.md has no counterpart to the new "Sprite highlighting demo (#3754)" subsection. The Chinese doc otherwise mirrors the English one section by section, so please add a translation or note that it will follow.


// Smooth the mask before expanding it so fine alpha noise does not become a jagged outline.
// Supersampling keeps both edges antialiased at a fixed screen thickness.
const outline = computed(() => {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This computed allocates a new canvas and calls drawImage 66 times every time it re-runs, and it re-runs more often than it needs to:

  • It depends on the whole spriteConfig, so any change rebuilds it, including x/y/rotation/visible.
  • It also rebuilds on every mapScale change, for example while a panel is being resized.

Large or SVG costumes at a high stage scale could cause hover jank. Consider:

  • Keying the bitmap only on image, width, height, abs(scaleX) and mapScale.
  • Caching the result per image and scale, so hovering the same sprite again or moving the Ruler snap between sprites doesn't rebuild it.

heading: sprite.heading
}))
editorCtx.project.sprites
.filter((sprite) => sprite.visible)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

This filter changes Ruler behavior in both editor modes: hidden sprites are no longer snap targets. That is probably intended, since an invisible sprite shouldn't attract the snap. However, the product doc says Ruler snapping "keeps its existing availability in both editor modes". Please mention this change in the doc or the PR description.

clear()
return
}
this.hoveredPosition.value = fromMonacoPosition({

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Nit: every Monaco mouseMove assigns a new Position object, even when the cursor stays on the same character. This re-triggers the hoveredResource getter, which scans items and slots. The in-place-input branch (L55) also returns a fresh { uri } object, so resourceHover is emitted again and getResourceModel re-runs on each move. Consider skipping the assignment when the line and column haven't changed, or comparing by uri before emitting.

@wenjiangping

Copy link
Copy Markdown
Collaborator Author

@xgopilot /review

@fennoai

fennoai Bot commented Nov 13, 2026 •

Copy link
Copy Markdown
Contributor

The PR review was submitted successfully as a single non-blocking COMMENT event with all 3 inline comments placed on valid diff locations (inline=3 body_only=0 dropped=0), plus a summary body with 2 additional minor notes.

Review complete

I reviewed PR #3763 ([Component] Feature: Demo sprite hover and ruler highlights) across code quality, performance, security, and documentation accuracy using four parallel subagents, then deduplicated and verified the findings before posting.

Submitted: one non-blocking PR review → #3763 (review)

Inline findings (all in SpriteHighlight.vue, none blocking):

  1. The outline computed getter mutates the module-level outlineCache as a side effect — recommend moving bitmap generation + LRU bookkeeping into a watch/watchEffect writing a shallowRef to keep the getter pure.
  2. Large sprites exceed the cache's ~4M-pixel budget and are never cached, so the most expensive outlines regenerate on every hover; the scale-based key also causes per-frame cache misses during resize/size edits.
  3. getImageData is unguarded against canvas tainting — safe today (blob URLs), but a decorative-only try/catch returning null would harden it.

Body notes: duplicated 120ms grace-period constant across three files, and slightly ambiguous "16 MiB pixel budget" comment wording.

Dropped as low-value/non-issues: #3754 doc heading (it's the tracking issue, confirmed by the PR body), previewResource ignoring previewEnabled (bounded/harmless), O(n) eviction reduce (bounded at 8 entries), and type-naming nits. The EN/ZH docs and the implemented behavior were confirmed consistent.

View task

@fennoai fennoai Bot left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Review: Demo sprite hover and ruler highlights

Thorough, well-structured PR. Lifecycle cleanup (timers, listeners, disposers) is handled carefully across StageViewer, StageRuler, SpriteNameLabel, and ResourceReferenceController, and the new tests (bitmap reuse/eviction, Ruler snap-target reporting, resource-hover resolution) give good coverage of the tricky parts. The docs updates (EN/ZH) accurately mirror the implemented behavior.

Findings are concentrated in SpriteHighlight.vue's outline-bitmap generation. None are blocking; the inline notes are worth considering.

Additional minor notes (no inline anchor):

  • Duplicated 120 grace-period constant. The hover-leave grace period of 120ms is repeated independently in StageViewer.vue (handleSpriteHover), SpriteNameLabel.vue (hideTimer), and resource/index.ts (clearLater). Correct, but a shared named constant would keep the three in sync.
  • maxCachedPixels comment wording. SpriteHighlight.vue:4 calls 4 * 1024 * 1024 a "16 MiB pixel budget". The number is a ~4M pixel count (which is ~16 MiB only after assuming 4 bytes/pixel). The accounting is correct, but wording like "~4M-pixel budget (≈16 MiB RGBA)" would read less ambiguously.

Nice work overall.

const ctx = canvas.getContext('2d')
if (ctx == null) return null

const mask = document.createElement('canvas')

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Computed getter mutates a module-level cache as a side effect.

The outline computed getter reads from and mutates the module-level outlineCache (the splice/push LRU promotion on a cache hit, and the push + eviction loop on a cache miss around L89-98). Vue computed getters are expected to be pure, and Vue may evaluate a computed more than once in some edge cases, which makes the LRU ordering non-deterministic and can generate a bitmap that is immediately discarded. The heavy imperative work (canvas allocation, getImageData, pixel loops) also doesn't belong in a getter.

Consider moving the bitmap generation + cache bookkeeping into a watch/watchEffect that writes to a shallowRef, keeping outline pure (the downstream config computed is already nicely pure). This matches the watch(..., onCleanup) pattern used elsewhere (e.g. useRenderableImage) and also makes the retained-canvas lifecycle explicit.

ctx.globalCompositeOperation = 'destination-out'
drawSilhouette(1.5)
const result = { canvas, scale: scale * pixelRatio, padding: padding * pixelRatio }
if (canvas.width * canvas.height <= maxCachedPixels) {

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Large sprites skip the cache, so the most expensive bitmaps regenerate on every hover.

The cache only stores entries where canvas.width * canvas.height <= maxCachedPixels (~4M backing pixels). With pixelRatio = 2, a sprite whose scaled size approaches ~1024×1024 already exceeds this, so exactly the largest/most-expensive outlines are never cached — each hover-enter (and each re-render that changes the sprite config identity) pays the full getImageData + two full-buffer alpha loops + 2×33 drawImage pipeline again.

Additionally, because scale = abs(scaleX) * mapScale is part of the cache key, a continuous size edit or panel/window resize while a sprite is highlighted produces a new key every frame — each intermediate value misses the cache, regenerates, and is immediately evicted.

Consider clamping the mask working resolution (cap the canvas and upscale on display) and/or quantizing scale in the key, so the heavy path isn't re-run per frame for large sprites and during resize.

width * scale * pixelRatio,
height * scale * pixelRatio
)
const maskData = maskCtx.getImageData(0, 0, mask.width, mask.height)

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

getImageData is unguarded against canvas tainting.

This is safe today — sprite images come from useRenderableImage, which uses same-origin blob: URLs, so drawing them does not taint the canvas and getImageData succeeds. But the safety depends entirely on that invariant. If a future caller ever passes a sprite whose image is loaded cross-origin without crossOrigin='anonymous', this throws a SecurityError from inside a computed, surfacing as an unhandled render error. Since the highlight is purely decorative, a try/catch that returns null (degrade to no outline) would make the component robust to the image source.

This branch was successfully deployed

1 active deployment
Preview – builder — a9b7c5b5 Deployed Nov 12, 2026 by vercel[bot]
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant