Skip to content

Commit 43e42a8

Browse files
fix(library): preserve compatibility and drag anchors
1 parent 26a33bb commit 43e42a8

6 files changed

Lines changed: 84 additions & 37 deletions

File tree

library/lib/components/DraggableGhost.tsx

Lines changed: 23 additions & 18 deletions
Original file line numberDiff line numberDiff line change
@@ -82,11 +82,9 @@ export const DraggableGhost: React.FC<DraggableGhostProps> = ({
8282
const startRef = useRef<XYPosition | null>(null)
8383
const maxTravelRef = useRef(0)
8484
const pointerTypeRef = useRef<string>("mouse")
85-
// Where the cursor grabbed the preview, as a FRACTION of its box; backed out on
86-
// drop so that same point stays under the pointer. A pixel offset would not
87-
// work: the ghost and the dropped node are drawn at sizes the preview does not
88-
// share, so only a fraction lines up at any zoom.
89-
const grabFractionRef = useRef<XYPosition>({ x: 0, y: 0 })
85+
// Where the cursor grabbed the preview in the dropped node's flow-space units;
86+
// the same offset positions both the ghost and the committed node.
87+
const grabOffsetRef = useRef<XYPosition>({ x: 0, y: 0 })
9088
// True once a press has turned into a drag, so the trailing click is ignored.
9189
const draggedRef = useRef(false)
9290

@@ -113,13 +111,19 @@ export const DraggableGhost: React.FC<DraggableGhostProps> = ({
113111
const previewRect = (
114112
previewElement ?? event.currentTarget
115113
).getBoundingClientRect()
116-
const grabX = previewRect.width
117-
? (event.clientX - previewRect.left) / previewRect.width
118-
: 0
119-
const grabY = previewRect.height
120-
? (event.clientY - previewRect.top) / previewRect.height
121-
: 0
122-
grabFractionRef.current = { x: grabX, y: grabY }
114+
// Sidebar previews scale uniformly. Recover the grab point in their
115+
// unscaled painted coordinates from the horizontal scale (the vertical
116+
// extent may include a label band), then map each axis onto the drop size.
117+
const previewScale = previewRect.width / dropElementConfig.width || 1
118+
const grabOffset = {
119+
x:
120+
((event.clientX - previewRect.left) / previewScale) *
121+
(ghostDropWidth / dropElementConfig.width),
122+
y:
123+
((event.clientY - previewRect.top) / previewScale) *
124+
(ghostDropHeight / dropElementConfig.height),
125+
}
126+
grabOffsetRef.current = grabOffset
123127

124128
// Draw the ghost at the on-screen size the node will have at this zoom. The
125129
// drop size is used rather than the palette size so an element that drops
@@ -129,12 +133,13 @@ export const DraggableGhost: React.FC<DraggableGhostProps> = ({
129133
const zoom = getViewport().zoom
130134
setGhostRender({ scale: zoom })
131135

132-
const ghostWidth = ghostDropWidth * zoom
133-
const ghostHeight = ghostDropHeight * zoom
134-
setGhostOffset({ x: grabX * ghostWidth, y: grabY * ghostHeight })
136+
setGhostOffset({
137+
x: grabOffset.x * zoom,
138+
y: grabOffset.y * zoom,
139+
})
135140
setGhostPosition({
136-
x: event.clientX - grabX * ghostWidth,
137-
y: event.clientY - grabY * ghostHeight,
141+
x: event.clientX - grabOffset.x * zoom,
142+
y: event.clientY - grabOffset.y * zoom,
138143
})
139144

140145
setIsDragging(true)
@@ -195,7 +200,7 @@ export const DraggableGhost: React.FC<DraggableGhostProps> = ({
195200
: DROPS.TAP_SLOP_MOUSE_PX
196201
const placed =
197202
maxTravelRef.current >= slop &&
198-
dropRef.current(event, grabFractionRef.current)
203+
dropRef.current(event, grabOffsetRef.current)
199204
if (placed) suppressTrailingClick()
200205
else draggedRef.current = false
201206
}

library/lib/hooks/usePalettePlacement.ts

Lines changed: 3 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -130,7 +130,7 @@ export function usePalettePlacement(dropElementConfig: DropElementConfig) {
130130
const dropAtPointer = useCallback(
131131
(
132132
event: { clientX: number; clientY: number },
133-
grabFraction: XYPosition
133+
grabOffset: XYPosition
134134
): boolean => {
135135
const canvas = getCanvas()
136136
if (!canvas) {
@@ -146,14 +146,6 @@ export function usePalettePlacement(dropElementConfig: DropElementConfig) {
146146
event.clientY > bounds.bottom
147147
if (outside) return false
148148

149-
// The grabbed fraction maps straight onto the drop size, so the cursor stays
150-
// over the same relative point of the node it becomes. Taking it from the
151-
// preview's pixel height instead would be wrong for the elements whose
152-
// preview reserves a label band the shape itself does not have.
153-
const dropWidth = dropElementConfig.dropWidth ?? dropElementConfig.width
154-
const dropHeight =
155-
dropElementConfig.dropHeight ?? dropElementConfig.height
156-
157149
// Parent is hit-tested at the snapped cursor (where the ghost is
158150
// anchored); the node's top-left is the cursor backed out by the offset.
159151
const parent = findDropParent(
@@ -166,8 +158,8 @@ export function usePalettePlacement(dropElementConfig: DropElementConfig) {
166158
x: event.clientX,
167159
y: event.clientY,
168160
})
169-
absolute.x -= Math.floor((grabFraction.x * dropWidth) / snapPx) * snapPx
170-
absolute.y -= Math.floor((grabFraction.y * dropHeight) / snapPx) * snapPx
161+
absolute.x -= Math.floor(grabOffset.x / snapPx) * snapPx
162+
absolute.y -= Math.floor(grabOffset.y / snapPx) * snapPx
171163

172164
let position = absolute
173165
if (parent) {

library/lib/i18n/labels.ts

Lines changed: 4 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -27,9 +27,9 @@ export interface ApollonLabels {
2727
* Apple hardware, "Ctrl" elsewhere) so the sentence reads naturally in any
2828
* language without the host having to detect the platform itself.
2929
*/
30-
scrollLockHint: (modifier: string) => string
30+
scrollLockHint?: (modifier: string) => string
3131
/** Same situation, but for a touch device, where there is no modifier key. */
32-
scrollLockHintTouch: string
32+
scrollLockHintTouch?: string
3333
/**
3434
* Accessible name for an authored straight-edge waypoint handle.
3535
* Optional so a complete dictionary written against an older library release
@@ -71,7 +71,8 @@ export interface ApollonLabels {
7171
deleteAssessment: string
7272
deleteAssessmentFor: (name: string) => string
7373
assessmentFor: (type: string) => string
74-
previousAssessment: string
74+
/** Optional for dictionaries authored before assessment navigation existed. */
75+
previousAssessment?: string
7576
nextAssessment: string
7677
noComment: string
7778
notGraded: string

library/lib/i18n/useLabels.ts

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { useMetadataStore } from "@/store/context"
2-
import type { ApollonLabels } from "./labels"
2+
import type { ResolvedApollonLabels } from "./labels"
33

44
/** The editor's active label set (English defaults merged with host overrides).
55
* Reactive — a host swapping `labels` re-renders chrome without a remount. */
6-
export const useLabels = (): ApollonLabels => useMetadataStore((s) => s.labels)
6+
export const useLabels = (): ResolvedApollonLabels =>
7+
useMetadataStore((s) => s.labels)

library/tests/unit/labels.test.ts

Lines changed: 15 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
import { describe, expect, it } from "vitest"
22
import { DEFAULT_LABELS, mergeLabels } from "@/i18n/labels"
3+
import type { ApollonLabels } from "@/i18n/labels"
34

45
describe("mergeLabels", () => {
56
it("fills labels introduced after a host's partial dictionary was written", () => {
@@ -8,4 +9,18 @@ describe("mergeLabels", () => {
89
expect(labels.zoomIn).toBe("Vergrößern")
910
expect(labels.moveEdgeWaypoint).toBe(DEFAULT_LABELS.moveEdgeWaypoint)
1011
})
12+
13+
it("keeps a complete dictionary from before the new host labels assignable", () => {
14+
const {
15+
scrollLockHint: _scrollLockHint,
16+
scrollLockHintTouch: _scrollLockHintTouch,
17+
previousAssessment: _previousAssessment,
18+
...legacyDictionary
19+
} = DEFAULT_LABELS
20+
const compatibleDictionary: ApollonLabels = legacyDictionary
21+
22+
expect(mergeLabels(compatibleDictionary).scrollLockHintTouch).toBe(
23+
DEFAULT_LABELS.scrollLockHintTouch
24+
)
25+
})
1126
})

library/tests/unit/paletteTapPlacement.test.tsx

Lines changed: 36 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -103,14 +103,17 @@ beforeEach(() => {
103103

104104
afterEach(() => canvas.remove())
105105

106-
const mountGhost = () => {
106+
const mountGhost = (
107+
dropConfig: DropElementConfig = config,
108+
previewRect: DOMRect = PREVIEW_RECT
109+
) => {
107110
const { getByRole } = render(
108-
<DraggableGhost dropElementConfig={config}>
111+
<DraggableGhost dropElementConfig={dropConfig}>
109112
<div data-testid="entry">entry</div>
110113
</DraggableGhost>
111114
)
112115
const wrapper = getByRole("button")
113-
wrapper.getBoundingClientRect = () => PREVIEW_RECT
116+
wrapper.getBoundingClientRect = () => previewRect
114117
return wrapper
115118
}
116119

@@ -186,6 +189,36 @@ describe("palette tap-to-place", () => {
186189
expect(setSelectedElementsId).not.toHaveBeenCalled()
187190
})
188191

192+
it("preserves a grab inside a preview label band for the ghost and drop", () => {
193+
const labelConfig = {
194+
...config,
195+
width: 60,
196+
height: 60,
197+
} as DropElementConfig
198+
const labelPreviewRect = {
199+
...PREVIEW_RECT,
200+
right: 60,
201+
bottom: 90,
202+
width: 60,
203+
height: 90,
204+
} as DOMRect
205+
const wrapper = mountGhost(labelConfig, labelPreviewRect)
206+
207+
// The preview includes a 30px label band below its 60px node body. Its
208+
// painted midpoint is therefore 45px down, not 30px down.
209+
fireEvent.pointerDown(wrapper, { clientX: 30, clientY: 45 })
210+
fireEvent.pointerMove(document, { clientX: 400, clientY: 300 })
211+
212+
const ghost = Array.from(
213+
document.querySelectorAll<HTMLElement>("[data-draggable-preview]")
214+
).find((element) => element.style.position === "fixed")
215+
expect(ghost?.style.left).toBe("370px")
216+
expect(ghost?.style.top).toBe("255px")
217+
218+
fireEvent.pointerUp(document, { clientX: 400, clientY: 300 })
219+
expect(placedNodes()[0].position).toEqual({ x: 370, y: 255 })
220+
})
221+
189222
it("a wobble that releases off-canvas places nothing on drop, then centres on the click", () => {
190223
const wrapper = mountGhost()
191224
fireEvent.pointerDown(wrapper, { clientX: 30, clientY: 30 })

0 commit comments

Comments
 (0)