diff options
Diffstat (limited to 'src')
22 files changed, 1157 insertions, 110 deletions
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts index 05007960d..a7bd05a21 100644 --- a/src/client/apis/gpt/GPT.ts +++ b/src/client/apis/gpt/GPT.ts @@ -12,6 +12,7 @@ enum GPTCallType { DESCRIBE = 'describe', MERMAID = 'mermaid', DATA = 'data', + DRAW = 'draw', } type GPTCallOpts = { @@ -53,6 +54,12 @@ const callTypeMap: { [type: string]: GPTCallOpts } = { temp: 0, prompt: 'List unique differences between the content of the UserAnswer and Rubric. Before each difference, label it and provide any additional information the UserAnswer missed and explain it in second person without separating it into UserAnswer and Rubric content and additional information. If there are no differences, say correct', }, + draw: { + model: 'gpt-4o', + maxTokens: 1024, + temp: 0.5, + prompt: 'Given an item, a level of complexity from 1-10, and a size in pixels, generate a detailed and colored line drawing representation of it. Make sure every element has the stroke field filled out. More complex drawings will have much more detail and strokes. The drawing should be in SVG format with no additional text or comments. For path coordinates, make sure you format with a comma between numbers, like M100,200 C150,250 etc. The only supported commands are line, ellipse, circle, rect, and path with M, Q, C, and L so only use those.', + }, }; let lastCall = ''; @@ -63,10 +70,10 @@ let lastResp = ''; * @param inputText Text to process * @returns AI Output */ -const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: any) => { +const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: any, dontCache?: boolean) => { const inputText = [GPTCallType.SUMMARY, GPTCallType.FLASHCARD, GPTCallType.QUIZ].includes(callType) ? inputTextIn + '.' : inputTextIn; const opts: GPTCallOpts = callTypeMap[callType]; - if (lastCall === inputText) return lastResp; + if (lastCall === inputText && dontCache !== true) return lastResp; try { lastCall = inputText; diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index e095bc659..141695d86 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -730,6 +730,7 @@ pie title Minerals in my tap water static inkTools():Button[] { return [ { title: "Pen", toolTip: "Pen (Ctrl+P)", btnType: ButtonType.ToggleButton, icon: "pen-nib", toolType: "pen", scripts: {onClick:'{ return setActiveTool(this.toolType, false, _readOnly_);}' }}, + { title: "Highlight", toolTip: "Highlight (Ctrl+H)", btnType: ButtonType.ToggleButton, icon: "highlighter",toolType: "highlighter", scripts: {onClick:'{ return setActiveTool(this.toolType, false, _readOnly_);}' }}, { title: "Write", toolTip: "Write (Ctrl+Shift+P)", btnType: ButtonType.ToggleButton, icon: "pen", toolType: "write", scripts: {onClick:'{ return setActiveTool(this.toolType, false, _readOnly_);}' }, funcs: {hidden:"IsNoviceMode()" }}, { title: "Eraser", toolTip: "Eraser (Ctrl+E)", btnType: ButtonType.MultiToggleButton, scripts: {onClick: '{ return setActiveTool(this.toolType, false, _readOnly_);}'}, funcs: {toolType:"activeEraserTool()"}, subMenu: [ @@ -742,9 +743,10 @@ pie title Minerals in my tap water { title: "Square", toolTip: "Square (double tap to lock mode)", btnType: ButtonType.ToggleButton, icon: "square", toolType: Gestures.Rectangle, scripts: {onClick:`{ return setActiveTool(this.toolType, false, _readOnly_);}`, onDoubleClick:`{ return setActiveTool(this.toolType, true, _readOnly_);}`} }, { title: "Line", toolTip: "Line (double tap to lock mode)", btnType: ButtonType.ToggleButton, icon: "minus", toolType: Gestures.Line, scripts: {onClick:`{ return setActiveTool(this.toolType, false, _readOnly_);}`, onDoubleClick:`{ return setActiveTool(this.toolType, true, _readOnly_);}`} }, { title: "Mask", toolTip: "Mask", btnType: ButtonType.ToggleButton, icon: "user-circle",toolType: "inkMask", scripts: {onClick:'{ return setInkProperty(this.toolType, value, _readOnly_);}'}, funcs: {hidden:"IsNoviceMode()" } }, - { title: "Labels", toolTip: "Lab els", btnType: ButtonType.ToggleButton, icon: "text-width", toolType: "labels", scripts: {onClick:'{ return setInkProperty(this.toolType, value, _readOnly_);}'}, }, + { title: "Labels", toolTip: "Labels", btnType: ButtonType.ToggleButton, icon: "text-width", toolType: "labels", scripts: {onClick:'{ return setInkProperty(this.toolType, value, _readOnly_);}'}, }, { title: "Width", toolTip: "Stroke width", btnType: ButtonType.NumberSliderButton, toolType: "strokeWidth", ignoreClick: true, scripts: {script: '{ return setInkProperty(this.toolType, value, _readOnly_);}'}, numBtnMin: 1}, { title: "Ink", toolTip: "Stroke color", btnType: ButtonType.ColorButton, icon: "pen", toolType: "strokeColor", ignoreClick: true, scripts: {script: '{ return setInkProperty(this.toolType, value, _readOnly_);}'} }, + { title: "Smart Draw", toolTip: "Draw with GPT", btnType: ButtonType.ToggleButton, icon: "user-pen", toolType: "smartdraw", scripts: {onClick:'{ return setActiveTool(this.toolType, false, _readOnly_);}'}, funcs: {hidden: "IsNoviceMode()"}}, ]; } diff --git a/src/client/util/bezierFit.ts b/src/client/util/bezierFit.ts index d6f3f2340..f5696afaf 100644 --- a/src/client/util/bezierFit.ts +++ b/src/client/util/bezierFit.ts @@ -2,8 +2,18 @@ /* eslint-disable prefer-destructuring */ /* eslint-disable no-param-reassign */ /* eslint-disable camelcase */ +import e from 'cors'; import { Point } from '../../pen-gestures/ndollar'; +export enum SVGType { + Rect = 'rect', + Path = 'path', + Circle = 'circle', + Ellipse = 'ellipse', + Line = 'line', + Polygon = 'polygon', +} + class SmartRect { minx: number = 0; miny: number = 0; @@ -557,6 +567,12 @@ function FitCubic(d: Point[], first: number, last: number, tHat1: Point, tHat2: const negThatCenter = new Point(-tHatCenter.X, -tHatCenter.Y); FitCubic(d, splitPoint2D, last, negThatCenter, tHat2, error, result); } +/** + * Convert polyline coordinates to a (multi) segment bezier curve + * @param d - polyline coordinates + * @param error - how much error to allow in fitting (measured in pixels) + * @returns + */ export function FitCurve(d: Point[], error: number) { const tHat1 = ComputeLeftTangent(d, 0); // Unit tangent vectors at endpoints const tHat2 = ComputeRightTangent(d, d.length - 1); @@ -586,6 +602,136 @@ export function FitOneCurve(d: Point[], tHat1?: Point, tHat2?: Point) { return { finalCtrls, error }; } +// alpha determines how far away the tangents are, or the "tightness" of the bezier +export function GenerateControlPoints(coordinates: Point[], alpha = 0.1) { + const firstEnd = coordinates.length ? [coordinates[0], coordinates[0]] : []; + const lastEnd = coordinates.length ? [coordinates.lastElement(), coordinates.lastElement()] : []; + const points: Point[] = coordinates.slice(1, coordinates.length - 1).flatMap((pt, index, inkData) => { + const prevPt: Point = index === 0 ? firstEnd[0] : inkData[index - 1]; + const nextPt: Point = index === inkData.length - 1 ? lastEnd[0] : inkData[index + 1]; + if (prevPt.X === nextPt.X) { + const verticalDist = nextPt.Y - prevPt.Y; + return [{ X: pt.X, Y: pt.Y - alpha * verticalDist }, pt, pt, { X: pt.X, Y: pt.Y + alpha * verticalDist }]; + } else if (prevPt.Y === nextPt.Y) { + const horizDist = nextPt.X - prevPt.X; + return [{ X: pt.X - alpha * horizDist, Y: pt.Y }, pt, pt, { X: pt.X + alpha * horizDist, Y: pt.Y }]; + } + // tangent vectors between the adjacent points + const tanX = nextPt.X - prevPt.X; + const tanY = nextPt.Y - prevPt.Y; + const ctrlPt1: Point = { X: pt.X - alpha * tanX, Y: pt.Y - alpha * tanY }; + const ctrlPt2: Point = { X: pt.X + alpha * tanX, Y: pt.Y + alpha * tanY }; + return [ctrlPt1, pt, pt, ctrlPt2]; + }); + return [...firstEnd, ...points, ...lastEnd]; +} + +export function SVGToBezier(name: SVGType, attributes: any): Point[] { + console.log('in svg to bezier', name, attributes); + switch (name) { + case 'line': + const x1 = parseInt(attributes.x1); + const x2 = parseInt(attributes.x2); + const y1 = parseInt(attributes.y1); + const y2 = parseInt(attributes.y2); + return [ + { X: x1, Y: y1 }, + { X: x1, Y: y1 }, + { X: x2, Y: y2 }, + { X: x2, Y: y2 }, + ]; + case 'circle': + case 'ellipse': + const c = 0.551915024494; + const centerX = parseInt(attributes.cx); + const centerY = parseInt(attributes.cy); + const radiusX = parseInt(attributes.rx) || parseInt(attributes.r); + const radiusY = parseInt(attributes.ry) || parseInt(attributes.r); + return [ + { X: centerX, Y: centerY + radiusY }, + { X: centerX + c * radiusX, Y: centerY + radiusY }, + { X: centerX + radiusX, Y: centerY + c * radiusY }, + { X: centerX + radiusX, Y: centerY }, + { X: centerX + radiusX, Y: centerY }, + { X: centerX + radiusX, Y: centerY - c * radiusY }, + { X: centerX + c * radiusX, Y: centerY - radiusY }, + { X: centerX, Y: centerY - radiusY }, + { X: centerX, Y: centerY - radiusY }, + { X: centerX - c * radiusX, Y: centerY - radiusY }, + { X: centerX - radiusX, Y: centerY - c * radiusY }, + { X: centerX - radiusX, Y: centerY }, + { X: centerX - radiusX, Y: centerY }, + { X: centerX - radiusX, Y: centerY + c * radiusY }, + { X: centerX - c * radiusX, Y: centerY + radiusY }, + { X: centerX, Y: centerY + radiusY }, + ]; + case 'rect': + const x = parseInt(attributes.x); + const y = parseInt(attributes.y); + const width = parseInt(attributes.width); + const height = parseInt(attributes.height); + return [ + { X: x, Y: y }, + { X: x, Y: y }, + { X: x + width, Y: y }, + { X: x + width, Y: y }, + { X: x + width, Y: y }, + { X: x + width, Y: y }, + { X: x + width, Y: y + height }, + { X: x + width, Y: y + height }, + { X: x + width, Y: y + height }, + { X: x + width, Y: y + height }, + { X: x, Y: y + height }, + { X: x, Y: y + height }, + { X: x, Y: y + height }, + { X: x, Y: y + height }, + { X: x, Y: y }, + { X: x, Y: y }, + ]; + case 'path': + const coordList: Point[] = []; + const startPt = attributes.d.match(/M(-?\d+\.?\d*),(-?\d+\.?\d*)/); + coordList.push({ X: parseInt(startPt[1]), Y: parseInt(startPt[2]) }); + const matches: RegExpMatchArray[] = Array.from( + attributes.d.matchAll(/Q(-?\d+\.?\d*),(-?\d+\.?\d*) (-?\d+\.?\d*),(-?\d+\.?\d*)|C(-?\d+\.?\d*),(-?\d+\.?\d*) (-?\d+\.?\d*),(-?\d+\.?\d*) (-?\d+\.?\d*),(-?\d+\.?\d*)|L(-?\d+\.?\d*),(-?\d+\.?\d*)/g) + ); + let lastPt: Point; + matches.forEach(match => { + if (match[0].startsWith('Q')) { + coordList.push({ X: parseInt(match[1]), Y: parseInt(match[2]) }); + coordList.push({ X: parseInt(match[1]), Y: parseInt(match[2]) }); + coordList.push({ X: parseInt(match[3]), Y: parseInt(match[4]) }); + coordList.push({ X: parseInt(match[3]), Y: parseInt(match[4]) }); + lastPt = { X: parseInt(match[3]), Y: parseInt(match[4]) }; + } else if (match[0].startsWith('C')) { + coordList.push({ X: parseInt(match[5]), Y: parseInt(match[6]) }); + coordList.push({ X: parseInt(match[7]), Y: parseInt(match[8]) }); + coordList.push({ X: parseInt(match[9]), Y: parseInt(match[10]) }); + coordList.push({ X: parseInt(match[9]), Y: parseInt(match[10]) }); + lastPt = { X: parseInt(match[9]), Y: parseInt(match[10]) }; + } else { + coordList.push(lastPt || { X: parseInt(startPt[1]), Y: parseInt(startPt[2]) }); + coordList.push({ X: parseInt(match[11]), Y: parseInt(match[12]) }); + coordList.push({ X: parseInt(match[11]), Y: parseInt(match[12]) }); + coordList.push({ X: parseInt(match[11]), Y: parseInt(match[12]) }); + lastPt = { X: parseInt(match[11]), Y: parseInt(match[12]) }; + } + }); + const hasZ = attributes.d.match(/Z/); + if (hasZ) { + coordList.push(lastPt); + coordList.push({ X: parseInt(startPt[1]), Y: parseInt(startPt[2]) }); + coordList.push({ X: parseInt(startPt[1]), Y: parseInt(startPt[2]) }); + } else { + coordList.pop(); + } + return coordList; + case 'polygon': + default: + return []; + } +} + /* static double GetTValueFromSValue (const BezierRep &parent, double t, double endT, bool left, double influenceDistance, double &excess) { double dist = 0; diff --git a/src/client/views/GestureOverlay.tsx b/src/client/views/GestureOverlay.tsx index e3e252593..a1f7712c1 100644 --- a/src/client/views/GestureOverlay.tsx +++ b/src/client/views/GestureOverlay.tsx @@ -240,15 +240,15 @@ export class GestureOverlay extends ObservableReactComponent<React.PropsWithChil this._points.length = 0; switch (shape) { case Gestures.Rectangle: - this._points.push({ X: left, Y: top }); - this._points.push({ X: left, Y: top }); - this._points.push({ X: right, Y: top }); - this._points.push({ X: right, Y: top }); + this._points.push({ X: left, Y: top }); // curr pt + this._points.push({ X: left, Y: top }); // curr first ctrl pt + this._points.push({ X: right, Y: top }); // next ctrl pt + this._points.push({ X: right, Y: top }); // next pt - this._points.push({ X: right, Y: top }); - this._points.push({ X: right, Y: top }); - this._points.push({ X: right, Y: bottom }); - this._points.push({ X: right, Y: bottom }); + this._points.push({ X: right, Y: top }); // next pt + this._points.push({ X: right, Y: top }); // next first ctrl pt + this._points.push({ X: right, Y: bottom }); // next next ctrl pt + this._points.push({ X: right, Y: bottom }); // next next pt this._points.push({ X: right, Y: bottom }); this._points.push({ X: right, Y: bottom }); @@ -291,13 +291,13 @@ export class GestureOverlay extends ObservableReactComponent<React.PropsWithChil 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. - this._points.push({ X: centerX, Y: centerY + radius }); - this._points.push({ X: centerX + c * radius, Y: centerY + radius }); - this._points.push({ X: centerX + radius, Y: centerY + c * radius }); - this._points.push({ X: centerX + radius, Y: centerY }); + this._points.push({ X: centerX, Y: centerY + radius }); // curr pt + this._points.push({ X: centerX + c * radius, Y: centerY + radius }); // curr first ctrl pt + this._points.push({ X: centerX + radius, Y: centerY + c * radius }); // next pt ctrl pt + this._points.push({ X: centerX + radius, Y: centerY }); // next pt - this._points.push({ X: centerX + radius, Y: centerY }); - this._points.push({ X: centerX + radius, Y: centerY - c * radius }); + this._points.push({ X: centerX + radius, Y: centerY }); // next pt + this._points.push({ X: centerX + radius, Y: centerY - c * radius }); // next first ctrl pt this._points.push({ X: centerX + c * radius, Y: centerY - radius }); this._points.push({ X: centerX, Y: centerY - radius }); diff --git a/src/client/views/LightboxView.scss b/src/client/views/LightboxView.scss index 6da5c0338..3e65843df 100644 --- a/src/client/views/LightboxView.scss +++ b/src/client/views/LightboxView.scss @@ -1,7 +1,7 @@ .lightboxView-navBtn { margin: auto; position: absolute; - right: 10; + right: 19; top: 10; background: transparent; border-radius: 8; @@ -16,7 +16,7 @@ .lightboxView-tabBtn { margin: auto; position: absolute; - right: 45; + right: 54; top: 10; background: transparent; border-radius: 8; @@ -28,10 +28,26 @@ opacity: 1; } } +.lightboxView-paletteBtn { + margin: auto; + position: absolute; + right: 89; + top: 10; + background: transparent; + border-radius: 8; + opacity: 0.7; + width: 25; + flex-direction: column; + display: flex; + &:hover { + opacity: 1; + } +} + .lightboxView-penBtn { margin: auto; position: absolute; - right: 80; + right: 124; top: 10; background: transparent; border-radius: 8; @@ -46,7 +62,7 @@ .lightboxView-exploreBtn { margin: auto; position: absolute; - right: 115; + right: 159; top: 10; background: transparent; border-radius: 8; diff --git a/src/client/views/LightboxView.tsx b/src/client/views/LightboxView.tsx index 7198c7f05..e93e4949b 100644 --- a/src/client/views/LightboxView.tsx +++ b/src/client/views/LightboxView.tsx @@ -23,6 +23,7 @@ import { DocumentView } from './nodes/DocumentView'; import { OpenWhere, OpenWhereMod } from './nodes/OpenWhere'; import { ScriptingGlobals } from '../util/ScriptingGlobals'; import { OverlayView } from './OverlayView'; +import { DrawingPalette } from './smartdraw/DrawingPalette'; interface LightboxViewProps { PanelWidth: number; @@ -59,6 +60,7 @@ export class LightboxView extends ObservableReactComponent<LightboxViewProps> { @observable private _doc: Opt<Doc> = undefined; @observable private _docTarget: Opt<Doc> = undefined; @observable private _docView: Opt<DocumentView> = undefined; + @observable private _showPalette: boolean = false; @computed get leftBorder() { return Math.min(this._props.PanelWidth / 4, this._props.maxBorder[0]); } // prettier-ignore @computed get topBorder() { return Math.min(this._props.PanelHeight / 4, this._props.maxBorder[1]); } // prettier-ignore @@ -202,6 +204,9 @@ export class LightboxView extends ObservableReactComponent<LightboxViewProps> { toggleFitWidth = () => { this._doc && (this._doc._layout_fitWidth = !this._doc._layout_fitWidth); }; + togglePalette = () => { + this._showPalette = !this._showPalette; + }; togglePen = () => { Doc.ActiveTool = Doc.ActiveTool === InkTool.Pen ? InkTool.None : InkTool.Pen; }; @@ -319,8 +324,10 @@ export class LightboxView extends ObservableReactComponent<LightboxViewProps> { <LightboxTourBtn lightboxDoc={this.lightboxDoc} navBtn={this.renderNavBtn} future={this.future} stepInto={this.stepInto} /> {toggleBtn('lightboxView-navBtn', 'toggle reading view', this._doc?._layout_fitWidth, 'book-open', 'book', this.toggleFitWidth)} {toggleBtn('lightboxView-tabBtn', 'open document in a tab', false, 'file-download', '', this.downloadDoc)} + {toggleBtn('lightboxView-paletteBtn', 'toggle annotation palette', this._showPalette === true, 'palette', '', this.togglePalette)} {toggleBtn('lightboxView-penBtn', 'toggle pen annotation', Doc.ActiveTool === InkTool.Pen, 'pen', '', this.togglePen)} {toggleBtn('lightboxView-exploreBtn', 'toggle navigate only mode', SnappingManager.ExploreMode, 'globe-americas', '', this.toggleExplore)} + {this._showPalette && <DrawingPalette />} </div> ); } diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index ef1bcfb64..30720a3a2 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -76,6 +76,7 @@ import { PresBox } from './nodes/trails'; import { AnchorMenu } from './pdf/AnchorMenu'; import { GPTPopup } from './pdf/GPTPopup/GPTPopup'; import { TopBar } from './topbar/TopBar'; +import { SmartDrawHandler } from './smartdraw/SmartDrawHandler'; const { LEFT_MENU_WIDTH, TOPBAR_HEIGHT } = require('./global/globalCssVariables.module.scss'); // prettier-ignore const _global = (window /* browser */ || global) /* node */ as any; @@ -317,6 +318,7 @@ export class MainView extends ObservableReactComponent<{}> { fa.faCompass, fa.faSnowflake, fa.faStar, + fa.faSplotch, fa.faMicrophone, fa.faCircleHalfStroke, fa.faKeyboard, @@ -394,6 +396,7 @@ export class MainView extends ObservableReactComponent<{}> { fa.faArrowsLeftRight, fa.faPause, fa.faPen, + fa.faUserPen, fa.faPenNib, fa.faPhone, fa.faPlay, @@ -439,6 +442,7 @@ export class MainView extends ObservableReactComponent<{}> { fa.faEyeDropper, fa.faPaintRoller, fa.faBars, + fa.faBarsStaggered, fa.faBrush, fa.faShapes, fa.faEllipsisH, @@ -1089,6 +1093,7 @@ export class MainView extends ObservableReactComponent<{}> { <TaskCompletionBox /> <ContextMenu /> <ImageLabelHandler /> + <SmartDrawHandler /> <AnchorMenu /> <MapAnchorMenu /> <DirectionsAnchorMenu /> diff --git a/src/client/views/MarqueeAnnotator.tsx b/src/client/views/MarqueeAnnotator.tsx index c18ac6738..f06f3efe0 100644 --- a/src/client/views/MarqueeAnnotator.tsx +++ b/src/client/views/MarqueeAnnotator.tsx @@ -28,6 +28,7 @@ export interface MarqueeAnnotatorProps { marqueeContainer: HTMLDivElement; docView: () => DocumentView; savedAnnotations: () => ObservableMap<number, HTMLDivElement[]>; + savedTapes: () => ObservableMap<number, HTMLDivElement[]>; selectionText: () => string; annotationLayer: HTMLDivElement; addDocument: (doc: Doc) => boolean; @@ -74,6 +75,7 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP backgroundColor: color, annotationOn: this.props.Document, title: 'Annotation on ' + this.props.Document.title, + a, }); marqueeAnno.x = NumCast(doc.freeform_panX_min) + (parseInt(anno.style.left || '0') - containerOffset[0]) / scale; marqueeAnno.y = NumCast(doc.freeform_panY_min) + (parseInt(anno.style.top || '0') - containerOffset[1]) / scale; @@ -127,6 +129,140 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP savedAnnoMap.clear(); return textRegionAnno; }; + + // @undoBatch + // makeTapeDocument = (color: string, isLinkButton?: boolean, savedTapes?: ObservableMap<number, HTMLDivElement[]>): Opt<Doc> => { + // const savedTapeMap = savedTapes?.values() && Array.from(savedTapes?.values()).length ? savedTapes : this.props.savedTapes(); + // if (savedTapeMap.size === 0) return undefined; + // const tapes = Array.from(savedTapeMap.values())[0]; + // const doc = this.props.Document; + // const scale = (this.props.annotationLayerScaling?.() || 1) * NumCast(doc._freeform_scale, 1); + // if (tapes.length && (tapes[0] as any).marqueeing) { + // const anno = tapes[0]; + // const containerOffset = this.props.containerOffset?.() || [0, 0]; + // const tape = Docs.Create.FreeformDocument([], { + // onClick: isLinkButton ? FollowLinkScript() : undefined, + // backgroundColor: color, + // annotationOn: this.props.Document, + // title: 'Tape on ' + this.props.Document.title, + // }); + // tape.x = NumCast(doc.freeform_panX_min) + (parseInt(anno.style.left || '0') - containerOffset[0]) / scale; + // tape.y = NumCast(doc.freeform_panY_min) + (parseInt(anno.style.top || '0') - containerOffset[1]) / scale; + // tape._height = parseInt(anno.style.height || '0') / scale; + // tape._width = parseInt(anno.style.width || '0') / scale; + // anno.remove(); + // savedTapeMap.clear(); + // return tape; + // } + + // const textRegionAnno = Docs.Create.ConfigDocument({ + // annotationOn: this.props.Document, + // text: this.props.selectionText() as any, // text want an RTFfield, but strings are acceptable, too. + // text_html: this.props.selectionText() as any, + // backgroundColor: 'transparent', + // presentation_duration: 2100, + // presentation_transition: 500, + // presentation_zoomText: true, + // title: '>' + this.props.Document.title, + // }); + // const textRegionAnnoProto = textRegionAnno[DocData]; + // let minX = Number.MAX_VALUE; + // let maxX = -Number.MAX_VALUE; + // let minY = Number.MAX_VALUE; + // let maxY = -Number.MIN_VALUE; + // const annoRects: string[] = []; + // savedAnnoMap.forEach((value: HTMLDivElement[]) => + // value.forEach(anno => { + // const x = parseInt(anno.style.left ?? '0'); + // const y = parseInt(anno.style.top ?? '0'); + // const height = parseInt(anno.style.height ?? '0'); + // const width = parseInt(anno.style.width ?? '0'); + // annoRects.push(`${x}:${y}:${width}:${height}`); + // anno.remove(); + // minY = Math.min(NumCast(y), minY); + // minX = Math.min(NumCast(x), minX); + // maxY = Math.max(NumCast(y) + NumCast(height), maxY); + // maxX = Math.max(NumCast(x) + NumCast(width), maxX); + // }) + // ); + + // textRegionAnnoProto.y = Math.max(minY, 0); + // textRegionAnnoProto.x = Math.max(minX, 0); + // textRegionAnnoProto.height = Math.max(maxY, 0) - Math.max(minY, 0); + // textRegionAnnoProto.width = Math.max(maxX, 0) - Math.max(minX, 0); + // textRegionAnnoProto.backgroundColor = color; + // // mainAnnoDocProto.text = this._selectionText; + // textRegionAnnoProto.text_inlineAnnotations = new List<string>(annoRects); + // textRegionAnnoProto.opacity = 0; + // textRegionAnnoProto.layout_unrendered = true; + // savedAnnoMap.clear(); + // return textRegionAnno; + // }; + + @undoBatch + makeTapeDocument = (color: string, isLinkButton?: boolean, savedTapes?: ObservableMap<number, HTMLDivElement[]>): Opt<Doc> => { + // const savedAnnoMap = savedTapes?.values() && Array.from(savedTapes?.values()).length ? savedTapes : this.props.savedTapes(); + // if (savedAnnoMap.size === 0) return undefined; + // const savedAnnos = Array.from(savedAnnoMap.values())[0]; + const doc = this.props.Document; + const scale = (this.props.annotationLayerScaling?.() || 1) * NumCast(doc._freeform_scale, 1); + const marqueeAnno = Docs.Create.FreeformDocument([], { + onClick: isLinkButton ? FollowLinkScript() : undefined, + backgroundColor: color, + annotationOn: this.props.Document, + title: 'Annotation on ' + this.props.Document.title, + }); + marqueeAnno.x = NumCast(doc.freeform_panX_min) / scale; + marqueeAnno.y = NumCast(doc.freeform_panY_min) / scale; + marqueeAnno._height = parseInt('100') / scale; + marqueeAnno._width = parseInt('100') / scale; + return marqueeAnno; + // } + + // const textRegionAnno = Docs.Create.ConfigDocument({ + // annotationOn: this.props.Document, + // text: this.props.selectionText() as any, // text want an RTFfield, but strings are acceptable, too. + // text_html: this.props.selectionText() as any, + // backgroundColor: 'transparent', + // presentation_duration: 2100, + // presentation_transition: 500, + // presentation_zoomText: true, + // title: '>' + this.props.Document.title, + // }); + // const textRegionAnnoProto = textRegionAnno[DocData]; + // let minX = Number.MAX_VALUE; + // let maxX = -Number.MAX_VALUE; + // let minY = Number.MAX_VALUE; + // let maxY = -Number.MIN_VALUE; + // const annoRects: string[] = []; + // savedAnnoMap.forEach((value: HTMLDivElement[]) => + // value.forEach(anno => { + // const x = parseInt(anno.style.left ?? '0'); + // const y = parseInt(anno.style.top ?? '0'); + // const height = parseInt(anno.style.height ?? '0'); + // const width = parseInt(anno.style.width ?? '0'); + // annoRects.push(`${x}:${y}:${width}:${height}`); + // anno.remove(); + // minY = Math.min(NumCast(y), minY); + // minX = Math.min(NumCast(x), minX); + // maxY = Math.max(NumCast(y) + NumCast(height), maxY); + // maxX = Math.max(NumCast(x) + NumCast(width), maxX); + // }) + // ); + + // textRegionAnnoProto.y = Math.max(minY, 0); + // textRegionAnnoProto.x = Math.max(minX, 0); + // textRegionAnnoProto.height = Math.max(maxY, 0) - Math.max(minY, 0); + // textRegionAnnoProto.width = Math.max(maxX, 0) - Math.max(minX, 0); + // textRegionAnnoProto.backgroundColor = color; + // // mainAnnoDocProto.text = this._selectionText; + // textRegionAnnoProto.text_inlineAnnotations = new List<string>(annoRects); + // textRegionAnnoProto.opacity = 0; + // textRegionAnnoProto.layout_unrendered = true; + // savedAnnoMap.clear(); + // return textRegionAnno; + }; + @action highlight = (color: string, isLinkButton: boolean, savedAnnotations?: ObservableMap<number, HTMLDivElement[]>, addAsAnnotation?: boolean) => { // creates annotation documents for current highlights @@ -136,6 +272,15 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP return annotationDoc as Doc; }; + @action + tape = (color: string, isLinkButton: boolean, savedTapes?: ObservableMap<number, HTMLDivElement[]>, addAsAnnotation?: boolean) => { + // creates annotation documents for current highlights + const effectiveAcl = GetEffectiveAcl(this.props.Document[DocData]); + const tape = [AclAugment, AclSelfEdit, AclEdit, AclAdmin].includes(effectiveAcl) && this.makeTapeDocument(color, isLinkButton, savedTapes); + addAsAnnotation && tape && this.props.addDocument(tape); + return tape as Doc; + }; + public static previewNewAnnotation = action((savedAnnotations: ObservableMap<number, HTMLDivElement[]>, annotationLayer: HTMLDivElement, div: HTMLDivElement, page: number) => { div.style.backgroundColor = '#ACCEF7'; div.style.opacity = '0.5'; @@ -182,6 +327,7 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP AnchorMenu.Instance.OnClick = undoable(() => this.props.anchorMenuClick?.()?.(this.highlight(this.props.highlightDragSrcColor ?? 'rgba(173, 216, 230, 0.75)', true, undefined, true)), 'make sidebar annotation'); AnchorMenu.Instance.OnAudio = unimplementedFunction; AnchorMenu.Instance.Highlight = (color: string) => this.highlight(color, false, undefined, true); + AnchorMenu.Instance.Tape = (color: string) => this.tape(color, false, undefined, true); AnchorMenu.Instance.GetAnchor = (savedAnnotations?: ObservableMap<number, HTMLDivElement[]> /* , addAsAnnotation?: boolean */) => this.highlight('rgba(173, 216, 230, 0.75)', true, savedAnnotations, true); AnchorMenu.Instance.onMakeAnchor = () => AnchorMenu.Instance.GetAnchor(undefined, true); diff --git a/src/client/views/collections/CollectionMenu.scss b/src/client/views/collections/CollectionMenu.scss index 3ec875df4..45d9394ed 100644 --- a/src/client/views/collections/CollectionMenu.scss +++ b/src/client/views/collections/CollectionMenu.scss @@ -6,7 +6,7 @@ align-content: center; justify-content: space-between; background-color: $dark-gray; - height: 35px; + height: 40px; border-bottom: $standard-border; padding: 0 10px; align-items: center; diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index d611db1f8..8f8cb9083 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -1,7 +1,7 @@ /* eslint-disable react/jsx-props-no-spreading */ /* eslint-disable jsx-a11y/click-events-have-key-events */ /* eslint-disable jsx-a11y/no-static-element-interactions */ -import { Bezier } from 'bezier-js'; +import { Bezier, Point } from 'bezier-js'; import { Colors } from 'browndash-components'; import { action, computed, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; @@ -27,6 +27,7 @@ import { aggregateBounds, clamp, emptyFunction, intersectRect, Utils } from '../ import { Docs } from '../../../documents/Documents'; import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes'; import { DocUtils } from '../../../documents/DocUtils'; +import { FitCurve, GenerateControlPoints } from '../../../util/bezierFit'; import { DragManager } from '../../../util/DragManager'; import { dropActionType } from '../../../util/DropActionTypes'; import { CompileScript } from '../../../util/Scripting'; @@ -55,6 +56,7 @@ import { CollectionFreeFormPannableContents } from './CollectionFreeFormPannable import { CollectionFreeFormRemoteCursors } from './CollectionFreeFormRemoteCursors'; import './CollectionFreeFormView.scss'; import { MarqueeView } from './MarqueeView'; +import { DrawingOptions, SmartDrawHandler } from '../../smartdraw/SmartDrawHandler'; @observer class CollectionFreeFormOverlayView extends React.Component<{ elements: () => ViewDefResult[] }> { @@ -118,6 +120,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @observable _panZoomTransition: number = 0; // sets the pan/zoom transform ease time- used by nudge(), focus() etc to smoothly zoom/pan. set to 0 to use document's transition time or default of 0 @observable _firstRender = false; // this turns off rendering of the collection's content so that there's instant feedback when a tab is switched of what content will be shown. could be used for performance improvement @observable _showAnimTimeline = false; + @observable _showDrawingEditor = false; @observable _deleteList: DocumentView[] = []; @observable _timelineRef = React.createRef<Timeline>(); @observable _marqueeViewRef = React.createRef<MarqueeView>(); @@ -497,28 +500,30 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection if (!this.Document.isGroup) { // group freeforms don't pan when dragged -- instead let the event go through to allow the group itself to drag // prettier-ignore + const hit = this._clusters.handlePointerDown(this.screenToFreeformContentsXf.transformPoint(e.clientX, e.clientY)); switch (Doc.ActiveTool) { - case InkTool.Highlighter: break; - case InkTool.Write: break; - case InkTool.Pen: break; // the GestureOverlay handles ink stroke input -- either as gestures, or drying as ink strokes that are added to document views + case InkTool.Highlighter: + case InkTool.Write: + case InkTool.Pen: + break; // the GestureOverlay handles ink stroke input -- either as gestures, or drying as ink strokes that are added to document views case InkTool.StrokeEraser: case InkTool.SegmentEraser: - this._batch = UndoManager.StartBatch('collectionErase'); - this._eraserPts.length = 0; - setupMoveUpEvents(this, e, this.onEraserMove, this.onEraserUp, emptyFunction); - break; case InkTool.RadiusEraser: this._batch = UndoManager.StartBatch('collectionErase'); this._eraserPts.length = 0; - setupMoveUpEvents(this, e, this.onRadiusEraserMove, this.onEraserUp, emptyFunction); + setupMoveUpEvents(this, e, this.onEraserMove, this.onEraserUp, this.onEraserClick, hit !== -1); + e.stopPropagation(); break; + case InkTool.SmartDraw: + setupMoveUpEvents(this, e, this.onPointerMove, emptyFunction, this.showSmartDraw, hit !== -1); + e.stopPropagation(); case InkTool.None: if (!(this._props.layoutEngine?.() || StrCast(this.layoutDoc._layoutEngine))) { const hit = this._clusters.handlePointerDown(this.screenToFreeformContentsXf.transformPoint(e.clientX, e.clientY)); setupMoveUpEvents(this, e, this.onPointerMove, emptyFunction, emptyFunction, hit !== -1, false); } break; - default: + default: } } } @@ -569,6 +574,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection } } }; + @action onEraserUp = (): void => { this._deleteList.lastElement()?._props.removeDocument?.(this._deleteList.map(ink => ink.Document)); @@ -609,34 +615,48 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection _eraserLock = 0; _eraserPts: number[][] = []; // keep track of the last few eraserPts to make the eraser circle 'stretch' - /** - * Erases strokes by intersecting them with an invisible "eraser stroke". - * By default this iterates through all intersected ink strokes, determines their segmentation, draws back the non-intersected segments, - * and deletes the original stroke. - */ - @action - onEraserMove = (e: PointerEvent, down: number[], delta: number[]) => { + erase = (e: PointerEvent, delta: number[]) => { + e.stopImmediatePropagation(); const currPoint = { X: e.clientX, Y: e.clientY }; this._eraserPts.push([currPoint.X, currPoint.Y]); this._eraserPts = this._eraserPts.slice(Math.max(0, this._eraserPts.length - 5)); - // if (this._eraserLock) return false; // leaving this commented out in case the idea is revisited in the future - this.getEraserIntersections({ X: currPoint.X - delta[0], Y: currPoint.Y - delta[1] }, currPoint).forEach(intersect => { - if (!this._deleteList.includes(intersect.inkView)) { - this._deleteList.push(intersect.inkView); - SetActiveInkWidth(StrCast(intersect.inkView.Document.stroke_width?.toString()) || '1'); - SetActiveInkColor(StrCast(intersect.inkView.Document.color?.toString()) || 'black'); - // create a new curve by appending all curves of the current segment together in order to render a single new stroke. - if (Doc.ActiveTool !== InkTool.StrokeEraser) { - // this._eraserLock++; - const segments = this.segmentErase(intersect.inkView, intersect.t); // intersect.t is where the eraser intersected the ink stroke - want to remove the segment that starts at the intersection just before this t value and goes to the one just after it - const newStrokes = segments?.map(segment => { - const points = segment.reduce((data, curve) => [...data, ...curve.points.map(p => intersect.inkView.ComponentView?.ptToScreen?.({ X: p.x, Y: p.y }) ?? { X: 0, Y: 0 })], [] as PointData[]); - const bounds = InkField.getBounds(points); - const B = this.screenToFreeformContentsXf.transformBounds(bounds.left, bounds.top, bounds.width, bounds.height); - const inkWidth = ActiveInkWidth() * this.ScreenToLocalBoxXf().Scale; - return Docs.Create.InkDocument( - points, - { title: 'stroke', + if (Doc.ActiveTool === InkTool.RadiusEraser) { + const strokeMap: Map<DocumentView, number[]> = this.getRadiusEraserIntersections({ X: currPoint.X - delta[0], Y: currPoint.Y - delta[1] }, currPoint); + strokeMap.forEach((intersects, stroke) => { + if (!this._deleteList.includes(stroke)) { + this._deleteList.push(stroke); + SetActiveInkWidth(StrCast(stroke.Document.stroke_width?.toString()) || '1'); + SetActiveInkColor(StrCast(stroke.Document.color?.toString()) || 'black'); + const segments = this.radiusErase(stroke, intersects.sort()); + segments?.forEach(segment => + this.forceStrokeGesture( + e, + Gestures.Stroke, + segment.reduce((data, curve) => [...data, ...curve.points.map(p => stroke.ComponentView?.ptToScreen?.({ X: p.x, Y: p.y }) ?? { X: 0, Y: 0 })], [] as PointData[]) + ) + ); + } + stroke.layoutDoc.opacity = 0; + stroke.layoutDoc.dontIntersect = true; + }); + } else { + this.getEraserIntersections({ X: currPoint.X - delta[0], Y: currPoint.Y - delta[1] }, currPoint).forEach(intersect => { + if (!this._deleteList.includes(intersect.inkView)) { + this._deleteList.push(intersect.inkView); + SetActiveInkWidth(StrCast(intersect.inkView.Document.stroke_width?.toString()) || '1'); + SetActiveInkColor(StrCast(intersect.inkView.Document.color?.toString()) || 'black'); + // create a new curve by appending all curves of the current segment together in order to render a single new stroke. + if (Doc.ActiveTool !== InkTool.StrokeEraser) { + // this._eraserLock++; + const segments = this.segmentErase(intersect.inkView, intersect.t); // intersect.t is where the eraser intersected the ink stroke - want to remove the segment that starts at the intersection just before this t value and goes to the one just after it + const newStrokes = segments?.map(segment => { + const points = segment.reduce((data, curve) => [...data, ...curve.points.map(p => intersect.inkView.ComponentView?.ptToScreen?.({ X: p.x, Y: p.y }) ?? { X: 0, Y: 0 })], [] as PointData[]); + const bounds = InkField.getBounds(points); + const B = this.screenToFreeformContentsXf.transformBounds(bounds.left, bounds.top, bounds.width, bounds.height); + const inkWidth = ActiveInkWidth() * this.ScreenToLocalBoxXf().Scale; + return Docs.Create.InkDocument( + points, + { title: 'stroke', x: B.x - inkWidth / 2, y: B.y - inkWidth / 2, _width: B.width + inkWidth, @@ -655,14 +675,30 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection newStrokes && this.addDocument?.(newStrokes); // setTimeout(() => this._eraserLock--); } - // Lower ink opacity to give the user a visual indicator of deletion. - intersect.inkView.layoutDoc.opacity = 0; - intersect.inkView.layoutDoc.dontIntersect = true; - } - }); + }); + } + return false; + }; + + /** + * Erases strokes by intersecting them with an invisible "eraser stroke". + * By default this iterates through all intersected ink strokes, determines their segmentation, draws back the non-intersected segments, + * and deletes the original stroke. + */ + @action + onEraserMove = (e: PointerEvent, down: number[], delta: number[]) => { + this.erase(e, delta); + // if (this._eraserLock) return false; // leaving this commented out in case the idea is revisited in the future return false; }; + @action + onEraserClick = (e: PointerEvent, doubleTap?: boolean) => { + e.preventDefault(); + e.stopImmediatePropagation(); + this.erase(e, [0, 0]); + }; + /** * Erases strokes by intersecting them with a circle of variable radius. Essentially creates an InkField for the * eraser circle, then determines its intersections with other ink strokes. Each stroke's DocumentView and its @@ -672,32 +708,32 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection * @param delta * @returns */ - @action - onRadiusEraserMove = (e: PointerEvent, down: number[], delta: number[]) => { - const currPoint = { X: e.clientX, Y: e.clientY }; - this._eraserPts.push([currPoint.X, currPoint.Y]); - this._eraserPts = this._eraserPts.slice(Math.max(0, this._eraserPts.length - 5)); - const strokeMap: Map<DocumentView, number[]> = this.getRadiusEraserIntersections({ X: currPoint.X - delta[0], Y: currPoint.Y - delta[1] }, currPoint); - - strokeMap.forEach((intersects, stroke) => { - if (!this._deleteList.includes(stroke)) { - this._deleteList.push(stroke); - SetActiveInkWidth(StrCast(stroke.Document.stroke_width?.toString()) || '1'); - SetActiveInkColor(StrCast(stroke.Document.color?.toString()) || 'black'); - const segments = this.radiusErase(stroke, intersects.sort()); - segments?.forEach(segment => - this.forceStrokeGesture( - e, - Gestures.Stroke, - segment.reduce((data, curve) => [...data, ...curve.points.map(p => stroke.ComponentView?.ptToScreen?.({ X: p.x, Y: p.y }) ?? { X: 0, Y: 0 })], [] as PointData[]) - ) - ); - } - stroke.layoutDoc.opacity = 0; - stroke.layoutDoc.dontIntersect = true; - }); - return false; - }; + // @action + // onRadiusEraserMove = (e: PointerEvent, down: number[], delta: number[]) => { + // const currPoint = { X: e.clientX, Y: e.clientY }; + // this._eraserPts.push([currPoint.X, currPoint.Y]); + // this._eraserPts = this._eraserPts.slice(Math.max(0, this._eraserPts.length - 5)); + // const strokeMap: Map<DocumentView, number[]> = this.getRadiusEraserIntersections({ X: currPoint.X - delta[0], Y: currPoint.Y - delta[1] }, currPoint); + + // strokeMap.forEach((intersects, stroke) => { + // if (!this._deleteList.includes(stroke)) { + // this._deleteList.push(stroke); + // SetActiveInkWidth(StrCast(stroke.Document.stroke_width?.toString()) || '1'); + // SetActiveInkColor(StrCast(stroke.Document.color?.toString()) || 'black'); + // const segments = this.radiusErase(stroke, intersects.sort()); + // segments?.forEach(segment => + // this.forceStrokeGesture( + // e, + // Gestures.Stroke, + // segment.reduce((data, curve) => [...data, ...curve.points.map(p => stroke.ComponentView?.ptToScreen?.({ X: p.x, Y: p.y }) ?? { X: 0, Y: 0 })], [] as PointData[]) + // ) + // ); + // } + // stroke.layoutDoc.opacity = 0; + // stroke.layoutDoc.dontIntersect = true; + // }); + // return false; + // }; forceStrokeGesture = (e: PointerEvent, gesture: Gestures, points: InkData, text?: any) => { this.onGesture(e, new GestureUtils.GestureEvent(gesture, points, InkField.getBounds(points), text)); @@ -728,7 +764,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection // increase radius slightly based on the erased stroke's width, added to make eraser look more realistic const radius = ActiveEraserWidth() + 5 + inkStrokeWidth * 0.1; // add 5 to prevent eraser from being too small const c = 0.551915024494; // circle tangent length to side ratio - const movement = { x: endInkCoordsIn.X - startInkCoordsIn.X, y: endInkCoordsIn.Y - startInkCoordsIn.Y }; + const movement = { x: Math.max(endInkCoordsIn.X - startInkCoordsIn.X, 1), y: Math.max(endInkCoordsIn.Y - startInkCoordsIn.Y, 1) }; const moveLen = Math.sqrt(movement.x ** 2 + movement.y ** 2); const direction = { x: (movement.x / moveLen) * radius, y: (movement.y / moveLen) * radius }; const normal = { x: -direction.y, y: direction.x }; // prettier-ignore @@ -1239,6 +1275,60 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }; @action + showSmartDraw = (e: PointerEvent, doubleTap?: boolean) => { + SmartDrawHandler.Instance.displaySmartDrawHandler(e.pageX, e.pageY, this.createDrawing, this.removeDrawing); + }; + + _drawing: Doc[] = []; + @undoBatch + createDrawing = (e: React.PointerEvent<Element>, strokeData: [InkData, string, string][], opts: DrawingOptions, gptRes: string, containerDoc?: Doc) => { + strokeData.forEach((stroke: [InkData, string, string]) => { + const bounds = InkField.getBounds(stroke[0]); + const B = this.screenToFreeformContentsXf.transformBounds(bounds.left, bounds.top, bounds.width, bounds.height); + const inkWidth = ActiveInkWidth() * this.ScreenToLocalBoxXf().Scale; + const inkDoc = Docs.Create.InkDocument( + stroke[0], + { title: 'stroke', + x: B.x - inkWidth / 2, + y: B.y - inkWidth / 2, + _width: B.width + inkWidth, + _height: B.height + inkWidth, + stroke_showLabel: BoolCast(Doc.UserDoc().activeInkHideTextLabels)}, // prettier-ignore + inkWidth, + stroke[1], + undefined, + stroke[2] === 'none' ? undefined : stroke[2] + ); + this._drawing.push(inkDoc); + this.addDocument(inkDoc); + }); + const collection = this._marqueeViewRef.current?.collection(undefined, true, this._drawing); + if (collection) { + const docData = collection[DocData]; + docData.title = opts.text; + docData.drawingInput = opts.text; + docData.drawingComplexity = opts.complexity; + docData.drawingColored = opts.autoColor; + docData.drawingSize = opts.size; + docData.drawingData = gptRes; + } + this._batch?.end(); + }; + + removeDrawing = (doc?: Doc) => { + this._batch = UndoManager.StartBatch('regenerateDrawing'); + if (doc) { + const docData: Doc = doc[DocData]; + const children = docData.data as unknown as Doc[]; + this._props.removeDocument?.(doc); + this._props.removeDocument?.(children); + } else { + this._props.removeDocument?.(this._drawing); + } + this._drawing = []; + }; + + @action zoom = (pointX: number, pointY: number, deltaY: number): void => { if (this.Document.isGroup || this.Document[(this._props.viewField ?? '_') + 'freeform_noZoom']) return; let deltaScale = deltaY > 0 ? 1 / 1.05 : 1.05; @@ -1831,8 +1921,10 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @action onCursorMove = (e: React.PointerEvent) => { - this._eraserX = e.clientX; - this._eraserY = e.clientY; + const locPt = this.ScreenToLocalBoxXf().transformPoint(e.clientX, e.clientY); + this._eraserX = locPt[0]; + this._eraserY = locPt[1]; + // Doc.ActiveTool === InkTool.RadiusEraser ? this._childPointerEvents = 'none' : this._childPointerEvents = 'all' // super.setCursorPosition(this.getTransform().transformPoint(e.clientX, e.clientY)); }; @@ -1939,6 +2031,14 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }), icon: 'eye', }); + optionItems.push({ + description: (this._showDrawingEditor ? 'Close' : 'Show') + ' Drawing Editor', + event: action(() => { + this._showDrawingEditor = !this._showDrawingEditor; + this._showDrawingEditor ? SmartDrawHandler.Instance.displayRegenerate(this._downX, this._downY - 10, this.createDrawing, this.removeDrawing) : SmartDrawHandler.Instance.hideRegenerate(); + }), + icon: 'pen-to-square', + }); this._props.renderDepth && optionItems.push({ description: 'Use Background Color as Default', @@ -2143,8 +2243,8 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection onPointerMove={this.onCursorMove} style={{ position: 'fixed', - left: this._eraserX - 60, - top: this._eraserY - 100, + left: this._eraserX, + top: this._eraserY, width: (ActiveEraserWidth() + 5) * 2, height: (ActiveEraserWidth() + 5) * 2, borderRadius: '50%', diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss index e7413bf8e..9b8727e1a 100644 --- a/src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss +++ b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss @@ -42,3 +42,17 @@ } } } + +.complexity-slider { + width: 50%; /* Full-width */ + height: 25px; /* Specified height */ + background: #d3d3d3; /* Grey background */ + outline: none; /* Remove outline */ + opacity: 0.7; /* Set transparency (for mouse-over effects on hover) */ + -webkit-transition: 0.2s; /* 0.2 seconds transition on hover */ + transition: opacity 0.2s; + + :hover { + opacity: 1; /* Fully shown on mouse-over */ + } +} diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index dc15c83c5..5aff3ed6f 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -36,6 +36,7 @@ import { CollectionFreeFormView } from './CollectionFreeFormView'; import { ImageLabelHandler } from './ImageLabelHandler'; import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; import './MarqueeView.scss'; +import { collectionOf } from '@turf/turf'; interface MarqueeViewProps { getContainerTransform: () => Transform; @@ -374,8 +375,8 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps return doc; })(Doc.MakeCopy(Doc.UserDoc().emptyCollection as Doc, true)); newCollection.isSystem = undefined; - newCollection._width = this.Bounds.width; - newCollection._height = this.Bounds.height; + newCollection._width = this.Bounds.width || 1; // if width/height are unset/0, then groups won't autoexpand to contain their children + newCollection._height = this.Bounds.height || 1; newCollection._dragWhenActive = makeGroup; newCollection.x = this.Bounds.left; newCollection.y = this.Bounds.top; @@ -426,6 +427,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps this._props.selectDocuments([newCollection]); MarqueeOptionsMenu.Instance.fadeOut(true); this.hideMarquee(); + return newCollection; }); /** diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts index 2b7de5082..d46e72d75 100644 --- a/src/client/views/global/globalScripts.ts +++ b/src/client/views/global/globalScripts.ts @@ -506,9 +506,9 @@ ScriptingGlobals.add(function setInkProperty(option: 'inkMask' | 'labels' | 'fil setMode: () => { SetActiveInkColor(StrCast(value)); selected?.type === DocumentType.INK && setActiveTool(GestureOverlay.Instance.InkShape ?? InkTool.Pen, true, false);}, }], [ 'eraserWidth', { - checkResult: () => ActiveEraserWidth(), + checkResult: () => ActiveEraserWidth() === 0 ? 1 : ActiveEraserWidth(), setInk: (doc: Doc) => { }, - setMode: () => { SetEraserWidth(value.toString());}, + setMode: () => { SetEraserWidth(value);}, }] ]); diff --git a/src/client/views/nodes/PDFBox.scss b/src/client/views/nodes/PDFBox.scss index 7bca1230f..6e24b2931 100644 --- a/src/client/views/nodes/PDFBox.scss +++ b/src/client/views/nodes/PDFBox.scss @@ -20,15 +20,27 @@ top: 0; left: 0; + .pdfBox-sidebarBtn-container { + display: flex; + flex-direction: row; + position: absolute; + width: 53px; + height: 33px; + right: 5px; + align-items: center; + justify-content: space-between; + z-index: 1; + } + // glr: This should really be the same component as text and PDFs .pdfBox-sidebarBtn { background: $black; height: 25px; width: 25px; - right: 5px; + // right: 5px; color: $white; display: flex; - position: absolute; + // position: absolute; align-items: center; justify-content: center; border-radius: 3px; diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index 8db68ddfe..782df99f6 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -1,6 +1,8 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ /* eslint-disable jsx-a11y/control-has-associated-label */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { IconButton } from 'browndash-components'; +import { black } from 'colors'; import { action, computed, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as Pdfjs from 'pdfjs-dist'; @@ -503,17 +505,30 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } @computed get sidebarHandle() { return ( - <div - className="pdfBox-sidebarBtn" - key="sidebar" - title="Toggle Sidebar" - style={{ - display: !this._props.isContentActive() ? 'none' : undefined, - top: StrCast(this.layoutDoc._layout_showTitle) === 'title' ? 20 : 5, - backgroundColor: this.SidebarShown ? Colors.MEDIUM_BLUE : Colors.BLACK, - }} - onPointerDown={e => this.sidebarBtnDown(e, true)}> - <FontAwesomeIcon style={{ color: Colors.WHITE }} icon="comment-alt" size="sm" /> + <div className="pdfBox-sidebarBtn-container"> + <div + className="pdfBox-sidebarBtn" + key="sidebar" + title="Toggle Sidebar" + style={{ + display: !this._props.isContentActive() ? 'none' : undefined, + top: StrCast(this.layoutDoc._layout_showTitle) === 'title' ? 20 : 5, + backgroundColor: this.SidebarShown ? Colors.MEDIUM_BLUE : Colors.BLACK, + }}> + {/* // onPointerDown={e => this.sidebarBtnDown(e, true)} */} + <IconButton tooltip="Toggle Annotation Palette" icon={<FontAwesomeIcon style={{ color: Colors.WHITE }} icon="palette" />} onPointerDown={e => this.sidebarBtnDown(e, true)} /> + </div> + <div + className="pdfBox-sidebarBtn" + key="sidebar" + title="Toggle Sidebar" + style={{ + display: !this._props.isContentActive() ? 'none' : undefined, + top: StrCast(this.layoutDoc._layout_showTitle) === 'title' ? 20 : 5, + backgroundColor: this.SidebarShown ? Colors.MEDIUM_BLUE : Colors.BLACK, + }}> + <IconButton tooltip="Toggle Sidebar" icon={<FontAwesomeIcon style={{ color: Colors.WHITE }} icon="comment-alt" size="sm" />} onPointerDown={e => this.sidebarBtnDown(e, true)} /> + </div> </div> ); } diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx index 2f6824466..df990b0c0 100644 --- a/src/client/views/pdf/AnchorMenu.tsx +++ b/src/client/views/pdf/AnchorMenu.tsx @@ -51,6 +51,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { public StartDrag: (e: PointerEvent, ele: HTMLElement) => void = unimplementedFunction; public StartCropDrag: (e: PointerEvent, ele: HTMLElement) => void = unimplementedFunction; public Highlight: (color: string) => Opt<Doc> = (/* color: string */) => undefined; + public Tape: (color: string) => Opt<Doc> = (/* color: string */) => undefined; public GetAnchor: (savedAnnotations: Opt<ObservableMap<number, HTMLDivElement[]>>, addAsAnnotation: boolean) => Opt<Doc> = emptyFunction; public Delete: () => void = unimplementedFunction; public PinToPres: () => void = unimplementedFunction; @@ -172,6 +173,12 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { AnchorMenu.Instance.fadeOut(true); }; + @action + tapeClicked = () => { + this.Tape(this.highlightColor); + // AnchorMenu.Instance.fadeOut(true); + }; + @computed get highlighter() { return ( <Group> @@ -182,6 +189,13 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> { colorPicker={this.highlightColor} color={SettingsManager.userColor} /> + <IconButton + tooltip="Click to Add Tape" // + icon={<FontAwesomeIcon icon="tape" />} + onClick={this.tapeClicked} + colorPicker={this.highlightColor} + color={SettingsManager.userColor} + /> <ColorPicker selectedColor={this.highlightColor} setFinalColor={this.changeHighlightColor} setSelectedColor={this.changeHighlightColor} size={Size.XSMALL} /> </Group> ); diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index db47a84e1..fbe3518ec 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -65,6 +65,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { @observable _pageSizes: { width: number; height: number }[] = []; @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); + @observable _savedTapes = new ObservableMap<number, HTMLDivElement[]>(); @observable _textSelecting = true; @observable _showWaiting = true; @observable Index: number = -1; @@ -581,6 +582,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { return <div className={'pdfViewerDash-text' + (this._props.pointerEvents?.() !== 'none' && this._textSelecting && this._props.isContentActive() ? '-selected' : '')} ref={this._viewer} />; } savedAnnotations = () => this._savedAnnotations; + savedTapes = () => this._savedTapes; addDocumentWrapper = (doc: Doc | Doc[]) => this._props.addDocument!(doc); render() { TraceMobx(); @@ -614,6 +616,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { docView={this._props.pdfBox.DocumentView!} finishMarquee={this.finishMarquee} savedAnnotations={this.savedAnnotations} + savedTapes={this.savedTapes} selectionText={this.selectionText} annotationLayer={this._annotationLayer.current} marqueeContainer={this._mainCont.current} diff --git a/src/client/views/smartdraw/DrawingPalette.scss b/src/client/views/smartdraw/DrawingPalette.scss new file mode 100644 index 000000000..0f1152b71 --- /dev/null +++ b/src/client/views/smartdraw/DrawingPalette.scss @@ -0,0 +1,11 @@ +.drawing-palette { + display: grid; + grid-template-columns: auto; + position: absolute; + right: 14px; + width: 170px; + height: 170px; + top: 50px; + border-radius: 5px; + background-color: white; +} diff --git a/src/client/views/smartdraw/DrawingPalette.tsx b/src/client/views/smartdraw/DrawingPalette.tsx new file mode 100644 index 000000000..87a39bc85 --- /dev/null +++ b/src/client/views/smartdraw/DrawingPalette.tsx @@ -0,0 +1,89 @@ +import { computed, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { returnAll, returnFalse, returnOne, returnZero } from '../../../ClientUtils'; +import { Doc, StrListCast } from '../../../fields/Doc'; +import { emptyFunction } from '../../../Utils'; +import { CollectionViewType } from '../../documents/DocumentTypes'; +import { MarqueeView } from '../collections/collectionFreeForm'; +import { CollectionGridView } from '../collections/collectionGrid'; +import { CollectionStackingView } from '../collections/CollectionStackingView'; +import { DocumentView } from '../nodes/DocumentView'; +import { FieldViewProps } from '../nodes/FieldView'; +import { ObservableReactComponent } from '../ObservableReactComponent'; +import './DrawingPalette.scss'; + +@observer +export class DrawingPalette extends ObservableReactComponent<{}> { + @observable private _savedDrawings: Doc[] = []; + @observable _marqueeViewRef = React.createRef<MarqueeView>(); + private _stackRef = React.createRef<CollectionStackingView>(); + + constructor(props: any) { + super(props); + makeObservable(this); + } + + panelWidth = () => 100; + panelHeight = () => 100; + + getCollection = () => { + return this._marqueeViewRef.current?.collection(undefined, false, this._savedDrawings) || new Doc(); + }; + + @computed get savedDrawingAnnos() { + // const savedAnnos = Doc.MyDrawingAnnos; + return ( + <div className="collectionMenu-contMenuButtons" style={{ height: '100%' }}> + {/* <DocumentView PanelHeight={this.panelWidth} PanelWidth={this.panelHeight} Document={savedAnnos} renderDepth={2} isContentActive={returnFalse} childFilters={this.childFilters} /> */} + {/* <CollectionStackingView + {...this._props} + Document={savedAnnos} + // setContentViewBox={emptyFunction} + // NativeWidth={returnZero} + // NativeHeight={returnZero} + ref={this._stackRef} + PanelHeight={this.panelWidth} + PanelWidth={this.panelHeight} + // childFilters={this.childFilters} + // sortFunc={this.sortByLinkAnchorY} + // setHeight={this.setHeightCallback} + // isAnnotationOverlay={false} + // select={emptyFunction} + NativeDimScaling={returnOne} + // childlayout_showTitle={this.layout_showTitle} + isContentActive={returnFalse} + isSelected={returnFalse} + isAnyChildContentActive={returnFalse} + // childDocumentsActive={this._props.isContentActive} + whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged} + childHideDecorationTitle + // ScreenToLocalTransform={this.screenToLocalTransform} + renderDepth={this._props.renderDepth + 1} + type_collection={CollectionViewType.Stacking} + // fieldKey={'drawing-palette'} + pointerEvents={returnAll} + /> */} + </div> + ); + } + + render() { + return ( + <div className="drawing-palette"> + {/* {this._savedDrawings.map(doc => { + return <DocumentView + Document={doc} + renderDepth={0} + PanelWidth={this.panelWidth} + PanelHeight={this.panelHeight} + isContentActive={this.isContentActive} />; + })} */} + {/* <CollectionGridView {...this._props} /> */} + {} + {/* <DocumentView Document={this.getCollection()} /> */} + {this.savedDrawingAnnos} + </div> + ); + } +} diff --git a/src/client/views/smartdraw/SmartDrawHandler.tsx b/src/client/views/smartdraw/SmartDrawHandler.tsx new file mode 100644 index 000000000..d24cc9d50 --- /dev/null +++ b/src/client/views/smartdraw/SmartDrawHandler.tsx @@ -0,0 +1,409 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { action, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import React from 'react'; +import { SettingsManager } from '../../util/SettingsManager'; +import { ObservableReactComponent } from '../ObservableReactComponent'; +import { Button, IconButton } from 'browndash-components'; +import ReactLoading from 'react-loading'; +import { AiOutlineSend } from 'react-icons/ai'; +// import './ImageLabelHandler.scss'; +import { gptAPICall, GPTCallType } from '../../apis/gpt/GPT'; +import { InkData } from '../../../fields/InkField'; +import { SVGToBezier } from '../../util/bezierFit'; +const { parse } = require('svgson'); +import { Slider, Switch } from '@mui/material'; +import { Doc } from '../../../fields/Doc'; +import { DocData } from '../../../fields/DocSymbols'; +import { DocumentView } from '../nodes/DocumentView'; + +export interface DrawingOptions { + text: string; + complexity: number; + size: number; + autoColor: boolean; + x: number; + y: number; +} + +@observer +export class SmartDrawHandler extends ObservableReactComponent<{}> { + static Instance: SmartDrawHandler; + + @observable private _display: boolean = false; + @observable private _pageX: number = 0; + @observable private _pageY: number = 0; + @observable private _yRelativeToTop: boolean = true; + @observable private _isLoading: boolean = false; + @observable private _userInput: string = ''; + @observable private _showOptions: boolean = false; + @observable private _showEditBox: boolean = false; + @observable private _showRegenerate: boolean = false; + @observable private _complexity: number = 5; + @observable private _size: number = 200; + @observable private _autoColor: boolean = true; + @observable private _regenInput: string = ''; + private _addFunc: (e: React.PointerEvent<Element>, strokeList: [InkData, string, string][], opts: DrawingOptions, gptRes: string, containerDoc?: Doc) => void = () => {}; + private _deleteFunc: (doc?: Doc) => void = () => {}; + private _lastInput: DrawingOptions = { text: '', complexity: 5, size: 300, autoColor: true, x: 0, y: 0 }; + private _lastResponse: string = ''; + private _selectedDoc: Doc | undefined = undefined; + + constructor(props: any) { + super(props); + makeObservable(this); + SmartDrawHandler.Instance = this; + } + + @action + setUserInput = (input: string) => { + this._userInput = input; + }; + + @action + setRegenInput = (input: string) => { + this._regenInput = input; + }; + + @action + setShowOptions = () => { + this._showOptions = !this._showOptions; + }; + + @action + setComplexity = (val: number) => { + this._complexity = val; + }; + + @action + setSize = (val: number) => { + this._size = val; + }; + + @action + setAutoColor = () => { + this._autoColor = !this._autoColor; + }; + + @action + displaySmartDrawHandler = (x: number, y: number, addFunc: (e: React.PointerEvent<Element>, strokeData: [InkData, string, string][], opts: DrawingOptions, gptRes: string, containerDoc?: Doc) => void, deleteFunc: (doc?: Doc) => void) => { + this._pageX = x; + this._pageY = y; + this._display = true; + this._addFunc = addFunc; + this._deleteFunc = deleteFunc; + }; + + @action + displayRegenerate = (x: number, y: number, addFunc: (e: React.PointerEvent<Element>, strokeData: [InkData, string, string][], opts: DrawingOptions, gptRes: string, containerDoc?: Doc) => void, deleteFunc: (doc?: Doc) => void) => { + this._selectedDoc = DocumentView.SelectedDocs().lastElement(); + const docData = this._selectedDoc[DocData]; + this._addFunc = addFunc; + this._deleteFunc = deleteFunc; + this._pageX = x; + this._pageY = y; + this._showRegenerate = true; + this._lastResponse = docData.drawingData as string; + this._lastInput = { text: docData.drawingInput as string, complexity: docData.drawingComplexity as number, size: docData.drawingSize as number, autoColor: docData.drawingColored as boolean, x: this._pageX, y: this._pageY }; + }; + + @action + hideSmartDrawHandler = () => { + this._showRegenerate = false; + this._display = false; + this._isLoading = false; + this._showOptions = false; + this._userInput = ''; + this._complexity = 5; + this._size = 300; + this._autoColor = true; + // this._regenInput = '' + }; + + @action + hideRegenerate = () => { + this._showRegenerate = false; + this._isLoading = false; + this._regenInput = ''; + }; + + _errorOccurredOnce = false; + @action + drawWithGPT = async (e: React.PointerEvent<Element>, input: string) => { + if (input === '') return; + this._lastInput = { text: input, complexity: this._complexity, size: this._size, autoColor: this._autoColor, x: e.clientX, y: e.clientY }; + this._isLoading = true; + this._showOptions = false; + try { + const res = await gptAPICall(`"${input}", "${this._complexity}", "${this._size}"`, GPTCallType.DRAW, undefined, true); + if (!res) { + console.error('GPT call failed'); + return; + } + console.log(res); + await this.parseResponse(e, res, { X: e.clientX, Y: e.clientY }, false); + this.hideSmartDrawHandler(); + this._showRegenerate = true; + this._errorOccurredOnce = false; + } catch (err) { + if (this._errorOccurredOnce) { + console.error('GPT call failed', err); + this._errorOccurredOnce = false; + } else { + this._errorOccurredOnce = true; + this.drawWithGPT(e, input); + } + } + this._isLoading = false; + }; + + @action + edit = () => { + this._showEditBox = !this._showEditBox; + }; + + @action + regenerate = async (e: React.PointerEvent<Element>) => { + this._isLoading = true; + try { + let res; + if (this._regenInput !== '') { + const prompt: string = `This is your previously generated svg code: ${this._lastResponse} for the user input "${this._lastInput.text}". Please regenerate it with the provided specifications.`; + res = await gptAPICall(`"${this._regenInput}"`, GPTCallType.DRAW, prompt, true); + this._lastInput.text = `${this._lastInput.text} + ${this._regenInput}`; + } else { + res = await gptAPICall(`"${this._lastInput.text}", "${this._lastInput.complexity}", "${this._lastInput.size}"`, GPTCallType.DRAW, undefined, true); + } + if (!res) { + console.error('GPT call failed'); + return; + } + console.log(res); + this.parseResponse(e, res, { X: this._lastInput.x, Y: this._lastInput.y }, true); + } catch (err) { + console.error('GPT call failed', err); + } + this._isLoading = false; + this._regenInput = ''; + this._showEditBox = false; + }; + + @action + parseResponse = async (e: React.PointerEvent<Element>, res: string, startPoint: { X: number; Y: number }, regenerate: boolean) => { + const svg = res.match(/<svg[^>]*>([\s\S]*?)<\/svg>/g); + console.log('start point is', startPoint); + if (svg) { + this._lastResponse = svg[0]; + const svgObject = await parse(svg[0]); + const svgStrokes: any = svgObject.children; + const strokeData: [InkData, string, string][] = []; + console.log('autocolor is', this._autoColor); + svgStrokes.forEach((child: any) => { + const convertedBezier: InkData = SVGToBezier(child.name, child.attributes); + strokeData.push([ + convertedBezier.map(point => { + return { X: point.X + startPoint.X - this._size / 1.5, Y: point.Y + startPoint.Y - this._size / 2 }; + }), + (regenerate ? this._lastInput.autoColor : this._autoColor) ? child.attributes.stroke : undefined, + (regenerate ? this._lastInput.autoColor : this._autoColor) ? child.attributes.fill : undefined, + ]); + }); + if (regenerate) { + this._deleteFunc(this._selectedDoc); + this._addFunc(e, strokeData, this._lastInput, svg[0], this._selectedDoc); + } else { + this._addFunc(e, strokeData, this._lastInput, svg[0]); + } + } + }; + + render() { + if (this._display) { + return ( + <div + id="label-handler" + className="contextMenu-cont" + style={{ + display: this._display ? '' : 'none', + left: this._pageX, + ...(this._yRelativeToTop ? { top: Math.max(0, this._pageY) } : { bottom: this._pageY }), + background: SettingsManager.userBackgroundColor, + color: SettingsManager.userColor, + }}> + <div> + <IconButton + tooltip={'Cancel'} + onClick={() => { + this.hideSmartDrawHandler(); + this.hideRegenerate(); + }} + icon={<FontAwesomeIcon icon="xmark" />} + color={SettingsManager.userColor} + style={{ width: '19px' }} + /> + <input + aria-label="label-input" + id="new-label" + type="text" + style={{ color: 'black' }} + value={this._userInput} + onChange={e => { + this.setUserInput(e.target.value); + }} + placeholder="Enter item to draw" + /> + <Button + style={{ alignSelf: 'flex-end' }} + text="Send" + icon={this._isLoading ? <ReactLoading type="spin" color={SettingsManager.userVariantColor} width={16} height={20} /> : <AiOutlineSend />} + iconPlacement="right" + color={SettingsManager.userColor} + onClick={e => { + this.drawWithGPT(e as React.PointerEvent<Element>, this._userInput); + }} + /> + </div> + {this._showOptions && ( + <> + <div style={{ display: 'flex', flexDirection: 'row', justifyContent: 'space-around' }}> + <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', width: '30%' }}> + Auto color + <Switch + sx={{ + '& .MuiSwitch-switchBase.Mui-checked': { + color: SettingsManager.userColor, + }, + '& .MuiSwitch-switchBase.Mui-checked + .MuiSwitch-track': { + backgroundColor: SettingsManager.userVariantColor, + }, + }} + defaultChecked={true} + size="small" + onChange={this.setAutoColor} + /> + </div> + <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', width: '31%' }}> + Complexity + <Slider + sx={{ + '& .MuiSlider-thumb': { + color: SettingsManager.userColor, + '&.Mui-focusVisible, &:hover, &.Mui-active': { + boxShadow: `0px 0px 0px 8px${SettingsManager.userColor.slice(0, 7)}10`, + }, + }, + '& .MuiSlider-track': { + color: SettingsManager.userVariantColor, + }, + '& .MuiSlider-rail': { + color: SettingsManager.userColor, + }, + }} + style={{ width: '80%' }} + min={1} + max={10} + step={1} + size="small" + value={this._complexity} + onChange={(e, val) => { + this.setComplexity(val as number); + }} + valueLabelDisplay="auto" + /> + </div> + <div style={{ display: 'flex', flexDirection: 'column', justifyContent: 'center', width: '39%' }}> + Size (in pixels) + <Slider + sx={{ + '& .MuiSlider-thumb': { + color: SettingsManager.userColor, + '&.Mui-focusVisible, &:hover, &.Mui-active': { + boxShadow: `0px 0px 0px 8px${SettingsManager.userColor.slice(0, 7)}20`, + }, + }, + '& .MuiSlider-track': { + color: SettingsManager.userVariantColor, + }, + '& .MuiSlider-rail': { + color: SettingsManager.userColor, + }, + }} + style={{ width: '80%' }} + min={50} + max={700} + step={10} + size="small" + value={this._size} + onChange={(e, val) => { + this.setSize(val as number); + }} + valueLabelDisplay="auto" + /> + </div> + </div> + </> + )} + </div> + ); + } else if (this._showRegenerate) { + return ( + <div + id="smartdraw-options-menu" + className="contextMenu-cont" + style={{ + left: this._pageX, + ...(this._yRelativeToTop ? { top: Math.max(0, this._pageY) } : { bottom: this._pageY }), + background: SettingsManager.userBackgroundColor, + color: SettingsManager.userColor, + }}> + <div + style={{ + display: 'flex', + flexDirection: 'row', + }}> + <IconButton + tooltip="Regenerate" + icon={this._isLoading && this._regenInput === '' ? <ReactLoading type="spin" color={SettingsManager.userVariantColor} width={16} height={20} /> : <FontAwesomeIcon icon={'rotate'} />} + color={SettingsManager.userColor} + onClick={e => { + this.regenerate(e as React.PointerEvent<Element>); + }} + /> + <IconButton tooltip="Edit with GPT" icon={<FontAwesomeIcon icon="pen-to-square" />} color={SettingsManager.userColor} onClick={this.edit} /> + {this._showEditBox && ( + <div + style={{ + display: 'flex', + flexDirection: 'row', + }}> + <input + aria-label="Edit instructions input" + id="regen-input" + type="text" + style={{ color: 'black' }} + value={this._regenInput} + onChange={e => { + this.setRegenInput(e.target.value); + }} + placeholder="Edit instructions" + /> + <Button + style={{ alignSelf: 'flex-end' }} + text="Send" + icon={this._isLoading && this._regenInput !== '' ? <ReactLoading type="spin" color={SettingsManager.userVariantColor} width={16} height={20} /> : <AiOutlineSend />} + iconPlacement="right" + color={SettingsManager.userColor} + onClick={e => { + this.regenerate(e as React.PointerEvent<Element>); + }} + /> + </div> + )} + </div> + </div> + ); + } else { + return <></>; + } + } +} diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index 72ec16b42..bc1abd26e 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -174,6 +174,53 @@ export function updateCachedAcls(doc: Doc) { return undefined; } +export function ActiveInkPen(): Doc { return Doc.UserDoc(); } // prettier-ignore +export function ActiveInkColor(): string { return StrCast(ActiveInkPen()?.activeInkColor, 'black'); } // prettier-ignore +export function ActiveFillColor(): string { return StrCast(ActiveInkPen()?.activeFillColor, ''); } // prettier-ignore +export function ActiveIsInkMask(): boolean { return BoolCast(ActiveInkPen()?.activeIsInkMask, false); } // prettier-ignore +export function ActiveInkHideTextLabels(): boolean { return BoolCast(ActiveInkPen().activeInkHideTextLabels, false); } // prettier-ignore +export function ActiveArrowStart(): string { return StrCast(ActiveInkPen()?.activeArrowStart, ''); } // prettier-ignore +export function ActiveArrowEnd(): string { return StrCast(ActiveInkPen()?.activeArrowEnd, ''); } // prettier-ignore +export function ActiveArrowScale(): number { return NumCast(ActiveInkPen()?.activeArrowScale, 1); } // prettier-ignore +export function ActiveDash(): string { return StrCast(ActiveInkPen()?.activeDash, '0'); } // prettier-ignore +export function ActiveInkWidth(): number { return Number(ActiveInkPen()?.activeInkWidth); } // prettier-ignore +export function ActiveInkBezierApprox(): string { return StrCast(ActiveInkPen()?.activeInkBezier); } // prettier-ignore +export function ActiveEraserWidth(): number { return NumCast(ActiveInkPen()?.eraserWidth); } // prettier-ignore + +export function SetActiveInkWidth(width: string): void { + !isNaN(parseInt(width)) && ActiveInkPen() && (ActiveInkPen().activeInkWidth = width); +} +export function SetActiveBezierApprox(bezier: string): void { + ActiveInkPen() && (ActiveInkPen().activeInkBezier = isNaN(parseInt(bezier)) ? '' : bezier); +} +export function SetActiveInkColor(value: string) { + ActiveInkPen() && (ActiveInkPen().activeInkColor = value); +} +export function SetActiveIsInkMask(value: boolean) { + ActiveInkPen() && (ActiveInkPen().activeIsInkMask = value); +} +export function SetActiveInkHideTextLabels(value: boolean) { + ActiveInkPen() && (ActiveInkPen().activeInkHideTextLabels = value); +} +export function SetActiveFillColor(value: string) { + ActiveInkPen() && (ActiveInkPen().activeFillColor = value); +} +export function SetActiveArrowStart(value: string) { + ActiveInkPen() && (ActiveInkPen().activeArrowStart = value); +} +export function SetActiveArrowEnd(value: string) { + ActiveInkPen() && (ActiveInkPen().activeArrowEnd = value); +} +export function SetActiveArrowScale(value: number) { + ActiveInkPen() && (ActiveInkPen().activeArrowScale = value); +} +export function SetActiveDash(dash: string): void { + !isNaN(parseInt(dash)) && ActiveInkPen() && (ActiveInkPen().activeDash = dash); +} +export function SetEraserWidth(width: number): void { + !isNaN(width) && ActiveInkPen() && (ActiveInkPen().eraserWidth = width); +} + @scriptingGlobal @Deserializable('Doc', updateCachedAcls, ['id']) export class Doc extends RefField { @@ -229,6 +276,7 @@ export class Doc extends RefField { public static get MyPublishedDocs() { return DocListCast(Doc.ActiveDashboard?.myPublishedDocs).concat(DocListCast(DocCast(Doc.UserDoc().myPublishedDocs)?.data)); } // prettier-ignore public static get MyDashboards() { return DocCast(Doc.UserDoc().myDashboards); } // prettier-ignore public static get MyTemplates() { return DocCast(Doc.UserDoc().myTemplates); } // prettier-ignore + public static get MyDrawingAnnos() { return DocCast(Doc.UserDoc().myDrawingAnnos); } // prettier-ignore public static get MyImports() { return DocCast(Doc.UserDoc().myImports); } // prettier-ignore public static get MyFilesystem() { return DocCast(Doc.UserDoc().myFilesystem); } // prettier-ignore public static get MyTools() { return DocCast(Doc.UserDoc().myTools); } // prettier-ignore diff --git a/src/fields/InkField.ts b/src/fields/InkField.ts index 32abf0076..123d32301 100644 --- a/src/fields/InkField.ts +++ b/src/fields/InkField.ts @@ -17,6 +17,7 @@ export enum InkTool { Stamp = 'stamp', Write = 'write', PresentationPin = 'presentationpin', + SmartDraw = 'smartdraw', } export type Segment = Array<Bezier>; |