import * as fitCurve from 'fit-curve'; import { action, computed, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { setupMoveUpEvents } from '../../ClientUtils'; import { emptyFunction, intersectRect, rectContains } from '../../Utils'; import { Doc } from '../../fields/Doc'; import { InkData, InkField, InkTool } from '../../fields/InkField'; import { NumCast } from '../../fields/Types'; import { Gestures } from '../../pen-gestures/GestureTypes'; import { GestureUtils } from '../../pen-gestures/GestureUtils'; import { Result } from '../../pen-gestures/ndollar'; import { DocumentType } from '../documents/DocumentTypes'; import { Docs } from '../documents/Documents'; import { InteractionUtils } from '../util/InteractionUtils'; import { ScriptingGlobals } from '../util/ScriptingGlobals'; import { SnappingManager } from '../util/SnappingManager'; import { undoable } from '../util/UndoManager'; import './GestureOverlay.scss'; import { InkingStroke } from './InkingStroke'; import { ObservableReactComponent } from './ObservableReactComponent'; import { CollectionFreeFormView } from './collections/collectionFreeForm'; import { ActiveInkArrowEnd, ActiveInkArrowScale, ActiveInkArrowStart, ActiveInkColor, ActiveInkDash, ActiveInkFillColor, ActiveInkWidth, DocumentView, SetActiveInkArrowStart, SetActiveInkColor, SetActiveInkDash, SetActiveInkFillColor, SetActiveInkWidth, } from './nodes/DocumentView'; export enum ToolglassTools { InkToText = 'inktotext', IgnoreGesture = 'ignoregesture', RadialMenu = 'radialmenu', None = 'none', } interface GestureOverlayProps { isActive: boolean; } @observer /** * class for gestures. will determine if what the user drew is a gesture, and will transform the ink stroke into the shape the user * drew or perform the gesture's action */ export class GestureOverlay extends ObservableReactComponent> { static Instance: GestureOverlay; static Instances: GestureOverlay[] = []; @observable public SavedColor?: string = undefined; @observable public SavedWidth?: number = undefined; @observable public Tool: ToolglassTools = ToolglassTools.None; @observable private _thumbX?: number = undefined; @observable private _thumbY?: number = undefined; @observable private _pointerY?: number = undefined; @observable private _points: { X: number; Y: number }[] = []; @observable private _clipboardDoc?: JSX.Element = undefined; @observable private _debugCusps: { X: number; Y: number }[] = []; @observable private _debugGestures = false; @computed private get height(): number { return 2 * Math.max(this._pointerY && this._thumbY ? this._thumbY - this._pointerY : 100, 100); } @computed private get showBounds() { return this.Tool !== ToolglassTools.None; } private _overlayRef = React.createRef(); constructor(props: GestureOverlayProps) { super(props); makeObservable(this); GestureOverlay.Instances.push(this); } componentWillUnmount() { GestureOverlay.Instances.splice(GestureOverlay.Instances.indexOf(this), 1); GestureOverlay.Instance = GestureOverlay.Instances.lastElement(); } componentDidMount() { GestureOverlay.Instance = this; } @action onPointerDown = (e: React.PointerEvent) => { (document.activeElement as HTMLElement)?.blur(); if (!(e.target as HTMLElement)?.className?.toString().startsWith('lm_')) { if (Doc.ActiveTool === InkTool.Ink) { this._points.push({ X: e.clientX, Y: e.clientY }); setupMoveUpEvents(this, e, this.onPointerMove, this.onPointerUp, emptyFunction); } } }; @action onPointerMove = (e: PointerEvent) => { this._points.push({ X: e.clientX, Y: e.clientY }); if (this._points.length > 1) { const initialPoint = this._points[0]; const xInGlass = initialPoint.X > (this._thumbX ?? Number.MAX_SAFE_INTEGER) && initialPoint.X < (this._thumbX ?? Number.MAX_SAFE_INTEGER) + this.height; const yInGlass = initialPoint.Y > (this._thumbY ?? Number.MAX_SAFE_INTEGER) - this.height && initialPoint.Y < (this._thumbY ?? Number.MAX_SAFE_INTEGER); if (this.Tool !== ToolglassTools.None && xInGlass && yInGlass) { switch (this.Tool) { case ToolglassTools.RadialMenu: return true; default: } // prettier-ignore } } return false; }; @action primCreated() { if (!SnappingManager.KeepGestureMode) { SnappingManager.SetInkShape(undefined); Doc.ActiveTool = InkTool.None; } } /** * If what the user drew is a scribble, this returns the documents that were scribbled over * I changed it so it doesnt use triangles. It will modify an intersect array, with its length being * how many sharp cusps there are. The first index will have a boolean that is based on if there is an * intersection in the first 1/length percent of the stroke. The second index will be if there is an intersection * in the 2nd 1/length percent of the stroke. This array will be used in determineIfScribble(). * @param ffview freeform view where scribble is drawn * @param scribbleStroke scribble stroke in screen space coordinats * @returns array of documents scribbled over */ isScribble = (ffView: CollectionFreeFormView, cuspArray: { X: number; Y: number }[], scribbleStroke: { X: number; Y: number }[]) => { const intersectArray = cuspArray.map(() => false); const scribbleBounds = InkField.getBounds(scribbleStroke); const docsToDelete = ffView.childDocs .map(doc => DocumentView.getDocumentView(doc)) .filter(dv => dv?.ComponentView instanceof InkingStroke) .map(dv => dv?.ComponentView as InkingStroke) .filter(otherInk => { const otherScreenPts = otherInk.inkScaledData?.().inkData.map(otherInk.ptToScreen); if (intersectRect(InkField.getBounds(otherScreenPts), scribbleBounds)) { const intersects = this.findInkIntersections(scribbleStroke, otherScreenPts).map(intersect => { const percentage = intersect.split('/')[0]; intersectArray[Math.floor(Number(percentage) * cuspArray.length)] = true; }); return intersects.length > 0; } }); return !this.determineIfScribble(intersectArray) ? undefined : [ ...docsToDelete.map(stroke => stroke.Document), // bcz: NOTE: docsInBoundingBox test should be replaced with a docsInConvexHull test ...this.docsInBoundingBox({ topLeft : ffView.ScreenToContentsXf().transformPoint(scribbleBounds.left, scribbleBounds.top), bottomRight: ffView.ScreenToContentsXf().transformPoint(scribbleBounds.right,scribbleBounds.bottom)}, ffView.childDocs.filter(doc => !docsToDelete.map(s => s.Document).includes(doc)) )]; // prettier-ignore }; /** * Returns all docs in array that overlap bounds. Note that the bounds should be given in screen space coordinates. * @param boundingBox screen space bounding box * @param childDocs array of docs to test against bounding box * @returns list of docs that overlap rect */ docsInBoundingBox = (boundingBox: { topLeft: number[]; bottomRight: number[] }, childDocs: Doc[]): Doc[] => { const rect = { left: boundingBox.topLeft[0], top: boundingBox.topLeft[1], width: boundingBox.bottomRight[0] - boundingBox.topLeft[0], height: boundingBox.bottomRight[1] - boundingBox.topLeft[1] }; return childDocs.filter(doc => rectContains(rect, { left: NumCast(doc.x), top: NumCast(doc.y), width: NumCast(doc._width), height: NumCast(doc._height) })); }; /** * Determines if what the array of cusp/intersection data corresponds to a scribble. * true if there are at least 4 cusps and either: * 1) the initial and final quarters of the array contain objects * 2) or a declining percentage (ranges from 0.5 to 0.2 - based on the number of cusps) of cusp lines intersect strokes * @param intersectArray array of booleans coresponding to which scribble sections (regions separated by a cusp) contain Docs * @returns truthy if it's a scribble */ determineIfScribble = (intersectArray: boolean[]) => { const quarterArrayLength = Math.ceil(intersectArray.length / 3.9); // use 3.9 instead of 4 to work better with strokes with only 4 cusps const { start, end } = intersectArray.reduce((res, val, i) => ({ // test for scribbles at start and end of scribble stroke start: res.start || (val && i <= quarterArrayLength), end: res.end || (val && i >= intersectArray.length - quarterArrayLength) }), { start: false, end: false }); // prettier-ignore const percentCuspsWithContent = intersectArray.filter(value => value).length / intersectArray.length; return intersectArray.length > 3 && (percentCuspsWithContent >= Math.max(0.2, 1 / (intersectArray.length - 1)) || (start && end)); }; /** * determines if inks intersect * @param line is pointData * @param triangle triangle with 3 points * @returns will return an array, with its lenght being equal to how many intersections there are betweent the 2 strokes. * each item in the array will contain a number between 0-1 or a number 0-1 seperated by a comma. If one of the curves is a line, then * then there will just be a number that reprents how far that intersection is along the scribble. For example, * .1 means that the intersection occurs 10% into the scribble, so near the beginning of it. but if they are both curves, then * it will return two numbers, one for each curve, seperated by a comma. Sometimes, the percentage it returns is inaccurate, * espcially in the beginning and end parts of the stroke. dont know why. hope this makes sense */ findInkIntersections = (scribble: InkData, inkStroke: InkData): string[] => { const intersectArray: string[] = []; const scribbleBounds = InkField.getBounds(scribble); for (let i = 0; i < scribble.length - 3; i += 4) { // for each segment of scribble const scribbleSeg = InkField.Segment(scribble, i); for (let j = 0; j < inkStroke.length - 3; j += 4) { // for each segment of ink stroke const strokeSeg = InkField.Segment(inkStroke, j); const strokeBounds = InkField.getBounds(strokeSeg.points.map(pt => ({ X: pt.x, Y: pt.y }))); if (intersectRect(scribbleBounds, strokeBounds)) { const result = InkField.bintersects(scribbleSeg, strokeSeg)[0]; if (result !== undefined) { intersectArray.push(result.toString()); } } } // prettier-ignore } // prettier-ignore return intersectArray; }; dryInk = () => { const newPoints = this._points.reduce((p, pts) => { p.push([pts.X, pts.Y]); return p; }, [] as number[][]); newPoints.pop(); const controlPoints: { X: number; Y: number }[] = []; const bezierCurves = fitCurve.default(newPoints, 10); Array.from(bezierCurves).forEach(curve => { controlPoints.push({ X: curve[0][0], Y: curve[0][1] }); controlPoints.push({ X: curve[1][0], Y: curve[1][1] }); controlPoints.push({ X: curve[2][0], Y: curve[2][1] }); controlPoints.push({ X: curve[3][0], Y: curve[3][1] }); }); const dist = Math.sqrt((controlPoints[0].X - controlPoints.lastElement().X) * (controlPoints[0].X - controlPoints.lastElement().X) + (controlPoints[0].Y - controlPoints.lastElement().Y) * (controlPoints[0].Y - controlPoints.lastElement().Y)); if (controlPoints.length > 4 && dist < 10) controlPoints[controlPoints.length - 1] = controlPoints[0]; this._points.length = 0; this._points.push(...controlPoints); this.dispatchGesture(Gestures.Stroke); }; @action onPointerUp = (e: PointerEvent) => { const ffView = CollectionFreeFormView.DownFfview; CollectionFreeFormView.DownFfview = undefined; if (this._points.length > 1) { const B = this.svgBounds; const points = this._points.map(p => ({ X: p.X - B.left, Y: p.Y - B.top })); const { Name, Score } = (SnappingManager.InkShape ? new Result(SnappingManager.InkShape, 1, Date.now) : Doc.UserDoc().recognizeGestures && points.length > 2 ? GestureUtils.GestureRecognizer.Recognize([points]) : undefined) ?? new Result(Gestures.Stroke, 1, Date.now); // prettier-ignore const cuspArray = this.getCusps(points); const rect = this._overlayRef.current?.getBoundingClientRect(); this._debugCusps = rect ? cuspArray.map(p => ({ X: p.X + B.left - rect?.left, Y: p.Y + B.top - rect.top })) : []; // if any of the shape is activated in the CollectionFreeFormViewChrome // need to decide when to turn gestures back on const actionPerformed = ((name: Gestures) => { switch (name) { case Gestures.Line: if (cuspArray.length > 2 && Score < 1) return undefined; // eslint-disable-next-line no-fallthrough case Gestures.Triangle: case Gestures.Rectangle: case Gestures.Circle: this.makeBezierPolygon(this._points, Name, true); return this.dispatchGesture(name); case Gestures.RightAngle: return ffView && this.convertToText(ffView).length > 0; default: } })(Score < 0.7 ? Gestures.Stroke : (Name as Gestures)); // if no gesture (or if the gesture was unsuccessful), "dry" the stroke into an ink document if (!actionPerformed) { const scribbledOver = ffView && this.isScribble(ffView, cuspArray, this._points); this.dryInk(); if (scribbledOver) { // can undo the erase without undoing the scribble, or undo a second time to undo the scribble setTimeout(undoable(() => ffView.removeDocument(scribbledOver.concat([ffView.childDocs.lastElement()])), 'scribble erase')); } } } else { ffView?._marqueeViewRef?.current?.setPreviewCursor?.(this._points[0].X, this._points[0].Y, false, false, undefined); e.preventDefault(); } this.primCreated(); this._points.length = 0; }; /** * used in the rightAngle gesture to convert handwriting into text. will only work on collections * TODO: make it work on individual ink docs. */ convertToText = (ffView: CollectionFreeFormView) => { let minX = 999999999; let maxX = -999999999; let minY = 999999999; let maxY = -999999999; const textDocs: Doc[] = []; ffView.childDocs .filter(doc => doc.type === DocumentType.COL) .forEach(doc => { if (typeof doc.width === 'number' && typeof doc.height === 'number' && typeof doc.x === 'number' && typeof doc.y === 'number') { const bounds = DocumentView.getDocumentView(doc)?.getBounds; if (bounds) { if (intersectRect({ ...bounds, width: bounds.right - bounds.left, height: bounds.bottom - bounds.top }, InkField.getBounds(this._points))) { if (doc.x < minX) { minX = doc.x; } if (doc.x > maxX) { maxX = doc.x; } if (doc.y < minY) { minY = doc.y; } if (doc.y + doc.height > maxY) { maxY = doc.y + doc.height; } const newDoc = Docs.Create.TextDocument(doc.transcription as string, { title: '', x: doc.x as number, y: minY }); newDoc.height = doc.height; newDoc.width = doc.width; if (ffView.addDocument && ffView.removeDocument) { ffView.addDocument(newDoc); ffView.removeDocument(doc); } textDocs.push(newDoc); } } } }); return textDocs; }; /** * Returns array of coordinates corresponding to the sharp cusps in an input stroke * @param points array of X,Y stroke coordinates * @returns array containing the coordinates of the sharp cusps */ getCusps(points: InkData) { const arrayOfPoints: { X: number; Y: number }[] = []; arrayOfPoints.push(points[0]); for (let i = 0; i < points.length - 4; i++) { const point1 = points[i]; const point2 = points[i + 2]; const point3 = points[i + 4]; if (this.find_angle(point1, point2, point3) < 90) { // NOTE: this is not an accurate way to find cusps -- it is highly dependent on sampling rate and doesn't work well with slowly drawn scribbles arrayOfPoints.push(point2); i += 2; } } arrayOfPoints.push(points[points.length - 1]); return arrayOfPoints; } /** * takes in three points and then determines the angle of the points. used to determine if the cusp * is sharp enoug * @returns */ find_angle(A: { X: number; Y: number }, B: { X: number; Y: number }, C: { X: number; Y: number }) { const AB = Math.sqrt(Math.pow(B.X - A.X, 2) + Math.pow(B.Y - A.Y, 2)); const BC = Math.sqrt(Math.pow(B.X - C.X, 2) + Math.pow(B.Y - C.Y, 2)); const AC = Math.sqrt(Math.pow(C.X - A.X, 2) + Math.pow(C.Y - A.Y, 2)); return Math.acos((BC * BC + AB * AB - AC * AC) / (2 * BC * AB)) * (180 / Math.PI); } makeBezierPolygon = (points: { X: number; Y: number }[], shape: string, gesture: boolean) => { const xs = this._points.map(p => p.X); const ys = this._points.map(p => p.Y); let right = Math.max(...xs); let left = Math.min(...xs); let bottom = Math.max(...ys); let top = Math.min(...ys); const firstx = points[0].X; const firsty = points[0].Y; let lastx = points[points.length - 2].X; let lasty = points[points.length - 2].Y; let fourth = (lastx - firstx) / 4; if (isNaN(fourth) || fourth === 0) { fourth = 0.01; } let m = (lasty - firsty) / (lastx - firstx); if (isNaN(m) || m === 0) { m = 0.01; } // const b = firsty - m * firstx; if (shape === 'noRec') { return undefined; } if (!gesture) { // if shape options is activated in inkOptionMenu // take second to last point because _point[length-1] is _points[0] right = points[points.length - 2].X; left = points[0].X; bottom = points[points.length - 2].Y; top = points[0].Y; if (shape !== Gestures.Arrow && shape !== Gestures.Line && shape !== Gestures.Circle) { if (left > right) { const temp = right; right = left; left = temp; } if (top > bottom) { const temp = top; top = bottom; bottom = temp; } } } points.length = 0; switch (shape) { case Gestures.Rectangle: points.push({ X: left, Y: top }); // curr pt points.push({ X: left, Y: top }); // curr first ctrl pt points.push({ X: right, Y: top }); // next ctrl pt points.push({ X: right, Y: top }); // next pt points.push({ X: right, Y: top }); // next pt points.push({ X: right, Y: top }); // next first ctrl pt points.push({ X: right, Y: bottom }); // next next ctrl pt points.push({ X: right, Y: bottom }); // next next pt points.push({ X: right, Y: bottom }); points.push({ X: right, Y: bottom }); points.push({ X: left, Y: bottom }); points.push({ X: left, Y: bottom }); points.push({ X: left, Y: bottom }); points.push({ X: left, Y: bottom }); points.push({ X: left, Y: top }); points.push({ X: left, Y: top }); break; case Gestures.Triangle: points.push({ X: left, Y: bottom }); points.push({ X: left, Y: bottom }); points.push({ X: right, Y: bottom }); points.push({ X: right, Y: bottom }); points.push({ X: right, Y: bottom }); points.push({ X: right, Y: bottom }); points.push({ X: (right + left) / 2, Y: top }); points.push({ X: (right + left) / 2, Y: top }); points.push({ X: (right + left) / 2, Y: top }); points.push({ X: (right + left) / 2, Y: top }); points.push({ X: left, Y: bottom }); points.push({ X: left, Y: bottom }); break; case Gestures.Circle: { // Approximation of a circle using 4 Bézier curves in which the constant "c" reduces the maximum radial drift to 0.019608%, // making the curves indistinguishable from a circle. // Source: https://spencermortensen.com/articles/bezier-circle/ const c = 0.551915024494; const centerX = (Math.max(left, right) + Math.min(left, right)) / 2; const centerY = (Math.max(top, bottom) + Math.min(top, bottom)) / 2; const radius = Math.max(centerX - Math.min(left, right), centerY - Math.min(top, bottom)); // Dividing the circle into four equal sections, and fitting each section to a cubic Bézier curve. points.push({ X: centerX, Y: centerY + radius }); // curr pt points.push({ X: centerX + c * radius, Y: centerY + radius }); // curr first ctrl pt points.push({ X: centerX + radius, Y: centerY + c * radius }); // next pt ctrl pt points.push({ X: centerX + radius, Y: centerY }); // next pt points.push({ X: centerX + radius, Y: centerY }); // next pt points.push({ X: centerX + radius, Y: centerY - c * radius }); // next first ctrl pt points.push({ X: centerX + c * radius, Y: centerY - radius }); points.push({ X: centerX, Y: centerY - radius }); points.push({ X: centerX, Y: centerY - radius }); points.push({ X: centerX - c * radius, Y: centerY - radius }); points.push({ X: centerX - radius, Y: centerY - c * radius }); points.push({ X: centerX - radius, Y: centerY }); points.push({ X: centerX - radius, Y: centerY }); points.push({ X: centerX - radius, Y: centerY + c * radius }); points.push({ X: centerX - c * radius, Y: centerY + radius }); points.push({ X: centerX, Y: centerY + radius }); } break; case Gestures.Line: if (Math.abs(firstx - lastx) < 10 && Math.abs(firsty - lasty) > 10) { lastx = firstx; } if (Math.abs(firsty - lasty) < 10 && Math.abs(firstx - lastx) > 10) { lasty = firsty; } points.push({ X: firstx, Y: firsty }); points.push({ X: firstx, Y: firsty }); points.push({ X: lastx, Y: lasty }); points.push({ X: lastx, Y: lasty }); break; case Gestures.Arrow: { const x1 = left; const y1 = top; const x2 = right; const y2 = bottom; const L1 = Math.sqrt(Math.abs(x1 - x2) ** 2 + Math.abs(y1 - y2) ** 2); const L2 = L1 / 5; const angle = 0.785398; const x3 = x2 + (L2 / L1) * ((x1 - x2) * Math.cos(angle) + (y1 - y2) * Math.sin(angle)); const y3 = y2 + (L2 / L1) * ((y1 - y2) * Math.cos(angle) - (x1 - x2) * Math.sin(angle)); const x4 = x2 + (L2 / L1) * ((x1 - x2) * Math.cos(angle) - (y1 - y2) * Math.sin(angle)); const y4 = y2 + (L2 / L1) * ((y1 - y2) * Math.cos(angle) + (x1 - x2) * Math.sin(angle)); points.push({ X: x1, Y: y1 }); points.push({ X: x2, Y: y2 }); points.push({ X: x3, Y: y3 }); points.push({ X: x4, Y: y4 }); points.push({ X: x2, Y: y2 }); } break; default: } return points; }; dispatchGesture = (gesture: Gestures, stroke?: InkData, text?: string) => { const points = (stroke ?? this._points).slice(); return ( document.elementFromPoint(points[0].X, points[0].Y)?.dispatchEvent( new CustomEvent('dashOnGesture', { bubbles: true, detail: { points, gesture, bounds: InkField.getBounds(points), text, }, }) ) || false ); }; @computed get svgBounds() { return InkField.getBounds(this._points); } get elements() { const selView = CollectionFreeFormView.DownFfview; const width = Number(ActiveInkWidth()) * NumCast(selView?.Document._freeform_scale, 1); // * (selView?.screenToViewTransform().Scale || 1); const rect = this._overlayRef.current?.getBoundingClientRect(); const B = { left: -20000, right: 20000, top: -20000, bottom: 20000, width: 40000, height: 40000 }; // this.getBounds(this._points, true); B.left -= width / 2; B.right += width / 2; B.top = B.top - width / 2 - (rect?.y || 0); B.bottom += width / 2; B.width += width; B.height += width; const fillColor = ActiveInkFillColor(); const strokeColor = fillColor && fillColor !== 'transparent' ? fillColor : ActiveInkColor(); return [ this.props.children, this._points.length <= 1 ? null : ( {InteractionUtils.CreatePolyline( this._points.map(p => ({ X: p.X - (rect?.x || 0), Y: p.Y - (rect?.y || 0) })), B.left, B.top, strokeColor, width, width, 'miter', 'round', '', 'none' /* ActiveFillColor() */, ActiveInkArrowStart(), ActiveInkArrowEnd(), ActiveInkArrowScale(), ActiveInkDash(), 1, 1, SnappingManager.InkShape, 'none', 1.0, false )} ), ]; } @action public closeFloatingDoc = () => { this._clipboardDoc = undefined; }; render() { return (
{this.elements} {this._debugGestures && this._debugCusps.map(c =>
)}
{this._clipboardDoc}
); } } ScriptingGlobals.add('GestureOverlay', GestureOverlay); // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function setPen(width: string, color: string, fill: string, arrowStart: string, arrowEnd: string, dash: string) { runInAction(() => { GestureOverlay.Instance.SavedColor = ActiveInkColor(); SetActiveInkColor(color); GestureOverlay.Instance.SavedWidth = ActiveInkWidth(); SetActiveInkWidth(width); SetActiveInkFillColor(fill); SetActiveInkArrowStart(arrowStart); SetActiveInkArrowStart(arrowEnd); SetActiveInkDash(dash); }); }); // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function resetPen() { runInAction(() => { SetActiveInkColor(GestureOverlay.Instance.SavedColor ?? 'rgb(0, 0, 0)'); SetActiveInkWidth(GestureOverlay.Instance.SavedWidth?.toString() ?? '2'); }); }, 'resets the pen tool'); ScriptingGlobals.add( // eslint-disable-next-line prefer-arrow-callback function createText(text: string, X: number, Y: number) { GestureOverlay.Instance.dispatchGesture(Gestures.Text, [{ X, Y }], text); }, 'creates a text document with inputted text and coordinates', '(text: any, x: any, y: any)' );