From cc857929500c5b0b0bca3941d67263f10dd4692d Mon Sep 17 00:00:00 2001 From: David Claux Date: Wed, 12 Apr 2023 18:51:32 -0700 Subject: [PATCH] Add export/import support to InkingManager (#499) Co-authored-by: Ryan Bliss --- lerna-package-lock.json | 37 ++-- packages/live-share-canvas/README.md | 4 +- .../src/canvas/DryWetCanvas.ts | 72 +------ .../live-share-canvas/src/core/Geometry.ts | 9 + .../src/core/InkingManager.ts | 114 ++++++++++- .../live-share-canvas/src/core/Internals.ts | 186 +++++++++++++++++- packages/live-share-canvas/src/core/Stroke.ts | 32 +++ .../03.live-canvas-demo/package.json | 2 +- .../03.live-canvas-demo/src/stage-view.ts | 34 ++++ .../03.live-canvas-demo/src/utils.ts | 42 ++++ 10 files changed, 438 insertions(+), 94 deletions(-) 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 `${shapes}`; + } + + /** + * 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" + ); +}