@devaccel-labs/app-comments-toolbar 1.0.0 → 1.3.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +152 -18
- package/dist/react.js +516 -61
- package/dist/server.js +342 -14
- package/dist/types/capture/api.d.ts +30 -0
- package/dist/types/capture/editor.d.ts +10 -0
- package/dist/types/capture/ignore.d.ts +19 -0
- package/dist/types/capture/index.d.ts +1 -0
- package/dist/types/capture/picker.d.ts +9 -0
- package/dist/types/capture/picture.d.ts +29 -0
- package/dist/types/capture/surface.d.ts +39 -0
- package/dist/types/core/index.d.ts +2 -2
- package/dist/types/core/resolve.d.ts +2 -1
- package/dist/types/core/types.d.ts +11 -0
- package/dist/types/debug-context.d.ts +7 -0
- package/dist/types/dictation.d.ts +9 -0
- package/dist/types/embed/controller.d.ts +61 -0
- package/dist/types/embed/index.d.ts +1 -0
- package/dist/types/embed/options.d.ts +35 -0
- package/dist/types/help/index.d.ts +1 -0
- package/dist/types/help/panel-index.d.ts +1 -0
- package/dist/types/help/panel-styles.d.ts +1 -0
- package/dist/types/help/panel.d.ts +4 -0
- package/dist/types/help/pill-styles.d.ts +11 -0
- package/dist/types/help/pill.d.ts +44 -0
- package/dist/types/help/react.d.ts +12 -0
- package/dist/types/help/requests.d.ts +2 -0
- package/dist/types/help/types.d.ts +119 -0
- package/dist/types/help/wire.d.ts +61 -0
- package/dist/types/native-wire.d.ts +63 -0
- package/dist/types/native.d.ts +43 -0
- package/dist/types/react.d.ts +8 -1
- package/dist/types/replay.d.ts +6 -0
- package/dist/types/runtime/api.d.ts +250 -0
- package/dist/types/runtime/attachment-model.d.ts +16 -0
- package/dist/types/runtime/attachments.d.ts +43 -7
- package/dist/types/runtime/boot.d.ts +39 -0
- package/dist/types/runtime/browser-label.d.ts +5 -0
- package/dist/types/runtime/build-id.d.ts +7 -0
- package/dist/types/runtime/capture-ui.d.ts +45 -21
- package/dist/types/runtime/clarity-model.d.ts +260 -0
- package/dist/types/runtime/clarity-ui.d.ts +62 -0
- package/dist/types/runtime/client.d.ts +6 -197
- package/dist/types/runtime/composer-size.d.ts +30 -0
- package/dist/types/runtime/dashboard.d.ts +17 -2
- package/dist/types/runtime/debug-context-core.d.ts +88 -0
- package/dist/types/runtime/debug-context-host.d.ts +39 -0
- package/dist/types/runtime/debug-context-model.d.ts +352 -0
- package/dist/types/runtime/debug-context-redact.d.ts +48 -0
- package/dist/types/runtime/debug-context.d.ts +36 -0
- package/dist/types/runtime/details-model.d.ts +7 -0
- package/dist/types/runtime/details.d.ts +7 -5
- package/dist/types/runtime/dictation-engine.d.ts +98 -0
- package/dist/types/runtime/dictation-model.d.ts +28 -0
- package/dist/types/runtime/dictation-ui.d.ts +56 -0
- package/dist/types/runtime/dictation.d.ts +66 -0
- package/dist/types/runtime/draft-dock.d.ts +32 -0
- package/dist/types/runtime/draft-model.d.ts +104 -0
- package/dist/types/runtime/draft-store.d.ts +15 -0
- package/dist/types/runtime/edits.d.ts +1 -1
- package/dist/types/runtime/font.d.ts +16 -0
- package/dist/types/runtime/format.d.ts +30 -1
- package/dist/types/runtime/host-watch.d.ts +20 -0
- package/dist/types/runtime/icons.d.ts +16 -2
- package/dist/types/runtime/index.d.ts +3 -1
- package/dist/types/runtime/ink.d.ts +8 -0
- package/dist/types/runtime/kind-model.d.ts +14 -0
- package/dist/types/runtime/live-model.d.ts +25 -0
- package/dist/types/runtime/loader.d.ts +5 -11
- package/dist/types/runtime/markup-editor.d.ts +54 -0
- package/dist/types/runtime/markup-model.d.ts +126 -0
- package/dist/types/runtime/markup.d.ts +69 -0
- package/dist/types/runtime/mention-parse.d.ts +18 -0
- package/dist/types/runtime/mentions.d.ts +4 -4
- package/dist/types/runtime/page-background.d.ts +9 -0
- package/dist/types/runtime/page-image.d.ts +123 -0
- package/dist/types/runtime/placement.d.ts +6 -3
- package/dist/types/runtime/record.d.ts +18 -2
- package/dist/types/runtime/related.d.ts +1 -1
- package/dist/types/runtime/replay-model.d.ts +80 -0
- package/dist/types/runtime/replay.d.ts +5 -38
- package/dist/types/runtime/runtime.d.ts +6 -0
- package/dist/types/runtime/screen.d.ts +47 -0
- package/dist/types/runtime/screenshot.d.ts +60 -6
- package/dist/types/runtime/session-source.d.ts +45 -0
- package/dist/types/runtime/session.d.ts +20 -18
- package/dist/types/runtime/shield.d.ts +9 -0
- package/dist/types/runtime/shot.d.ts +45 -0
- package/dist/types/runtime/sketch.d.ts +26 -0
- package/dist/types/runtime/styles.d.ts +1 -1
- package/dist/types/runtime/sync.d.ts +1 -0
- package/dist/types/runtime/thread-model.d.ts +18 -0
- package/dist/types/runtime/ticket-form-model.d.ts +57 -0
- package/dist/types/runtime/ticket-form.d.ts +9 -0
- package/dist/types/runtime/toolbar.d.ts +37 -1
- package/dist/types/runtime/ui-scale.d.ts +17 -0
- package/dist/types/runtime/update.d.ts +37 -0
- package/dist/types/runtime/use-debug-context.d.ts +27 -0
- package/dist/types/server/index.d.ts +22 -0
- package/dist/types/server/support.d.ts +20 -0
- package/dist/types/server.d.ts +2 -0
- package/dist/types/vite/plugin.d.ts +15 -0
- package/dist/vite.js +121 -41
- package/package.json +26 -13
- package/src/capture/api.ts +128 -0
- package/src/capture/editor.tsx +178 -0
- package/src/capture/ignore.ts +38 -0
- package/src/capture/index.ts +27 -0
- package/src/capture/picker.tsx +171 -0
- package/src/capture/picture.ts +90 -0
- package/src/capture/surface.tsx +185 -0
- package/src/compiler/annotate.ts +202 -0
- package/src/compiler/babel.d.ts +11 -0
- package/src/compiler/enabled.ts +55 -0
- package/src/compiler/index.ts +2 -0
- package/src/core/index.ts +8 -0
- package/src/core/resolve.ts +307 -0
- package/src/core/types.ts +58 -0
- package/src/debug-context.ts +47 -0
- package/src/dictation.ts +17 -0
- package/src/embed/controller.ts +215 -0
- package/src/embed/index.ts +68 -0
- package/src/embed/options.ts +100 -0
- package/src/help/index.ts +38 -0
- package/src/help/panel-index.ts +17 -0
- package/src/help/panel-styles.ts +123 -0
- package/src/help/panel.ts +840 -0
- package/src/help/pill-styles.ts +75 -0
- package/src/help/pill.ts +338 -0
- package/src/help/react.tsx +143 -0
- package/src/help/requests.ts +111 -0
- package/src/help/types.ts +124 -0
- package/src/help/wire.ts +55 -0
- package/src/native-wire.ts +124 -0
- package/src/native.ts +282 -0
- package/src/react.ts +27 -0
- package/src/replay.ts +13 -0
- package/src/runtime/api.ts +531 -0
- package/src/runtime/attachment-model.ts +42 -0
- package/src/runtime/attachments.tsx +1260 -0
- package/src/runtime/boot.ts +95 -0
- package/src/runtime/browser-label.ts +31 -0
- package/src/runtime/build-id.ts +10 -0
- package/src/runtime/capture-ui.tsx +306 -0
- package/src/runtime/capture.ts +508 -0
- package/src/runtime/clarity-model.ts +634 -0
- package/src/runtime/clarity-ui.tsx +454 -0
- package/src/runtime/client.ts +151 -0
- package/src/runtime/composer-size.tsx +146 -0
- package/src/runtime/dashboard.tsx +1130 -0
- package/src/runtime/debug-context-core.ts +681 -0
- package/src/runtime/debug-context-host.ts +56 -0
- package/src/runtime/debug-context-model.ts +1319 -0
- package/src/runtime/debug-context-redact.ts +259 -0
- package/src/runtime/debug-context.ts +613 -0
- package/src/runtime/details-model.ts +30 -0
- package/src/runtime/details.tsx +395 -0
- package/src/runtime/dictation-engine.ts +616 -0
- package/src/runtime/dictation-model.ts +79 -0
- package/src/runtime/dictation-ui.tsx +363 -0
- package/src/runtime/dictation.ts +271 -0
- package/src/runtime/dock.ts +108 -0
- package/src/runtime/draft-dock.tsx +124 -0
- package/src/runtime/draft-model.ts +251 -0
- package/src/runtime/draft-store.ts +149 -0
- package/src/runtime/edits.tsx +165 -0
- package/src/runtime/font.ts +42 -0
- package/src/runtime/format.ts +129 -0
- package/src/runtime/freshness.ts +23 -0
- package/src/runtime/handoff.ts +76 -0
- package/src/runtime/host-watch.ts +64 -0
- package/src/runtime/icons.tsx +295 -0
- package/src/runtime/idle.ts +20 -0
- package/src/runtime/index.ts +11 -0
- package/src/runtime/ink.ts +6 -0
- package/src/runtime/kind-model.ts +20 -0
- package/src/runtime/live-model.ts +32 -0
- package/src/runtime/loader.tsx +87 -0
- package/src/runtime/markup-editor.tsx +1333 -0
- package/src/runtime/markup-model.ts +485 -0
- package/src/runtime/markup.ts +382 -0
- package/src/runtime/members.ts +58 -0
- package/src/runtime/mention-parse.ts +56 -0
- package/src/runtime/mentions.tsx +205 -0
- package/src/runtime/page-background.ts +51 -0
- package/src/runtime/page-image.ts +1592 -0
- package/src/runtime/placement.ts +336 -0
- package/src/runtime/record.ts +402 -0
- package/src/runtime/redact.ts +30 -0
- package/src/runtime/related.ts +34 -0
- package/src/runtime/render-page.ts +2 -0
- package/src/runtime/replay-model.ts +232 -0
- package/src/runtime/replay.ts +173 -0
- package/src/runtime/runtime-version.ts +7 -0
- package/src/runtime/runtime.tsx +51 -0
- package/src/runtime/screen.ts +182 -0
- package/src/runtime/screenshot.ts +260 -0
- package/src/runtime/session-source.ts +112 -0
- package/src/runtime/session.ts +108 -0
- package/src/runtime/shield.ts +34 -0
- package/src/runtime/shot.tsx +422 -0
- package/src/runtime/sketch.ts +139 -0
- package/src/runtime/styles.ts +1541 -0
- package/src/runtime/sync.ts +300 -0
- package/src/runtime/thread-model.ts +71 -0
- package/src/runtime/ticket-form-model.ts +128 -0
- package/src/runtime/ticket-form.tsx +342 -0
- package/src/runtime/toolbar.tsx +4427 -0
- package/src/runtime/ui-scale.ts +75 -0
- package/src/runtime/update.ts +98 -0
- package/src/runtime/use-debug-context.ts +81 -0
- package/src/server/index.ts +133 -0
- package/src/server/support.ts +499 -0
- package/src/server.ts +4 -0
- package/src/vite/plugin.ts +102 -0
- package/src/vite.ts +1 -0
- package/dist/chunks/chunk-CSKXLF33.js +0 -153
- package/dist/chunks/chunk-HUM5FQII.js +0 -160
- package/dist/chunks/render-page-76MLIT6M.js +0 -1421
- package/dist/chunks/runtime-JX75TKER.js +0 -9267
- package/dist/chunks/screenshot-66ARTBGR.js +0 -12
|
@@ -0,0 +1,382 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Markup drawn over an attached image before it is sent: pen, highlighter, arrows, boxes, ellipses, and text
|
|
3
|
+
* labels. Shapes live in the image's own pixel space, so the same list draws the on-screen overlay (scaled to
|
|
4
|
+
* the pane) and the flattened file (at full size). This file is the canvas renderer and flattening; the
|
|
5
|
+
* DOM-free shape model is `./markup-model.ts`, re-exported here. Everything takes its canvas as an argument, so
|
|
6
|
+
* it runs under unit tests without a real canvas.
|
|
7
|
+
*/
|
|
8
|
+
import {
|
|
9
|
+
arrowHead,
|
|
10
|
+
contrastText,
|
|
11
|
+
flattenedName,
|
|
12
|
+
HIGHLIGHT_ALPHA,
|
|
13
|
+
markupFont,
|
|
14
|
+
markupMetrics,
|
|
15
|
+
strokeWidth,
|
|
16
|
+
textLayout,
|
|
17
|
+
toRect,
|
|
18
|
+
type ArrowShape,
|
|
19
|
+
type Box,
|
|
20
|
+
type BoxShape,
|
|
21
|
+
type MarkupMetrics,
|
|
22
|
+
type MarkupShape,
|
|
23
|
+
type MeasureText,
|
|
24
|
+
type StrokeShape,
|
|
25
|
+
type TextShape,
|
|
26
|
+
} from "./markup-model.js";
|
|
27
|
+
|
|
28
|
+
export * from "./markup-model.js";
|
|
29
|
+
|
|
30
|
+
/** Canvas area browsers reliably allocate (Safari's cap is 16 777 216 px); a bigger image is flattened smaller. */
|
|
31
|
+
export const MAX_CANVAS_PIXELS = 16_000_000;
|
|
32
|
+
/** The selection outline's blue, matching the toolbar's accent. */
|
|
33
|
+
const SELECTION_INK = "#0a84ff";
|
|
34
|
+
/** JPEG qualities tried at full size, once the PNG is over the limit. */
|
|
35
|
+
const JPEG_FULL_SIZE = [0.92, 0.85, 0.75] as const;
|
|
36
|
+
/** JPEG qualities retried after a halving; PNG is not worth another try smaller. */
|
|
37
|
+
const JPEG_SMALLER = [0.85, 0.75] as const;
|
|
38
|
+
/** Halvings tried after the full-size attempts run out. */
|
|
39
|
+
const MAX_HALVINGS = 3;
|
|
40
|
+
|
|
41
|
+
/** The 2D context calls the renderer uses. A real CanvasRenderingContext2D satisfies it; tests pass a recorder. */
|
|
42
|
+
export type MarkupContext = Pick<
|
|
43
|
+
CanvasRenderingContext2D,
|
|
44
|
+
| "save"
|
|
45
|
+
| "restore"
|
|
46
|
+
| "beginPath"
|
|
47
|
+
| "closePath"
|
|
48
|
+
| "moveTo"
|
|
49
|
+
| "lineTo"
|
|
50
|
+
| "quadraticCurveTo"
|
|
51
|
+
| "arcTo"
|
|
52
|
+
| "ellipse"
|
|
53
|
+
| "rect"
|
|
54
|
+
| "stroke"
|
|
55
|
+
| "fill"
|
|
56
|
+
| "fillText"
|
|
57
|
+
| "measureText"
|
|
58
|
+
| "drawImage"
|
|
59
|
+
| "scale"
|
|
60
|
+
| "setLineDash"
|
|
61
|
+
> & {
|
|
62
|
+
strokeStyle: CanvasRenderingContext2D["strokeStyle"];
|
|
63
|
+
fillStyle: CanvasRenderingContext2D["fillStyle"];
|
|
64
|
+
lineWidth: number;
|
|
65
|
+
lineCap: CanvasLineCap;
|
|
66
|
+
lineJoin: CanvasLineJoin;
|
|
67
|
+
globalAlpha: number;
|
|
68
|
+
globalCompositeOperation: GlobalCompositeOperation;
|
|
69
|
+
font: string;
|
|
70
|
+
textBaseline: CanvasTextBaseline;
|
|
71
|
+
textAlign: CanvasTextAlign;
|
|
72
|
+
};
|
|
73
|
+
|
|
74
|
+
export type FlattenCanvas = {
|
|
75
|
+
width: number;
|
|
76
|
+
height: number;
|
|
77
|
+
getContext(kind: "2d"): MarkupContext | null;
|
|
78
|
+
toBlob(callback: (blob: Blob | null) => void, type?: string, quality?: number): void;
|
|
79
|
+
};
|
|
80
|
+
|
|
81
|
+
export type FlattenInput = {
|
|
82
|
+
/** The original image, already decoded. */
|
|
83
|
+
image: CanvasImageSource;
|
|
84
|
+
/** The image's natural size; shapes are in this space. */
|
|
85
|
+
width: number;
|
|
86
|
+
height: number;
|
|
87
|
+
shapes: MarkupShape[];
|
|
88
|
+
/** File name of the original; the result keeps its base name with the new extension. */
|
|
89
|
+
name: string;
|
|
90
|
+
maxBytes: number;
|
|
91
|
+
/** Test seam; defaults to a detached `<canvas>`. */
|
|
92
|
+
createCanvas?: (width: number, height: number) => FlattenCanvas;
|
|
93
|
+
};
|
|
94
|
+
|
|
95
|
+
/** Measures with `ctx.measureText`, or estimates 0.6 em per character when there is no canvas (jsdom). */
|
|
96
|
+
export function createMeasure(
|
|
97
|
+
ctx: Pick<MarkupContext, "measureText" | "font"> | null,
|
|
98
|
+
): MeasureText {
|
|
99
|
+
if (!ctx) return (text, px) => text.length * px * 0.6;
|
|
100
|
+
return (text, px) => {
|
|
101
|
+
ctx.font = markupFont(px);
|
|
102
|
+
return ctx.measureText(text).width;
|
|
103
|
+
};
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/**
|
|
107
|
+
* Draws shapes in order on a context already scaled to image px. Pen strokes are smoothed through quadratic
|
|
108
|
+
* midpoints with round caps and joins; a one-point stroke draws a dot. Highlighter strokes draw the same way at
|
|
109
|
+
* `HIGHLIGHT_ALPHA` with normal ("source-over") compositing, so the overlay on screen matches the file. Arrows
|
|
110
|
+
* draw a shaft and a filled head sized from the stroke width (head length 4x width, min 10u), with the shaft
|
|
111
|
+
* stopping at the head's base.
|
|
112
|
+
* Rectangles have rounded corners (radius 1.5x width, clamped to half the short side). Text draws a filled
|
|
113
|
+
* rounded label (radius 0.3 em) in the shape colour with `contrastText` lines. Each shape restores the context.
|
|
114
|
+
*/
|
|
115
|
+
export function drawShapes(
|
|
116
|
+
ctx: MarkupContext,
|
|
117
|
+
shapes: MarkupShape[],
|
|
118
|
+
metrics: MarkupMetrics,
|
|
119
|
+
): void {
|
|
120
|
+
for (const shape of shapes) drawShape(ctx, shape, metrics);
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
/** A dashed outline around `box` (padded by 4 screen px / `scale`), for the selected shape on screen. */
|
|
124
|
+
export function drawSelection(ctx: MarkupContext, box: Box, scale: number): void {
|
|
125
|
+
const zoom = Number.isFinite(scale) && scale > 0 ? scale : 1;
|
|
126
|
+
const pad = 4 / zoom;
|
|
127
|
+
ctx.save();
|
|
128
|
+
ctx.strokeStyle = SELECTION_INK;
|
|
129
|
+
ctx.lineWidth = 1 / zoom;
|
|
130
|
+
ctx.setLineDash([4 / zoom, 3 / zoom]);
|
|
131
|
+
ctx.beginPath();
|
|
132
|
+
ctx.rect(box.x - pad, box.y - pad, box.w + 2 * pad, box.h + 2 * pad);
|
|
133
|
+
ctx.stroke();
|
|
134
|
+
ctx.setLineDash([]);
|
|
135
|
+
ctx.restore();
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* Draws the image and its shapes into one file. Tries PNG at full size (scaled down to fit
|
|
140
|
+
* `MAX_CANVAS_PIXELS`); if that is over `maxBytes`, JPEG at quality 0.92, 0.85, 0.75 on a white background;
|
|
141
|
+
* then halves the size and tries again, up to three times. Rejects with a readable Error when the canvas has no
|
|
142
|
+
* 2D context, `toBlob` gives null, or nothing fits.
|
|
143
|
+
*/
|
|
144
|
+
export async function flattenMarkup(input: FlattenInput): Promise<{ blob: Blob; name: string }> {
|
|
145
|
+
const { image, width, height, shapes, name, maxBytes } = input;
|
|
146
|
+
const createCanvas = input.createCanvas ?? detachedCanvas;
|
|
147
|
+
const metrics = markupMetrics(width, height);
|
|
148
|
+
const area = width * height;
|
|
149
|
+
const fit = area > MAX_CANVAS_PIXELS ? Math.sqrt(MAX_CANVAS_PIXELS / area) : 1;
|
|
150
|
+
|
|
151
|
+
for (let step = 0; step <= MAX_HALVINGS; step += 1) {
|
|
152
|
+
const scale = fit / 2 ** step;
|
|
153
|
+
const canvasWidth = Math.max(1, Math.floor(width * scale));
|
|
154
|
+
const canvasHeight = Math.max(1, Math.floor(height * scale));
|
|
155
|
+
const attempts: FlattenAttempt[] =
|
|
156
|
+
step === 0
|
|
157
|
+
? [
|
|
158
|
+
{ type: "image/png" },
|
|
159
|
+
...JPEG_FULL_SIZE.map((quality): FlattenAttempt => ({ type: "image/jpeg", quality })),
|
|
160
|
+
]
|
|
161
|
+
: JPEG_SMALLER.map((quality): FlattenAttempt => ({ type: "image/jpeg", quality }));
|
|
162
|
+
|
|
163
|
+
for (const attempt of attempts) {
|
|
164
|
+
const canvas = createCanvas(canvasWidth, canvasHeight);
|
|
165
|
+
const ctx = canvas.getContext("2d");
|
|
166
|
+
if (!ctx) throw new Error("Couldn’t draw the marked-up image.");
|
|
167
|
+
// Shapes are in image px; the canvas may be smaller, so draw everything through one scale.
|
|
168
|
+
const k = width > 0 ? canvasWidth / width : 1;
|
|
169
|
+
ctx.scale(k, k);
|
|
170
|
+
if (attempt.type === "image/jpeg") {
|
|
171
|
+
// JPEG has no alpha, so a transparent PNG screenshot would turn black without this.
|
|
172
|
+
ctx.fillStyle = "#ffffff";
|
|
173
|
+
ctx.beginPath();
|
|
174
|
+
ctx.rect(0, 0, width, height);
|
|
175
|
+
ctx.fill();
|
|
176
|
+
}
|
|
177
|
+
ctx.drawImage(image, 0, 0, width, height);
|
|
178
|
+
drawShapes(ctx, shapes, metrics);
|
|
179
|
+
const blob = await toBlob(canvas, attempt.type, attempt.quality);
|
|
180
|
+
if (!blob) throw new Error("Couldn’t save the marked-up image.");
|
|
181
|
+
if (blob.size <= maxBytes) return { blob, name: flattenedName(name, attempt.type) };
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
throw new Error("The marked-up image is over the upload limit.");
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
/** Decodes an image URL. Rejects with a readable Error when it does not load. */
|
|
188
|
+
export function loadImage(url: string): Promise<HTMLImageElement> {
|
|
189
|
+
return new Promise((resolve, reject) => {
|
|
190
|
+
const image = new Image();
|
|
191
|
+
// The source is a same-page blob: URL, so the decode is local and needs no crossOrigin.
|
|
192
|
+
image.decoding = "async";
|
|
193
|
+
image.onload = () => {
|
|
194
|
+
if (typeof image.decode !== "function") {
|
|
195
|
+
resolve(image);
|
|
196
|
+
return;
|
|
197
|
+
}
|
|
198
|
+
// A decode failure after load still leaves a usable element in every browser we support.
|
|
199
|
+
const settled = () => resolve(image);
|
|
200
|
+
image.decode().then(settled, settled);
|
|
201
|
+
};
|
|
202
|
+
image.onerror = () => reject(new Error("Couldn’t open this image."));
|
|
203
|
+
image.src = url;
|
|
204
|
+
});
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
// ----------------------------------------------------------------------------
|
|
208
|
+
// Private helpers
|
|
209
|
+
// ----------------------------------------------------------------------------
|
|
210
|
+
|
|
211
|
+
type FlattenAttempt = { type: "image/png" | "image/jpeg"; quality?: number };
|
|
212
|
+
|
|
213
|
+
/** One shape, saved and restored so nothing it sets leaks into the next. */
|
|
214
|
+
function drawShape(ctx: MarkupContext, shape: MarkupShape, metrics: MarkupMetrics): void {
|
|
215
|
+
ctx.save();
|
|
216
|
+
try {
|
|
217
|
+
ctx.lineCap = "round";
|
|
218
|
+
ctx.lineJoin = "round";
|
|
219
|
+
ctx.strokeStyle = shape.color;
|
|
220
|
+
ctx.fillStyle = shape.color;
|
|
221
|
+
ctx.lineWidth = metrics.stroke(shape.size);
|
|
222
|
+
ctx.globalAlpha = shape.kind === "highlight" ? HIGHLIGHT_ALPHA : 1;
|
|
223
|
+
ctx.globalCompositeOperation = "source-over";
|
|
224
|
+
switch (shape.kind) {
|
|
225
|
+
case "pen":
|
|
226
|
+
case "highlight":
|
|
227
|
+
drawStroke(ctx, shape, metrics);
|
|
228
|
+
break;
|
|
229
|
+
case "arrow":
|
|
230
|
+
drawArrow(ctx, shape, metrics);
|
|
231
|
+
break;
|
|
232
|
+
case "rect":
|
|
233
|
+
drawRect(ctx, shape, metrics);
|
|
234
|
+
break;
|
|
235
|
+
case "ellipse":
|
|
236
|
+
drawEllipse(ctx, shape, metrics);
|
|
237
|
+
break;
|
|
238
|
+
case "text":
|
|
239
|
+
drawLabel(ctx, shape, metrics);
|
|
240
|
+
break;
|
|
241
|
+
default: {
|
|
242
|
+
const unknown: never = shape;
|
|
243
|
+
throw new Error(`Unknown markup shape: ${JSON.stringify(unknown)}`);
|
|
244
|
+
}
|
|
245
|
+
}
|
|
246
|
+
} finally {
|
|
247
|
+
ctx.restore();
|
|
248
|
+
}
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
function drawStroke(ctx: MarkupContext, shape: StrokeShape, metrics: MarkupMetrics): void {
|
|
252
|
+
const width = strokeWidth(shape, metrics);
|
|
253
|
+
const points = shape.points;
|
|
254
|
+
ctx.lineWidth = width;
|
|
255
|
+
if (points.length === 0) return;
|
|
256
|
+
if (points.length === 1) {
|
|
257
|
+
// A single tap is a dot the width of the stroke.
|
|
258
|
+
const only = points[0]!;
|
|
259
|
+
const radius = width / 2;
|
|
260
|
+
ctx.beginPath();
|
|
261
|
+
ctx.ellipse(only.x, only.y, radius, radius, 0, 0, Math.PI * 2);
|
|
262
|
+
ctx.fill();
|
|
263
|
+
return;
|
|
264
|
+
}
|
|
265
|
+
ctx.beginPath();
|
|
266
|
+
const first = points[0]!;
|
|
267
|
+
ctx.moveTo(first.x, first.y);
|
|
268
|
+
if (points.length === 2) {
|
|
269
|
+
const last = points[1]!;
|
|
270
|
+
ctx.lineTo(last.x, last.y);
|
|
271
|
+
} else {
|
|
272
|
+
// Curve through the midpoints, using each point as the control point, then land on the last point.
|
|
273
|
+
for (let i = 1; i < points.length - 1; i += 1) {
|
|
274
|
+
const current = points[i]!;
|
|
275
|
+
const next = points[i + 1]!;
|
|
276
|
+
ctx.quadraticCurveTo(
|
|
277
|
+
current.x,
|
|
278
|
+
current.y,
|
|
279
|
+
(current.x + next.x) / 2,
|
|
280
|
+
(current.y + next.y) / 2,
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
const last = points[points.length - 1]!;
|
|
284
|
+
ctx.lineTo(last.x, last.y);
|
|
285
|
+
}
|
|
286
|
+
ctx.stroke();
|
|
287
|
+
}
|
|
288
|
+
|
|
289
|
+
function drawArrow(ctx: MarkupContext, shape: ArrowShape, metrics: MarkupMetrics): void {
|
|
290
|
+
const width = metrics.stroke(shape.size);
|
|
291
|
+
const { length, halfWidth } = arrowHead(width, metrics.unit);
|
|
292
|
+
const dx = shape.to.x - shape.from.x;
|
|
293
|
+
const dy = shape.to.y - shape.from.y;
|
|
294
|
+
const distance = Math.hypot(dx, dy);
|
|
295
|
+
if (distance === 0) return;
|
|
296
|
+
const ux = dx / distance;
|
|
297
|
+
const uy = dy / distance;
|
|
298
|
+
const base = { x: shape.to.x - ux * length, y: shape.to.y - uy * length };
|
|
299
|
+
ctx.lineWidth = width;
|
|
300
|
+
if (distance > length) {
|
|
301
|
+
ctx.beginPath();
|
|
302
|
+
ctx.moveTo(shape.from.x, shape.from.y);
|
|
303
|
+
ctx.lineTo(base.x, base.y);
|
|
304
|
+
ctx.stroke();
|
|
305
|
+
}
|
|
306
|
+
const nx = -uy;
|
|
307
|
+
const ny = ux;
|
|
308
|
+
ctx.beginPath();
|
|
309
|
+
ctx.moveTo(shape.to.x, shape.to.y);
|
|
310
|
+
ctx.lineTo(base.x + nx * halfWidth, base.y + ny * halfWidth);
|
|
311
|
+
ctx.lineTo(base.x - nx * halfWidth, base.y - ny * halfWidth);
|
|
312
|
+
ctx.closePath();
|
|
313
|
+
ctx.fill();
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
function drawRect(ctx: MarkupContext, shape: BoxShape, metrics: MarkupMetrics): void {
|
|
317
|
+
const width = metrics.stroke(shape.size);
|
|
318
|
+
const rect = toRect(shape.box);
|
|
319
|
+
ctx.lineWidth = width;
|
|
320
|
+
ctx.beginPath();
|
|
321
|
+
roundRectPath(ctx, rect, Math.min(1.5 * width, rect.w / 2, rect.h / 2));
|
|
322
|
+
ctx.stroke();
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
function drawEllipse(ctx: MarkupContext, shape: BoxShape, metrics: MarkupMetrics): void {
|
|
326
|
+
const rect = toRect(shape.box);
|
|
327
|
+
const rx = rect.w / 2;
|
|
328
|
+
const ry = rect.h / 2;
|
|
329
|
+
ctx.lineWidth = metrics.stroke(shape.size);
|
|
330
|
+
ctx.beginPath();
|
|
331
|
+
ctx.ellipse(rect.x + rx, rect.y + ry, Math.max(rx, 0), Math.max(ry, 0), 0, 0, Math.PI * 2);
|
|
332
|
+
ctx.stroke();
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
function drawLabel(ctx: MarkupContext, shape: TextShape, metrics: MarkupMetrics): void {
|
|
336
|
+
const layout = textLayout(shape, metrics, createMeasure(ctx));
|
|
337
|
+
const { box, font, pad, lineHeight, lines } = layout;
|
|
338
|
+
ctx.fillStyle = shape.color;
|
|
339
|
+
ctx.beginPath();
|
|
340
|
+
roundRectPath(ctx, box, Math.min(0.3 * font, box.w / 2, box.h / 2));
|
|
341
|
+
ctx.fill();
|
|
342
|
+
ctx.font = markupFont(font);
|
|
343
|
+
ctx.textBaseline = "top";
|
|
344
|
+
ctx.textAlign = "left";
|
|
345
|
+
ctx.fillStyle = contrastText(shape.color);
|
|
346
|
+
for (let i = 0; i < lines.length; i += 1) {
|
|
347
|
+
const top = box.y + pad + i * lineHeight + (lineHeight - font) / 2;
|
|
348
|
+
ctx.fillText(lines[i]!, box.x + pad, top);
|
|
349
|
+
}
|
|
350
|
+
}
|
|
351
|
+
|
|
352
|
+
/** A closed rounded rectangle; the caller opens the path. */
|
|
353
|
+
function roundRectPath(ctx: MarkupContext, box: Box, radius: number): void {
|
|
354
|
+
const r = Math.max(0, Math.min(radius, box.w / 2, box.h / 2));
|
|
355
|
+
const right = box.x + box.w;
|
|
356
|
+
const bottom = box.y + box.h;
|
|
357
|
+
ctx.moveTo(box.x + r, box.y);
|
|
358
|
+
ctx.lineTo(right - r, box.y);
|
|
359
|
+
ctx.arcTo(right, box.y, right, box.y + r, r);
|
|
360
|
+
ctx.lineTo(right, bottom - r);
|
|
361
|
+
ctx.arcTo(right, bottom, right - r, bottom, r);
|
|
362
|
+
ctx.lineTo(box.x + r, bottom);
|
|
363
|
+
ctx.arcTo(box.x, bottom, box.x, bottom - r, r);
|
|
364
|
+
ctx.lineTo(box.x, box.y + r);
|
|
365
|
+
ctx.arcTo(box.x, box.y, box.x + r, box.y, r);
|
|
366
|
+
ctx.closePath();
|
|
367
|
+
}
|
|
368
|
+
|
|
369
|
+
/** The production canvas: a detached element the browser never paints. */
|
|
370
|
+
function detachedCanvas(width: number, height: number): FlattenCanvas {
|
|
371
|
+
const canvas = document.createElement("canvas");
|
|
372
|
+
canvas.width = width;
|
|
373
|
+
canvas.height = height;
|
|
374
|
+
return canvas;
|
|
375
|
+
}
|
|
376
|
+
|
|
377
|
+
/** `toBlob` as a promise, so each attempt can be awaited and measured. */
|
|
378
|
+
function toBlob(canvas: FlattenCanvas, type: string, quality?: number): Promise<Blob | null> {
|
|
379
|
+
return new Promise((resolve) => {
|
|
380
|
+
canvas.toBlob((blob) => resolve(blob), type, quality);
|
|
381
|
+
});
|
|
382
|
+
}
|
|
@@ -0,0 +1,58 @@
|
|
|
1
|
+
import { useEffect, useState } from "react";
|
|
2
|
+
import { listMembers, type Member } from "./client.js";
|
|
3
|
+
import type { Session } from "./session.js";
|
|
4
|
+
|
|
5
|
+
/** Who can be @mentioned changes rarely: a fetched list is reused this long, then fetched again when next needed. */
|
|
6
|
+
const MEMBERS_TTL_MS = 10 * 60_000;
|
|
7
|
+
|
|
8
|
+
type LoadMembers = (
|
|
9
|
+
session: Session,
|
|
10
|
+
memberToken: string,
|
|
11
|
+
environment?: string,
|
|
12
|
+
) => Promise<Member[]>;
|
|
13
|
+
|
|
14
|
+
/** One request per environment, shared by every caller and kept for MEMBERS_TTL_MS. A failed request isn't kept. */
|
|
15
|
+
export function membersCache(load: LoadMembers, now = Date.now): LoadMembers {
|
|
16
|
+
const kept = new Map<string, { at: number; list: Promise<Member[]> }>();
|
|
17
|
+
return (session, memberToken, environment) => {
|
|
18
|
+
const key = [session.apiUrl, session.project, session.environment, environment ?? ""].join(
|
|
19
|
+
"\n",
|
|
20
|
+
);
|
|
21
|
+
const entry = kept.get(key);
|
|
22
|
+
if (entry && now() - entry.at < MEMBERS_TTL_MS) return entry.list;
|
|
23
|
+
const list = load(session, memberToken, environment);
|
|
24
|
+
kept.set(key, { at: now(), list });
|
|
25
|
+
list.catch(() => {
|
|
26
|
+
if (kept.get(key)?.list === list) kept.delete(key);
|
|
27
|
+
});
|
|
28
|
+
return list;
|
|
29
|
+
};
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const loadMembers = membersCache(listMembers);
|
|
33
|
+
|
|
34
|
+
/**
|
|
35
|
+
* People who can be @mentioned in `environment` (this one when omitted), fetched only while `wanted`, which the
|
|
36
|
+
* toolbar sets when a composer or reply box can be typed in.
|
|
37
|
+
*/
|
|
38
|
+
export function useMembers(
|
|
39
|
+
session: Session | null,
|
|
40
|
+
memberToken: string | null,
|
|
41
|
+
environment: string | undefined,
|
|
42
|
+
wanted: boolean,
|
|
43
|
+
) {
|
|
44
|
+
const [members, setMembers] = useState<{ environment?: string; list: Member[] }>({ list: [] });
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
if (!wanted || !session || !memberToken) return;
|
|
47
|
+
let stop = false;
|
|
48
|
+
loadMembers(session, memberToken, environment)
|
|
49
|
+
.then((list) => {
|
|
50
|
+
if (!stop) setMembers({ environment, list });
|
|
51
|
+
})
|
|
52
|
+
.catch(() => undefined);
|
|
53
|
+
return () => {
|
|
54
|
+
stop = true;
|
|
55
|
+
};
|
|
56
|
+
}, [environment, memberToken, session, wanted]);
|
|
57
|
+
return members.environment === environment ? members.list : [];
|
|
58
|
+
}
|
|
@@ -0,0 +1,56 @@
|
|
|
1
|
+
/** Mention parsing for the composer and message text. DOM-free, so native clients share it. */
|
|
2
|
+
import type { Member, Mentioned } from "./api.js";
|
|
3
|
+
|
|
4
|
+
/** The `@query` being typed just before `caret`, and where its `@` is, or null. */
|
|
5
|
+
export function mentionQueryAt(text: string, caret: number) {
|
|
6
|
+
const query = text.slice(0, caret).match(/(^|\s)@([^\s@]{0,30})$/)?.[2];
|
|
7
|
+
return query === undefined ? null : { query, start: caret - query.length - 1 };
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/** Most members the @-suggestion list shows. */
|
|
11
|
+
const MENTION_SUGGESTIONS_MAX = 6;
|
|
12
|
+
|
|
13
|
+
/** Members whose name contains, or whose email starts with, `query` (any case); never the writer. */
|
|
14
|
+
export function mentionMatches(
|
|
15
|
+
members: readonly Member[],
|
|
16
|
+
query: string,
|
|
17
|
+
selfEmail: string | null | undefined,
|
|
18
|
+
): Member[] {
|
|
19
|
+
const needle = query.toLowerCase();
|
|
20
|
+
return members
|
|
21
|
+
.filter((member) => member.email !== selfEmail)
|
|
22
|
+
.filter(
|
|
23
|
+
(member) =>
|
|
24
|
+
member.name.toLowerCase().includes(needle) ||
|
|
25
|
+
(member.email ?? "").toLowerCase().startsWith(needle),
|
|
26
|
+
)
|
|
27
|
+
.slice(0, MENTION_SUGGESTIONS_MAX);
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** `text` with the `@query` at `start` (up to `caret`) replaced by `@Name `, and the caret after it. */
|
|
31
|
+
export function insertMention(text: string, start: number, caret: number, member: Member) {
|
|
32
|
+
const inserted = `@${member.name} `;
|
|
33
|
+
return {
|
|
34
|
+
text: `${text.slice(0, start)}${inserted}${text.slice(caret)}`,
|
|
35
|
+
caret: start + inserted.length,
|
|
36
|
+
};
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
function escapeRegex(value: string) {
|
|
40
|
+
return value.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
|
|
41
|
+
}
|
|
42
|
+
|
|
43
|
+
/** Members picked while writing whose `@Name` is still in the text. */
|
|
44
|
+
export function mentionsIn(text: string, picked: Map<string, Member>) {
|
|
45
|
+
return [...picked.values()].filter((member) => text.includes(`@${member.name}`));
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
/** Splits a message body around its mentions: odd entries are the `@Name` parts. */
|
|
49
|
+
export function mentionParts(body: string, mentions: readonly Mentioned[] | undefined): string[] {
|
|
50
|
+
if (!mentions?.length) return [body];
|
|
51
|
+
const pattern = new RegExp(
|
|
52
|
+
`(@(?:${mentions.map((mention) => escapeRegex(mention.name)).join("|")}))`,
|
|
53
|
+
"g",
|
|
54
|
+
);
|
|
55
|
+
return body.split(pattern);
|
|
56
|
+
}
|
|
@@ -0,0 +1,205 @@
|
|
|
1
|
+
import {
|
|
2
|
+
useCallback,
|
|
3
|
+
useLayoutEffect,
|
|
4
|
+
useMemo,
|
|
5
|
+
useRef,
|
|
6
|
+
useState,
|
|
7
|
+
type ReactNode,
|
|
8
|
+
type TextareaHTMLAttributes,
|
|
9
|
+
} from "react";
|
|
10
|
+
import { createPortal } from "react-dom";
|
|
11
|
+
import type { Member, Mentioned } from "./client.js";
|
|
12
|
+
import { colorFor, initials } from "./format.js";
|
|
13
|
+
import { insertMention, mentionMatches, mentionParts, mentionQueryAt } from "./mention-parse.js";
|
|
14
|
+
|
|
15
|
+
export { mentionParts, mentionQueryAt, mentionsIn } from "./mention-parse.js";
|
|
16
|
+
|
|
17
|
+
type Props = Omit<TextareaHTMLAttributes<HTMLTextAreaElement>, "value" | "onChange"> & {
|
|
18
|
+
value: string;
|
|
19
|
+
onValueChange: (next: string) => void;
|
|
20
|
+
members: Member[];
|
|
21
|
+
selfEmail?: string;
|
|
22
|
+
onMention: (member: Member) => void;
|
|
23
|
+
/** Grow the field to its text. The composer uses this so a long draft stays visible. */
|
|
24
|
+
grow?: boolean;
|
|
25
|
+
};
|
|
26
|
+
|
|
27
|
+
export function MessageText({ body, mentions }: { body: string; mentions?: Mentioned[] }) {
|
|
28
|
+
if (!mentions?.length) return <>{body}</>;
|
|
29
|
+
const parts: ReactNode[] = mentionParts(body, mentions).map((part, index) =>
|
|
30
|
+
index % 2 === 1 ? (
|
|
31
|
+
<span key={index} className="cs-mention">
|
|
32
|
+
{part}
|
|
33
|
+
</span>
|
|
34
|
+
) : (
|
|
35
|
+
part
|
|
36
|
+
),
|
|
37
|
+
);
|
|
38
|
+
return <>{parts}</>;
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
export function MentionTextarea({
|
|
42
|
+
value,
|
|
43
|
+
onValueChange,
|
|
44
|
+
members,
|
|
45
|
+
selfEmail,
|
|
46
|
+
onMention,
|
|
47
|
+
onKeyDown,
|
|
48
|
+
grow = false,
|
|
49
|
+
...rest
|
|
50
|
+
}: Props) {
|
|
51
|
+
const [caret, setCaret] = useState(0);
|
|
52
|
+
const [index, setIndex] = useState(0);
|
|
53
|
+
const [dismissed, setDismissed] = useState<number | null>(null);
|
|
54
|
+
const active = mentionQueryAt(value, caret);
|
|
55
|
+
const query = active?.query ?? null;
|
|
56
|
+
const matches = useMemo(
|
|
57
|
+
() => (query === null ? [] : mentionMatches(members, query, selfEmail)),
|
|
58
|
+
[members, query, selfEmail],
|
|
59
|
+
);
|
|
60
|
+
const open =
|
|
61
|
+
active !== null && active !== undefined && matches.length > 0 && dismissed !== active.start;
|
|
62
|
+
const selected = matches[Math.min(index, matches.length - 1)];
|
|
63
|
+
const [measured, setMeasured] = useState<{ left: number; top?: number; bottom?: number } | null>(
|
|
64
|
+
null,
|
|
65
|
+
);
|
|
66
|
+
const box = open ? measured : null;
|
|
67
|
+
const field = useRef<HTMLTextAreaElement | null>(null);
|
|
68
|
+
const [root, setRoot] = useState<HTMLElement | null>(null);
|
|
69
|
+
const ref = useCallback((node: HTMLTextAreaElement | null) => {
|
|
70
|
+
field.current = node;
|
|
71
|
+
const parent = node?.closest("#cs-root");
|
|
72
|
+
setRoot(parent instanceof HTMLElement ? parent : null);
|
|
73
|
+
}, []);
|
|
74
|
+
|
|
75
|
+
useLayoutEffect(() => {
|
|
76
|
+
if (!grow) return;
|
|
77
|
+
const node = field.current;
|
|
78
|
+
if (!node || node.value !== value) return;
|
|
79
|
+
node.style.height = "auto";
|
|
80
|
+
node.style.height = `${node.scrollHeight}px`;
|
|
81
|
+
}, [grow, value]);
|
|
82
|
+
|
|
83
|
+
useLayoutEffect(() => {
|
|
84
|
+
if (!open || !root) return;
|
|
85
|
+
const place = () => {
|
|
86
|
+
const node = field.current;
|
|
87
|
+
if (!node || node.value !== value) return;
|
|
88
|
+
const rect = node.getBoundingClientRect();
|
|
89
|
+
const rootBox = root.getBoundingClientRect();
|
|
90
|
+
const below = rootBox.bottom - rect.bottom > 220;
|
|
91
|
+
const left = Math.min(
|
|
92
|
+
Math.max(8, rect.left - rootBox.left),
|
|
93
|
+
Math.max(8, rootBox.width - 272),
|
|
94
|
+
);
|
|
95
|
+
const next = below
|
|
96
|
+
? { left, top: rect.bottom - rootBox.top + 6 }
|
|
97
|
+
: { left, bottom: rootBox.bottom - rect.top + 6 };
|
|
98
|
+
setMeasured((prev) =>
|
|
99
|
+
prev && prev.left === next.left && prev.top === next.top && prev.bottom === next.bottom
|
|
100
|
+
? prev
|
|
101
|
+
: next,
|
|
102
|
+
);
|
|
103
|
+
};
|
|
104
|
+
place();
|
|
105
|
+
window.addEventListener("resize", place);
|
|
106
|
+
window.addEventListener("scroll", place, true);
|
|
107
|
+
return () => {
|
|
108
|
+
window.removeEventListener("resize", place);
|
|
109
|
+
window.removeEventListener("scroll", place, true);
|
|
110
|
+
};
|
|
111
|
+
}, [open, root, value]);
|
|
112
|
+
|
|
113
|
+
const insert = (member: Member) => {
|
|
114
|
+
if (!active) return;
|
|
115
|
+
const { text: next, caret: position } = insertMention(value, active.start, caret, member);
|
|
116
|
+
onValueChange(next);
|
|
117
|
+
onMention(member);
|
|
118
|
+
setIndex(0);
|
|
119
|
+
requestAnimationFrame(() => {
|
|
120
|
+
field.current?.focus();
|
|
121
|
+
field.current?.setSelectionRange(position, position);
|
|
122
|
+
setCaret(position);
|
|
123
|
+
});
|
|
124
|
+
};
|
|
125
|
+
|
|
126
|
+
const rootNode = open && box ? root : null;
|
|
127
|
+
|
|
128
|
+
return (
|
|
129
|
+
<>
|
|
130
|
+
<textarea
|
|
131
|
+
ref={ref}
|
|
132
|
+
{...rest}
|
|
133
|
+
value={value}
|
|
134
|
+
aria-autocomplete="list"
|
|
135
|
+
aria-expanded={open}
|
|
136
|
+
onChange={(event) => {
|
|
137
|
+
onValueChange(event.target.value);
|
|
138
|
+
setCaret(event.target.selectionStart ?? event.target.value.length);
|
|
139
|
+
setIndex(0);
|
|
140
|
+
setDismissed(null);
|
|
141
|
+
}}
|
|
142
|
+
onSelect={(event) => setCaret(event.currentTarget.selectionStart ?? 0)}
|
|
143
|
+
onKeyDown={(event) => {
|
|
144
|
+
if (open && selected && !event.metaKey && !event.ctrlKey) {
|
|
145
|
+
if (event.key === "ArrowDown" || event.key === "ArrowUp") {
|
|
146
|
+
event.preventDefault();
|
|
147
|
+
const step = event.key === "ArrowDown" ? 1 : -1;
|
|
148
|
+
setIndex((current) => (current + step + matches.length) % matches.length);
|
|
149
|
+
return;
|
|
150
|
+
}
|
|
151
|
+
if (event.key === "Enter" || event.key === "Tab") {
|
|
152
|
+
event.preventDefault();
|
|
153
|
+
insert(selected);
|
|
154
|
+
return;
|
|
155
|
+
}
|
|
156
|
+
if (event.key === "Escape") {
|
|
157
|
+
event.preventDefault();
|
|
158
|
+
event.stopPropagation();
|
|
159
|
+
setDismissed(active.start);
|
|
160
|
+
return;
|
|
161
|
+
}
|
|
162
|
+
}
|
|
163
|
+
onKeyDown?.(event);
|
|
164
|
+
}}
|
|
165
|
+
/>
|
|
166
|
+
{open && box && rootNode
|
|
167
|
+
? createPortal(
|
|
168
|
+
<div className="cs-people cs-hit" role="listbox" data-cs-ui="mention-list" style={box}>
|
|
169
|
+
{matches.map((member) => (
|
|
170
|
+
<div
|
|
171
|
+
key={member.id}
|
|
172
|
+
role="option"
|
|
173
|
+
aria-selected={member === selected}
|
|
174
|
+
data-cs-ui="mention-option"
|
|
175
|
+
className={member === selected ? "cs-person is-on" : "cs-person"}
|
|
176
|
+
onMouseDown={(event) => {
|
|
177
|
+
event.preventDefault();
|
|
178
|
+
insert(member);
|
|
179
|
+
}}
|
|
180
|
+
onMouseEnter={() => setIndex(matches.indexOf(member))}
|
|
181
|
+
>
|
|
182
|
+
<span
|
|
183
|
+
className="cs-avatar"
|
|
184
|
+
style={{
|
|
185
|
+
width: 18,
|
|
186
|
+
height: 18,
|
|
187
|
+
fontSize: 7,
|
|
188
|
+
background: colorFor(member.name),
|
|
189
|
+
}}
|
|
190
|
+
>
|
|
191
|
+
{initials(member.name)}
|
|
192
|
+
</span>
|
|
193
|
+
<span className="cs-person-text">
|
|
194
|
+
<strong>{member.name}</strong>
|
|
195
|
+
<small>{member.email}</small>
|
|
196
|
+
</span>
|
|
197
|
+
</div>
|
|
198
|
+
))}
|
|
199
|
+
</div>,
|
|
200
|
+
rootNode,
|
|
201
|
+
)
|
|
202
|
+
: null}
|
|
203
|
+
</>
|
|
204
|
+
);
|
|
205
|
+
}
|