Skip to content

Commit 21c6f99

Browse files
feat(library): restore per-element highlighting via setElementHighlights (#762)
Co-authored-by: Claude Opus 4.8 <noreply@anthropic.com>
1 parent af7c085 commit 21c6f99

9 files changed

Lines changed: 401 additions & 8 deletions

File tree

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
---
2+
"@tumaet/apollon": minor
3+
---
4+
5+
feat: add `ApollonEditor.setElementHighlights()` for host-driven element highlighting
6+
7+
Restores the per-element highlight capability that v3 exposed via the
8+
`UMLModelElement.highlight` field and `ApollonEditor.select()`, both of which
9+
were dropped in the v4 rewrite. Hosting apps (e.g. Artemis marking elements
10+
that are missing assessment feedback, or Athena marking elements that have
11+
automatic-feedback suggestions) can now call:
12+
13+
```ts
14+
editor.setElementHighlights(new Map([["element-id", "rgba(23,162,184,0.3)"]]))
15+
editor.setElementHighlights(null) // clear
16+
```
17+
18+
The highlight is a translucent overlay painted over each given node, edge, or
19+
class member id. It is an ephemeral view concern: it is not written into the
20+
model, not serialized by `get model`, and not shared with collaborators. A
21+
companion `getElementHighlights()` returns the current highlight record.

docs/library/api.md

Lines changed: 6 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -162,10 +162,12 @@ Every field is optional.
162162

163163
### Assessment
164164

165-
| Member | Type | Purpose |
166-
| ----------------------------------- | ---------------------------------- | ----------------------------------------------------------- |
167-
| `addOrUpdateAssessment(assessment)` | `(Assessment) => void` | Attach or update a score/feedback assessment on an element. |
168-
| `getInteractiveForSerialization()` | `InteractiveElements \| undefined` | Interactive-element flags for inclusion in a saved model. |
165+
| Member | Type | Purpose |
166+
| ----------------------------------- | ----------------------------------------------------------------- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
167+
| `addOrUpdateAssessment(assessment)` | `(Assessment) => void` | Attach or update a score/feedback assessment on an element. |
168+
| `setElementHighlights(highlights)` | `(Map<string, string> \| Record<string, string> \| null) => void` | Paint a translucent highlight overlay over the given element ids (id → CSS color) — e.g. to flag elements missing feedback or carrying suggestions. Host-driven and ephemeral: never written to the model, serialized, or shared with collaborators. Each call replaces the previous set; pass `null` or an empty map to clear. |
169+
| `getElementHighlights()` | `() => Record<string, string>` | The current highlight map (element id → CSS color). |
170+
| `getInteractiveForSerialization()` | `InteractiveElements \| undefined` | Interactive-element flags for inclusion in a saved model. |
169171

170172
## Subscriptions
171173

library/lib/apollon-editor.tsx

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -687,6 +687,40 @@ export class ApollonEditor {
687687
.updateMetaData(model.title, parseDiagramType(model.type))
688688
}
689689

690+
/**
691+
* Host-driven element highlighting. Paints a translucent overlay over each
692+
* given node / edge / class-member id in the supplied CSS color — the v4
693+
* replacement for v3's `UMLModelElement.highlight` field and
694+
* `ApollonEditor.select()`. Typical hosts: an assessment editor marking
695+
* elements that are missing feedback, or Athena marking elements that have
696+
* automatic-feedback suggestions.
697+
*
698+
* The highlight is an ephemeral view overlay: it is NOT written into the
699+
* model, NOT serialized by `get model`, and NOT shared with collaborators.
700+
* Each call replaces the previous highlight set; pass `null` (or an empty
701+
* map) to clear all highlights. Passing `undefined` is a no-op.
702+
*
703+
* @param highlights map / record of element id -> CSS color (any valid CSS
704+
* color string, e.g. `"rgba(23,162,184,0.3)"`), or `null` to clear.
705+
*/
706+
public setElementHighlights(
707+
highlights: Map<string, string> | Record<string, string> | null | undefined
708+
): void {
709+
if (highlights === undefined) return
710+
const record =
711+
highlights === null
712+
? {}
713+
: Object.fromEntries(
714+
highlights instanceof Map ? highlights : Object.entries(highlights)
715+
)
716+
this.assessmentSelectionStore.getState().setElementHighlights(record)
717+
}
718+
719+
/** Returns a copy of the current highlight record (id -> CSS color). */
720+
public getElementHighlights(): Record<string, string> {
721+
return { ...this.assessmentSelectionStore.getState().highlightedElements }
722+
}
723+
690724
public getSelectedElements(): string[] {
691725
const { mode, readonly } = this.metadataStore.getState()
692726
if (mode === Apollon.ApollonMode.Assessment && readonly) {

library/lib/components/AssessmentSelectableElement.tsx

Lines changed: 33 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,10 @@
11
import { INTERACTIVE_SELECTION_COLOR } from "@/constants"
22
import { useAssessmentSelection } from "@/hooks"
3-
import { useDiagramStore, useMetadataStore } from "@/store"
3+
import {
4+
useAssessmentSelectionStore,
5+
useDiagramStore,
6+
useMetadataStore,
7+
} from "@/store"
48
import { ApollonMode, ApollonView } from "@/typings"
59
import { FC } from "react"
610
import { useShallow } from "zustand/shallow"
@@ -42,6 +46,25 @@ export const AssessmentSelectableElement: FC<
4246
handleElementMouseLeave,
4347
} = useAssessmentSelection(elementId)
4448

49+
// Host-driven highlight overlay rect (see `highlightedElements` in the store).
50+
const highlightColor = useAssessmentSelectionStore(
51+
(state) => state.highlightedElements[elementId]
52+
)
53+
const highlightRect = highlightColor ? (
54+
<rect
55+
aria-hidden
56+
x={0}
57+
y={yOffset}
58+
width={width}
59+
height={itemHeight}
60+
fill={highlightColor}
61+
stroke={highlightColor}
62+
strokeWidth={1}
63+
rx={2}
64+
pointerEvents="none"
65+
/>
66+
) : null
67+
4568
const showInteractiveInteraction =
4669
mode === ApollonMode.Modelling &&
4770
view === ApollonView.Highlight &&
@@ -83,7 +106,12 @@ export const AssessmentSelectableElement: FC<
83106
}
84107

85108
if (!showAssessmentInteraction) {
86-
return <g data-apollon-element-id={elementId}>{children}</g>
109+
return (
110+
<g data-apollon-element-id={elementId}>
111+
{children}
112+
{highlightRect}
113+
</g>
114+
)
87115
}
88116

89117
const handleSVGClick = (e: React.PointerEvent<SVGGElement>) => {
@@ -118,6 +146,9 @@ export const AssessmentSelectableElement: FC<
118146
pointerEvents="none"
119147
/>
120148
)}
149+
{/* Host highlight paints last, over the selection rect, matching the
150+
div wrapper's layering invariant (host overlay on top). */}
151+
{highlightRect}
121152
</g>
122153
)
123154
}

