diff options
| author | Nathan-SR <144961007+Nathan-SR@users.noreply.github.com> | 2024-06-03 13:33:37 -0400 |
|---|---|---|
| committer | Nathan-SR <144961007+Nathan-SR@users.noreply.github.com> | 2024-06-03 13:33:37 -0400 |
| commit | 9e77f980e7704999ef0a1c1845d660bccb13ff8a (patch) | |
| tree | 14ca0da5915e4382a7bcb15f7d0b241941c8291f /src/client/views/collections | |
| parent | 1be63695875c9242fba43d580465e8765cf3991d (diff) | |
| parent | 202e994515392892676f8f080852db1e32b8dbd3 (diff) | |
Merge branch 'master' into nathan-starter
Diffstat (limited to 'src/client/views/collections')
17 files changed, 1588 insertions, 149 deletions
diff --git a/src/client/views/collections/CollectionCardDeckView.scss b/src/client/views/collections/CollectionCardDeckView.scss new file mode 100644 index 000000000..a089b248d --- /dev/null +++ b/src/client/views/collections/CollectionCardDeckView.scss @@ -0,0 +1,84 @@ +@import '../global/globalCssVariables.module.scss'; + +.collectionCardView-outer { + height: 100%; + width: 100%; + position: relative; + background-color: white; + overflow: hidden; +} + +.card-wrapper { + display: grid; + grid-template-columns: repeat(10, 1fr); + // width: 100%; + transform-origin: top left; + + position: absolute; + align-items: center; + justify-items: center; + justify-content: center; + + transition: transform 0.3s cubic-bezier(0.455, 0.03, 0.515, 0.955); +} + +.card-button-container { + display: flex; + padding: 3px; + // width: 300px; + background-color: rgb(218, 218, 218); /* Background color of the container */ + border-radius: 50px; /* Rounds the corners of the container */ + transform: translateY(75px); + // box-shadow: 0 4px 8px rgba(0,0,0,0.1); /* Optional: Adds shadow for depth */ + align-items: center; /* Centers buttons vertically */ + justify-content: start; /* Centers buttons horizontally */ +} + +button { + width: 35px; + height: 35px; + border-radius: 50%; + background-color: $dark-gray; + // border-color: $medium-blue; + margin: 5px; // transform: translateY(-50px); +} + +// button:hover { +// transform: translateY(-50px); +// } + +// .card-wrapper::after { +// content: ""; +// width: 100%; /* Forces wrapping */ +// } + +// .card-wrapper > .card-item:nth-child(10n)::after { +// content: ""; +// width: 100%; /* Forces wrapping after every 10th item */ +// } + +// .card-row{ +// display: flex; +// position: absolute; +// align-items: center; +// transition: transform 0.3s cubic-bezier(0.455, 0.03, 0.515, 0.955); + +// } + +.card-item-inactive, +.card-item-active, +.card-item { + position: relative; + transition: transform 0.5s ease-in-out; + display: flex; + flex-direction: column; +} + +.card-item-inactive { + opacity: 0.5; +} + +.card-item-active { + position: absolute; + z-index: 100; +} diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx new file mode 100644 index 000000000..de46180e6 --- /dev/null +++ b/src/client/views/collections/CollectionCardDeckView.tsx @@ -0,0 +1,514 @@ +import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { ClientUtils, DashColor, returnFalse, returnZero } from '../../../ClientUtils'; +import { numberRange } from '../../../Utils'; +import { Doc, NumListCast } from '../../../fields/Doc'; +import { DocData } from '../../../fields/DocSymbols'; +import { Id } from '../../../fields/FieldSymbols'; +import { BoolCast, Cast, DateCast, NumCast, RTFCast, ScriptCast, StrCast } from '../../../fields/Types'; +import { URLField } from '../../../fields/URLField'; +import { gptImageLabel } from '../../apis/gpt/GPT'; +import { DocumentType } from '../../documents/DocumentTypes'; +import { DragManager } from '../../util/DragManager'; +import { SnappingManager } from '../../util/SnappingManager'; +import { Transform } from '../../util/Transform'; +import { undoable } from '../../util/UndoManager'; +import { StyleProp } from '../StyleProp'; +import { DocumentView } from '../nodes/DocumentView'; +import { GPTPopup, GPTPopupMode } from '../pdf/GPTPopup/GPTPopup'; +import './CollectionCardDeckView.scss'; +import { CollectionSubView } from './CollectionSubView'; + +enum cardSortings { + Time = 'time', + Type = 'type', + Color = 'color', + Custom = 'custom', + None = '', +} +@observer +export class CollectionCardView extends CollectionSubView() { + private _dropDisposer?: DragManager.DragDropDisposer; + private _childDocumentWidth = 600; // target width of a Doc... + private _disposers: { [key: string]: IReactionDisposer } = {}; + private _textToDoc = new Map<string, Doc>(); + + @observable _forceChildXf = false; + @observable _isLoading = false; + @observable _hoveredNodeIndex = -1; + @observable _docRefs = new ObservableMap<Doc, DocumentView>(); + @observable _maxRowCount = 10; + + static getButtonGroup(groupFieldKey: 'chat' | 'star' | 'idea' | 'like', doc: Doc): number | undefined { + return Cast(doc[groupFieldKey], 'number', null); + } + + static imageUrlToBase64 = async (imageUrl: string): Promise<string> => { + try { + const response = await fetch(imageUrl); + const blob = await response.blob(); + + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.readAsDataURL(blob); + reader.onloadend = () => resolve(reader.result as string); + reader.onerror = error => reject(error); + }); + } catch (error) { + console.error('Error:', error); + throw error; + } + }; + + protected createDashEventsTarget = (ele: HTMLDivElement | null) => { + this._dropDisposer?.(); + if (ele) { + this._dropDisposer = DragManager.MakeDropTarget(ele, this.onInternalDrop.bind(this), this.layoutDoc); + } + }; + + constructor(props: any) { + super(props); + makeObservable(this); + } + + componentDidMount(): void { + this._disposers.sort = reaction( + () => ({ cardSort: this.cardSort, field: this.cardSort_customField }), + ({ cardSort, field }) => (cardSort === cardSortings.Custom && field === 'chat' ? this.openChatPopup() : GPTPopup.Instance.setVisible(false)) + ); + } + + componentWillUnmount() { + Object.keys(this._disposers).forEach(key => this._disposers[key]?.()); + this._dropDisposer?.(); + } + + @computed get cardSort_customField() { + return StrCast(this.Document.cardSort_customField) as any as 'chat' | 'star' | 'idea' | 'like'; + } + + @computed get cardSort() { + return StrCast(this.Document.cardSort) as any as cardSortings; + } + /** + * how much to scale down the contents of the view so that everything will fit + */ + @computed get fitContentScale() { + const length = Math.min(this.childDocsWithoutLinks.length, this._maxRowCount); + return (this._childDocumentWidth * length) / this._props.PanelWidth(); + } + + @computed get translateWrapperX() { + let translate = 0; + + if (this.inactiveDocs().length !== this.childDocsWithoutLinks.length && this.inactiveDocs().length < 10) { + translate += this.panelWidth() / 2; + } + return translate; + } + + /** + * The child documents to be rendered-- either all of them except the Links or the docs in the currently active + * custom group + */ + @computed get childDocsWithoutLinks() { + const regularDocs = this.childDocs.filter(l => l.type !== DocumentType.LINK); + const activeGroups = NumListCast(this.Document.cardSort_visibleSortGroups); + + if (activeGroups.length > 0 && this.cardSort === cardSortings.Custom) { + return regularDocs.filter(doc => { + // Get the group number for the current index + const groupNumber = CollectionCardView.getButtonGroup(this.cardSort_customField, doc); + // Check if the group number is in the active groups + return groupNumber !== undefined && activeGroups.includes(groupNumber); + }); + } + + // Default return for non-custom cardSort or other cases, filtering out links + return regularDocs; + } + + /** + * Determines the order in which the cards will be rendered depending on the current sort type + */ + @computed get sortedDocs() { + return this.sort(this.childDocsWithoutLinks, this.cardSort, BoolCast(this.layoutDoc.sortDesc)); + } + + @action + setHoveredNodeIndex = (index: number) => { + if (!DocumentView.SelectedDocs().includes(this.childDocs[index])) { + this._hoveredNodeIndex = index; + } + }; + /** + * Translates the hovered node to the center of the screen + * @param index + * @returns + */ + translateHover = (index: number) => (this._hoveredNodeIndex === index && !DocumentView.SelectedDocs().includes(this.childDocs[index]) ? -50 : 0); + + isSelected = (index: number) => DocumentView.SelectedDocs().includes(this.childDocs[index]); + + /** + * Returns all the documents except the one that's currently selected + */ + inactiveDocs = () => this.childDocsWithoutLinks.filter(d => !DocumentView.SelectedDocs().includes(d)); + + panelWidth = () => this._childDocumentWidth; + panelHeight = (layout: Doc) => () => (this.panelWidth() * NumCast(layout._height)) / NumCast(layout._width); + onChildDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick); + isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this._props.isAnyChildContentActive(); + isChildContentActive = () => !!this.isContentActive(); + + /** + * Returns the degree to rotate a card dependind on the amount of cards in their row and their index in said row + * @param amCards + * @param index + * @returns + */ + rotate = (amCards: number, index: number) => { + const possRotate = -30 + index * (30 / ((amCards - (amCards % 2)) / 2)); + const stepMag = Math.abs(-30 + (amCards / 2 - 1) * (30 / ((amCards - (amCards % 2)) / 2))); + + if (amCards % 2 === 0 && possRotate === 0) { + return possRotate + Math.abs(-30 + (index - 1) * (30 / (amCards / 2))); + } + if (amCards % 2 === 0 && index > (amCards + 1) / 2) { + return possRotate + stepMag; + } + + return possRotate; + }; + /** + * Returns the degree to which a card should be translated in the y direction for the arch effect + */ + translateY = (amCards: number, index: number, realIndex: number) => { + const evenOdd = amCards % 2; + const apex = (amCards - evenOdd) / 2; + const stepMag = 200 / ((amCards - evenOdd) / 2) + Math.abs((apex - index) * 25); + + let rowOffset = 0; + if (realIndex > this._maxRowCount - 1) { + rowOffset = 400 * ((realIndex - (realIndex % this._maxRowCount)) / this._maxRowCount); + } + if (evenOdd === 1 || index < apex - 1) { + return Math.abs(stepMag * (apex - index)) - rowOffset; + } + if (index === apex || index === apex - 1) { + return 0 - rowOffset; + } + + return Math.abs(stepMag * (apex - index - 1)) - rowOffset; + }; + + /** + * Translates the selected node to the middle fo the screen + * @param index + * @returns + */ + translateSelected = (index: number): number => { + // if (this.isSelected(index)) { + const middleOfPanel = this._props.PanelWidth() / 2; + const scaledNodeWidth = this.panelWidth() * 1.25; + + // Calculate the position of the node's left edge before scaling + const nodeLeftEdge = index * this.panelWidth(); + // Find the center of the node after scaling + const scaledNodeCenter = nodeLeftEdge + scaledNodeWidth / 2; + + // Calculate the translation needed to align the scaled node's center with the panel's center + const translation = middleOfPanel - scaledNodeCenter - scaledNodeWidth - scaledNodeWidth / 4; + + return translation; + }; + + /** + * Called in the sortedDocsType method. Compares the cards' value in regards to the desired sort type-- earlier cards are move to the + * front, latter cards to the back + * @param docs + * @param sortType + * @param isDesc + * @returns + */ + sort = (docs: Doc[], sortType: cardSortings, isDesc: boolean) => { + if (sortType === cardSortings.None) return docs; + docs.sort((docA, docB) => { + const [typeA, typeB] = (() => { + switch (sortType) { + case cardSortings.Time: + return [DateCast(docA.author_date)?.date ?? Date.now(), + DateCast(docB.author_date)?.date ?? Date.now()]; + case cardSortings.Color: + return [DashColor(StrCast(docA.backgroundColor)).hsv().toString(), // If docA.type is undefined, use an empty string + DashColor(StrCast(docB.backgroundColor)).hsv().toString()]; // If docB.type is undefined, use an empty string + case cardSortings.Custom: + return [CollectionCardView.getButtonGroup(this.cardSort_customField, docA)??0, + CollectionCardView.getButtonGroup(this.cardSort_customField, docB)??0]; + default: return [StrCast(docA.type), // If docA.type is undefined, use an empty string + StrCast(docB.type)]; // If docB.type is undefined, use an empty string + } // prettier-ignore + })(); + + const out = typeA < typeB ? -1 : typeA > typeB ? 1 : 0; + return isDesc ? -out : out; // Reverse the sort order if descending is true + }); + + return docs; + }; + + displayDoc = (doc: Doc, screenToLocalTransform: () => Transform) => ( + <DocumentView + // eslint-disable-next-line react/jsx-props-no-spreading + {...this._props} + ref={action((r: DocumentView) => r?.ContentDiv && this._docRefs.set(doc, r))} + Document={doc} + NativeWidth={returnZero} + NativeHeight={returnZero} + fitWidth={returnFalse} + onDoubleClickScript={this.onChildDoubleClick} + renderDepth={this._props.renderDepth + 1} + LayoutTemplate={this._props.childLayoutTemplate} + LayoutTemplateString={this._props.childLayoutString} + ScreenToLocalTransform={screenToLocalTransform} // makes sure the box wrapper thing is in the right spot + isContentActive={this.isChildContentActive} + isDocumentActive={this._props.childDocumentsActive?.() || this.Document._childDocumentsActive ? this._props.isDocumentActive : this.isContentActive} + PanelWidth={this.panelWidth} + PanelHeight={this.panelHeight(doc)} + /> + ); + + /** + * Determines how many cards are in the row of a card at a specific index + * @param index + * @returns + */ + overflowAmCardsCalc = (index: number) => { + if (this.inactiveDocs().length < this._maxRowCount) { + return this.inactiveDocs().length; + } + // 13 - 3 = 10 + const totalCards = this.inactiveDocs().length; + // if 9 or less + if (index < totalCards - (totalCards % 10)) { + return this._maxRowCount; + } + // (3) + return totalCards % 10; + }; + /** + * Determines the index a card is in in a row + * @param realIndex + * @returns + */ + overflowIndexCalc = (realIndex: number) => realIndex % 10; + /** + * Translates the cards in the second rows and beyond over to the right + * @param realIndex + * @param calcIndex + * @param calcRowCards + * @returns + */ + translateOverflowX = (realIndex: number, calcRowCards: number) => (realIndex < this._maxRowCount ? 0 : (10 - calcRowCards) * (this.panelWidth() / 2)); + + /** + * Determines how far to translate a card in the y direction depending on its index, whether or not its being hovered, or if it's selected + * @param isHovered + * @param isSelected + * @param realIndex + * @param amCards + * @param calcRowIndex + * @returns + */ + calculateTranslateY = (isHovered: boolean, isSelected: boolean, realIndex: number, amCards: number, calcRowIndex: number) => { + if (isSelected) return 50 * this.fitContentScale; + const trans = isHovered ? this.translateHover(realIndex) : 0; + return trans + this.translateY(amCards, calcRowIndex, realIndex); + }; + + /** + * Toggles the buttons between on and off when creating custom sort groupings/changing those created by gpt + * @param childPairIndex + * @param buttonID + * @param doc + */ + toggleButton = undoable((buttonID: number, doc: Doc) => { + this.cardSort_customField && (doc[this.cardSort_customField] = buttonID); + }, 'toggle custom button'); + + /** + * A list of the text content of all the child docs. RTF documents will have just their text and pdf documents will have the first 50 words. + * Image documents are converted to bse64 and gpt generates a description for them. all other documents use their title. This string is + * inputted into the gpt prompt to sort everything together + * @returns + */ + childPairStringList = () => { + const docToText = (doc: Doc) => { + switch (doc.type) { + case DocumentType.PDF: return StrCast(doc.text).split(/\s+/).slice(0, 50).join(' '); // first 50 words of pdf text + case DocumentType.IMG: return this.getImageDesc(doc); + case DocumentType.RTF: return StrCast(RTFCast(doc.text).Text); + default: return StrCast(doc.title); + } // prettier-ignore + }; + const docTextPromises = this.childDocsWithoutLinks.map(async doc => { + const docText = (await docToText(doc)) ?? ''; + this._textToDoc.set(docText.replace(/\n/g, ' ').trim(), doc); + return `======${docText.replace(/\n/g, ' ').trim()}======`; + }); + return Promise.all<string>(docTextPromises); + }; + + /** + * Calls the gpt API to generate descriptions for the images in the view + * @param image + * @returns + */ + getImageDesc = async (image: Doc) => { + if (StrCast(image.description)) return StrCast(image.description); // Return existing description + const { href } = (image.data as URLField).url; + const hrefParts = href.split('.'); + const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`; + try { + const hrefBase64 = await CollectionCardView.imageUrlToBase64(hrefComplete); + const response = await gptImageLabel(hrefBase64); + image[DocData].description = response.trim(); + return response; // Return the response from gptImageLabel + } catch (error) { + console.log('bad things have happened'); + } + return ''; + }; + + /** + * Converts the gpt output into a hashmap that can be used for sorting. lists are seperated by ==== while elements within the list are seperated by ~~~~~~ + * @param gptOutput + */ + processGptOutput = (gptOutput: string) => { + // Split the string into individual list items + const listItems = gptOutput.split('======').filter(item => item.trim() !== ''); + listItems.forEach((item, index) => { + // Split the item by '~~~~~~' to get all descriptors + const parts = item.split('~~~~~~').map(part => part.trim()); + + parts.forEach(part => { + // Find the corresponding Doc in the textToDoc map + const doc = this._textToDoc.get(part); + if (doc) { + doc.chat = index; + } + }); + }); + }; + /** + * Opens up the chat popup and starts the process for smart sorting. + */ + openChatPopup = async () => { + GPTPopup.Instance.setVisible(true); + GPTPopup.Instance.setMode(GPTPopupMode.SORT); + const sortDesc = await this.childPairStringList(); // Await the promise to get the string result + GPTPopup.Instance.setCardsDoneLoading(true); // Set dataDoneLoading to true after data is loaded + GPTPopup.Instance.setSortDesc(sortDesc.join()); + GPTPopup.Instance.onSortComplete = (sortResult: string) => this.processGptOutput(sortResult); + }; + + /** + * Renders the buttons to customize sorting depending on which group the card belongs to and the amount of total groups + * @param childPairIndex + * @param doc + * @returns + */ + renderButtons = (doc: Doc, cardSort: cardSortings) => { + if (cardSort !== cardSortings.Custom) return ''; + const amButtons = Math.max(4, this.childDocs?.reduce((set, d) => this.cardSort_customField && set.add(NumCast(d[this.cardSort_customField])), new Set<number>()).size ?? 0); + const activeButtonIndex = CollectionCardView.getButtonGroup(this.cardSort_customField, doc); + const totalWidth = amButtons * 35 + amButtons * 2 * 5 + 6; + return ( + <div className="card-button-container" style={{ width: `${totalWidth}px` }}> + {numberRange(amButtons).map(i => ( + // eslint-disable-next-line jsx-a11y/control-has-associated-label + <button + key={i} + type="button" + style={{ backgroundColor: activeButtonIndex === i ? '#4476f7' : '#323232' }} // + onClick={() => this.toggleButton(i, doc)} + /> + ))} + </div> + ); + }; + /** + * Actually renders all the cards + */ + renderCards = () => { + const anySelected = this.childDocs.some(doc => DocumentView.SelectedDocs().includes(doc)); + // Map sorted documents to their rendered components + return this.sortedDocs.map((doc, index) => { + const realIndex = this.sortedDocs.filter(sortDoc => !DocumentView.SelectedDocs().includes(sortDoc)).indexOf(doc); + const calcRowIndex = this.overflowIndexCalc(realIndex); + const amCards = this.overflowAmCardsCalc(realIndex); + const isSelected = DocumentView.SelectedDocs().includes(doc); + + const childScreenToLocal = () => { + this._forceChildXf; + const dref = this._docRefs.get(doc); + const { translateX, translateY, scale } = ClientUtils.GetScreenTransform(dref?.ContentDiv); + return new Transform(-translateX + (dref?.centeringX || 0) * scale, + -translateY + (dref?.centeringY || 0) * scale, 1) + .scale(1 / scale).rotate(!isSelected ? -this.rotate(amCards, calcRowIndex) : 0); // prettier-ignore + }; + + return ( + <div + key={doc[Id]} + className={`card-item${isSelected ? '-active' : anySelected ? '-inactive' : ''}`} + onPointerUp={() => { + // this turns off documentDecorations during a transition, then turns them back on afterward. + SnappingManager.SetIsResizing(this.Document[Id]); + setTimeout( + action(() => { + SnappingManager.SetIsResizing(undefined); + this._forceChildXf = !this._forceChildXf; + }), + 700 + ); + }} + style={{ + width: this.panelWidth(), + height: 'max-content', // this.panelHeight(childPair.layout)(), + transform: `translateY(${this.calculateTranslateY(this._hoveredNodeIndex === index, isSelected, realIndex, amCards, calcRowIndex)}px) + translateX(${isSelected ? this.translateSelected(calcRowIndex) : this.translateOverflowX(realIndex, amCards)}px) + rotate(${!isSelected ? this.rotate(amCards, calcRowIndex) : 0}deg) + scale(${isSelected ? 1.25 : 1})`, + }} + onMouseEnter={() => this.setHoveredNodeIndex(index)}> + {this.displayDoc(doc, childScreenToLocal)} + {this.renderButtons(doc, this.cardSort)} + </div> + ); + }); + }; + render() { + return ( + <div + className="collectionCardView-outer" + ref={this.createDashEventsTarget} + style={{ + background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor), + color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color), + }}> + <div + className="card-wrapper" + style={{ + transform: ` scale(${1 / this.fitContentScale}) translateX(${this.translateWrapperX}px)`, + height: `${100 * this.fitContentScale}%`, + }} + onMouseLeave={() => this.setHoveredNodeIndex(-1)}> + {this.renderCards()} + </div> + </div> + ); + } +} diff --git a/src/client/views/collections/CollectionCarouselView.scss b/src/client/views/collections/CollectionCarouselView.scss index 130b31325..f115bb40a 100644 --- a/src/client/views/collections/CollectionCarouselView.scss +++ b/src/client/views/collections/CollectionCarouselView.scss @@ -13,7 +13,10 @@ } } .carouselView-back, -.carouselView-fwd { +.carouselView-fwd, +.carouselView-star, +.carouselView-remove, +.carouselView-check { position: absolute; display: flex; top: 42.5%; @@ -34,6 +37,19 @@ .carouselView-back { left: 20; } +.carouselView-star { + top: 0; + right: 20; +} +.carouselView-remove { + top: 80%; + left: 52%; +} +.carouselView-check { + top: 80%; + right: 52%; +} + .carouselView-back:hover, .carouselView-fwd:hover { background: lightgray; diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx index 45b64d3e6..2adad68e0 100644 --- a/src/client/views/collections/CollectionCarouselView.tsx +++ b/src/client/views/collections/CollectionCarouselView.tsx @@ -5,12 +5,14 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { computed, makeObservable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; +import { StopEvent, returnFalse, returnOne, returnTrue, returnZero } from '../../../ClientUtils'; import { emptyFunction } from '../../../Utils'; -import { StopEvent, returnFalse, returnOne, returnZero } from '../../../ClientUtils'; import { Doc, Opt } from '../../../fields/Doc'; import { DocCast, NumCast, ScriptCast, StrCast } from '../../../fields/Types'; import { DocumentType } from '../../documents/DocumentTypes'; import { DragManager } from '../../util/DragManager'; +import { ContextMenu } from '../ContextMenu'; +import { ContextMenuProps } from '../ContextMenuItem'; import { StyleProp } from '../StyleProp'; import { DocumentView } from '../nodes/DocumentView'; import { FieldViewProps } from '../nodes/FieldView'; @@ -18,9 +20,20 @@ import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; import './CollectionCarouselView.scss'; import { CollectionSubView } from './CollectionSubView'; +enum cardMode { + PRACTICE = 'practice', + STAR = 'star', + QUIZ = 'quiz', +} +enum practiceVal { + MISSED = 'missed', + CORRECT = 'correct', +} @observer export class CollectionCarouselView extends CollectionSubView() { private _dropDisposer?: DragManager.DragDropDisposer; + get practiceField() { return this.fieldKey + "_practice"; } // prettier-ignore + get starField() { return this.fieldKey + "_star"; } // prettier-ignore constructor(props: any) { super(props); @@ -41,15 +54,76 @@ export class CollectionCarouselView extends CollectionSubView() { @computed get carouselItems() { return this.childLayoutPairs.filter(pair => pair.layout.type !== DocumentType.LINK); } + @computed get marginX() { + return NumCast(this.layoutDoc.caption_xMargin, 50); + } + + move = (dir: number) => { + const moveToCardWithField = (match: (doc: Doc) => boolean): boolean => { + let startInd = (NumCast(this.layoutDoc._carousel_index) + dir) % this.carouselItems.length; + while (!match(this.carouselItems?.[startInd].layout) && (startInd + dir + this.carouselItems.length) % this.carouselItems.length !== this.layoutDoc._carousel_index) { + startInd = (startInd + dir + this.carouselItems.length) % this.carouselItems.length; + } + if (match(this.carouselItems?.[startInd].layout)) { + this.layoutDoc._carousel_index = startInd; + return true; + } + return match(this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)].layout); + }; + switch (StrCast(this.layoutDoc.filterOp)) { + case cardMode.STAR: // go to a flashcard that is starred, skip the ones that aren't + if (!moveToCardWithField((doc: Doc) => !!doc[this.starField])) { + this.layoutDoc.filterOp = undefined; // if there aren't any starred, show all cards + } + break; + case cardMode.PRACTICE: // go to a new index that is missed, skip the ones that are correct + if (!moveToCardWithField((doc: Doc) => doc[this.practiceField] !== practiceVal.CORRECT)) { + this.layoutDoc.filterOp = undefined; // if all of the cards are correct, show all cards and exit practice mode + this.carouselItems.forEach(item => { // reset all the practice values + item.layout[this.practiceField] = undefined; + }); + } + break; + default: moveToCardWithField(returnTrue); + } // prettier-ignore + }; + + /** + * Goes to the next Doc in the stack subject to the currently selected filter option. + */ advance = (e: React.MouseEvent) => { e.stopPropagation(); - this.layoutDoc._carousel_index = (NumCast(this.layoutDoc._carousel_index) + 1) % this.carouselItems.length; + this.move(1); }; + + /** + * Goes to the previous Doc in the stack subject to the currently selected filter option. + */ goback = (e: React.MouseEvent) => { e.stopPropagation(); - this.layoutDoc._carousel_index = (NumCast(this.layoutDoc._carousel_index) - 1 + this.carouselItems.length) % this.carouselItems.length; + this.move(-1); + }; + + /* + * Stars the document when the star button is pressed. + */ + star = (e: React.MouseEvent) => { + e.stopPropagation(); + const curDoc = this.carouselItems[NumCast(this.layoutDoc._carousel_index)]; + curDoc.layout[this.starField] = curDoc.layout[this.starField] ? undefined : true; }; + + /* + * Sets a flashcard to either missed or correct depending on if they got the question right in practice mode. + */ + setPracticeVal = (e: React.MouseEvent, val: string) => { + e.stopPropagation(); + const curDoc = this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)]; + curDoc.layout[this.practiceField] = val; + this.advance(e); + }; + captionStyleProvider = (doc: Doc | undefined, captionProps: Opt<FieldViewProps>, property: string): any => { // first look for properties on the document in the carousel, then fallback to properties on the container const childValue = doc?.['caption_' + property] ? this._props.styleProvider?.(doc, captionProps, property) : undefined; @@ -58,10 +132,18 @@ export class CollectionCarouselView extends CollectionSubView() { panelHeight = () => this._props.PanelHeight() - (StrCast(this.layoutDoc._layout_showCaption) ? 50 : 0); onContentDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick); onContentClick = () => ScriptCast(this.layoutDoc.onChildClick); - @computed get marginX() { - return NumCast(this.layoutDoc.caption_xMargin, 50); - } captionWidth = () => this._props.PanelWidth() - 2 * this.marginX; + specificMenu = (): void => { + const cm = ContextMenu.Instance; + + const revealOptions = cm.findByDescription('Filter Flashcards'); + const revealItems: ContextMenuProps[] = revealOptions && 'subitems' in revealOptions ? revealOptions.subitems : []; + revealItems.push({description: 'All', event: () => {this.layoutDoc.filterOp = undefined;}, icon: 'layer-group',}); // prettier-ignore + revealItems.push({description: 'Star', event: () => {this.layoutDoc.filterOp = cardMode.STAR;}, icon: 'star',}); // prettier-ignore + revealItems.push({description: 'Practice Mode', event: () => {this.layoutDoc.filterOp = cardMode.PRACTICE;}, icon: 'check',}); // prettier-ignore + revealItems.push({description: 'Quiz Cards', event: () => {this.layoutDoc.filterOp = cardMode.QUIZ;}, icon: 'pencil',}); // prettier-ignore + !revealOptions && cm.addItem({ description: 'Filter Flashcards', addDivider: false, noexpand: true, subitems: revealItems, icon: 'layer-group' }); + }; @computed get content() { const index = NumCast(this.layoutDoc._carousel_index); const curDoc = this.carouselItems?.[index]; @@ -107,6 +189,7 @@ export class CollectionCarouselView extends CollectionSubView() { ); } @computed get buttons() { + if (!this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)]) return null; return ( <> <div key="back" className="carouselView-back" onClick={this.goback}> @@ -115,6 +198,15 @@ export class CollectionCarouselView extends CollectionSubView() { <div key="fwd" className="carouselView-fwd" onClick={this.advance}> <FontAwesomeIcon icon="chevron-right" size="2x" /> </div> + <div key="star" className="carouselView-star" onClick={this.star}> + <FontAwesomeIcon icon="star" color={this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)].layout[this.starField] ? 'yellow' : 'gray'} size="1x" /> + </div> + <div key="remove" className="carouselView-remove" onClick={e => this.setPracticeVal(e, practiceVal.MISSED)} style={{ visibility: this.layoutDoc.filterOp === cardMode.PRACTICE ? 'visible' : 'hidden' }}> + <FontAwesomeIcon icon="xmark" color="red" size="1x" /> + </div> + <div key="check" className="carouselView-check" onClick={e => this.setPracticeVal(e, practiceVal.CORRECT)} style={{ visibility: this.layoutDoc.filterOp === cardMode.PRACTICE ? 'visible' : 'hidden' }}> + <FontAwesomeIcon icon="check" color="green" size="1x" /> + </div> </> ); } @@ -124,11 +216,35 @@ export class CollectionCarouselView extends CollectionSubView() { <div className="collectionCarouselView-outer" ref={this.createDashEventsTarget} + onContextMenu={this.specificMenu} style={{ background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor), color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color), }}> {this.content} + {/* Displays a message to the user to add more flashcards if they are in practice mode and no flashcards are there. */} + <p + style={{ + display: !this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)] && this.layoutDoc.filterOp === cardMode.PRACTICE ? 'flex' : 'none', + justifyContent: 'center', + alignItems: 'center', + height: '100%', + zIndex: '-1', + }}> + Add flashcards! + </p> + {/* Displays a message to the user that a flashcard was recently missed if they had previously gotten it wrong. */} + <p + style={{ + color: 'red', + zIndex: '999', + position: 'relative', + left: '10px', + top: '10px', + display: this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)] ? (this.carouselItems?.[NumCast(this.layoutDoc._carousel_index)].layout[this.practiceField] === practiceVal.MISSED ? 'block' : 'none') : 'none', + }}> + Recently missed! + </p> {this.Document._chromeHidden ? null : this.buttons} </div> ); diff --git a/src/client/views/collections/CollectionDockingView.tsx b/src/client/views/collections/CollectionDockingView.tsx index 8fb2b30f1..73179a266 100644 --- a/src/client/views/collections/CollectionDockingView.tsx +++ b/src/client/views/collections/CollectionDockingView.tsx @@ -129,7 +129,6 @@ export class CollectionDockingView extends CollectionSubView() { } @undoBatch - @action public static ReplaceTab(document: Doc, mods: OpenWhereMod, stack: any, panelName: string, addToSplit?: boolean, keyValue?: boolean): boolean { const instance = CollectionDockingView.Instance; if (!instance) return false; @@ -634,7 +633,7 @@ export class CollectionDockingView extends CollectionSubView() { ScriptingGlobals.add( // eslint-disable-next-line prefer-arrow-callback function openInLightbox(doc: any) { - CollectionDockingView.Instance?._props.addDocTab(doc, OpenWhere.lightbox); + CollectionDockingView.Instance?._props.addDocTab(doc, OpenWhere.lightboxAlways); }, 'opens up document in a lightbox', '(doc: any)' diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx index a4708bed5..e250d7a90 100644 --- a/src/client/views/collections/CollectionSubView.tsx +++ b/src/client/views/collections/CollectionSubView.tsx @@ -4,7 +4,7 @@ import * as rp from 'request-promise'; import { ClientUtils, returnFalse } from '../../../ClientUtils'; import CursorField from '../../../fields/CursorField'; import { Doc, DocListCast, GetDocFromUrl, GetHrefFromHTML, Opt, RTFIsFragment, StrListCast } from '../../../fields/Doc'; -import { AclPrivate } from '../../../fields/DocSymbols'; +import { AclPrivate, DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { List } from '../../../fields/List'; import { listSpec } from '../../../fields/Schema'; @@ -93,7 +93,7 @@ export function CollectionSubView<X>() { } get dataDoc() { - return this._props.TemplateDataDocument instanceof Doc && this.Document.isTemplateForField ? Doc.GetProto(this._props.TemplateDataDocument) : this.Document.resolvedDataDoc ? this.Document : Doc.GetProto(this.Document); // if the layout document has a resolvedDataDoc, then we don't want to get its parent which would be the unexpanded template + return this._props.TemplateDataDocument instanceof Doc && this.Document.isTemplateForField ? Doc.GetProto(this._props.TemplateDataDocument) : this.Document.resolvedDataDoc ? this.Document : this.Document[DocData]; // if the layout document has a resolvedDataDoc, then we don't want to get its parent which would be the unexpanded template } get childContainerViewPath() { diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index beb8c0666..c39df2c76 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -64,9 +64,6 @@ export class CollectionTreeView extends CollectionSubView<Partial<collectionTree makeObservable(this); } - get dataDoc() { - return this._props.TemplateDataDocument || this.Document; - } @computed get treeViewtruncateTitleWidth() { return NumCast(this.Document.treeView_TruncateTitleWidth, this.panelWidth()); } diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index b52c7c54c..5c304b4a9 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -33,6 +33,7 @@ import { CollectionLinearView } from './collectionLinear'; import { CollectionMulticolumnView } from './collectionMulticolumn/CollectionMulticolumnView'; import { CollectionMultirowView } from './collectionMulticolumn/CollectionMultirowView'; import { CollectionSchemaView } from './collectionSchema/CollectionSchemaView'; +import { CollectionCardView } from './CollectionCardDeckView'; @observer export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewProps>() { @@ -104,6 +105,7 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr case CollectionViewType.Masonry: return <CollectionStackingView key="collview" {...props} />; case CollectionViewType.Time: return <CollectionTimeView key="collview" {...props} />; case CollectionViewType.Grid: return <CollectionGridView key="collview" {...props} />; + case CollectionViewType.Card: return <CollectionCardView key="collview" {...props} />; case CollectionViewType.Freeform: default: return <CollectionFreeFormView key="collview" {...props} />; } diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx index afd584154..46f61290e 100644 --- a/src/client/views/collections/TabDocView.tsx +++ b/src/client/views/collections/TabDocView.tsx @@ -549,15 +549,7 @@ export class TabDocView extends ObservableReactComponent<TabDocViewProps> { // prettier-ignore switch (whereFields[0]) { case undefined: - case OpenWhere.lightbox: if (this.layoutDoc?._isLightbox) { - const lightboxView = !docs[0].annotationOn && DocCast(docs[0].embedContainer) ? DocumentView.getFirstDocumentView(DocCast(docs[0].embedContainer)) : undefined; - const data = lightboxView?.dataDoc[Doc.LayoutFieldKey(lightboxView.Document)]; - if (lightboxView && (!data || data instanceof List)) { - lightboxView.layoutDoc[Doc.LayoutFieldKey(lightboxView.Document)] = new List<Doc>(docs); - return true; - } - } - return LightboxView.Instance.AddDocTab(docs[0], OpenWhere.lightbox); + case OpenWhere.lightbox: return LightboxView.Instance.AddDocTab(docs[0], location); case OpenWhere.close: return CollectionDockingView.CloseSplit(docs[0], whereMods); case OpenWhere.replace: return CollectionDockingView.ReplaceTab(docs[0], whereMods, this.stack, panelName, undefined, keyValue); case OpenWhere.toggle: return CollectionDockingView.ToggleSplit(docs[0], whereMods, this.stack, TabDocView.DontSelectOnActivate, keyValue); diff --git a/src/client/views/collections/TreeView.tsx b/src/client/views/collections/TreeView.tsx index 3a187171a..b82421e6b 100644 --- a/src/client/views/collections/TreeView.tsx +++ b/src/client/views/collections/TreeView.tsx @@ -231,7 +231,7 @@ export class TreeView extends ObservableReactComponent<TreeViewProps> { } else { // choose an appropriate embedding or make one. --- choose the first embedding that (1) user owns, (2) has no context field ... otherwise make a new embedding const bestEmbedding = docView.Document.author === ClientUtils.CurrentUserEmail() && !Doc.IsDataProto(docView.Document) ? docView.Document : Doc.BestEmbedding(docView.Document); - this._props.addDocTab(bestEmbedding, OpenWhere.lightbox); + this._props.addDocTab(bestEmbedding, OpenWhere.lightboxAlways); } }; @@ -779,7 +779,7 @@ export class TreeView extends ObservableReactComponent<TreeViewProps> { @computed get renderBullet() { TraceMobx(); - const iconType = this.treeView._props.styleProvider?.(this.Document, this.treeView._props, StyleProp.TreeViewIcon + (this.treeViewOpen ? ':open' : !this.childDocs.length ? ':empty' : '')) || 'question'; + const iconType = this.treeView._props.styleProvider?.(this.Document, this.treeView._props, StyleProp.TreeViewIcon + (this.treeViewOpen ? ':treeOpen' : !this.childDocs.length ? ':empty' : '')) || 'question'; const color = SettingsManager.userColor; const checked = this.onCheckedClick ? this.Document.treeView_Checked ?? 'unchecked' : undefined; return ( diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts b/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts index 26a52cd2a..6ad67a864 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts @@ -179,7 +179,6 @@ export class CollectionFreeFormClusters { }; styleProvider = (doc: Opt<Doc>, props: Opt<FieldViewProps>, property: string) => { - let styleProp = this.viewStyleProvider?.(doc, props, property); // bcz: check 'props' used to be renderDepth + 1 if (doc && this.childDocs?.includes(doc)) switch (property.split(':')[0]) { case StyleProp.BackgroundColor: @@ -189,14 +188,9 @@ export class CollectionFreeFormClusters { if (this._clusterSets.length <= cluster) { setTimeout(() => doc && this.addDocument(doc)); } else { - // choose a cluster color from a palette - const colors = ['#da42429e', '#31ea318c', 'rgba(197, 87, 20, 0.55)', '#4a7ae2c4', 'rgba(216, 9, 255, 0.5)', '#ff7601', '#1dffff', 'yellow', 'rgba(27, 130, 49, 0.55)', 'rgba(0, 0, 0, 0.268)']; - styleProp = colors[cluster % colors.length]; - const set = this._clusterSets[cluster]?.filter(s => s.backgroundColor); - // override the cluster color with an explicitly set color on a non-background document. then override that with an explicitly set color on a background document - set?.forEach(s => { - styleProp = StrCast(s.backgroundColor); - }); + const palette = ['#da42429e', '#31ea318c', 'rgba(197, 87, 20, 0.55)', '#4a7ae2c4', 'rgba(216, 9, 255, 0.5)', '#ff7601', '#1dffff', 'yellow', 'rgba(27, 130, 49, 0.55)', 'rgba(0, 0, 0, 0.268)']; + // override palette cluster color with an explicitly set cluster doc color + return this._clusterSets[cluster]?.reduce((b, s) => StrCast(s.backgroundColor, b), palette[cluster % palette.length]); } } } @@ -208,7 +202,7 @@ export class CollectionFreeFormClusters { break; default: } - return styleProp; + return this.viewStyleProvider?.(doc, props, property); // bcz: check 'props' used to be renderDepth + 1 }; tryToSelect = (addToSel: boolean) => { diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx index a4496a417..de51cc73c 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx @@ -245,7 +245,7 @@ export function computePivotLayout(poolData: Map<string, PoolData>, pivotDoc: Do y: -y + (pivotAxisWidth - hgt) / 2, width: wid, height: hgt, - backgroundColor: StrCast(layoutDoc.backgroundColor), + backgroundColor: StrCast(layoutDoc.backgroundColor, 'white'), pair: { layout: doc }, replica: val.replicas[i], }); diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index dbd9fb11f..b6e1fca77 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -9,7 +9,7 @@ import { computedFn } from 'mobx-utils'; import * as React from 'react'; import { ClientUtils, DashColor, lightOrDark, OmitKeys, returnFalse, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../../ClientUtils'; import { DateField } from '../../../../fields/DateField'; -import { ActiveInkWidth, Doc, DocListCast, Field, FieldType, Opt, SetActiveInkColor, SetActiveInkWidth } from '../../../../fields/Doc'; +import { ActiveEraserWidth, ActiveInkWidth, Doc, DocListCast, Field, FieldType, Opt, SetActiveInkColor, SetActiveInkWidth } from '../../../../fields/Doc'; import { DocData, Height, Width } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { InkData, InkField, InkTool, Segment } from '../../../../fields/InkField'; @@ -42,7 +42,7 @@ import { DocumentView } from '../../nodes/DocumentView'; import { FieldViewProps } from '../../nodes/FieldView'; import { FocusViewOptions } from '../../nodes/FocusViewOptions'; import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; -import { OpenWhere } from '../../nodes/OpenWhere'; +import { OpenWhere, OpenWhereMod } from '../../nodes/OpenWhere'; import { PinDocView, PinProps } from '../../PinFuncs'; import { StyleProp } from '../../StyleProp'; import { CollectionSubView } from '../CollectionSubView'; @@ -55,6 +55,7 @@ import { CollectionFreeFormRemoteCursors } from './CollectionFreeFormRemoteCurso import './CollectionFreeFormView.scss'; import { MarqueeView } from './MarqueeView'; +@observer class CollectionFreeFormOverlayView extends React.Component<{ elements: () => ViewDefResult[] }> { render() { return this.props.elements().filter(ele => ele.bounds?.z).map(ele => ele.ele); // prettier-ignore @@ -98,9 +99,14 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection private _batch: UndoManager.Batch | undefined = undefined; private _brushtimer: any; private _brushtimer1: any; - private _eraserLock = 0; private _keyTimer: NodeJS.Timeout | undefined; // timer for turning off transition flag when key frame change has completed. Need to clear this if you do a second navigation before first finishes, or else first timer can go off during second naviation. + private _presEaseFunc: string = 'ease'; + + @action + setPresEaseFunc = (easeFunc: string) => { + this._presEaseFunc = easeFunc; + }; private get isAnnotationOverlay() { return this._props.isAnnotationOverlay; } // prettier-ignore private get scaleFieldKey() { return (this._props.viewField ?? '') + '_freeform_scale'; } // prettier-ignore private get panXFieldKey() { return (this._props.viewField ?? '') + '_freeform_panX'; } // prettier-ignore @@ -120,12 +126,13 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @observable _lightboxDoc: Opt<Doc> = undefined; @observable _paintedId = 'id' + Utils.GenerateGuid().replace(/-/g, ''); @observable _keyframeEditing = false; - + @observable _eraserX: number = 0; + @observable _eraserY: number = 0; + @observable _showEraserCircle: boolean = false; // to determine whether the radius eraser should show constructor(props: any) { super(props); makeObservable(this); } - @computed get layoutEngine() { return this._props.layoutEngine?.() || StrCast(this.layoutDoc._layoutEngine); } @@ -355,6 +362,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection // which will override any group focus. (If we allowed the group to focus, it would mark didMove even if there were no net movement) return undefined; } + if (options.easeFunc) this.setPresEaseFunc(options.easeFunc); if (this._lightboxDoc) return undefined; if (options.pointFocus) return this.focusOnPoint(options); const anchorInCollection = DocListCast(this.Document[this.fieldKey ?? Doc.LayoutFieldKey(this.Document)]).includes(anchor); @@ -397,38 +405,40 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const fromScreenXf = NumCast(refDoc.z) ? this.ScreenToLocalBoxXf() : this.screenToFreeformContentsXf; const [xpo, ypo] = fromScreenXf.transformPoint(de.x, de.y); const [x, y] = [xpo - docDragData.offset[0], ypo - docDragData.offset[1]]; - const zsorted = this.childLayoutPairs - .map(pair => pair.layout) - .slice() - .sort((doc1, doc2) => NumCast(doc1.zIndex) - NumCast(doc2.zIndex)); - zsorted.forEach((doc, index) => { - doc.zIndex = doc.stroke_isInkMask ? 5000 : index + 1; - }); - const dvals = CollectionFreeFormDocumentView.getValues(refDoc, NumCast(refDoc.activeFrame, 1000)); - const dropPos = this.Document._currentFrame !== undefined ? [NumCast(dvals.x), NumCast(dvals.y)] : [NumCast(refDoc.x), NumCast(refDoc.y)]; - - docDragData.droppedDocuments.forEach((d, i) => { - const layoutDoc = Doc.Layout(d); - const delta = Utils.rotPt(x - dropPos[0], y - dropPos[1], fromScreenXf.Rotate); - if (this.Document._currentFrame !== undefined) { - CollectionFreeFormDocumentView.setupKeyframes([d], NumCast(this.Document._currentFrame), false); - const pvals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000)); // get filled in values (uses defaults when not value is specified) for position - const vals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000), false); // get non-default values for everything else - vals.x = NumCast(pvals.x) + delta.x; - vals.y = NumCast(pvals.y) + delta.y; - CollectionFreeFormDocumentView.setValues(NumCast(this.Document._currentFrame), d, vals); - } else { - d.x = NumCast(d.x) + delta.x; - d.y = NumCast(d.y) + delta.y; - } - d._layout_modificationDate = new DateField(); - const nd = [Doc.NativeWidth(layoutDoc), Doc.NativeHeight(layoutDoc)]; - layoutDoc._width = NumCast(layoutDoc._width, 300); - layoutDoc._height = NumCast(layoutDoc._height, nd[0] && nd[1] ? (nd[1] / nd[0]) * NumCast(layoutDoc._width) : 300); - !d._keepZWhenDragged && (d.zIndex = zsorted.length + 1 + i); // bringToFront + runInAction(() => { + // needs to be in action to avoid having each edit trigger a freeform layout engine recompute - this triggers just one for each document at the end + const zsorted = this.childLayoutPairs + .map(pair => pair.layout) // + .sort((doc1, doc2) => NumCast(doc1.zIndex) - NumCast(doc2.zIndex)); + zsorted.forEach((doc, index) => { + doc.zIndex = doc.stroke_isInkMask ? 5000 : index + 1; + }); + const dvals = CollectionFreeFormDocumentView.getValues(refDoc, NumCast(refDoc.activeFrame, 1000)); + const dropPos = this.Document._currentFrame !== undefined ? [NumCast(dvals.x), NumCast(dvals.y)] : [NumCast(refDoc.x), NumCast(refDoc.y)]; + + docDragData.droppedDocuments.forEach((d, i) => { + const layoutDoc = Doc.Layout(d); + const delta = Utils.rotPt(x - dropPos[0], y - dropPos[1], fromScreenXf.Rotate); + if (this.Document._currentFrame !== undefined) { + CollectionFreeFormDocumentView.setupKeyframes([d], NumCast(this.Document._currentFrame), false); + const pvals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000)); // get filled in values (uses defaults when not value is specified) for position + const vals = CollectionFreeFormDocumentView.getValues(d, NumCast(d.activeFrame, 1000), false); // get non-default values for everything else + vals.x = NumCast(pvals.x) + delta.x; + vals.y = NumCast(pvals.y) + delta.y; + CollectionFreeFormDocumentView.setValues(NumCast(this.Document._currentFrame), d, vals); + } else { + d.x = NumCast(d.x) + delta.x; + d.y = NumCast(d.y) + delta.y; + } + d._layout_modificationDate = new DateField(); + const nd = [Doc.NativeWidth(layoutDoc), Doc.NativeHeight(layoutDoc)]; + layoutDoc._width = NumCast(layoutDoc._width, 300); + layoutDoc._height = NumCast(layoutDoc._height, nd[0] && nd[1] ? (nd[1] / nd[0]) * NumCast(layoutDoc._width) : 300); + !d._keepZWhenDragged && (d.zIndex = zsorted.length + 1 + i); // bringToFront + }); + (docDragData.droppedDocuments.length === 1 || de.shiftKey) && this._clusters.addDocuments(docDragData.droppedDocuments); }); - (docDragData.droppedDocuments.length === 1 || de.shiftKey) && this._clusters.addDocuments(docDragData.droppedDocuments); return true; } @@ -490,17 +500,24 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection 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.Eraser: + 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); + break; 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: } } } @@ -546,7 +563,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }; @action onEraserUp = (): void => { - this._deleteList.forEach(ink => ink._props.removeDocument?.(ink.Document)); + this._deleteList.lastElement()?._props.removeDocument?.(this._deleteList.map(ink => ink.Document)); this._deleteList = []; this._batch?.end(); }; @@ -581,41 +598,92 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection this._lastY = e.clientY; }; + _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. - * However, if Shift is held, then no segmentation is done -- instead any intersected stroke is deleted in its entirety. */ @action onEraserMove = (e: PointerEvent, down: number[], delta: number[]) => { const currPoint = { X: e.clientX, Y: e.clientY }; - if (this._eraserLock) return false; // bcz: should be fixed by putting it on a queue to be processed after the last eraser movement is processed. + 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 (!e.shiftKey) { - this._eraserLock++; - const segments = this.segmentInkStroke(intersect.inkView, intersect.t); - segments.forEach(segment => - this.forceStrokeGesture( - e, - Gestures.Stroke, - 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[]) - ) - ); - setTimeout(() => this._eraserLock--); + 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, + _height: B.height + inkWidth, + stroke_showLabel: BoolCast(Doc.UserDoc().activeInkHideTextLabels)}, // prettier-ignore + inkWidth + ); + }); + newStrokes && this.addDocument?.(newStrokes); + // setTimeout(() => this._eraserLock--); } // Lower ink opacity to give the user a visual indicator of deletion. - intersect.inkView.layoutDoc.opacity = 0.5; + intersect.inkView.layoutDoc.opacity = 0; intersect.inkView.layoutDoc.dontIntersect = true; } }); return false; }; + + /** + * 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 + * intersection t-values are put into a map, which gets looped through to take out the erased parts. + * @param e + * @param down + * @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; + }; + forceStrokeGesture = (e: PointerEvent, gesture: Gestures, points: InkData, text?: any) => { this.onGesture(e, new GestureUtils.GestureEvent(gesture, points, InkField.getBounds(points), text)); }; @@ -634,32 +702,125 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }; /** + * Creates the eraser outline for a radius eraser. The outline is used to intersect with ink strokes and determine + * what falls inside the eraser outline. + * @param startInkCoordsIn + * @param endInkCoordsIn + * @param inkStrokeWidth + * @returns + */ + createEraserOutline = (startInkCoordsIn: { X: number; Y: number }, endInkCoordsIn: { X: number; Y: number }, inkStrokeWidth: number) => { + // 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 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 + + const startCoords = { X: startInkCoordsIn.X - direction.x, Y: startInkCoordsIn.Y - direction.y }; + const endCoords = { X: endInkCoordsIn.X + direction.x, Y: endInkCoordsIn.Y + direction.y }; + return new InkField([ + // left bot arc + { X: startCoords.X, Y: startCoords.Y }, // prettier-ignore + { X: startCoords.X + normal.x * c, Y: startCoords.Y + normal.y * c }, // prettier-ignore + { X: startCoords.X + direction.x + normal.x - direction.x * c, Y: startCoords.Y + direction.y + normal.y - direction.y * c }, + { X: startCoords.X + direction.x + normal.x, Y: startCoords.Y + direction.y + normal.y }, // prettier-ignore + + // bot + { X: startCoords.X + direction.x + normal.x, Y: startCoords.Y + direction.y + normal.y }, // prettier-ignore + { X: startCoords.X + direction.x + normal.x + direction.x * c, Y: startCoords.Y + direction.y + normal.y + direction.y * c }, + { X: endCoords.X - direction.x + normal.x - direction.x * c, Y: endCoords.Y - direction.y + normal.y - direction.y * c }, // prettier-ignore + { X: endCoords.X - direction.x + normal.x, Y: endCoords.Y - direction.y + normal.y }, // prettier-ignore + + // right bot arc + { X: endCoords.X - direction.x + normal.x, Y: endCoords.Y - direction.y + normal.y }, // prettier-ignore + { X: endCoords.X - direction.x + normal.x + direction.x * c, Y: endCoords.Y - direction.y + normal.y + direction.y * c}, // prettier-ignore + { X: endCoords.X + normal.x * c, Y: endCoords.Y + normal.y * c }, // prettier-ignore + { X: endCoords.X, Y: endCoords.Y }, // prettier-ignore + + // right top arc + { X: endCoords.X, Y: endCoords.Y }, // prettier-ignore + { X: endCoords.X - normal.x * c, Y: endCoords.Y - normal.y * c }, // prettier-ignore + { X: endCoords.X - direction.x - normal.x + direction.x * c, Y: endCoords.Y - direction.y - normal.y + direction.y * c}, // prettier-ignore + { X: endCoords.X - direction.x - normal.x, Y: endCoords.Y - direction.y - normal.y }, // prettier-ignore + + // top + { X: endCoords.X - direction.x - normal.x, Y: endCoords.Y - direction.y - normal.y }, // prettier-ignore + { X: endCoords.X - direction.x - normal.x - direction.x * c, Y: endCoords.Y - direction.y - normal.y - direction.y * c}, // prettier-ignore + { X: startCoords.X + direction.x - normal.x + direction.x * c, Y: startCoords.Y + direction.y - normal.y + direction.y * c }, + { X: startCoords.X + direction.x - normal.x, Y: startCoords.Y + direction.y - normal.y }, // prettier-ignore + + // left top arc + { X: startCoords.X + direction.x - normal.x, Y: startCoords.Y + direction.y - normal.y }, // prettier-ignore + { X: startCoords.X + direction.x - normal.x - direction.x * c, Y: startCoords.Y + direction.y - normal.y - direction.y * c }, // prettier-ignore + { X: startCoords.X - normal.x * c, Y: startCoords.Y - normal.y * c }, // prettier-ignore + { X: startCoords.X, Y: startCoords.Y }, // prettier-ignore + ]); + }; + + /** + * Ray-tracing algorithm to determine whether a point is inside the eraser outline. + * @param eraserOutline + * @param point + * @returns + */ + insideEraserOutline = (eraserOutline: InkData, point: { X: number; Y: number }) => { + let isInside = false; + if (!isNaN(eraserOutline[0].X) && !isNaN(eraserOutline[0].Y)) { + let [minX, minY] = [eraserOutline[0].X, eraserOutline[0].Y]; + let [maxX, maxY] = [eraserOutline[0].X, eraserOutline[0].Y]; + for (let i = 1; i < eraserOutline.length; i++) { + const currPoint: { X: number; Y: number } = eraserOutline[i]; + minX = Math.min(currPoint.X, minX); + maxX = Math.max(currPoint.X, maxX); + minY = Math.min(currPoint.Y, minY); + maxY = Math.max(currPoint.Y, maxY); + } + + if (point.X < minX || point.X > maxX || point.Y < minY || point.Y > maxY) { + return false; + } + + for (let i = 0, j = eraserOutline.length - 1; i < eraserOutline.length; j = i, i++) { + if (eraserOutline[i].Y > point.Y !== eraserOutline[j].Y > point.Y && point.X < ((eraserOutline[j].X - eraserOutline[i].X) * (point.Y - eraserOutline[i].Y)) / (eraserOutline[j].Y - eraserOutline[i].Y) + eraserOutline[i].X) { + isInside = !isInside; + } + } + } + return isInside; + }; + + /** * Determines if the Eraser tool has intersected with an ink stroke in the current freeform collection. * @returns an array of tuples containing the intersected ink DocumentView and the t-value where it was intersected */ getEraserIntersections = (lastPoint: { X: number; Y: number }, currPoint: { X: number; Y: number }) => { const eraserMin = { X: Math.min(lastPoint.X, currPoint.X), Y: Math.min(lastPoint.Y, currPoint.Y) }; const eraserMax = { X: Math.max(lastPoint.X, currPoint.X), Y: Math.max(lastPoint.Y, currPoint.Y) }; - // prettier-ignore - return this.childDocs + + return this.childDocs .map(doc => DocumentView.getDocumentView(doc, this.DocumentView?.())) .filter(inkView => inkView?.ComponentView instanceof InkingStroke) - .map(inkView => inkView!) - .map(inkView => ({ inkViewBounds: inkView.getBounds, inkStroke: inkView.ComponentView as InkingStroke, inkView })) - .filter(({ inkViewBounds }) => + .map(inkView => ({ inkViewBounds: inkView!.getBounds, inkStroke: inkView!.ComponentView as InkingStroke, inkView: inkView! })) + .filter( + ({ inkViewBounds }) => inkViewBounds && // bounding box of eraser segment and ink stroke overlap eraserMin.X <= inkViewBounds.right && eraserMin.Y <= inkViewBounds.bottom && eraserMax.X >= inkViewBounds.left && - eraserMax.Y >= inkViewBounds.top) + eraserMax.Y >= inkViewBounds.top + ) .reduce( (intersections, { inkStroke, inkView }) => { - const { inkData } = inkStroke.inkScaledData(); + const { inkData } = inkStroke.inkScaledData(); // get bezier curve as set of control points // Convert from screen space to ink space for the intersection. const prevPointInkSpace = inkStroke.ptFromScreen(lastPoint); const currPointInkSpace = inkStroke.ptFromScreen(currPoint); for (let i = 0; i < inkData.length - 3; i += 4) { + // iterate over each segment of bezier curve const rawIntersects = InkField.Segment(inkData, i).intersects({ + // segment's are indexed by 0, 4, 8, // compute all unique intersections p1: { x: prevPointInkSpace.X, y: prevPointInkSpace.Y }, p2: { x: currPointInkSpace.X, y: currPointInkSpace.Y }, @@ -675,54 +836,342 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }; /** - * Performs segmentation of the ink stroke - creates "segments" or subsections of the current ink stroke at points in which the - * ink stroke intersects any other ink stroke (including itself). + * Same as getEraserIntersections but specific to the radius eraser. The key difference is that the radius eraser + * will often intersect multiple strokes, depending on what strokes are inside the eraser. Populates a Map of each + * intersected DocumentView to the t-values where the eraser intersected it, then returns this map. + * @returns + */ + getRadiusEraserIntersections = (lastPoint: { X: number; Y: number }, currPoint: { X: number; Y: number }) => { + // set distance of the eraser's bounding box based on the zoom + let boundingBoxDist = ActiveEraserWidth() + 5; + this.zoomScaling() < 1 ? (boundingBoxDist /= this.zoomScaling() * 1.5) : (boundingBoxDist *= this.zoomScaling()); + + const eraserMin = { X: Math.min(lastPoint.X, currPoint.X) - boundingBoxDist, Y: Math.min(lastPoint.Y, currPoint.Y) - boundingBoxDist }; + const eraserMax = { X: Math.max(lastPoint.X, currPoint.X) + boundingBoxDist, Y: Math.max(lastPoint.Y, currPoint.Y) + boundingBoxDist }; + const strokeToTVals = new Map<DocumentView, number[]>(); + const intersectingStrokes = this.childDocs + .map(doc => DocumentView.getDocumentView(doc, this.DocumentView?.())) + .filter(inkView => inkView?.ComponentView instanceof InkingStroke) // filter to all inking strokes + .map(inkView => ({ inkViewBounds: inkView!.getBounds, inkStroke: inkView!.ComponentView as InkingStroke, inkView: inkView! })) + .filter( + ({ inkViewBounds }) => + inkViewBounds && // bounding box of eraser segment and ink stroke overlap + eraserMin.X <= inkViewBounds.right && + eraserMin.Y <= inkViewBounds.bottom && + eraserMax.X >= inkViewBounds.left && + eraserMax.Y >= inkViewBounds.top + ); + + intersectingStrokes.forEach(({ inkStroke, inkView }) => { + const { inkData, inkStrokeWidth } = inkStroke.inkScaledData(); + const prevPointInkSpace = inkStroke.ptFromScreen(lastPoint); + const currPointInkSpace = inkStroke.ptFromScreen(currPoint); + const eraserInkData = this.createEraserOutline(prevPointInkSpace, currPointInkSpace, inkStrokeWidth).inkData; + + // add the ends of the stroke in as "intersections" + if (this.insideEraserOutline(eraserInkData, inkData[0])) { + strokeToTVals.set(inkView, [0]); + } + if (this.insideEraserOutline(eraserInkData, inkData[inkData.length - 1])) { + const inkList = strokeToTVals.get(inkView); + if (inkList !== undefined) { + inkList.push(Math.floor(inkData.length / 4) + 1); + } else { + strokeToTVals.set(inkView, [Math.floor(inkData.length / 4) + 1]); + } + } + + for (let i = 0; i < inkData.length - 3; i += 4) { + // iterate over each segment of bezier curve + for (let j = 0; j < eraserInkData.length - 3; j += 4) { + const intersectCurve: Bezier = InkField.Segment(inkData, i); // other curve + const eraserCurve: Bezier = InkField.Segment(eraserInkData, j); // eraser curve + this.bintersects(intersectCurve, eraserCurve).forEach((val: string | number, k: number) => { + // Converting the Bezier.js Split type to a t-value number. + const t = +val.toString().split('/')[0]; + if (k % 2 === 0) { + // here, add to the map + const inkList = strokeToTVals.get(inkView); + if (inkList !== undefined) { + const tValOffset = ActiveEraserWidth() / 1050; // to prevent tVals from being added when too close, but scaled by eraser width + const inList = inkList.some(ival => Math.abs(ival - (t + Math.floor(i / 4))) <= tValOffset); + if (!inList) { + inkList.push(t + Math.floor(i / 4)); + } + } else { + strokeToTVals.set(inkView, [t + Math.floor(i / 4)]); + } + } + }); + } + } + }); + return strokeToTVals; + }; + + /** + * Splits the passed in ink stroke at the intersection t values, taking out the erased parts. + * Operates in pairs of t values, where the first t value is the start of the erased portion and the following t value is the end. + * @param ink the ink stroke DocumentView to split + * @param tVals all the t values to split the ink stroke at + * @returns a list of the new segments with the erased part removed + */ + @action + radiusErase = (ink: DocumentView, tVals: number[]): Segment[] => { + const segments: Segment[] = []; + const inkStroke = ink?.ComponentView as InkingStroke; + const { inkData } = inkStroke.inkScaledData(); + let currSegment: Segment = []; + + // any radius erase stroke will always result in even tVals, since the ends are included + if (tVals.length % 2 !== 0) { + for (let i = 0; i < inkData.length - 3; i += 4) { + currSegment.push(InkField.Segment(inkData, i)); + } + segments.push(currSegment); + return segments; // return the full original stroke + } + + let continueErasing = false; // used to erase segments if they are completely enclosed in the eraser + let firstSegment: Segment = []; // used to keep track of the first segment for closed curves + + // early return if nothing to split on + if (tVals.length === 0 || (tVals.length === 1 && tVals[0] === 0)) { + for (let i = 0; i < inkData.length - 3; i += 4) { + currSegment.push(InkField.Segment(inkData, i)); + } + segments.push(currSegment); + return segments; + } + + // loop through all segments of an ink stroke, string together the pieces, excluding the erased parts, + // and push each piece we want to keep to the return list + for (let i = 0; i < inkData.length - 3; i += 4) { + const currCurveT = Math.floor(i / 4); + const inkBezier: Bezier = InkField.Segment(inkData, i); + // filter to this segment's t-values + const segmentTs = tVals.filter(t => t >= currCurveT && t < currCurveT + 1); + + if (segmentTs.length > 0) { + for (let j = 0; j < segmentTs.length; j++) { + if (segmentTs[j] === 0) { + // if the first end of the segment is within the eraser + continueErasing = true; + } else if (segmentTs[j] === Math.floor(inkData.length / 4) + 1) { + // the last end + break; + } else if (!continueErasing) { + currSegment.push(inkBezier.split(0, segmentTs[j] - currCurveT)); + continueErasing = true; + } else { + // we've reached the end of the part to take out... + continueErasing = false; + if (currSegment.length > 0) { + segments.push(currSegment); // ...so we add it to the list and reset currSegment + if (firstSegment.length === 0) { + firstSegment = currSegment; + } + currSegment = []; + } + currSegment.push(inkBezier.split(segmentTs[j] - currCurveT, 1)); + } + } + } else if (!continueErasing) { + // push the bezier piece if not in the eraser circle + currSegment.push(inkBezier); + } + } + + if (currSegment.length > 0) { + // add the first segment onto the last to avoid fragmentation for closed curves + if (InkingStroke.IsClosed(inkData)) { + currSegment = currSegment.concat(firstSegment); + } + segments.push(currSegment); + } + return segments; + }; + + /** + * Erases ink strokes by segments. Locates intersections of the current ink stroke with all other ink strokes (including itself), + * then erases the segment that was intersected by the eraser. This is done by creating either 1 or two resulting segments + * (this depends on whether the eraser his the middle or end of a stroke), and returning the segments to "redraw." * @param ink The ink DocumentView intersected by the eraser. * @param excludeT The index of the curve in the ink document that the eraser intersection occurred. * @returns The ink stroke represented as a list of segments, excluding the segment in which the eraser intersection occurred. */ @action - segmentInkStroke = (ink: DocumentView, excludeT: number): Segment[] => { + segmentErase = (ink: DocumentView, excludeT: number): Segment[] => { const segments: Segment[] = []; - let segment: Segment = []; - let startSegmentT = 0; + let segment1: Segment = []; + let segment2: Segment = []; const { inkData } = (ink?.ComponentView as InkingStroke).inkScaledData(); - // This iterates through all segments of the curve and splits them where they intersect another curve. - // if 'excludeT' is specified, then any segment containing excludeT will be skipped (ie, deleted) + let intersections: number[] = []; // list of the ink stroke's intersections + const segmentIndexes: number[] = []; // list of indexes of the curve's segment where each intersection occured + + // loops through each segment and adds intersections to the list for (let i = 0; i < inkData.length - 3; i += 4) { - const inkSegment = InkField.Segment(inkData, i); - // Getting all t-value intersections of the current curve with all other curves. - const tVals = this.getInkIntersections(i, ink, inkSegment).sort(); - if (tVals.length) { - // eslint-disable-next-line no-loop-func - tVals.forEach((t, index) => { - const docCurveTVal = t + Math.floor(i / 4); - if (excludeT < startSegmentT || excludeT > docCurveTVal) { - const localStartTVal = startSegmentT - Math.floor(i / 4); - t !== (localStartTVal < 0 ? 0 : localStartTVal) && segment.push(inkSegment.split(localStartTVal < 0 ? 0 : localStartTVal, t)); - if (segment.length && (Math.abs(segment[0].points[0].x - segment[0].points.lastElement().x) > 0.5 || Math.abs(segment[0].points[0].y - segment[0].points.lastElement().y) > 0.5)) segments.push(segment); - } - // start a new segment from the intersection t value - if (tVals.length - 1 === index) { - const split = inkSegment.split(t).right; - if (split && (Math.abs(split.points[0].x - split.points.lastElement().x) > 0.5 || Math.abs(split.points[0].y - split.points.lastElement().y) > 0.5)) segment = [split]; - else segment = []; - } else segment = []; - startSegmentT = docCurveTVal; - }); + const inkSegment: Bezier = InkField.Segment(inkData, i); + let currIntersects = this.getInkIntersections(i, ink, inkSegment).sort(); + // get current segment's intersections (if any) and add the curve index + currIntersects = currIntersects.filter(tVal => tVal > 0 && tVal < 1).map(tVal => tVal + Math.floor(i / 4)); + if (currIntersects.length) { + intersections = [...intersections, ...currIntersects]; + for (let j = 0; j < currIntersects.length; j++) { + segmentIndexes.push(Math.floor(i / 4)); + } + } + } + + let isClosedCurve = false; + if (InkingStroke.IsClosed(inkData)) { + isClosedCurve = true; + if (intersections.length === 1) { + // delete whole stroke if a closed curve has 1 intersection + return segments; + } + } + + if (intersections.length) { + // this is the indexes of the closest intersection(s) + const closestTs = this.getClosestTs(intersections, excludeT, 0, intersections.length - 1); + + // find the segments that need to be split + let splitSegment1 = -1; // stays -1 if left end is deleted + let splitSegment2 = -1; // stays -1 if right end is deleted + if (closestTs[0] !== -1 && closestTs[1] !== -1) { + // if not on the ends + splitSegment1 = segmentIndexes[closestTs[0]]; + splitSegment2 = segmentIndexes[closestTs[1]]; + } else if (closestTs[0] === -1) { + // for a curve before an intersection + splitSegment2 = segmentIndexes[closestTs[1]]; } else { - segment.push(inkSegment); + // for a curve after an intersection + splitSegment1 = segmentIndexes[closestTs[0]]; } + // so by this point splitSegment1 and splitSegment2 will be the index(es) of the segment(s) to split + + let hasSplit = false; + let continueErasing = false; + // loop through segments again and split them if they match the split segments + for (let i = 0; i < inkData.length - 3; i += 4) { + const currCurveT = Math.floor(i / 4); + const inkSegment: Bezier = InkField.Segment(inkData, i); + + // case where the current curve is the first to split + if (splitSegment1 !== -1 && splitSegment2 !== -1) { + if (splitSegment1 === splitSegment2 && splitSegment1 === currCurveT) { + // if it's the same segment + segment1.push(inkSegment.split(0, intersections[closestTs[0]] - currCurveT)); + segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, 1)); + hasSplit = true; + } else if (splitSegment1 === currCurveT) { + segment1.push(inkSegment.split(0, intersections[closestTs[0]] - currCurveT)); + continueErasing = true; + } else if (splitSegment2 === currCurveT) { + segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, 1)); + continueErasing = false; + hasSplit = true; + } else if (!continueErasing && !hasSplit) { + // segment doesn't get pushed if continueErasing is true + segment1.push(inkSegment); + } else if (!continueErasing && hasSplit) { + segment2.push(inkSegment); + } + } else if (splitSegment1 === -1) { + // case where first end is erased + if (currCurveT === splitSegment2) { + if (isClosedCurve && currCurveT === segmentIndexes.lastElement()) { + segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, intersections.lastElement() - currCurveT)); + continueErasing = true; + } else { + segment2.push(inkSegment.split(intersections[closestTs[1]] - currCurveT, 1)); + } + hasSplit = true; + } else if (isClosedCurve && currCurveT === segmentIndexes.lastElement()) { + segment2.push(inkSegment.split(0, intersections.lastElement() - currCurveT)); + continueErasing = true; + } else if (hasSplit && !continueErasing) { + segment2.push(inkSegment); + } + } + // case where last end is erased + else if (currCurveT === segmentIndexes[0] && isClosedCurve) { + if (isClosedCurve && currCurveT === segmentIndexes.lastElement()) { + segment1.push(inkSegment.split(intersections[0] - currCurveT, intersections.lastElement() - currCurveT)); + continueErasing = true; + } else { + segment1.push(inkSegment.split(intersections[0] - currCurveT, 1)); + } + hasSplit = true; + } else if (currCurveT === splitSegment1) { + segment1.push(inkSegment.split(0, intersections[closestTs[0]] - currCurveT)); + hasSplit = true; + continueErasing = true; + } else if ((isClosedCurve && hasSplit && !continueErasing) || (!isClosedCurve && !hasSplit)) { + segment1.push(inkSegment); + } + } + } + + // add the first segment onto the second one for closed curves, so they don't get fragmented into two pieces + if (isClosedCurve && segment1.length > 0 && segment2.length > 0) { + segment2 = segment2.concat(segment1); + segment1 = []; + } + + // push 1 or both segments if they are not empty + if (segment1.length && (Math.abs(segment1[0].points[0].x - segment1[0].points.lastElement().x) > 0.5 || Math.abs(segment1[0].points[0].y - segment1[0].points.lastElement().y) > 0.5)) { + segments.push(segment1); } - if (excludeT < startSegmentT || excludeT > inkData.length / 4) { - segment.length && segments.push(segment); + if (segment2.length && (Math.abs(segment2[0].points[0].x - segment2[0].points.lastElement().x) > 0.5 || Math.abs(segment2[0].points[0].y - segment2[0].points.lastElement().y) > 0.5)) { + segments.push(segment2); } + return segments; }; + /** + * Standard logarithmic search function to search a sorted list of tVals for the ones closest to excludeT. + * @param tVals list of tvalues (usage is for intersection t values) to search within + * @param excludeT the t value of where the eraser intersected the curve + * @param startIndex the start index to search from + * @param endIndex the end index to search to + * @returns 2-item array of the closest tVals indexes + */ + getClosestTs = (tVals: number[], excludeT: number, startIndex: number, endIndex: number): number[] => { + if (tVals[startIndex] >= excludeT) { + return [-1, startIndex]; + } + if (tVals[endIndex] < excludeT) { + return [endIndex, -1]; + } + const mid = Math.floor((startIndex + endIndex) / 2); + if (excludeT >= tVals[mid]) { + if (mid + 1 <= endIndex && tVals[mid + 1] > excludeT) { + return [mid, mid + 1]; + } + return this.getClosestTs(tVals, excludeT, mid + 1, endIndex); + } + if (mid - 1 >= startIndex && tVals[mid - 1] < excludeT) { + return [mid - 1, mid]; + } + return this.getClosestTs(tVals, excludeT, startIndex, mid - 1); + }; + // for some reason bezier.js doesn't handle the case of intersecting a linear curve, so we wrap the intersection // call in a test for linearity bintersects = (curve: Bezier, otherCurve: Bezier) => { + if ((curve as any)._linear) { + // bezier.js doesn't intersect properly if the curve is actually a line -- so get intersect other curve against this line, then figure out the t coordinates of the intersection on this line + const intersections = otherCurve.lineIntersects({ p1: curve.points[0], p2: curve.points[3] }); + if (intersections.length) { + const intPt = otherCurve.get(intersections[0]); + const intT = curve.project(intPt).t; + return intT ? [intT] : []; + } + } if ((otherCurve as any)._linear) { return curve.lineIntersects({ p1: otherCurve.points[0], p2: otherCurve.points[3] }); } @@ -761,10 +1210,10 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection if (apt.d !== undefined && apt.d < 1 && apt.t !== undefined && !tVals.includes(apt.t)) { tVals.push(apt.t); } - this.bintersects(curve, otherCurve).forEach((val: string | number /* , i: number */) => { + this.bintersects(curve, otherCurve).forEach((val: string | number, ival: number) => { // Converting the Bezier.js Split type to a t-value number. const t = +val.toString().split('/')[0]; - if (i % 2 === 0 && !tVals.includes(t)) tVals.push(t); // bcz: Hack! don't know why but intersection points are doubled from bezier.js (but not identical). + if (ival % 2 === 0 && !tVals.includes(t)) tVals.push(t); // bcz: Hack! don't know why but intersection points are doubled from bezier.js (but not identical). }); if (bpt.d !== undefined && bpt.d < 1 && bpt.t !== undefined && !tVals.includes(bpt.t)) { tVals.push(bpt.t); @@ -1066,9 +1515,10 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection /> ); } - addDocTab = action((docsIn: Doc | Doc[], where: OpenWhere) => { + addDocTab = action((docsIn: Doc | Doc[], location: OpenWhere) => { const docs = toList(docsIn); - if (this._props.isAnnotationOverlay) return this._props.addDocTab(docs, where); + if (this._props.isAnnotationOverlay) return this._props.addDocTab(docs, location); + const where = location.split(':')[0]; switch (where) { case OpenWhere.inParent: return this._props.addDocument?.(docs) || false; @@ -1095,15 +1545,14 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection } if (firstDoc === this.Document || this.childDocList?.includes(firstDoc) || this.childLayoutPairs.map(pair => pair.layout)?.includes(firstDoc)) { if (firstDoc.hidden) firstDoc.hidden = false; - return true; + if (!location.includes(OpenWhereMod.always)) return true; } } break; default: } - return this._props.addDocTab(docs, where); + return this._props.addDocTab(docs, location); }); - getCalculatedPositions(pair: { layout: Doc; data?: Doc }): PoolData { const random = (min: number, max: number, x: number, y: number) => /* min should not be equal to max */ min + (((Math.abs(x * y) * 9301 + 49297) % 233280) / 233280) * (max - min); const childDoc = pair.layout; @@ -1120,16 +1569,15 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const rotation = Cast(_rotation,'number', !this.layoutDoc._rotation_jitter ? null : NumCast(this.layoutDoc._rotation_jitter) * random(-1, 1, NumCast(x), NumCast(y)) ); - const childProps = { ...this._props, fieldKey: '', styleProvider: this._clusters.styleProvider }; return { x: isNaN(NumCast(x)) ? 0 : NumCast(x), y: isNaN(NumCast(y)) ? 0 : NumCast(y), z: Cast(z, 'number'), autoDim, rotation, - color: Cast(color, 'string') ? StrCast(color) : this._clusters.styleProvider(childDoc, childProps, StyleProp.Color), - backgroundColor: Cast(backgroundColor, 'string') ? StrCast(backgroundColor) : this._clusters.styleProvider(childDoc, childProps, StyleProp.BackgroundColor), - opacity: !_width ? 0 : this._keyframeEditing ? 1 : Cast(opacity, 'number') ?? this._clusters.styleProvider?.(childDoc, childProps, StyleProp.Opacity), + color: Cast(color, 'string', null), + backgroundColor: Cast(backgroundColor, 'string', null), + opacity: !_width ? 0 : this._keyframeEditing ? 1 : Cast(opacity, 'number', null), zIndex: Cast(zIndex, 'number'), width: _width, height: _height, @@ -1201,6 +1649,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection } doFreeformLayout(poolData: Map<string, PoolData>) { + this._clusters.initLayout(); this.childLayoutPairs.filter(pair => this.isCurrent(pair.layout)).map(pair => poolData.set(pair.layout[Id], this.getCalculatedPositions(pair))); return [] as ViewDefResult[]; } @@ -1229,7 +1678,6 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }) ); - this._clusters.initLayout(); return elements; }; @@ -1366,10 +1814,23 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection } ); - onCursorMove = () => { + @action + onCursorMove = (e: React.PointerEvent) => { + this._eraserX = e.clientX; + this._eraserY = e.clientY; // super.setCursorPosition(this.getTransform().transformPoint(e.clientX, e.clientY)); }; + @action + onMouseLeave = () => { + this._showEraserCircle = false; + }; + + @action + onMouseEnter = () => { + this._showEraserCircle = true; + }; + @undoBatch promoteCollection = () => { const childDocs = this.childDocs.slice(); @@ -1539,7 +2000,6 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection } this.childDocs.some(doc => !this._renderCutoffData.get(doc[Id])) && setTimeout(this.incrementalRender, 1); }); - showPresPaths = () => SnappingManager.ShowPresPaths; brushedView = () => this._brushedView; gridColor = () => DashColor(lightOrDark(this.backgroundColor)).fade(0.5).toString(); // prettier-ignore @@ -1584,6 +2044,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection </div> ); } + transitionFunc = () => (this._panZoomTransition ? `transform ${this._panZoomTransition}ms ${this._presEaseFunc}` : Cast(this.layoutDoc._viewTransition, 'string', Cast(this.Document._viewTransition, 'string', null))); get pannableContents() { this.incrementalRender(); // needs to happen synchronously or freshly typed text documents will flash and miss their first characters return ( @@ -1593,7 +2054,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection isAnnotationOverlay={this.isAnnotationOverlay} transform={this.PanZoomCenterXf} showPresPaths={this.showPresPaths} - transition={this.panZoomTransition} + transition={this.transitionFunc} viewDefDivClick={this._props.viewDefDivClick}> {this.props.children ?? null} {/* most likely case of children is document content that's being annoated: eg., an image */} {this.contentViews} @@ -1645,6 +2106,8 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection this._oldWheel = r; // prevent wheel events from passivly propagating up through containers r?.addEventListener('wheel', this.onPassiveWheel, { passive: false }); + r?.addEventListener('mouseleave', this.onMouseLeave); + r?.addEventListener('mouseenter', this.onMouseEnter); }} onWheel={this.onPointerWheel} onClick={this.onClick} @@ -1660,6 +2123,21 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection width: `${100 / this.nativeDimScaling}%`, height: this._props.getScrollHeight?.() ?? `${100 / this.nativeDimScaling}%`, }}> + {Doc.ActiveTool === InkTool.RadiusEraser && this._showEraserCircle && ( + <div + onPointerMove={this.onCursorMove} + style={{ + position: 'fixed', + left: this._eraserX - 60, + top: this._eraserY - 100, + width: (ActiveEraserWidth() + 5) * 2, + height: (ActiveEraserWidth() + 5) * 2, + borderRadius: '50%', + border: '1px solid gray', + transform: 'translate(-50%, -50%)', + }} + /> + )} {this.paintFunc ? ( <FormattedTextBox {...this.props} /> // need this so that any live dashfieldviews will update the underlying text that the code eval reads ) : this._lightboxDoc ? ( @@ -1698,6 +2176,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection ); } } + // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function nextKeyFrame(readOnly: boolean) { !readOnly && (DocumentView.Selected()[0].ComponentView as CollectionFreeFormView)?.changeKeyFrame(); diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss new file mode 100644 index 000000000..e7413bf8e --- /dev/null +++ b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss @@ -0,0 +1,44 @@ +#label-handler { + display: flex; + flex-direction: column; + align-items: center; + + > div:first-child { + display: flex; // Puts the input and button on the same row + align-items: center; // Vertically centers items in the flex container + + input { + color: black; + } + + .IconButton { + margin-left: 8px; // Adds space between the input and the icon button + width: 19px; + } + } + + > div:not(:first-of-type) { + display: flex; + flex-direction: column; + align-items: center; // Centers the content vertically in the flex container + width: 100%; + + > div { + display: flex; + justify-content: space-between; // Puts the content and delete button on opposite ends + align-items: center; + width: 100%; + margin-top: 8px; // Adds space between label rows + + p { + text-align: center; // Centers the text of the paragraph + flex-grow: 1; // Allows the paragraph to grow and occupy the available space + } + + .IconButton { + // Styling for the delete button + margin-left: auto; // Pushes the button to the far right + } + } + } +} diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx new file mode 100644 index 000000000..7f27c6b5c --- /dev/null +++ b/src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx @@ -0,0 +1,120 @@ +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { IconButton } from 'browndash-components'; +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 { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; +import './ImageLabelHandler.scss'; + +@observer +export class ImageLabelHandler extends ObservableReactComponent<{}> { + static Instance: ImageLabelHandler; + + @observable _display: boolean = false; + @observable _pageX: number = 0; + @observable _pageY: number = 0; + @observable _yRelativeToTop: boolean = true; + @observable _currentLabel: string = ''; + @observable _labelGroups: string[] = []; + + constructor(props: any) { + super(props); + makeObservable(this); + ImageLabelHandler.Instance = this; + console.log('Instantiated label handler!'); + } + + @action + displayLabelHandler = (x: number, y: number) => { + this._pageX = x; + this._pageY = y; + this._display = true; + this._labelGroups = []; + }; + + @action + hideLabelhandler = () => { + this._display = false; + this._labelGroups = []; + }; + + @action + addLabel = (label: string) => { + label = label.toUpperCase().trim(); + if (label.length > 0) { + if (!this._labelGroups.includes(label)) { + this._labelGroups = [...this._labelGroups, label]; + } + } + }; + + @action + removeLabel = (label: string) => { + const labelUp = label.toUpperCase(); + this._labelGroups = this._labelGroups.filter(group => group !== labelUp); + }; + + @action + groupImages = () => { + MarqueeOptionsMenu.Instance.groupImages(); + this._display = false; + }; + + 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'} onPointerDown={this.hideLabelhandler} icon={<FontAwesomeIcon icon="eye-slash" />} color={MarqueeOptionsMenu.Instance.userColor} style={{ width: '19px' }} /> + <input aria-label="label-input" id="new-label" type="text" style={{ color: 'black' }} /> + <IconButton + tooltip={'Add Label'} + onPointerDown={() => { + const input = document.getElementById('new-label') as HTMLInputElement; + const newLabel = input.value; + this.addLabel(newLabel); + this._currentLabel = ''; + input.value = ''; + }} + icon={<FontAwesomeIcon icon="plus" />} + color={MarqueeOptionsMenu.Instance.userColor} + style={{ width: '19px' }} + /> + <IconButton tooltip={'Group Images'} onPointerDown={this.groupImages} icon={<FontAwesomeIcon icon="object-group" />} color={MarqueeOptionsMenu.Instance.userColor} style={{ width: '19px' }} /> + </div> + <div> + {this._labelGroups.map(group => { + return ( + <div> + <p>{group}</p> + <IconButton + tooltip={'Remove Label'} + onPointerDown={() => { + this.removeLabel(group); + }} + icon={'x'} + color={MarqueeOptionsMenu.Instance.userColor} + style={{ width: '19px' }} + /> + </div> + ); + })} + </div> + </div> + ); + } else { + return <></>; + } + } +} diff --git a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx index adac5a102..f02cd9d45 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx @@ -18,6 +18,8 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> { public showMarquee: () => void = unimplementedFunction; public hideMarquee: () => void = unimplementedFunction; public pinWithView: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; + public classifyImages: (e: React.MouseEvent | undefined) => void = unimplementedFunction; + public groupImages: () => void = unimplementedFunction; public isShown = () => this._opacity > 0; constructor(props: any) { super(props); @@ -37,6 +39,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> { <IconButton tooltip="Summarize Documents" onPointerDown={this.summarize} icon={<FontAwesomeIcon icon="compress-arrows-alt" />} color={this.userColor} /> <IconButton tooltip="Delete Documents" onPointerDown={this.delete} icon={<FontAwesomeIcon icon="trash-alt" />} color={this.userColor} /> <IconButton tooltip="Pin selected region" onPointerDown={this.pinWithView} icon={<FontAwesomeIcon icon="map-pin" />} color={this.userColor} /> + <IconButton tooltip="Classify Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} /> </> ); return this.getElement(buttons); diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index b96444024..dc15c83c5 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -1,21 +1,23 @@ /* eslint-disable jsx-a11y/no-static-element-interactions */ +import similarity from 'compute-cosine-similarity'; import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { ClientUtils, lightOrDark, returnFalse } from '../../../../ClientUtils'; -import { intersectRect } from '../../../../Utils'; -import { Doc, Opt } from '../../../../fields/Doc'; +import { intersectRect, numberRange } from '../../../../Utils'; +import { Doc, NumListCast, Opt } from '../../../../fields/Doc'; import { AclAdmin, AclAugment, AclEdit, DocData } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { InkData, InkField, InkTool } from '../../../../fields/InkField'; import { List } from '../../../../fields/List'; import { RichTextField } from '../../../../fields/RichTextField'; -import { Cast, FieldValue, NumCast, StrCast } from '../../../../fields/Types'; +import { Cast, FieldValue, ImageCast, NumCast, StrCast } from '../../../../fields/Types'; import { ImageField } from '../../../../fields/URLField'; import { GetEffectiveAcl } from '../../../../fields/util'; +import { gptGetEmbedding, gptImageLabel } from '../../../apis/gpt/GPT'; import { CognitiveServices } from '../../../cognitive_services/CognitiveServices'; import { DocUtils } from '../../../documents/DocUtils'; -import { DocumentType } from '../../../documents/DocumentTypes'; +import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes'; import { Docs, DocumentOptions } from '../../../documents/Documents'; import { SnappingManager, freeformScrollMode } from '../../../util/SnappingManager'; import { Transform } from '../../../util/Transform'; @@ -28,7 +30,10 @@ import { DocumentView } from '../../nodes/DocumentView'; import { OpenWhere } from '../../nodes/OpenWhere'; import { pasteImageBitmap } from '../../nodes/WebBoxRenderer'; import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; +import { CollectionCardView } from '../CollectionCardDeckView'; import { SubCollectionViewProps } from '../CollectionSubView'; +import { CollectionFreeFormView } from './CollectionFreeFormView'; +import { ImageLabelHandler } from './ImageLabelHandler'; import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; import './MarqueeView.scss'; @@ -61,11 +66,13 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps } private _commandExecuted = false; + private _selectedDocs: Doc[] = []; @observable _lastX: number = 0; @observable _lastY: number = 0; @observable _downX: number = 0; @observable _downY: number = 0; @observable _visible: boolean = false; // selection rentangle for marquee selection/free hand lasso is visible + @observable _labelsVisibile: boolean = false; @observable _lassoPts: [number, number][] = []; @observable _lassoFreehand: boolean = false; @@ -267,6 +274,8 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps MarqueeOptionsMenu.Instance.hideMarquee = this.hideMarquee; MarqueeOptionsMenu.Instance.jumpTo(e.clientX, e.clientY); MarqueeOptionsMenu.Instance.pinWithView = this.pinWithView; + MarqueeOptionsMenu.Instance.classifyImages = this.classifyImages; + MarqueeOptionsMenu.Instance.groupImages = this.groupImages; document.addEventListener('pointerdown', hideMarquee, true); document.addEventListener('wheel', hideMarquee, true); } else { @@ -419,6 +428,66 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps this.hideMarquee(); }); + /** + * Classifies images and assigns the labels as document fields. + * TODO: Turn into lists of labels instead of individual fields. + */ + @undoBatch + classifyImages = action(async (e: React.MouseEvent | undefined) => { + this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG); + + const imageInfos = this._selectedDocs.map(async doc => { + const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.'); + return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 => + !hrefBase64 ? undefined : + gptImageLabel(hrefBase64).then(labels => + Promise.all(labels.split('\n').map(label => gptGetEmbedding(label))).then(embeddings => + ({ doc, embeddings, labels }))) ); // prettier-ignore + }); + + (await Promise.all(imageInfos)).forEach(imageInfo => { + if (imageInfo && Array.isArray(imageInfo.embeddings)) { + imageInfo.doc[DocData].data_labels = imageInfo.labels; + numberRange(3).forEach(n => { + imageInfo.doc[`data_labels_embedding_${n + 1}`] = new List<number>(imageInfo.embeddings[n]); + }); + } + }); + + if (e) { + ImageLabelHandler.Instance.displayLabelHandler(e.pageX, e.pageY); + } + }); + + /** + * Groups images to most similar labels. + */ + @undoBatch + groupImages = action(async () => { + const labelGroups = ImageLabelHandler.Instance._labelGroups; + const labelToEmbedding = new Map<string, number[]>(); + // Create embeddings for the labels. + await Promise.all(labelGroups.map(async label => gptGetEmbedding(label).then(labelEmbedding => labelToEmbedding.set(label, labelEmbedding)))); + + // For each image, loop through the labels, and calculate similarity. Associate it with the + // most similar one. + this._selectedDocs.forEach(doc => { + const embedLists = numberRange(3).map(n => Array.from(NumListCast(doc[`data_labels_embedding_${n + 1}`]))); + const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map(l => (embedding && similarity(Array.from(embedding), l)) || 0)); + const {label: mostSimilarLabelCollect} = + labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) })) + .reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur, + { label: '', similarityScore: 0, }); // prettier-ignore + + numberRange(3).forEach(n => { + doc[`data_labels_embedding_${n + 1}`] = undefined; + }); + doc[DocData].data_label = mostSimilarLabelCollect; + }); + this._props.Document._type_collection = CollectionViewType.Time; + this._props.Document.pivotField = 'data_label'; + }); + @undoBatch syntaxHighlight = action((e: KeyboardEvent | React.PointerEvent | undefined) => { const selected = this.marqueeSelect(false); @@ -579,7 +648,10 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps return false; } - marqueeSelect(selectBackgrounds: boolean = false) { + /** + * When this is called, returns the list of documents that have been selected by the marquee box. + */ + marqueeSelect(selectBackgrounds: boolean = false, docType: DocumentType | undefined = undefined) { const selection: Doc[] = []; const selectFunc = (doc: Doc) => { const layoutDoc = Doc.Layout(doc); @@ -590,10 +662,17 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps (this.touchesLine(bounds) || this.boundingShape(bounds)) && selection.push(doc); } }; - this._props - .activeDocuments() - .filter(doc => !doc.z && !doc._lockedPosition) - .map(selectFunc); + if (docType) { + this._props + .activeDocuments() + .filter(doc => !doc.z && !doc._lockedPosition && doc.type === docType) + .map(selectFunc); + } else { + this._props + .activeDocuments() + .filter(doc => !doc.z && !doc._lockedPosition) + .map(selectFunc); + } if (!selection.length && selectBackgrounds) this._props .activeDocuments() |
