-
Notifications
You must be signed in to change notification settings - Fork 1
Expand file tree
/
Copy pathrenderer.ts
More file actions
303 lines (284 loc) · 10.1 KB
/
Copy pathrenderer.ts
File metadata and controls
303 lines (284 loc) · 10.1 KB
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
import { createGestureFeedback } from '../layout/gesture-feedback.js';
import { createMenu, type Menu, type MenuLayoutModel } from '../layout/menu.js';
import { rafThrottle } from '../layout/raf-throttle.js';
import {
createStrokeCanvas,
type StrokeCanvas,
type StrokeCanvasOptions,
} from '../layout/stroke.js';
import type { AnyModelNode, ModelMenus } from '../types.js';
import { toLocalPoint, type Point } from '../utils.js';
import type { LayoutView } from './layout-view.js';
export type FeedbackEffect = {
readonly stroke: readonly Point[];
readonly canceled: boolean;
};
export type LayoutRenderer<M extends AnyModelNode> = {
render: (view: LayoutView<M>) => void;
showFeedback: (effect: FeedbackEffect) => void;
dispose: () => void;
};
/**
The menu DOM the renderer currently owns, keyed by the model reference it
was built from: reference equality on `model` is enough to decide
recreate-vs-patch, since the model tree is built once and frozen.
*/
type MenuHandle<M extends AnyModelNode> = {
model: ModelMenus<M>;
menu: Menu;
};
/**
One stroke canvas (upper or lower), owning its own reference-equality cache
so an unchanged stroke array skips the redraw. `upperStroke` and
`lowerStroke` are two independent instances of the exact same behavior.
*/
function createStrokeLayer({
parent,
canvasOptions,
}: {
parent: HTMLElement;
canvasOptions?: Omit<StrokeCanvasOptions, 'parent'>;
}): {
sync: (
stroke: readonly Point[] | null,
options?: { drawStartPoint?: boolean },
) => void;
element: () => HTMLCanvasElement | null;
dispose: () => void;
} {
let canvas: StrokeCanvas | null = null;
let previousStroke: readonly Point[] | null = null;
const draw = rafThrottle(
(stroke: readonly Point[], shouldDrawStartPoint: boolean) => {
// Strokes arrive in client coordinates, straight from the pointer; a
// canvas draws relative to its own top-left, which is the parent's.
// Converting here rather than in `sync` keeps that method's reference
// check comparing the array the caller passed, and picks up a parent
// that has since moved or scrolled.
const rect = parent.getBoundingClientRect();
const local = stroke.map((point) => toLocalPoint(point, rect));
canvas?.clear();
canvas?.drawStroke(local);
const [start] = local;
if (shouldDrawStartPoint && start !== undefined) {
canvas?.drawPoint(start);
}
},
);
return {
sync(stroke, { drawStartPoint: shouldDrawStartPoint = false } = {}) {
if (stroke === null) {
canvas?.remove();
canvas = null;
previousStroke = null;
} else if (stroke !== previousStroke) {
previousStroke = stroke;
canvas ??= createStrokeCanvas({ parent, ...canvasOptions });
draw(stroke, shouldDrawStartPoint);
}
},
element: () => canvas?.element ?? null,
dispose() {
draw.cancel();
canvas?.remove();
canvas = null;
},
};
}
/**
Whether `node` is painted before `other`. The two are always siblings under
the renderer's parent, never nested, so `compareDocumentPosition` returns
exactly one of the two ordering flags and nothing has to be masked off.
*/
function isPaintedBefore(node: Node, other: Node): boolean {
return (
node.compareDocumentPosition(other) === Node.DOCUMENT_POSITION_FOLLOWING
);
}
export type RendererOptions = {
readonly parent: HTMLElement;
/**
The color of the upper (current gesture) stroke.
*/
readonly strokeColor?: string | undefined;
/**
The width of the upper stroke.
*/
readonly strokeWidth?: number | undefined;
/**
The radius of the point marking the start of the upper stroke, drawn while
novice mode is open.
*/
readonly strokeStartPointRadius?: number | undefined;
/**
The color of the lower stroke, tracking movement accumulated before the
currently open menu.
*/
readonly lowerStrokeColor?: string | undefined;
/**
The width of the lower stroke. Defaults to `strokeWidth`.
*/
readonly lowerStrokeWidth?: number | undefined;
/**
The radius of the lower stroke's start point. Defaults to `lowerStrokeWidth`.
*/
readonly lowerStrokeStartPointRadius?: number | undefined;
/**
The duration a completed-gesture feedback trace stays visible, in ms.
*/
readonly gestureFeedbackDuration?: number | undefined;
/**
The width of a gesture-feedback stroke. Defaults to `strokeWidth`.
*/
readonly gestureFeedbackStrokeWidth?: number | undefined;
/**
The color of a selected gesture's feedback stroke. Defaults to `strokeColor`.
*/
readonly gestureFeedbackStrokeColor?: string | undefined;
/**
The color of a canceled gesture's feedback stroke.
*/
readonly gestureFeedbackCanceledStrokeColor?: string | undefined;
};
export function createRenderer<M extends AnyModelNode = AnyModelNode>({
parent,
strokeColor = '#000',
strokeWidth = 4,
strokeStartPointRadius = 8,
lowerStrokeColor = '#777',
lowerStrokeWidth = strokeWidth,
lowerStrokeStartPointRadius = lowerStrokeWidth,
gestureFeedbackDuration = 1000,
gestureFeedbackStrokeWidth = strokeWidth,
gestureFeedbackStrokeColor = strokeColor,
gestureFeedbackCanceledStrokeColor = '#DE6C52',
}: RendererOptions): LayoutRenderer<M> {
let menuHandle: MenuHandle<M> | null = null;
// Reference-equality cache: an unchanged active key skips the DOM scan
// `Menu.setActive` performs.
let previousActiveKey: string | null = null;
const upperStroke = createStrokeLayer({
parent,
canvasOptions: {
lineColor: strokeColor,
lineWidth: strokeWidth,
pointRadius: strokeStartPointRadius,
},
});
const lowerStroke = createStrokeLayer({
parent,
canvasOptions: {
lineColor: lowerStrokeColor,
lineWidth: lowerStrokeWidth,
pointRadius: lowerStrokeStartPointRadius,
},
});
// The parent's own inline cursor, read before the renderer writes one, and
// restored rather than cleared whenever the view asks for `default`: what
// the renderer did not set, it does not get to throw away.
const ownCursor = parent.style.cursor;
const gestureFeedback = createGestureFeedback({
parent,
duration: gestureFeedbackDuration,
strokeOptions: {
lineColor: gestureFeedbackStrokeColor,
lineWidth: gestureFeedbackStrokeWidth,
},
canceledStrokeOptions: {
lineColor: gestureFeedbackCanceledStrokeColor,
},
});
/**
The paint order the novice feedback depends on: the lower stroke, which
records movement made before the menu opened, goes behind the menu; the
upper stroke and its origin marker go in front of it, so the marker
stays visible and the line is not cut where it crosses an item.
A completed-gesture trace belongs in front of the menu as well. It
outlives the gesture that produced it, so a menu opened before it fades
is appended after it and would cover it.
Nothing in the stylesheet sets any of this, and each canvas and the menu
land wherever they were first needed, so sibling order is all that holds
it. Every render re-asserts that order, moving an element only when it
is out of place.
*/
function restack(): void {
const menuElement = menuHandle?.menu.element;
if (menuElement === undefined) {
return;
}
const lower = lowerStroke.element();
if (lower !== null && !isPaintedBefore(lower, menuElement)) {
menuElement.before(lower);
}
const upper = upperStroke.element();
if (upper !== null && !isPaintedBefore(menuElement, upper)) {
menuElement.after(upper);
}
// After the upper stroke, so a live gesture still draws over a fading
// trace of the previous one.
for (const trace of gestureFeedback.elements()) {
if (!isPaintedBefore(menuElement, trace)) {
menuElement.after(trace);
}
}
}
return {
render(view) {
parent.style.cursor = view.cursor === 'default' ? ownCursor : view.cursor;
// `projectLayout` sets `cursor: 'none'` exactly in novice mode: the
// only phase where the upper stroke's origin point (the gesture's
// start) is drawn alongside the line.
const isNoviceMode = view.cursor === 'none';
if (view.menu === null) {
menuHandle?.menu.remove();
menuHandle = null;
previousActiveKey = null;
} else {
if (menuHandle?.model !== view.menu.model) {
menuHandle?.menu.remove();
// `LayoutView.menu.center` is in client coordinates; the menu
// layout wants it relative to `parent`. The projector is
// DOM-free, so every such conversion is the renderer's to make
// (see `createStrokeLayer` and `showFeedback` for the others).
const cbr = parent.getBoundingClientRect();
menuHandle = {
model: view.menu.model,
menu: createMenu({
parent,
// `ModelMenus<M>`'s `items` are generically erased to
// `AnyModelNode` inside this function body, the same reason
// `recognize-mm-stroke.ts`'s `walkModelLoose` needs a cast:
// the compiler cannot prove genericness away. Every real menu
// item built by `model.ts` carries `key`/`label`/`angle`.
model: view.menu.model as unknown as MenuLayoutModel,
center: toLocalPoint(view.menu.center, cbr),
}),
};
previousActiveKey = null;
}
if (view.menu.activeKey !== previousActiveKey) {
previousActiveKey = view.menu.activeKey;
menuHandle.menu.setActive(view.menu.activeKey);
}
}
upperStroke.sync(view.upperStroke, { drawStartPoint: isNoviceMode });
lowerStroke.sync(view.lowerStroke);
restack();
},
showFeedback(effect) {
const rect = parent.getBoundingClientRect();
gestureFeedback.show(
effect.stroke.map((point) => toLocalPoint(point, rect)),
{ canceled: effect.canceled },
);
},
dispose() {
parent.style.cursor = ownCursor;
upperStroke.dispose();
lowerStroke.dispose();
menuHandle?.menu.remove();
menuHandle = null;
gestureFeedback.remove();
},
};
}