library/lib/components/wrapper/AssessmentSelectableWrapper.tsx

Lines changed: 55 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,7 +4,11 @@ import {
44
INTERACTIVE_SELECTION_FILL,
55
} from "@/constants"
66
import { useAssessmentSelection } from "@/hooks/useAssessmentSelection"
7-
import { useDiagramStore, useMetadataStore } from "@/store"
7+
import {
8+
useAssessmentSelectionStore,
9+
useDiagramStore,
10+
useMetadataStore,
11+
} from "@/store"
812
import { ApollonMode, ApollonView } from "@/typings"
913
import { useShallow } from "zustand/shallow"
1014

@@ -50,6 +54,31 @@ export const AssessmentSelectableWrapper: React.FC<
5054
view === ApollonView.Highlight &&
5155
!readonly
5256

57+
// Host-driven highlight (assessment "missing feedback" / Athena suggestions):
58+
// a translucent tint + ring over div-wrapped nodes, a stroke glow over
59+
// g-wrapped edges. Rendered wherever elements are displayed or assessed — not
60+
// in the quiz interactive-element picker (ApollonView.Highlight), which the
61+
// assessment hosts never enter.
62+
const highlightColor = useAssessmentSelectionStore(
63+
(state) => state.highlightedElements[elementId]
64+
)
65+
const highlightDivOverlay = highlightColor ? (
66+
<div
67+
aria-hidden
68+
style={{
69+
position: "absolute",
70+
inset: 0,
71+
backgroundColor: highlightColor,
72+
boxShadow: `0 0 0 2px ${highlightColor}`,
73+
borderRadius: 2,
74+
pointerEvents: "none",
75+
}}
76+
/>
77+
) : null
78+
const highlightEdgeFilter = highlightColor
79+
? `drop-shadow(0 0 2px ${highlightColor}) drop-shadow(0 0 2px ${highlightColor})`
80+
: undefined
81+
5382
if (showInteractiveInteraction) {
5483
const handleInteractiveClick = (event: React.PointerEvent) => {
5584
event.preventDefault()
@@ -103,11 +132,33 @@ export const AssessmentSelectableWrapper: React.FC<
103132
}
104133

105134
if (!showAssessmentInteraction) {
106-
return <>{children}</>
135+
// No host highlight is the hot path (99% of renders): return a zero-box
136+
// Fragment so ordinary modelling/editing pays no extra DOM/layout cost.
137+
if (!highlightColor) return <>{children}</>
138+
if (asElement == "g") {
139+
return (
140+
<g
141+
data-apollon-element-id={elementId}
142+
style={{ filter: highlightEdgeFilter }}
143+
>
144+
{children}
145+
</g>
146+
)
147+
}
148+
// A bare relative box is layout-neutral but gives the absolutely-positioned
149+
// overlay a containing block anchored to the node content box (like every
150+
// other branch).
151+
return (
152+
<div data-apollon-element-id={elementId} style={{ position: "relative" }}>
153+
{children}
154+
{highlightDivOverlay}
155+
</div>
156+
)
107157
}
108158

109159
const combinedStyle: React.CSSProperties = {
110160
cursor: "pointer",
161+
...(highlightColor && { position: "relative" }),
111162
...(isSelected && {
112163
backgroundColor: "rgba(25, 118, 210, 0.2)",
113164
border: "2px solid #1976d2",
@@ -122,6 +173,7 @@ export const AssessmentSelectableWrapper: React.FC<
122173
if (asElement == "g") {
123174
const gStyle = {
124175
cursor: "pointer",
176+
...(highlightColor && { filter: highlightEdgeFilter }),
125177
...(isSelected && {
126178
stroke: "rgba(25, 118, 210, 0.2)",
127179
}),
@@ -154,6 +206,7 @@ export const AssessmentSelectableWrapper: React.FC<
154206
onMouseLeave={handleElementMouseLeave}
155207
>
156208
{children}
209+
{highlightDivOverlay}
157210
</div>
158211
)
159212
}

library/lib/store/assessmentSelectionStore.ts

Lines changed: 18 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -8,13 +8,21 @@ export type AssessmentSelectionStore = {
88
highlightedElementId: string | null
99
// Whether assessment selection mode is active
1010
isAssessmentSelectionMode: boolean
11+
// Host-driven highlight overlays: element id -> CSS color. Used by hosting
12+
// apps (e.g. assessment "missing feedback" or Athena suggestion overlays)
13+
// to tint specific nodes / edges / class members. This is an ephemeral view
14+
// concern — it is NOT part of the model, never serialized, and never shared
15+
// with collaborators. It is the v4 replacement for the v3
16+
// `UMLModelElement.highlight` field + `ApollonEditor.select()` API.
17+
highlightedElements: Record<string, string>
1118

1219
// Actions
1320
setAssessmentSelectionMode: (isActive: boolean) => void
1421
selectElement: (elementId: string) => void
1522
selectMultipleElements: (elementIds: string[]) => void
1623
clearSelection: () => void
1724
setHighlightedElement: (elementId: string | null) => void
25+
setElementHighlights: (highlights: Record<string, string>) => void
1826
isElementSelected: (elementId: string) => boolean
1927
isElementHighlighted: (elementId: string) => boolean
2028
reset: () => void
@@ -24,12 +32,14 @@ type InitialAssessmentSelectionState = {
2432
selectedElementIds: string[]
2533
highlightedElementId: string | null
2634
isAssessmentSelectionMode: boolean
35+
highlightedElements: Record<string, string>
2736
}
2837

2938
const initialAssessmentSelectionState: InitialAssessmentSelectionState = {
3039
selectedElementIds: [],
3140
highlightedElementId: null,
3241
isAssessmentSelectionMode: false,
42+
highlightedElements: {},
3343
}
3444

3545
export const createAssessmentSelectionStore = (): UseBoundStore<
@@ -80,6 +90,14 @@ export const createAssessmentSelectionStore = (): UseBoundStore<
8090
)
8191
},
8292

93+
setElementHighlights: (highlights: Record<string, string>) => {
94+
set(
95+
{ highlightedElements: highlights },
96+
undefined,
97+
"setElementHighlights"
98+
)
99+
},
100+
83101
isElementSelected: (elementId: string) => {
84102
return get().selectedElementIds.includes(elementId)
85103
},
Lines changed: 109 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,109 @@
1+
import { describe, it, expect, beforeEach, afterEach } from "vitest"
2+
import { createAssessmentSelectionStore } from "@/store/assessmentSelectionStore"
3+
import { ApollonEditor } from "@/apollon-editor"
4+
5+
describe("assessmentSelectionStore host-driven highlights", () => {
6+
let store: ReturnType<typeof createAssessmentSelectionStore>
7+
8+
beforeEach(() => {
9+
store = createAssessmentSelectionStore()
10+
})
11+
12+
it("starts with an empty highlight map", () => {
13+
expect(store.getState().highlightedElements).toEqual({})
14+
})
15+
16+
it("setElementHighlights replaces the whole map", () => {
17+
store.getState().setElementHighlights({
18+
"node-1": "rgba(23,162,184,0.3)",
19+
"edge-2": "rgba(219,53,69,0.6)",
20+
})
21+
expect(store.getState().highlightedElements).toEqual({
22+
"node-1": "rgba(23,162,184,0.3)",
23+
"edge-2": "rgba(219,53,69,0.6)",
24+
})
25+
26+
// A subsequent call replaces rather than merges.
27+
store.getState().setElementHighlights({ "node-3": "#ff0000" })
28+
expect(store.getState().highlightedElements).toEqual({
29+
"node-3": "#ff0000",
30+
})
31+
32+
// An empty map clears all highlights.
33+
store.getState().setElementHighlights({})
34+
expect(store.getState().highlightedElements).toEqual({})
35+
})
36+
37+
it("host highlights are independent of selection state", () => {
38+
store.getState().setElementHighlights({ "node-1": "#ff0000" })
39+
store.getState().selectMultipleElements(["node-1", "node-2"])
40+
store.getState().setHighlightedElement("node-2")
41+
42+
// Toggling assessment-selection mode off clears the interactive selection
43+
// and hover, but must NOT wipe the host-driven highlight overlay.
44+
store.getState().setAssessmentSelectionMode(false)
45+
46+
const state = store.getState()
47+
expect(state.selectedElementIds).toEqual([])
48+
expect(state.highlightedElementId).toBeNull()
49+
expect(state.highlightedElements).toEqual({ "node-1": "#ff0000" })
50+
})
51+
52+
it("reset clears host highlights along with the rest of the state", () => {
53+
store.getState().setElementHighlights({ "node-1": "#ff0000" })
54+
store.getState().reset()
55+
expect(store.getState().highlightedElements).toEqual({})
56+
})
57+
})
58+
59+
describe("ApollonEditor host-driven highlight API", () => {
60+
let container: HTMLElement
61+
let editor: ApollonEditor
62+
63+
beforeEach(() => {
64+
container = document.createElement("div")
65+
document.body.appendChild(container)
66+
editor = new ApollonEditor(container)
67+
})
68+
69+
afterEach(() => {
70+
editor.destroy()
71+
container.remove()
72+
})
73+
74+
it("normalizes a Map input to a Record", () => {
75+
const map = new Map([
76+
["node-1", "#ff0000"],
77+
["edge-2", "#00ff00"],
78+
])
79+
editor.setElementHighlights(map)
80+
expect(editor.getElementHighlights()).toEqual({
81+
"node-1": "#ff0000",
82+
"edge-2": "#00ff00",
83+
})
84+
})
85+
86+
it("isolates stored highlights from the caller's input and returned snapshot", () => {
87+
// Map and Record inputs share one normalization path, so a single input
88+
// type exercises the defensive copy on the way in...
89+
const input = new Map([["node-1", "#ff0000"]])
90+
editor.setElementHighlights(input)
91+
input.set("node-1", "tampered")
92+
expect(editor.getElementHighlights()).toEqual({ "node-1": "#ff0000" })
93+
94+
// ...and the getter must hand back an independent copy on the way out.
95+
const snapshot = editor.getElementHighlights()
96+
snapshot["node-1"] = "tampered"
97+
expect(editor.getElementHighlights()).toEqual({ "node-1": "#ff0000" })
98+
})
99+
100+
it("clears on null but treats undefined as a no-op", () => {
101+
editor.setElementHighlights({ "node-1": "#ff0000" })
102+
editor.setElementHighlights(null)
103+
expect(editor.getElementHighlights()).toEqual({})
104+
105+
editor.setElementHighlights({ "node-1": "#ff0000" })
106+
editor.setElementHighlights(undefined)
107+
expect(editor.getElementHighlights()).toEqual({ "node-1": "#ff0000" })
108+
})
109+
})

0 commit comments

Comments
 (0)