aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/Utils.ts10
-rw-r--r--src/client/apis/gpt/GPT.ts76
-rw-r--r--src/client/documents/Documents.ts18
-rw-r--r--src/client/util/CurrentUserUtils.ts18
-rw-r--r--src/client/views/GlobalKeyHandler.ts2
-rw-r--r--src/client/views/MainView.tsx10
-rw-r--r--src/client/views/collections/CollectionCardDeckView.tsx65
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormLayoutEngines.tsx2
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx610
-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
-rw-r--r--src/client/views/global/globalScripts.ts18
-rw-r--r--src/client/views/nodes/FontIconBox/FontIconBox.tsx14
-rw-r--r--src/fields/Doc.ts4
-rw-r--r--src/fields/InkField.ts4
17 files changed, 966 insertions, 149 deletions
diff --git a/src/Utils.ts b/src/Utils.ts
index 3af057d82..23ae38bdb 100644
--- a/src/Utils.ts
+++ b/src/Utils.ts
@@ -3,6 +3,11 @@ import * as uuid from 'uuid';
export function clamp(n: number, lower: number, upper: number) {
return Math.max(lower, Math.min(upper, n));
}
+
+export function ptDistance(p1: { x: number; y: number }, p2: { x: number; y: number }) {
+ return Math.sqrt((p2.x - p1.x) ** 2 + (p2.y - p1.y) ** 2);
+}
+
export namespace Utils {
export function GuestID() {
return '__guest__';
@@ -206,6 +211,11 @@ export type Without<T, K extends keyof T> = Pick<T, Exclude<keyof T, K>>;
export type Predicate<K, V> = (entry: [K, V]) => boolean;
+/**
+ * creates a list of numbers ordered from 0 to 'num'
+ * @param num range of numbers
+ * @returns list of values from 0 to num -1
+ */
export function numberRange(num: number) {
return num > 0 && num < 1000 ? Array.from(Array(num)).map((v, i) => i) : [];
}
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
index 2fa92373f..9efe4ec39 100644
--- a/src/client/apis/gpt/GPT.ts
+++ b/src/client/apis/gpt/GPT.ts
@@ -94,8 +94,51 @@ const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: a
return 'Error connecting with API.';
}
};
+const gptImageCall = async (prompt: string, n?: number) => {
+ try {
+ const configuration: ClientOptions = {
+ apiKey: process.env.OPENAI_KEY,
+ dangerouslyAllowBrowser: true,
+ };
+
+ const openai = new OpenAI(configuration);
+ const response = await openai.images.generate({
+ prompt: prompt,
+ n: n ?? 1,
+ size: '1024x1024',
+ });
+ return response.data.map((data: any) => data.url);
+ // return response.data.data[0].url;
+ } catch (err) {
+ console.error(err);
+ }
+ return undefined;
+};
+
+const gptGetEmbedding = async (src: string): Promise<number[]> => {
+ try {
+ const configuration: ClientOptions = {
+ apiKey: process.env.OPENAI_KEY,
+ dangerouslyAllowBrowser: true,
+ };
+ const openai = new OpenAI(configuration);
+ const embeddingResponse = await openai.embeddings.create({
+ model: 'text-embedding-3-large',
+ input: [src],
+ encoding_format: 'float',
+ dimensions: 256,
+ });
-const gptImageLabel = async (imgUrl: string): Promise<string> => {
+ // Assume the embeddingResponse structure is correct; adjust based on actual API response
+ const { embedding } = embeddingResponse.data[0];
+ return embedding;
+ } catch (err) {
+ console.log(err);
+ return [];
+ }
+};
+
+const gptImageLabel = async (src: string): Promise<string> => {
try {
const configuration: ClientOptions = {
apiKey: process.env.OPENAI_KEY,
@@ -109,11 +152,12 @@ const gptImageLabel = async (imgUrl: string): Promise<string> => {
{
role: 'user',
content: [
- { type: 'text', text: 'Describe this image in 3-5 words' },
+ { type: 'text', text: 'Give three to five labels to describe this image.' },
{
type: 'image_url',
image_url: {
- url: `${imgUrl}`,
+ url: `${src}`,
+ detail: 'low',
},
},
],
@@ -122,34 +166,12 @@ const gptImageLabel = async (imgUrl: string): Promise<string> => {
});
if (response.choices[0].message.content) {
return response.choices[0].message.content;
- } else {
- return ':(';
}
+ return 'Missing labels';
} catch (err) {
console.log(err);
return 'Error connecting with API';
}
};
-const gptImageCall = async (prompt: string, n?: number) => {
- try {
- const configuration: ClientOptions = {
- apiKey: process.env.OPENAI_KEY,
- dangerouslyAllowBrowser: true,
- };
-
- const openai = new OpenAI(configuration);
- const response = await openai.images.generate({
- prompt: prompt,
- n: n ?? 1,
- size: '1024x1024',
- });
- return response.data.map((data: any) => data.url);
- // return response.data.data[0].url;
- } catch (err) {
- console.error(err);
- }
- return undefined;
-};
-
-export { gptAPICall, gptImageCall, gptImageLabel, GPTCallType };
+export { gptAPICall, gptImageCall, GPTCallType, gptImageLabel, gptGetEmbedding };
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 1f9d4228f..640fb251d 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -5,7 +5,22 @@ import { reaction } from 'mobx';
import { basename } from 'path';
import { ClientUtils, OmitKeys } from '../../ClientUtils';
import { DateField } from '../../fields/DateField';
-import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, ActiveIsInkMask, CreateLinkToActiveAudio, Doc, FieldType, Opt, updateCachedAcls } from '../../fields/Doc';
+import {
+ ActiveArrowEnd,
+ ActiveArrowStart,
+ ActiveDash,
+ ActiveFillColor,
+ ActiveInkBezierApprox,
+ ActiveInkColor,
+ ActiveEraserWidth,
+ ActiveInkWidth,
+ ActiveIsInkMask,
+ CreateLinkToActiveAudio,
+ Doc,
+ FieldType,
+ Opt,
+ updateCachedAcls,
+} from '../../fields/Doc';
import { Initializing } from '../../fields/DocSymbols';
import { HtmlField } from '../../fields/HtmlField';
import { InkField } from '../../fields/InkField';
@@ -837,6 +852,7 @@ export namespace Docs {
points: PointData[],
options: DocumentOptions = {},
strokeWidth = ActiveInkWidth(),
+ eraserWidth = ActiveEraserWidth(),
color = ActiveInkColor(),
strokeBezier = ActiveInkBezierApprox(),
fillColor = ActiveFillColor(),
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index f32f33c51..b9c4d8b5f 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -669,13 +669,13 @@ pie title Minerals in my tap water
static labelTools(): Button[] {
return [
{ title: "AI", icon:"robot", toolTip:"Add AI labels", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"chat", funcs: {hidden:`showFreeform ("chat", true)`},scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}},
- { title: "AIs", icon:"AI Sort", toolTip:"Filter AI labels", subMenu: this.cardGroupTools("chat"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("chat", true)`, linearView_IsOpen: `SelectionManager_selectedDocType(this.toolType, this.expertMode)`} },
+ { title: "AIs", icon:"AI Sort", toolTip:"Filter AI labels", subMenu: this.cardGroupTools("chat"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("chat", true)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} },
{ title: "Like", icon:"heart", toolTip:"Add Like labels", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"like", funcs: {hidden:`showFreeform ("like", true)`},scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}},
- { title: "Likes", icon:"Likes", toolTip:"Filter likes", width: 10, subMenu: this.cardGroupTools("heart"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("like", true)`, linearView_IsOpen: `SelectionManager_selectedDocType(this.toolType, this.expertMode)`} },
+ { title: "Likes", icon:"Likes", toolTip:"Filter likes", width: 10, subMenu: this.cardGroupTools("heart"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("like", true)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} },
{ title: "Star", icon:"star", toolTip:"Add Star labels", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"star", funcs: {hidden:`showFreeform ("star", true)`},scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}},
- { title: "Stars", icon:"Stars", toolTip:"Filter stars", width: 80, subMenu: this.cardGroupTools("star"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("star", true)`, linearView_IsOpen: `SelectionManager_selectedDocType(this.toolType, this.expertMode)`} },
+ { title: "Stars", icon:"Stars", toolTip:"Filter stars", width: 80, subMenu: this.cardGroupTools("star"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("star", true)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} },
{ title: "Idea", icon:"satellite", toolTip:"Add Idea labels", btnType: ButtonType.ToggleButton, expertMode: false, toolType:"idea", funcs: {hidden:`showFreeform ("idea", true)`},scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}},
- { title: "Ideas", icon:"Ideas", toolTip:"Filter ideas", width: 80, subMenu: this.cardGroupTools("satellite"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("idea", true)`, linearView_IsOpen: `SelectionManager_selectedDocType(this.toolType, this.expertMode)`} },
+ { title: "Ideas", icon:"Ideas", toolTip:"Filter ideas", width: 80, subMenu: this.cardGroupTools("satellite"), expertMode: false, toolType:CollectionViewType.Card, funcs: {hidden:`!showFreeform("idea", true)`, linearView_IsOpen: `SelectedDocType(this.toolType, this.expertMode)`} },
]
}
static cardGroupTools(icon: string): Button[] {
@@ -732,7 +732,13 @@ pie title Minerals in my tap water
return [
{ title: "Pen", toolTip: "Pen (Ctrl+P)", btnType: ButtonType.ToggleButton, icon: "pen-nib", toolType: "pen", scripts: {onClick:'{ return setActiveTool(this.toolType, false, _readOnly_);}' }},
{ title: "Write", toolTip: "Write (Ctrl+Shift+P)", btnType: ButtonType.ToggleButton, icon: "pen", toolType: "write", scripts: {onClick:'{ return setActiveTool(this.toolType, false, _readOnly_);}' }, funcs: {hidden:"IsNoviceMode()" }},
- { title: "Eraser", toolTip: "Eraser (Ctrl+E)", btnType: ButtonType.ToggleButton, icon: "eraser", toolType: "eraser", scripts: {onClick:'{ return setActiveTool(this.toolType, false, _readOnly_);}' }, funcs: {hidden:"IsNoviceMode()" }},
+ { title: "Eraser", toolTip: "Eraser (Ctrl+E)", btnType: ButtonType.MultiToggleButton, scripts: {onClick: '{ return setActiveTool(this.toolType, false, _readOnly_);}'}, funcs: {toolType:"activeEraserTool()"},
+ subMenu: [
+ { title: "Stroke", toolTip: "Stroke Erase", btnType: ButtonType.ToggleButton, icon: "eraser", toolType:InkTool.StrokeEraser, ignoreClick: true, scripts: {onClick: '{ return setActiveTool(this.toolType, false, _readOnly_);}'} },
+ { title: "Segment", toolTip: "Segment Erase", btnType: ButtonType.ToggleButton, icon: "xmark",toolType:InkTool.SegmentEraser,ignoreClick: true, scripts: {onClick: '{ return setActiveTool(this.toolType, false, _readOnly_);}'} },
+ { title: "Radius", toolTip: "Radius Erase", btnType: ButtonType.ToggleButton, icon: "circle-xmark",toolType:InkTool.RadiusEraser, ignoreClick: true, scripts: {onClick: '{ return setActiveTool(this.toolType, false, _readOnly_);}'} },
+ ]},
+ { title: "Eraser Width", toolTip: "Eraser Width", btnType: ButtonType.NumberSliderButton, toolType: "eraserWidth", ignoreClick: true, scripts: {script: '{ return setInkProperty(this.toolType, value, _readOnly_);}'}, numBtnMin: 1, funcs: {hidden:"NotRadiusEraser()"}},
{ title: "Circle", toolTip: "Circle (double tap to lock mode)", btnType: ButtonType.ToggleButton, icon: "circle", toolType: Gestures.Circle, scripts: {onClick:`{ return setActiveTool(this.toolType, false, _readOnly_);}`, onDoubleClick:`{ return setActiveTool(this.toolType, true, _readOnly_);}`} },
{ title: "Square", toolTip: "Square (double tap to lock mode)", btnType: ButtonType.ToggleButton, icon: "square", toolType: Gestures.Rectangle, scripts: {onClick:`{ return setActiveTool(this.toolType, false, _readOnly_);}`, onDoubleClick:`{ return setActiveTool(this.toolType, true, _readOnly_);}`} },
{ title: "Line", toolTip: "Line (double tap to lock mode)", btnType: ButtonType.ToggleButton, icon: "minus", toolType: Gestures.Line, scripts: {onClick:`{ return setActiveTool(this.toolType, false, _readOnly_);}`, onDoubleClick:`{ return setActiveTool(this.toolType, true, _readOnly_);}`} },
@@ -1090,6 +1096,8 @@ pie title Minerals in my tap water
}
// eslint-disable-next-line prefer-arrow-callback
+ScriptingGlobals.add(function NotRadiusEraser() { return Doc.ActiveTool !== InkTool.RadiusEraser; }, "is the active tool anything but the radius eraser");
+// eslint-disable-next-line prefer-arrow-callback
ScriptingGlobals.add(function MySharedDocs() { return Doc.MySharedDocs; }, "document containing all shared Docs");
// eslint-disable-next-line prefer-arrow-callback
ScriptingGlobals.add(function IsExploreMode() { return SnappingManager.ExploreMode; }, "is Dash in exploration mode");
diff --git a/src/client/views/GlobalKeyHandler.ts b/src/client/views/GlobalKeyHandler.ts
index 18ec0b6a9..7d01bbabb 100644
--- a/src/client/views/GlobalKeyHandler.ts
+++ b/src/client/views/GlobalKeyHandler.ts
@@ -280,7 +280,7 @@ export class KeyManager {
}
break;
case 'e':
- Doc.ActiveTool = Doc.ActiveTool === InkTool.Eraser ? InkTool.None : InkTool.Eraser;
+ Doc.ActiveTool = [InkTool.StrokeEraser, InkTool.SegmentEraser, InkTool.RadiusEraser].includes(Doc.ActiveTool) ? InkTool.None : InkTool.StrokeEraser;
break;
case 'p':
Doc.ActiveTool = Doc.ActiveTool === InkTool.Pen ? InkTool.None : InkTool.Pen;
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index 33c343176..31d88fb87 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -54,6 +54,7 @@ import { CollectionMenu } from './collections/CollectionMenu';
import { TabDocView } from './collections/TabDocView';
import './collections/TreeView.scss';
import { CollectionFreeFormView } from './collections/collectionFreeForm';
+import { ImageLabelHandler } from './collections/collectionFreeForm/ImageLabelHandler';
import { MarqueeOptionsMenu } from './collections/collectionFreeForm/MarqueeOptionsMenu';
import { CollectionLinearView } from './collections/collectionLinear';
import { LinkMenu } from './linking/LinkMenu';
@@ -76,8 +77,8 @@ import { AnchorMenu } from './pdf/AnchorMenu';
import { GPTPopup } from './pdf/GPTPopup/GPTPopup';
import { TopBar } from './topbar/TopBar';
-const _global = (window /* browser */ || global) /* node */ as any;
const { LEFT_MENU_WIDTH, TOPBAR_HEIGHT } = require('./global/globalCssVariables.module.scss'); // prettier-ignore
+const _global = (window /* browser */ || global) /* node */ as any;
@observer
export class MainView extends ObservableReactComponent<{}> {
@@ -371,6 +372,10 @@ export class MainView extends ObservableReactComponent<{}> {
fa.faCut,
fa.faEllipsisV,
fa.faEraser,
+ fa.faDeleteLeft,
+ fa.faXmarksLines,
+ fa.faCircleXmark,
+ fa.faXmark,
fa.faExclamation,
fa.faFileAlt,
fa.faFileAudio,
@@ -542,7 +547,7 @@ export class MainView extends ObservableReactComponent<{}> {
fa.faHourglassHalf,
fa.faRobot,
fa.faSatellite,
- fa.faStar
+ fa.faStar,
]
);
}
@@ -1083,6 +1088,7 @@ export class MainView extends ObservableReactComponent<{}> {
<PreviewCursor />
<TaskCompletionBox />
<ContextMenu />
+ <ImageLabelHandler />
<AnchorMenu />
<MapAnchorMenu />
<DirectionsAnchorMenu />
diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx
index 6036a2ead..de46180e6 100644
--- a/src/client/views/collections/CollectionCardDeckView.tsx
+++ b/src/client/views/collections/CollectionCardDeckView.tsx
@@ -161,7 +161,7 @@ export class CollectionCardView extends CollectionSubView() {
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() ? true : false);
+ 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
@@ -173,10 +173,10 @@ export class CollectionCardView extends CollectionSubView() {
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) {
+ if (amCards % 2 === 0 && possRotate === 0) {
return possRotate + Math.abs(-30 + (index - 1) * (30 / (amCards / 2)));
}
- if (amCards % 2 == 0 && index > (amCards + 1) / 2) {
+ if (amCards % 2 === 0 && index > (amCards + 1) / 2) {
return possRotate + stepMag;
}
@@ -194,10 +194,10 @@ export class CollectionCardView extends CollectionSubView() {
if (realIndex > this._maxRowCount - 1) {
rowOffset = 400 * ((realIndex - (realIndex % this._maxRowCount)) / this._maxRowCount);
}
- if (evenOdd == 1 || index < apex - 1) {
+ if (evenOdd === 1 || index < apex - 1) {
return Math.abs(stepMag * (apex - index)) - rowOffset;
}
- if (index == apex || index == apex - 1) {
+ if (index === apex || index === apex - 1) {
return 0 - rowOffset;
}
@@ -259,27 +259,26 @@ export class CollectionCardView extends CollectionSubView() {
return docs;
};
- displayDoc = (doc: Doc, screenToLocalTransform: () => Transform) => {
- return (
- <DocumentView
- {...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)}
- />
- );
- };
+ 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
@@ -296,7 +295,7 @@ export class CollectionCardView extends CollectionSubView() {
if (index < totalCards - (totalCards % 10)) {
return this._maxRowCount;
}
- //(3)
+ // (3)
return totalCards % 10;
};
/**
@@ -335,7 +334,9 @@ export class CollectionCardView extends CollectionSubView() {
* @param buttonID
* @param doc
*/
- toggleButton = undoable((buttonID: number, doc: Doc) => this.cardSort_customField && (doc[this.cardSort_customField] = buttonID), 'toggle custom button');
+ 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.
@@ -346,8 +347,7 @@ export class CollectionCardView extends CollectionSubView() {
childPairStringList = () => {
const docToText = (doc: Doc) => {
switch (doc.type) {
- case DocumentType.PDF: const words = StrCast(doc.text).split(/\s+/);
- return words.slice(0, 50).join(' '); // first 50 words of pdf text
+ 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);
@@ -368,7 +368,7 @@ export class CollectionCardView extends CollectionSubView() {
*/
getImageDesc = async (image: Doc) => {
if (StrCast(image.description)) return StrCast(image.description); // Return existing description
- const href = (image.data as URLField).url.href;
+ const { href } = (image.data as URLField).url;
const hrefParts = href.split('.');
const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`;
try {
@@ -422,12 +422,13 @@ export class CollectionCardView extends CollectionSubView() {
*/
renderButtons = (doc: Doc, cardSort: cardSortings) => {
if (cardSort !== cardSortings.Custom) return '';
- const amButtons = Math.max(4, this.childDocs?.reduce((set, doc) => this.cardSort_customField && set.add(NumCast(doc[this.cardSort_customField])), new Set<number>()).size ?? 0);
+ 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"
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 cc195385b..110f7816c 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';
@@ -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,7 +99,6 @@ 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';
@@ -126,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);
}
@@ -499,17 +500,23 @@ 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:
}
}
}
@@ -555,7 +562,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();
};
@@ -590,41 +597,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));
};
@@ -643,32 +701,127 @@ 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 = eraserOutline[0].X,
+ maxX = eraserOutline[0].X;
+ let minY = eraserOutline[0].Y,
+ maxY = 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 },
@@ -684,54 +837,356 @@ 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 = 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(val => Math.abs(val - (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
+ let 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)
+ let 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);
+ }
+ }
+ } else {
+ // case where last end is erased
+ 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);
+ }
+ }
+ }
}
}
- if (excludeT < startSegmentT || excludeT > inkData.length / 4) {
- segment.length && segments.push(segment);
+
+ // 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 (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];
+ } else if (tVals[endIndex] < excludeT) {
+ return [endIndex, -1];
+ } else {
+ const mid = Math.floor((startIndex + endIndex) / 2);
+ if (excludeT >= tVals[mid]) {
+ if (mid + 1 <= endIndex && tVals[mid + 1] > excludeT) {
+ return [mid, mid + 1];
+ } else {
+ return this.getClosestTs(tVals, excludeT, mid + 1, endIndex);
+ }
+ } else {
+ if (mid - 1 >= startIndex && tVals[mid - 1] < excludeT) {
+ return [mid - 1, mid];
+ } else {
+ 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] });
}
@@ -770,7 +1225,7 @@ 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, i: 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).
@@ -1113,7 +1568,6 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
}
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;
@@ -1334,8 +1788,8 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
({ code, first }) => {
if (!code.includes('dashDiv')) {
const script = CompileScript(code, { params: { docView: 'any' }, typecheck: false, editable: true });
- if (script.compiled) script.run({ this: this.DocumentView?.() });
// eslint-disable-next-line no-eval
+ if (script.compiled) script.run({ this: this.DocumentView?.() });
} else code && !first && eval?.(code);
},
{ fireImmediately: true }
@@ -1375,10 +1829,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();
@@ -1548,7 +2015,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
@@ -1655,6 +2121,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}
@@ -1670,6 +2138,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 ? (
@@ -1708,6 +2191,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()
diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts
index 2b804c5d3..7730ed385 100644
--- a/src/client/views/global/globalScripts.ts
+++ b/src/client/views/global/globalScripts.ts
@@ -3,6 +3,7 @@ import { action, runInAction } from 'mobx';
import { aggregateBounds } from '../../../Utils';
import {
ActiveFillColor,
+ ActiveEraserWidth,
ActiveInkColor,
ActiveInkHideTextLabels,
ActiveInkWidth,
@@ -15,6 +16,7 @@ import {
SetActiveInkHideTextLabels,
SetActiveInkWidth,
SetActiveIsInkMask,
+ SetEraserWidth,
} from '../../../fields/Doc';
import { DocData } from '../../../fields/DocSymbols';
import { InkTool } from '../../../fields/InkField';
@@ -448,6 +450,9 @@ function setActiveTool(tool: InkTool | Gestures, keepPrim: boolean, checkResult?
GestureOverlay.Instance.InkShape = tool as Gestures;
}
} else if (tool) {
+ if ([InkTool.StrokeEraser, InkTool.RadiusEraser, InkTool.SegmentEraser].includes(tool as any)) {
+ Doc.UserDoc().activeEraserTool = tool;
+ }
// pen or eraser
if (Doc.ActiveTool === tool && !GestureOverlay.Instance.InkShape && !keepPrim) {
Doc.ActiveTool = InkTool.None;
@@ -464,12 +469,16 @@ function setActiveTool(tool: InkTool | Gestures, keepPrim: boolean, checkResult?
ScriptingGlobals.add(setActiveTool, 'sets the active ink tool mode');
+ScriptingGlobals.add(function activeEraserTool() {
+ return StrCast(Doc.UserDoc().activeEraserTool, InkTool.StrokeEraser);
+}, 'returns the current eraser tool');
+
// toggle: Set overlay status of selected document
// eslint-disable-next-line prefer-arrow-callback
-ScriptingGlobals.add(function setInkProperty(option: 'inkMask' | 'labels' | 'fillColor' | 'strokeWidth' | 'strokeColor', value: any, checkResult?: boolean) {
+ScriptingGlobals.add(function setInkProperty(option: 'inkMask' | 'labels' | 'fillColor' | 'strokeWidth' | 'strokeColor' | 'eraserWidth', value: any, checkResult?: boolean) {
const selected = DocumentView.SelectedDocs().lastElement() ?? Doc.UserDoc();
// prettier-ignore
- const map: Map<'inkMask' | 'labels' | 'fillColor' | 'strokeWidth' | 'strokeColor', { checkResult: () => any; setInk: (doc: Doc) => void; setMode: () => void }> = new Map([
+ const map: Map<'inkMask' | 'labels' | 'fillColor' | 'strokeWidth' | 'strokeColor' | 'eraserWidth', { checkResult: () => any; setInk: (doc: Doc) => void; setMode: () => void }> = new Map([
['inkMask', {
checkResult: () => ((selected?._layout_isSvg ? BoolCast(selected[DocData].stroke_isInkMask) : ActiveIsInkMask())),
setInk: (doc: Doc) => { doc[DocData].stroke_isInkMask = !doc.stroke_isInkMask; },
@@ -495,6 +504,11 @@ ScriptingGlobals.add(function setInkProperty(option: 'inkMask' | 'labels' | 'fil
setInk: (doc: Doc) => { doc[DocData].color = String(value); },
setMode: () => { SetActiveInkColor(StrCast(value)); selected?.type === DocumentType.INK && setActiveTool(GestureOverlay.Instance.InkShape ?? InkTool.Pen, true, false);},
}],
+ [ 'eraserWidth', {
+ checkResult: () => ActiveEraserWidth(),
+ setInk: (doc: Doc) => { },
+ setMode: () => { SetEraserWidth(value.toString());},
+ }]
]);
if (checkResult) {
diff --git a/src/client/views/nodes/FontIconBox/FontIconBox.tsx b/src/client/views/nodes/FontIconBox/FontIconBox.tsx
index 5e3bb9fec..ffb668b03 100644
--- a/src/client/views/nodes/FontIconBox/FontIconBox.tsx
+++ b/src/client/views/nodes/FontIconBox/FontIconBox.tsx
@@ -5,7 +5,7 @@ import { Button, ColorPicker, Dropdown, DropdownType, IconButton, IListItemProps
import { action, computed, makeObservable, observable } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
-import { ClientUtils, returnTrue, setupMoveUpEvents } from '../../../../ClientUtils';
+import { ClientUtils, returnFalse, returnTrue, setupMoveUpEvents } from '../../../../ClientUtils';
import { Doc, DocListCast, StrListCast } from '../../../../fields/Doc';
import { BoolCast, DocCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types';
import { emptyFunction } from '../../../../Utils';
@@ -261,24 +261,28 @@ export class FontIconBox extends ViewBoxBaseComponent<ButtonProps>() {
// Determine the type of toggle button
const tooltip: string = StrCast(this.Document.toolTip);
- // const script = ScriptCast(this.Document.onClick);
- // const toggleStatus = script ? script.script.run({ this: this.Document, value: undefined, _readOnly_: true }).result : false;
+ const script = ScriptCast(this.Document.onClick)?.script;
+ const toggleStatus = script?.run({ this: this.Document, self: this.Document, value: undefined, _readOnly_: true }).result;
// Colors
const color = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color);
const items = DocListCast(this.dataDoc.data);
+ const multiDoc = this.Document;
return (
<MultiToggle
tooltip={`Toggle ${tooltip}`}
type={Type.PRIM}
color={color}
- background={SnappingManager.userBackgroundColor}
+ onPointerDown={e => script && !toggleStatus && setupMoveUpEvents(this, e, returnFalse, emptyFunction, e => script.run({ this: multiDoc, value: undefined, _readOnly_: false }))}
+ isToggle={script ? true : false}
+ toggleStatus={toggleStatus}
+ //background={SnappingManager.userBackgroundColor}
label={this.label}
items={DocListCast(this.dataDoc.data).map(item => ({
icon: <FontAwesomeIcon className={`fontIconBox-icon-${this.type}`} icon={StrCast(item.icon) as any} color={color} />,
tooltip: StrCast(item.toolTip),
val: StrCast(item.toolType),
}))}
- selectedVal={StrCast(items.find(itemDoc => ScriptCast(itemDoc.onClick).script.run({ this: itemDoc, value: undefined, _readOnly_: true }).result)?.toolType)}
+ selectedVal={StrCast(items.find(itemDoc => ScriptCast(itemDoc.onClick).script.run({ this: itemDoc, value: undefined, _readOnly_: true }).result)?.toolType ?? StrCast(multiDoc.toolType))}
setSelectedVal={(val: string | number) => {
const itemDoc = items.find(item => item.toolType === val);
itemDoc && ScriptCast(itemDoc.onClick).script.run({ this: itemDoc, value: val, _readOnly_: false });
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index 2d3dddc8b..1b3d963e8 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -185,6 +185,7 @@ export function ActiveArrowScale(): number { return NumCast(ActiveInkPen()?.acti
export function ActiveDash(): string { return StrCast(ActiveInkPen()?.activeDash, '0'); } // prettier-ignore
export function ActiveInkWidth(): number { return Number(ActiveInkPen()?.activeInkWidth); } // prettier-ignore
export function ActiveInkBezierApprox(): string { return StrCast(ActiveInkPen()?.activeInkBezier); } // prettier-ignore
+export function ActiveEraserWidth(): number { return Number(ActiveInkPen()?.eraserWidth); } // prettier-ignore
export function SetActiveInkWidth(width: string): void {
!isNaN(parseInt(width)) && ActiveInkPen() && (ActiveInkPen().activeInkWidth = width);
@@ -216,6 +217,9 @@ export function SetActiveArrowScale(value: number) {
export function SetActiveDash(dash: string): void {
!isNaN(parseInt(dash)) && ActiveInkPen() && (ActiveInkPen().activeDash = dash);
}
+export function SetEraserWidth(width: number): void {
+ ActiveInkPen() && (ActiveInkPen().eraserWidth = width);
+}
@scriptingGlobal
@Deserializable('Doc', updateCachedAcls, ['id'])
diff --git a/src/fields/InkField.ts b/src/fields/InkField.ts
index 46704eb2b..32abf0076 100644
--- a/src/fields/InkField.ts
+++ b/src/fields/InkField.ts
@@ -11,7 +11,9 @@ export enum InkTool {
None = 'none',
Pen = 'pen',
Highlighter = 'highlighter',
- Eraser = 'eraser',
+ StrokeEraser = 'strokeeraser',
+ SegmentEraser = 'segmenteraser',
+ RadiusEraser = 'radiuseraser',
Stamp = 'stamp',
Write = 'write',
PresentationPin = 'presentationpin',