aboutsummaryrefslogtreecommitdiff
path: root/src/client/views/collections
diff options
context:
space:
mode:
authorNathan-SR <144961007+Nathan-SR@users.noreply.github.com>2024-06-03 13:33:37 -0400
committerNathan-SR <144961007+Nathan-SR@users.noreply.github.com>2024-06-03 13:33:37 -0400
commit9e77f980e7704999ef0a1c1845d660bccb13ff8a (patch)
tree14ca0da5915e4382a7bcb15f7d0b241941c8291f /src/client/views/collections
parent1be63695875c9242fba43d580465e8765cf3991d (diff)
parent202e994515392892676f8f080852db1e32b8dbd3 (diff)
Merge branch 'master' into nathan-starter
Diffstat (limited to 'src/client/views/collections')
-rw-r--r--src/client/views/collections/CollectionCardDeckView.scss84
-rw-r--r--src/client/views/collections/CollectionCardDeckView.tsx514
-rw-r--r--src/client/views/collections/CollectionCarouselView.scss18
-rw-r--r--src/client/views/collections/CollectionCarouselView.tsx128
-rw-r--r--src/client/views/collections/CollectionDockingView.tsx3
-rw-r--r--src/client/views/collections/CollectionSubView.tsx4
-rw-r--r--src/client/views/collections/CollectionTreeView.tsx3
-rw-r--r--src/client/views/collections/CollectionView.tsx2
-rw-r--r--src/client/views/collections/TabDocView.tsx10
-rw-r--r--src/client/views/collections/TreeView.tsx4
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormClusters.ts14
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx2
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx687
-rw-r--r--src/client/views/collections/collectionFreeForm/ImageLabelHandler.scss44
-rw-r--r--src/client/views/collections/collectionFreeForm/ImageLabelHandler.tsx120
-rw-r--r--src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx3
-rw-r--r--src/client/views/collections/collectionFreeForm/MarqueeView.tsx97
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()