diff --git a/lerna-package-lock.json b/lerna-package-lock.json
index c54350fcd..3c5854d39 100644
--- a/lerna-package-lock.json
+++ b/lerna-package-lock.json
@@ -16,6 +16,7 @@
"@fluid-experimental/task-manager": "~1.2.3",
"@fluidframework/azure-client": "~1.0.2",
"@fluidframework/azure-local-service": "^1.1.0",
+ "@fluidframework/register-collection": "~1.2.3",
"@fluidframework/test-client-utils": "~1.2.3",
"@fluidframework/test-runtime-utils": "~1.2.3",
"@fluidframework/test-utils": "~1.2.3",
@@ -2170,17 +2171,17 @@
}
},
"node_modules/@fluidframework/register-collection": {
- "version": "1.3.5",
- "resolved": "https://registry.npmjs.org/@fluidframework/register-collection/-/register-collection-1.3.5.tgz",
- "integrity": "sha512-RTSLIkf43HjDllBIRzB2e1969AYR0aaHFoYErO++UunfVatR7y8kFpKB8mfyNkChQbAKKGqiUHCnobnJeUppkQ==",
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@fluidframework/register-collection/-/register-collection-1.2.8.tgz",
+ "integrity": "sha512-RJKt2ZA9Etd10gbq5hrqch6dUf3BYplDwqOgCB9s/5m2yqQaWHdjSLONc6wl10+L2fB9h6tnDBlLa9dWPmTNqw==",
"dependencies": {
- "@fluidframework/common-utils": "^0.32.2",
- "@fluidframework/core-interfaces": "^1.3.5",
- "@fluidframework/datastore-definitions": "^1.3.5",
- "@fluidframework/protocol-base": "^0.1036.5001",
+ "@fluidframework/common-utils": "^0.32.1",
+ "@fluidframework/core-interfaces": "^1.2.8",
+ "@fluidframework/datastore-definitions": "^1.2.8",
+ "@fluidframework/protocol-base": "^0.1036.5000",
"@fluidframework/protocol-definitions": "^0.1028.2000",
- "@fluidframework/runtime-definitions": "^1.3.5",
- "@fluidframework/shared-object-base": "^1.3.5"
+ "@fluidframework/runtime-definitions": "^1.2.8",
+ "@fluidframework/shared-object-base": "^1.2.8"
}
},
"node_modules/@fluidframework/request-handler": {
@@ -23679,17 +23680,17 @@
}
},
"@fluidframework/register-collection": {
- "version": "1.3.5",
- "resolved": "https://registry.npmjs.org/@fluidframework/register-collection/-/register-collection-1.3.5.tgz",
- "integrity": "sha512-RTSLIkf43HjDllBIRzB2e1969AYR0aaHFoYErO++UunfVatR7y8kFpKB8mfyNkChQbAKKGqiUHCnobnJeUppkQ==",
+ "version": "1.2.8",
+ "resolved": "https://registry.npmjs.org/@fluidframework/register-collection/-/register-collection-1.2.8.tgz",
+ "integrity": "sha512-RJKt2ZA9Etd10gbq5hrqch6dUf3BYplDwqOgCB9s/5m2yqQaWHdjSLONc6wl10+L2fB9h6tnDBlLa9dWPmTNqw==",
"requires": {
- "@fluidframework/common-utils": "^0.32.2",
- "@fluidframework/core-interfaces": "^1.3.5",
- "@fluidframework/datastore-definitions": "^1.3.5",
- "@fluidframework/protocol-base": "^0.1036.5001",
+ "@fluidframework/common-utils": "^0.32.1",
+ "@fluidframework/core-interfaces": "^1.2.8",
+ "@fluidframework/datastore-definitions": "^1.2.8",
+ "@fluidframework/protocol-base": "^0.1036.5000",
"@fluidframework/protocol-definitions": "^0.1028.2000",
- "@fluidframework/runtime-definitions": "^1.3.5",
- "@fluidframework/shared-object-base": "^1.3.5"
+ "@fluidframework/runtime-definitions": "^1.2.8",
+ "@fluidframework/shared-object-base": "^1.2.8"
}
},
"@fluidframework/request-handler": {
diff --git a/packages/live-share-canvas/README.md b/packages/live-share-canvas/README.md
index 91780000a..2d78c4b42 100644
--- a/packages/live-share-canvas/README.md
+++ b/packages/live-share-canvas/README.md
@@ -4,7 +4,7 @@ Easily add collaborative inking to your Teams meeting app, powered by [Fluid Fra
This package is an extension of Microsoft Live Share, and requires the `@microsoft/live-share` package. You can find it on [NPM](https://www.npmjs.com/package/@microsoft/live-share).
-You can find our API reference documentation at [aka.ms/livesharedocs](https://aka.ms/livesharedocs).
+You can find our documentation at [aka.ms/livesharecanvas](https://aka.ms/livesharecanvas).
## Installing
@@ -84,7 +84,7 @@ document.getElementById("btnPenBrushBlue").onclick = () => {
inkingManager.penBrush.color = { r: 0, g: 0, b: 255, a: 1};
}
-// Other tools and brush settings are available, please refer to the documentation
+// Other tools and brush settings are available, please refer to the documentation at https://aka.ms/livesharecanvas
```
## Code sample
diff --git a/packages/live-share-canvas/src/canvas/DryWetCanvas.ts b/packages/live-share-canvas/src/canvas/DryWetCanvas.ts
index f187c8f26..70c872c74 100644
--- a/packages/live-share-canvas/src/canvas/DryWetCanvas.ts
+++ b/packages/live-share-canvas/src/canvas/DryWetCanvas.ts
@@ -5,17 +5,12 @@
import { InkingCanvas } from "./InkingCanvas";
import {
- getPressureAdjustedSize,
IPointerPoint,
DefaultPenBrush,
IBrush,
toCssRgbaColor,
} from "../core";
-import {
- computeQuadBetweenTwoCircles,
- computeQuadBetweenTwoRectangles,
- IQuadPathSegment,
-} from "../core/Internals";
+import { computeQuadPath, IQuadPathSegment } from "../core/Internals";
/**
* Represents the base class from wet and dry canvases, implementing the common rendering logic.
@@ -26,65 +21,12 @@ export abstract class DryWetCanvas extends InkingCanvas {
private _points: IPointerPoint[] = [];
private computeQuadPath(tipSize: number): IQuadPathSegment[] {
- const result: IQuadPathSegment[] = [];
- const tipHalfSize = tipSize / 2;
-
- if (this._pendingPointsStartIndex < this._points.length) {
- let previousPoint: IPointerPoint | undefined = undefined;
- let previousPointPressureAdjustedTip = 0;
-
- if (this._pendingPointsStartIndex > 0) {
- previousPoint = this._points[this._pendingPointsStartIndex - 1];
- previousPointPressureAdjustedTip = getPressureAdjustedSize(
- tipHalfSize,
- previousPoint.pressure
- );
- }
-
- for (
- let i = this._pendingPointsStartIndex;
- i < this._points.length;
- i++
- ) {
- const p = this._points[i];
-
- let pressureAdjustedTip = getPressureAdjustedSize(
- tipHalfSize,
- p.pressure
- );
-
- const segment: IQuadPathSegment = {
- endPoint: p,
- tipSize: pressureAdjustedTip,
- };
-
- if (previousPoint !== undefined) {
- segment.quad =
- this.brush.tip === "ellipse"
- ? computeQuadBetweenTwoCircles(
- p,
- pressureAdjustedTip,
- previousPoint,
- previousPointPressureAdjustedTip
- )
- : computeQuadBetweenTwoRectangles(
- p,
- pressureAdjustedTip,
- pressureAdjustedTip,
- previousPoint,
- previousPointPressureAdjustedTip,
- previousPointPressureAdjustedTip
- );
- }
-
- result.push(segment);
-
- previousPoint = p;
- previousPointPressureAdjustedTip = pressureAdjustedTip;
- }
- }
-
- return result;
+ return computeQuadPath(
+ this._points,
+ this._pendingPointsStartIndex,
+ this.brush.tip,
+ tipSize
+ );
}
private renderQuadPath(
diff --git a/packages/live-share-canvas/src/core/Geometry.ts b/packages/live-share-canvas/src/core/Geometry.ts
index ab7bac29b..47cc97d4a 100644
--- a/packages/live-share-canvas/src/core/Geometry.ts
+++ b/packages/live-share-canvas/src/core/Geometry.ts
@@ -64,6 +64,15 @@ export function expandRect(rect: IRect, point: IPoint): IRect {
};
}
+export function combineRects(rect1: IRect, rect2: IRect): IRect {
+ return {
+ left: Math.min(rect1.left, rect2.left),
+ top: Math.min(rect1.top, rect2.top),
+ right: Math.max(rect1.right, rect2.right),
+ bottom: Math.max(rect1.bottom, rect2.bottom),
+ };
+}
+
/**
* Computes the distance between two points.
* @param p1 The first point.
diff --git a/packages/live-share-canvas/src/core/InkingManager.ts b/packages/live-share-canvas/src/core/InkingManager.ts
index 9ea677845..d0c7ee89f 100644
--- a/packages/live-share-canvas/src/core/InkingManager.ts
+++ b/packages/live-share-canvas/src/core/InkingManager.ts
@@ -16,6 +16,7 @@ import {
IPointerPoint,
IRect,
screenToViewport,
+ combineRects,
viewportToScreen,
} from "./Geometry";
import {
@@ -24,6 +25,7 @@ import {
IStrokeCreationOptions,
StrokeType,
StrokeMode,
+ IRawStroke,
} from "./Stroke";
import {
InputFilter,
@@ -46,6 +48,8 @@ import {
generateUniqueId,
computeEndArrow,
isPointInsideRectangle,
+ computeQuadPath,
+ renderQuadPathToSVG,
} from "./Internals";
/**
@@ -493,6 +497,7 @@ export class InkingManager extends EventEmitter {
string,
EphemeralCanvas
>();
+ private _lastPointerMovePosition?: IPoint;
private onHostResized = (
entries: ResizeObserverEntry[],
@@ -889,15 +894,15 @@ export class InkingManager extends EventEmitter {
}
private queuePointerMovedNotification(position?: IPoint) {
- if (this._pointerMovedNotificationTimeout !== undefined) {
- window.clearTimeout(this._pointerMovedNotificationTimeout);
- }
+ this._lastPointerMovePosition = position;
- this._pointerMovedNotificationTimeout = window.setTimeout(() => {
- this.notifyPointerMoved(position);
+ if (this._pointerMovedNotificationTimeout === undefined) {
+ this._pointerMovedNotificationTimeout = window.setTimeout(() => {
+ this.notifyPointerMoved(this._lastPointerMovePosition);
- this._pointerMovedNotificationTimeout = undefined;
- }, InkingManager.pointerMovedNotificationDelay);
+ this._pointerMovedNotificationTimeout = undefined;
+ }, InkingManager.pointerMovedNotificationDelay);
+ }
}
private notifyPointerMoved(position?: IPoint) {
@@ -1065,6 +1070,98 @@ export class InkingManager extends EventEmitter {
this.notifyClear();
}
+ /**
+ * Exports the current drawing as a collection of raw strokes.
+ * @returns A collection or raw strokes.
+ */
+ public exportRaw(): IRawStroke[] {
+ const result: IRawStroke[] = [];
+
+ this._strokes.forEach((stroke: IStroke) => {
+ result.push({
+ points: stroke.getAllPoints(),
+ brush: { ...stroke.brush },
+ });
+ });
+
+ return result;
+ }
+
+ /**
+ * Exports the current drawing to an SVG.
+ * @returns A serialized SVG string.
+ */
+ public exportSVG(): string {
+ let shapes = "";
+ let viewBox: IRect | undefined;
+ let largestTipSize = 0;
+
+ this._strokes.forEach((stroke: IStroke) => {
+ const boundingRect = stroke.getBoundingRect();
+
+ viewBox = viewBox
+ ? combineRects(viewBox, boundingRect)
+ : boundingRect;
+
+ if (stroke.brush.tipSize > largestTipSize) {
+ largestTipSize = stroke.brush.tipSize;
+ }
+
+ const quadPath = computeQuadPath(
+ stroke.getAllPoints(),
+ 0,
+ stroke.brush.tip,
+ stroke.brush.tipSize
+ );
+
+ let path = renderQuadPathToSVG(
+ quadPath,
+ stroke.brush.tip,
+ stroke.brush.color
+ );
+
+ if (stroke.brush.type === "highlighter") {
+ path = `${path}`;
+ }
+
+ shapes += path;
+ });
+
+ let svgViewBox = "";
+
+ if (viewBox) {
+ const halfTipSize = largestTipSize / 2;
+
+ // Add half the maximum brush tip size on each side
+ // of the view box, otherwise parts of some strokes
+ // would be clipped.
+ viewBox.left -= halfTipSize;
+ viewBox.top -= halfTipSize;
+ const viewBoxWidth = viewBox.right - viewBox.left + largestTipSize;
+ const viewBoxHeight = viewBox.bottom - viewBox.top + largestTipSize;
+
+ svgViewBox = `viewBox="${viewBox.left} ${viewBox.top} ${viewBoxWidth} ${viewBoxHeight}"`;
+ }
+
+ return ``;
+ }
+
+ /**
+ * Imports (adds) the specified strokes into the drawing.
+ * @param rawStrokes The strokes to import.
+ */
+ public importRaw(rawStrokes: IRawStroke[]) {
+ for (const rawStroke of rawStrokes) {
+ const stroke = new Stroke({
+ brush: rawStroke.brush,
+ points: rawStroke.points,
+ clientId: InkingManager.localClientId,
+ });
+
+ this.addStroke(stroke);
+ }
+ }
+
/**
* Starts a new wet stroke which will be drawn progressively on the canvas. Multiple wet strokes
* can be created at the same time and will not interfere with each other.
@@ -1087,6 +1184,7 @@ export class InkingManager extends EventEmitter {
canvas.resize(this.clientWidth, this.clientHeight);
canvas.offset = this.offset;
canvas.scale = this.scale;
+ canvas.referencePoint = this.referencePoint;
let stroke: WetStroke;
@@ -1400,6 +1498,8 @@ export class InkingManager extends EventEmitter {
if (this._referencePoint !== value) {
this._referencePoint = value;
+ this._dryCanvas.referencePoint = value;
+
this.reRender();
}
}
diff --git a/packages/live-share-canvas/src/core/Internals.ts b/packages/live-share-canvas/src/core/Internals.ts
index 2d02d7705..655c7421c 100644
--- a/packages/live-share-canvas/src/core/Internals.ts
+++ b/packages/live-share-canvas/src/core/Internals.ts
@@ -3,7 +3,16 @@
* Licensed under the Microsoft Live Share SDK License.
*/
import { v4 as uuid } from "uuid";
-import { IPoint, IPointerPoint, IRect, ISegment } from ".";
+import {
+ BrushTipShape,
+ IColor,
+ IPoint,
+ IPointerPoint,
+ IRect,
+ ISegment,
+ getPressureAdjustedSize,
+ toCssRgbaColor,
+} from ".";
/**
* Pre-calculated Pi x 2.
@@ -180,6 +189,181 @@ export function computeQuadBetweenTwoRectangles(
};
}
+/**
+ * Computes all the quad segments joining the specified points.
+ * @param points The points to join.
+ * @param startPointIndex The index at which to start in the points collection.
+ * @param tipShape The shape of brush tip used to determine how to join points in the path.
+ * @param tipSize The size of the brush tip.
+ * @returns A collection of quad segments.
+ */
+export function computeQuadPath(
+ points: IPointerPoint[],
+ startPointIndex: number,
+ tipShape: BrushTipShape,
+ tipSize: number
+): IQuadPathSegment[] {
+ const result: IQuadPathSegment[] = [];
+ const tipHalfSize = tipSize / 2;
+
+ if (startPointIndex < points.length) {
+ let previousPoint: IPointerPoint | undefined = undefined;
+ let previousPointPressureAdjustedTip = 0;
+
+ if (startPointIndex > 0) {
+ previousPoint = points[startPointIndex - 1];
+ previousPointPressureAdjustedTip = getPressureAdjustedSize(
+ tipHalfSize,
+ previousPoint.pressure
+ );
+ }
+
+ for (let i = startPointIndex; i < points.length; i++) {
+ const p = points[i];
+
+ let pressureAdjustedTip = getPressureAdjustedSize(
+ tipHalfSize,
+ p.pressure
+ );
+
+ const segment: IQuadPathSegment = {
+ endPoint: p,
+ tipSize: pressureAdjustedTip,
+ };
+
+ if (previousPoint !== undefined) {
+ segment.quad =
+ tipShape === "ellipse"
+ ? computeQuadBetweenTwoCircles(
+ p,
+ pressureAdjustedTip,
+ previousPoint,
+ previousPointPressureAdjustedTip
+ )
+ : computeQuadBetweenTwoRectangles(
+ p,
+ pressureAdjustedTip,
+ pressureAdjustedTip,
+ previousPoint,
+ previousPointPressureAdjustedTip,
+ previousPointPressureAdjustedTip
+ );
+ }
+
+ result.push(segment);
+
+ previousPoint = p;
+ previousPointPressureAdjustedTip = pressureAdjustedTip;
+ }
+ }
+
+ return result;
+}
+
+function toFixed(n: number): string {
+ return n.toFixed(5);
+}
+
+/**
+ * Renders a series of points as a closed and filled SVG tag.
+ * @param points The points making up the path.
+ * @param color The fill color.
+ * @returns A string representing an SVG path.
+ */
+export function renderFilledSVGPath(points: IPoint[], color: IColor): string {
+ let pathData = "";
+
+ for (let i = 0; i < points.length; i++) {
+ const instruction = i === 0 ? "M" : "L";
+ const p = points[i];
+
+ pathData += `${instruction}${toFixed(p.x)} ${toFixed(p.y)}`;
+ }
+
+ return ``;
+}
+
+/**
+ * Renders a filled circle as an SVG tag.
+ * @param center The center of the circle, in pixels.
+ * @param radius The radius of the circle, in pixels.
+ * @param color The color of the circle.
+ */
+export function renderFilledSVGCircle(
+ center: IPoint,
+ radius: number,
+ color: IColor
+): string {
+ // eslint-disable-next-line prettier/prettier
+ return ``;
+}
+
+/**
+ * Renders a filled rectangle as an SVG tag.
+ * @param center The center of the rectangle, in pixels.
+ * @param halfWidth The half-width of the rectangle, in pixels.
+ * @param halfHeight The half-height of the rectangle, in pixels.
+ * @param color The color of the rectangle.
+ */
+export function renderFilledSVGRectangle(
+ center: IPoint,
+ halfWidth: number,
+ halfHeight: number,
+ color: IColor
+): string {
+ const left: number = center.x - halfWidth;
+ const right: number = center.x + halfWidth;
+ const top: number = center.y - halfHeight;
+ const bottom: number = center.y + halfHeight;
+
+ return renderFilledSVGPath(
+ [
+ { x: left, y: top },
+ { x: right, y: top },
+ { x: right, y: bottom },
+ { x: left, y: bottom },
+ ],
+ color
+ );
+}
+
+/**
+ * Renders a quad path to a collection of SVG tags.
+ * @param path The path to render.
+ * @param tipShape The shape of the brush tip.
+ * @param color The color of the rendered path.
+ * @returns A serialized collection of SVG tags.
+ */
+export function renderQuadPathToSVG(
+ path: IQuadPathSegment[],
+ tipShape: BrushTipShape,
+ color: IColor
+): string {
+ let result = "";
+
+ for (let item of path) {
+ if (item.quad !== undefined) {
+ result += renderFilledSVGPath(
+ [item.quad.p1, item.quad.p2, item.quad.p3, item.quad.p4],
+ color
+ );
+ }
+
+ if (tipShape === "ellipse") {
+ result += renderFilledSVGCircle(item.endPoint, item.tipSize, color);
+ } else {
+ result += renderFilledSVGRectangle(
+ item.endPoint,
+ item.tipSize,
+ item.tipSize,
+ color
+ );
+ }
+ }
+
+ return result;
+}
+
/**
* Makes a rectangle of the specified width and height from the specified center.
* @param center The center of the rectangle.
diff --git a/packages/live-share-canvas/src/core/Stroke.ts b/packages/live-share-canvas/src/core/Stroke.ts
index 14a8242e6..6cbaed9de 100644
--- a/packages/live-share-canvas/src/core/Stroke.ts
+++ b/packages/live-share-canvas/src/core/Stroke.ts
@@ -87,6 +87,20 @@ export enum StrokeType {
persistent = 2,
}
+/**
+ * Represents the raw data of a stroke.
+ */
+export interface IRawStroke {
+ /**
+ * The stroke's points.
+ */
+ readonly points: IPointerPoint[];
+ /**
+ * The brush used to draw the stroke.
+ */
+ readonly brush: IBrush;
+}
+
/**
* Defines a stroke, i.e. a collection of points that can
* be rendered on a canvas.
@@ -118,6 +132,10 @@ export interface IStroke {
* Computes the stroke's bounding rectangle.
*/
getBoundingRect(): IRect;
+ /**
+ * Gets a copy of all the points in the stroke.
+ */
+ getAllPoints(): IPointerPoint[];
/**
* Splits this stroke into several other ones by "erasing"
* the portions that are within the eraser rectangle.
@@ -429,6 +447,20 @@ export class Stroke implements IStroke, Iterable {
return this._points[index];
}
+ /**
+ * Gets a copy of all the points in the stroke.
+ * @returns A collection of points.
+ */
+ getAllPoints(): IPointerPoint[] {
+ const result: IPointerPoint[] = [];
+
+ for (const p of this._points) {
+ result.push({ ...p });
+ }
+
+ return result;
+ }
+
/**
* Splits this stroke into several other ones by "erasing" the portions that
* are within the eraser rectangle.
diff --git a/samples/javascript/03.live-canvas-demo/package.json b/samples/javascript/03.live-canvas-demo/package.json
index f8d055d5f..73fb98277 100644
--- a/samples/javascript/03.live-canvas-demo/package.json
+++ b/samples/javascript/03.live-canvas-demo/package.json
@@ -10,7 +10,7 @@
"start:client": "vite",
"start:https": "vite --config vite.https-config.js",
"start:server": "npx @fluidframework/azure-local-service@latest",
- "start": "start-server-and-test start:server 7070 start:client",
+ "start": "npx tinylicious@0.7.2 7070",
"doctor": "eslint src/**/*.{j,t}s{,x} --fix --no-error-on-unmatched-pattern"
},
"dependencies": {
diff --git a/samples/javascript/03.live-canvas-demo/src/stage-view.ts b/samples/javascript/03.live-canvas-demo/src/stage-view.ts
index 676fc31fe..27df49461 100644
--- a/samples/javascript/03.live-canvas-demo/src/stage-view.ts
+++ b/samples/javascript/03.live-canvas-demo/src/stage-view.ts
@@ -55,6 +55,9 @@ const appTemplate = `
+
+
+
`;
@@ -284,6 +287,37 @@ export class StageView extends View {
: "Share cursor";
}
});
+
+ setupButton("btnExportRaw", () => {
+ const exportedStrokes = this._inkingManager.exportRaw();
+
+ Utils.writeTextToClipboard(
+ JSON.stringify(exportedStrokes),
+ "Exported strokes have been copied to the clipboard."
+ );
+ });
+
+ setupButton("btnImportRaw", async () => {
+ try {
+ const strokes = await Utils.parseStrokesFromClipboard();
+ this._inkingManager.importRaw(strokes);
+ } catch (error: any) {
+ console.error(error);
+ alert(
+ "Unable to parse strokes from clipboard.\nError: " +
+ error?.message
+ );
+ }
+ });
+
+ setupButton("btnExportSVG", () => {
+ const svg = this._inkingManager.exportSVG();
+
+ Utils.writeTextToClipboard(
+ svg,
+ "The SVG has been copied to the clipboard."
+ );
+ });
}
async start() {
diff --git a/samples/javascript/03.live-canvas-demo/src/utils.ts b/samples/javascript/03.live-canvas-demo/src/utils.ts
index 85e5a42d2..a402d3c2e 100644
--- a/samples/javascript/03.live-canvas-demo/src/utils.ts
+++ b/samples/javascript/03.live-canvas-demo/src/utils.ts
@@ -3,6 +3,8 @@
* Licensed under the Microsoft Live Share SDK License.
*/
+import { IRawStroke } from "@microsoft/live-share-canvas";
+
export function runningInTeams(): boolean {
const params = new URLSearchParams(window.location.search);
const config = params.get("inTeams");
@@ -25,3 +27,43 @@ export function toggleElementVisibility(elementId: string, isVisible: boolean) {
element.style.visibility = isVisible ? "visible" : "hidden";
}
}
+
+export function writeTextToClipboard(text: string, message: string) {
+ navigator.clipboard.writeText(text).then(
+ () => {
+ alert(message);
+ },
+ () => {
+ alert(
+ "The exported data couldn't be copied to the clipboard because permission to do so wasn't granted by the user."
+ );
+ }
+ );
+}
+
+export async function parseStrokesFromClipboard(): Promise {
+ // this will prompt user to consent to read from clipboard. if user denies, this will throw.
+ const text = await navigator.clipboard.readText();
+ return parseStrokesFromText(text);
+}
+
+function parseStrokesFromText(text: string): IRawStroke[] {
+ const rawJson: unknown = JSON.parse(text);
+ if (isStrokeList(rawJson)) {
+ return rawJson;
+ }
+ throw Error("Invalid JSON value in clipboard.");
+}
+
+function isStrokeList(value: unknown): value is IRawStroke[] {
+ return Array.isArray(value) && value.every(isStroke);
+}
+
+function isStroke(value: unknown): value is IRawStroke {
+ return (
+ typeof value === "object" &&
+ !!value &&
+ Array.isArray((value as any).points) &&
+ typeof (value as any).brush === "object"
+ );
+}