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, unimplementedFunction } from '../../../../Utils'; import { Doc, DocListCast, Opt } from '../../../../fields/Doc'; import { AclAdmin, AclAugment, AclEdit, DocData } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { InkData, InkTool } from '../../../../fields/InkField'; import { List } from '../../../../fields/List'; import { Cast, DocCast, NumCast, ScriptCast, StrCast } from '../../../../fields/Types'; import { ImageField } from '../../../../fields/URLField'; import { GetEffectiveAcl } from '../../../../fields/util'; import { DocUtils } from '../../../documents/DocUtils'; import { DocumentType } from '../../../documents/DocumentTypes'; import { Docs, DocumentOptions } from '../../../documents/Documents'; import { SnappingManager, freeformScrollMode } from '../../../util/SnappingManager'; import { Transform } from '../../../util/Transform'; import { UndoManager, undoBatch } from '../../../util/UndoManager'; import { ContextMenu } from '../../ContextMenu'; import { ObservableReactComponent } from '../../ObservableReactComponent'; import { MarqueeViewBounds } from '../../PinFuncs'; import { PreviewCursor } from '../../PreviewCursor'; import { DocumentView } from '../../nodes/DocumentView'; import { OpenWhere } from '../../nodes/OpenWhere'; import { pasteImageBitmap } from '../../nodes/WebBoxRenderer'; import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; import { SubCollectionViewProps } from '../CollectionSubView'; import { ImageLabelBoxData } from './ImageLabelBox'; import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; import { StrListCast } from '../../../../fields/Doc'; import { requestAiGeneratedPreset, DocumentDescriptor } from '../../nodes/scrapbook/AIPresetGenerator'; import { buildPlaceholdersFromConfigs, slotRealDocIntoPlaceholders } from '../../nodes/scrapbook/ScrapbookBox'; import './MarqueeView.scss'; interface MarqueeViewProps { Doc: Doc; getContainerTransform: () => Transform; getTransform: () => Transform; activeDocuments: () => Doc[]; selectDocuments: (docs: Doc[]) => void; addLiveTextDocument: (doc: Doc) => void; isSelected: () => boolean; panXFieldKey: string; panYFieldKey: string; trySelectCluster: (addToSel: boolean) => boolean; nudge?: (x: number, y: number, nudgeTime?: number) => boolean; ungroup?: () => void; setPreviewCursor?: (func: (x: number, y: number, drag: boolean, hide: boolean, doc: Opt) => void) => void; slowLoadDocuments: (files: File[] | string, options: DocumentOptions, generatedDocuments: Doc[], text: string, completed: ((doc: Doc[]) => void) | undefined, addDocument: (doc: Doc | Doc[]) => boolean) => Promise; } /** * A component that deals with the marquee select in the freeform canvas. */ @observer export class MarqueeView extends ObservableReactComponent { public static CurViewBounds(pinDoc: Doc, panelWidth: number, panelHeight: number) { const ps = NumCast(pinDoc._freeform_scale, 1); return { left: NumCast(pinDoc._freeform_panX) - panelWidth / 2 / ps, top: NumCast(pinDoc._freeform_panY) - panelHeight / 2 / ps, width: panelWidth / ps, height: panelHeight / ps }; } // eslint-disable-next-line no-use-before-define static Instance: MarqueeView; constructor(props: SubCollectionViewProps & MarqueeViewProps) { super(props); makeObservable(this); MarqueeView.Instance = this; } 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; // ─── New Observables for “Pick 1 of N AI Scrapbook” ─── @observable aiChoices: Doc[] = []; // temporary hidden Scrapbook docs @observable pickerX = 0; // popup x coordinate @observable pickerY = 0; // popup y coordinate @observable pickerVisible = false; // show/hide ScrapbookPicker @computed get Transform() { return this._props.getTransform(); } @computed get Bounds() { // nda - ternary argument to transformPoint is returning the lower of the downX/Y and lastX/Y and passing in as args x,y const topLeft = this.Transform.transformPoint(this._downX < this._lastX ? this._downX : this._lastX, this._downY < this._lastY ? this._downY : this._lastY); // nda - args to transformDirection is just x and y diff btw downX/Y and lastX/Y const size = this.Transform.transformDirection(this._lastX - this._downX, this._lastY - this._downY); const bounds: MarqueeViewBounds = { left: topLeft[0], top: topLeft[1], width: Math.abs(size[0]), height: Math.abs(size[1]) }; return bounds; } public AddInkDoc: (points: InkData) => Doc | void = unimplementedFunction; componentDidMount() { this._props.setPreviewCursor?.(this.setPreviewCursor); } @action cleanupInteractions = (all: boolean = false, hideMarquee: boolean = true) => { if (all) { document.removeEventListener('pointerup', this.onPointerUp, true); document.removeEventListener('pointermove', this.onPointerMove, true); } document.removeEventListener('keydown', this.marqueeCommand, true); hideMarquee && this.hideMarquee(); this._lassoPts = []; }; @action onKeyDown = (e: KeyboardEvent) => { // make textbox and add it to this collection // tslint:disable-next-line:prefer-const const cm = ContextMenu.Instance; const [x, y] = this.Transform.transformPoint(this._downX, this._downY); if (e.key === '?') { cm.setDefaultItem('?', (str: string) => this._props.addDocTab(Docs.Create.WebDocument(`https://wikipedia.org/wiki/${str}`, { _width: 400, x, y, _height: 512, _nativeWidth: 850, title: `wiki:${str}`, data_useCors: true }), OpenWhere.addRight) ); cm.displayMenu(this._downX, this._downY, undefined, true); e.stopPropagation(); } else if (e.key === 'u' && this._props.ungroup) { e.stopPropagation(); this._props.ungroup(); } else if (e.key === ':') { DocUtils.addDocumentCreatorMenuItems(this._props.addLiveTextDocument, this._props.addDocument || returnFalse, x, y); cm.displayMenu(this._downX, this._downY, undefined, true); e.stopPropagation(); } else if (e.key === 'a' && (e.ctrlKey || e.metaKey)) { e.preventDefault(); this._props.selectDocuments(this._props.activeDocuments()); e.stopPropagation(); } else if (e.key === 'q' && e.ctrlKey) { e.preventDefault(); (async () => { const text: string = await navigator.clipboard.readText(); const ns = text.split('\n').filter(t => t.trim() !== '\r' && t.trim() !== ''); for (let i = 0; i < ns.length - 1; i++) { while ( !(ns[i].trim() === '' || ns[i].endsWith('-\r') || ns[i].endsWith('-') || ns[i].endsWith(';\r') || ns[i].endsWith(';') || ns[i].endsWith('.\r') || ns[i].endsWith('.') || ns[i].endsWith(':\r') || ns[i].endsWith(':')) && i < ns.length - 1 ) { const sub = ns[i].endsWith('\r') ? 1 : 0; const br = ns[i + 1].trim() === ''; ns.splice(i, 2, ns[i].substr(0, ns[i].length - sub) + ns[i + 1].trimLeft()); if (br) break; } } let ypos = y; ns.forEach(line => { const indent = line.search(/\S|$/); const newBox = Docs.Create.TextDocument(line, { _width: 200, _height: 35, x: x + (indent / 3) * 10, y: ypos, title: line }); this._props.addDocument?.(newBox); ypos += 40 * this.Transform.Scale; }); })(); e.stopPropagation(); } else if (e.key === 'b' && e.ctrlKey) { document.body.focus(); // so that we can access the clipboard without an error setTimeout(() => // eslint-disable-next-line @typescript-eslint/no-explicit-any pasteImageBitmap((data: any, error: any) => { error && console.log(error); data && ClientUtils.convertDataUri(data, this._props.Document[Id] + '_icon_' + new Date().getTime()).then(returnedfilename => { this._props.Document.$icon = new ImageField(returnedfilename); }); }) ); } /* else if (e.key === 's' && e.ctrlKey) { e.preventDefault(); const slide = DocUtils.copyDragFactory(DocCast(Doc.UserDoc().emptySlide))!; slide.x = x; slide.y = y; DocumentView.SetSelectOnLoad(slide); TreeView._editTitleOnLoad = { id: slide[Id], parent: undefined }; this._props.addDocument?.(slide); e.stopPropagation(); } */ else if (e.key === 'p' && e.ctrlKey) { e.preventDefault(); (async () => { const text: string = await navigator.clipboard.readText(); const ns = text.split('\n').filter(t => t.trim() !== '\r' && t.trim() !== ''); this.pasteTable(ns, x, y); })(); e.stopPropagation(); } else if (!e.ctrlKey && !e.metaKey && DocumentView.Selected().length < 2) { FormattedTextBox.SelectOnLoadChar = Doc.UserDoc().defaultTextLayout && !this._props.childLayoutString ? e.key : ''; FormattedTextBox.LiveTextUndo = UndoManager.StartBatch('type new note'); this._props.addLiveTextDocument(DocUtils.GetNewTextDoc('-typed text-', x, y, 200, 100)); setTimeout(() => FormattedTextBox.LiveTextUndo?.end(), 100); e.stopPropagation(); } }; // heuristically converts pasted text into a table. // assumes each entry is separated by a tab // skips all rows until it gets to a row with more than one entry // assumes that 1st row has header entry for each column // assumes subsequent rows have entries for each column header OR // any row that has only one column is a section header-- this header is then added as a column to subsequent rows until the next header // assumes each cell is a string or a number pasteTable(ns: string[], x: number, y: number) { const csvRows = []; const headers = ns[0].split('\t'); csvRows.push(headers.join(',')); ns[0] = ''; const eachCell = ns.join('\t').split('\t'); let eachRow = []; for (let i = 1; i < eachCell.length; i++) { eachRow.push(eachCell[i].replace(/,/g, '')); if (i % headers.length === 0) { csvRows.push(eachRow); eachRow = []; } } const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' }); const options = { x: x, y: y, title: 'droppedTable', _width: 300, _height: 100, type: 'text/csv' }; const file = new File([blob], 'droppedTable', options); const loading = Docs.Create.LoadingDocument(file, options); DocUtils.uploadFileToDoc(file, {}, loading); this._props.addDocument?.(loading); } @action onPointerDown = (e: React.PointerEvent): void => { this._downX = this._lastX = e.clientX; this._downY = this._lastY = e.clientY; const scrollMode = e.altKey ? (Doc.UserDoc().freeformScrollMode === freeformScrollMode.Pan ? freeformScrollMode.Zoom : freeformScrollMode.Pan) : Doc.UserDoc().freeformScrollMode; // allow marquee if right drag/meta drag, or pan mode if (e.button === 2 || e.metaKey || (this._props.isContentActive() && scrollMode === freeformScrollMode.Pan && Doc.ActiveTool === InkTool.None)) { this.setPreviewCursor(e.clientX, e.clientY, true, false, this._props.Document); e.preventDefault(); } else PreviewCursor.Instance.Visible = false; }; @action onPointerMove = (e: PointerEvent): void => { this._lastX = e.pageX; this._lastY = e.pageY; this._lassoPts.push([e.clientX, e.clientY]); if (!e.cancelBubble) { if (!ClientUtils.isClick(this._lastX, this._lastY, this._downX, this._downY, Date.now())) { if (!this._commandExecuted) { this.showMarquee(); } e.stopPropagation(); e.preventDefault(); } } else { this.cleanupInteractions(true); // stop listening for events if another lower-level handle (e.g. another Marquee) has stopPropagated this } e.altKey && e.preventDefault(); }; @action onPointerUp = (e: PointerEvent): void => { if (this._visible) { const mselect = this.marqueeSelect(); if (!e.shiftKey) { DocumentView.DeselectAll(mselect.length ? undefined : this._props.Document); } const docs = mselect.length ? mselect : [this._props.Document]; this._props.selectDocuments(docs); } const hideMarquee = () => { this.hideMarquee(); MarqueeOptionsMenu.Instance.fadeOut(true); document.removeEventListener('pointerdown', hideMarquee, true); document.removeEventListener('wheel', hideMarquee, true); }; if (!this._commandExecuted && Math.abs(this.Bounds.height * this.Bounds.width) > 100) { MarqueeOptionsMenu.Instance.createCollection = this.collection; MarqueeOptionsMenu.Instance.delete = this.delete; MarqueeOptionsMenu.Instance.summarize = this.summary; MarqueeOptionsMenu.Instance.generateScrapbook = this.generateScrapbook; MarqueeOptionsMenu.Instance.showMarquee = this.showMarquee; 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 { this.hideMarquee(); } this.cleanupInteractions(true, this._commandExecuted); e.altKey && e.preventDefault(); }; clearSelection() { if (window.getSelection) { window.getSelection()?.removeAllRanges(); } else if (document.getSelection()) { document.getSelection()?.empty(); } } setPreviewCursor = action((x: number, y: number, drag: boolean, hide: boolean, doc: Opt) => { if (hide) { this._downX = this._lastX = x; this._downY = this._lastY = y; this._commandExecuted = false; PreviewCursor.Instance.Visible = false; PreviewCursor.Instance.Doc = undefined; } else if (drag) { this._downX = this._lastX = x; this._downY = this._lastY = y; this._commandExecuted = false; PreviewCursor.Instance.Visible = false; PreviewCursor.Instance.Doc = undefined; this.cleanupInteractions(true); document.addEventListener('pointermove', this.onPointerMove, true); document.addEventListener('pointerup', this.onPointerUp, true); document.addEventListener('keydown', this.marqueeCommand, true); } else { this._downX = x; this._downY = y; const effectiveAcl = GetEffectiveAcl(this._props.Document[DocData]); if ([AclAdmin, AclEdit, AclAugment].includes(effectiveAcl)) { PreviewCursor.Instance.Doc = doc; PreviewCursor.Show(x, y, this.onKeyDown, this._props.addLiveTextDocument, this._props.getTransform, this._props.addDocument, this._props.nudge, this._props.slowLoadDocuments); } this.clearSelection(); } }); @action onClick = (e: React.MouseEvent): void => { if (this._props.pointerEvents?.() === 'none') return; if (ClientUtils.isClick(e.clientX, e.clientY, this._downX, this._downY, Date.now())) { if (Doc.ActiveTool === InkTool.None) { if (!this._props.trySelectCluster(e.shiftKey)) { !SnappingManager.ExploreMode && this.setPreviewCursor(e.clientX, e.clientY, false, false, this._props.Document); } else e.stopPropagation(); } // let the DocumentView stopPropagation of this event when it selects this document } else { // why do we get a click event when the cursor have moved a big distance? // let's cut it off here so no one else has to deal with it. e.stopPropagation(); } }; @action showMarquee = () => { this._visible = true; }; @action hideMarquee = () => { this._visible = false; }; @undoBatch delete = action((e?: React.PointerEvent | KeyboardEvent | undefined, hide?: boolean) => { const selected = this.marqueeSelect(false); DocumentView.DeselectAll(); selected.forEach(doc => { hide ? (doc.hidden = true) : this._props.removeDocument?.(doc); }); this.cleanupInteractions(false); MarqueeOptionsMenu.Instance.fadeOut(true); this.hideMarquee(); }); public static getCollection = action((selected: Doc[], creator: Opt<(documents: Array, options: DocumentOptions, id?: string) => Doc>, makeGroup: Opt, bounds: MarqueeViewBounds) => { const newCollection = creator ? creator(selected, { title: 'nested stack' }) : ((doc: Doc) => { doc.$data = new List(selected); doc.$freeform_isGroup = makeGroup; doc.$title = makeGroup ? 'grouping' : 'nested freeform'; doc._freeform_panX = doc._freeform_panY = 0; return doc; })(DocCast(Doc.UserDoc().emptyCollection) ? Doc.MakeCopy(DocCast(Doc.UserDoc().emptyCollection)!, true) : Docs.Create.FreeformDocument([], {})); newCollection.isSystem = undefined; newCollection._width = bounds.width || 1; // if width/height are unset/0, then groups won't autoexpand to contain their children newCollection._height = bounds.height || 1; newCollection._dragWhenActive = makeGroup; newCollection.x = bounds.left; newCollection.y = bounds.top; newCollection.layout_fitWidth = true; selected.forEach(d => Doc.SetContainer(d, newCollection)); return newCollection; }); @undoBatch pileup = action(() => { const selected = this.marqueeSelect(false); DocumentView.DeselectAll(); selected.forEach(d => this._props.removeDocument?.(d)); const newCollection = DocUtils.pileup(selected, this.Bounds.left + this.Bounds.width / 2, this.Bounds.top + this.Bounds.height / 2)!; this._props.addDocument?.(newCollection); this._props.selectDocuments([newCollection]); MarqueeOptionsMenu.Instance.fadeOut(true); this.hideMarquee(); }); /** * This triggers the DocumentView.PinDoc method which is the universal method * used to pin documents to the currently active presentation trail. * * This one is unique in that it includes the bounds associated with marquee view. */ @undoBatch pinWithView = action(() => { this._props.pinToPres(this._props.Document, { pinViewport: this.Bounds }); MarqueeOptionsMenu.Instance.fadeOut(true); this.hideMarquee(); }); @undoBatch collection = action((e: KeyboardEvent | React.PointerEvent | undefined, group?: boolean, selection?: Doc[]) => { const selected = selection ?? this.marqueeSelect(false); const activeFrame = selected.reduce((v, d) => v ?? Cast(d._activeFrame, 'number', null), undefined as number | undefined); if (e instanceof KeyboardEvent ? 'cg'.includes(e.key) : true) { this._props.removeDocument?.(selected); } const newCollection = MarqueeView.getCollection(selected, (e as KeyboardEvent)?.key === 't' ? Docs.Create.StackingDocument : undefined, group, this.Bounds); newCollection._freeform_panX = this.Bounds.left + this.Bounds.width / 2; newCollection._freeform_panY = this.Bounds.top + this.Bounds.height / 2; newCollection._currentFrame = activeFrame; this._props.addDocument?.(newCollection); this._props.selectDocuments([newCollection]); MarqueeOptionsMenu.Instance.fadeOut(true); this.hideMarquee(); return newCollection; }); /** * Classifies images and assigns the labels as document fields. */ @undoBatch classifyImages = async () => { const groupButton = DocListCast(Doc.MyLeftSidebarMenu?.data).find(d => d.target === Doc.MyImageGrouper); if (groupButton) { this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG); ImageLabelBoxData.Instance.setData(this._selectedDocs); ScriptCast(groupButton.onClick)?.script.run({ this: groupButton }); } }; /** * Groups images to most similar labels. */ @undoBatch groupImages = action(async () => { const labelGroups: string[] = ImageLabelBoxData.Instance._labelGroups; const labelToCollection: Map = new Map(); const selectedImages = ImageLabelBoxData.Instance._docs; // Create new collections associated with each label and get the embeddings for the labels. let x_offset = 0; let y_offset = 0; let row_count = 0; const newColDim = 900; for (const label of labelGroups) { const newCollection = MarqueeView.getCollection([], undefined, false, this.Bounds); newCollection.$title = label + ' Collection'; newCollection.x = this.Bounds.left + x_offset; newCollection.y = this.Bounds.top + y_offset; newCollection._width = newColDim; newCollection._height = newColDim; newCollection._freeform_panX = this.Bounds.left + this.Bounds.width / 2; newCollection._freeform_panY = this.Bounds.top + this.Bounds.height / 2; x_offset += newColDim + 40; row_count += 1; if (row_count == 3) { y_offset += newColDim + 40; x_offset = 0; row_count = 0; } labelToCollection.set(label, newCollection); this._props.addDocument?.(newCollection); } for (const doc of selectedImages) { if (doc.$data_label) { Doc.AddDocToList(labelToCollection.get(doc.$data_label as string)!, undefined, doc); this._props.removeDocument?.(doc); } } //this._props.Document._type_collection = CollectionViewType.Time; // Change the collection view to a Time view. //this._props.Document.pivotField = 'data_label'; // Sets the pivot to be the 'data_label'. }); @undoBatch summary = action(() => { const selected = this.marqueeSelect(false).map(d => { this._props.removeDocument?.(d); d.x = NumCast(d.x) - this.Bounds.left; d.y = NumCast(d.y) - this.Bounds.top; return d; }); const summary = Docs.Create.TextDocument('', { backgroundColor: '#e2ad32', x: this.Bounds.left, y: this.Bounds.top, followLinkToggle: true, _width: 200, _height: 200, _layout_showSidebar: true, title: 'overview', }); const portal = Docs.Create.FreeformDocument(selected, { title: 'summarized documents', x: this.Bounds.left + 200, y: this.Bounds.top, freeform_isGroup: true, backgroundColor: 'transparent' }); DocUtils.MakeLink(summary, portal, { link_relationship: 'summary of:summarized by' }); portal.hidden = true; this._props.addDocument?.(portal); this._props.addLiveTextDocument(summary); MarqueeOptionsMenu.Instance.fadeOut(true); }); getAiPresetsDescriptors = (): DocumentDescriptor[] => this.marqueeSelect(false).map(doc => ({ type: typeof doc.$type === 'string' ? doc.$type : 'UNKNOWN', tags: Array.from(new Set(StrListCast(doc.$tags_chat))), })); generateScrapbook = action(async () => { const selectedDocs = this.marqueeSelect(false); if (!selectedDocs.length) return; const descriptors = this.getAiPresetsDescriptors(); if (descriptors.length === 0) { alert('No documents selected to generate a scrapbook from!'); return; } const aiPreset = await requestAiGeneratedPreset(descriptors); if (!aiPreset.length) { alert('Failed to generate preset'); return; } const scrapbookPlaceholders: Doc[] = buildPlaceholdersFromConfigs(aiPreset); /* const scrapbookPlaceholders: Doc[] = aiPreset.map(cfg => { const placeholderDoc = Docs.Create.TextDocument(cfg.tag); placeholderDoc.placeholder_docType = cfg.type as DocumentType; placeholderDoc.placeholder_acceptTags = new List(cfg.acceptTags ?? [cfg.tag]); const placeholder = new Doc(); placeholder.proto = placeholderDoc; placeholder.original = placeholderDoc; placeholder.x = cfg.x; placeholder.y = cfg.y; if (cfg.width != null) placeholder._width = cfg.width; if (cfg.height != null) placeholder._height = cfg.height; return placeholder; });*/ const scrapbook = Docs.Create.ScrapbookDocument(scrapbookPlaceholders, { backgroundColor: '#e2ad32', x: this.Bounds.left, y: this.Bounds.top, _width: 500, _height: 500, title: 'AI-generated Scrapbook', }); // 3) Now grab that new scrapbook’s flat placeholders const allPlaceholders = DocUtils.unwrapPlaceholders(scrapbookPlaceholders); // 4) Slot each selectedDocs[i] into the first matching placeholder selectedDocs.forEach(realDoc => slotRealDocIntoPlaceholders(realDoc, allPlaceholders)); const selected = selectedDocs.map(d => { this._props.removeDocument?.(d); d.x = NumCast(d.x) - this.Bounds.left; d.y = NumCast(d.y) - this.Bounds.top; return d; }); this._props.addDocument?.(scrapbook); const portal = Docs.Create.FreeformDocument(selected, { title: 'docs in scrapbook', x: this.Bounds.left + 200, y: this.Bounds.top, isGroup: true, backgroundColor: 'transparent' }); DocUtils.MakeLink(scrapbook, portal, { link_relationship: 'scrapbook of:in scrapbook' }); portal.hidden = true; this._props.addDocument?.(portal); MarqueeOptionsMenu.Instance.fadeOut(true); this.hideMarquee(); }); @action marqueeCommand = (e: KeyboardEvent) => { const ee = e as unknown as KeyboardEvent & { propagationIsStopped?: boolean }; if (this._commandExecuted || ee.propagationIsStopped) { return; } if (e.key === 'Backspace' || e.key === 'Delete' || e.key === 'd' || e.key === 'h') { this._commandExecuted = true; e.stopPropagation(); ee.propagationIsStopped = true; this.delete(e, e.key === 'h'); e.stopPropagation(); } if ('ctsSpg'.indexOf(e.key) !== -1) { this._commandExecuted = true; e.stopPropagation(); e.preventDefault(); ee.propagationIsStopped = true; if (e.key === 'g') this.collection(e, true); if (e.key === 'c' || e.key === 't') this.collection(e); if (e.key === 's' || e.key === 'S') this.summary(); if (e.key === 'g' || e.key === 'G') this.generateScrapbook(); // ← scrapbook shortcut if (e.key === 'p') this.pileup(); this.cleanupInteractions(false); } if (e.key === 'r' || e.key === ' ') { this._commandExecuted = true; e.stopPropagation(); e.preventDefault(); this._lassoFreehand = !this._lassoFreehand; } }; touchesLine(r1: { left: number; top: number; width: number; height: number }) { for (const lassoPt of this._lassoPts) { const topLeft = this.Transform.transformPoint(lassoPt[0], lassoPt[1]); if (r1.left < topLeft[0] && topLeft[0] < r1.left + r1.width && r1.top < topLeft[1] && topLeft[1] < r1.top + r1.height) { return true; } } return false; } boundingShape(r1: { left: number; top: number; width: number; height: number }) { const xs = this._lassoPts.map(pair => pair[0]); const ys = this._lassoPts.map(pair => pair[1]); const tl = this.Transform.transformPoint(Math.min(...xs), Math.min(...ys)); const br = this.Transform.transformPoint(Math.max(...xs), Math.max(...ys)); if (r1.left > tl[0] && r1.top > tl[1] && r1.left + r1.width < br[0] && r1.top + r1.height < br[1]) { let hasTop = false; let hasLeft = false; let hasBottom = false; let hasRight = false; for (const lassoPt of this._lassoPts) { const truePoint = this.Transform.transformPoint(lassoPt[0], lassoPt[1]); hasLeft = hasLeft || (truePoint[0] > tl[0] && truePoint[0] < r1.left && truePoint[1] > r1.top && truePoint[1] < r1.top + r1.height); hasTop = hasTop || (truePoint[1] > tl[1] && truePoint[1] < r1.top && truePoint[0] > r1.left && truePoint[0] < r1.left + r1.width); hasRight = hasRight || (truePoint[0] < br[0] && truePoint[0] > r1.left + r1.width && truePoint[1] > r1.top && truePoint[1] < r1.top + r1.height); hasBottom = hasBottom || (truePoint[1] < br[1] && truePoint[1] > r1.top + r1.height && truePoint[0] > r1.left && truePoint[0] < r1.left + r1.width); if (hasTop && hasLeft && hasBottom && hasRight) { return true; } } } return 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 bounds = { left: NumCast(doc.x), top: NumCast(doc.y), width: NumCast(doc._width), height: NumCast(doc._height) }; if (!this._lassoFreehand) { intersectRect(bounds, this.Bounds) && selection.push(doc); } else { (this.touchesLine(bounds) || this.boundingShape(bounds)) && selection.push(doc); } }; 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() .filter(doc => doc.z === undefined) .map(selectFunc); if (!selection.length) this._props .activeDocuments() .filter(doc => doc.z !== undefined) .map(selectFunc); return selection; }; @computed get marqueeDiv() { const cpt = this._lassoFreehand || !this._visible ? [0, 0] : [this._downX < this._lastX ? this._downX : this._lastX, this._downY < this._lastY ? this._downY : this._lastY]; const p = this._props.getContainerTransform().transformPoint(cpt[0], cpt[1]); const v = this._lassoFreehand ? [0, 0] : this._props.getContainerTransform().transformDirection(this._lastX - this._downX, this._lastY - this._downY); return (
{' '} {this._lassoFreehand ? ( s + pt[0] + ',' + pt[1] + ' ', '')} fill="none" stroke={lightOrDark((this._props.Document?.backgroundColor as string) ?? 'white')} strokeWidth="1" strokeDasharray="3" /> ) : ( )}
); } MarqueeRef: HTMLDivElement | null = null; /** * This is called for every drag movement when a document is dragged over this collection. * If the document is dragged within 25 pixels of the edge of the collection and paused, this will * auto scroll the collection so that it can be dragged farther (unless auto panning has been disabled) */ @action onDragMovePause = (e: CustomEvent) => { const ee = e as CustomEvent & { handlePan?: boolean }; if (ee.handlePan || this._props.isAnnotationOverlay) return; ee.handlePan = true; const bounds = this.MarqueeRef?.getBoundingClientRect(); if (!this._props.Document._freeform_noAutoPan && !this._props.renderDepth && bounds) { const dragX = e.detail.clientX; const dragY = e.detail.clientY; const deltaX = dragX - bounds.left < 25 ? -(25 + (bounds.left - dragX)) : bounds.right - dragX < 25 ? 25 - (bounds.right - dragX) : 0; const deltaY = dragY - bounds.top < 25 ? -(25 + (bounds.top - dragY)) : bounds.bottom - dragY < 25 ? 25 - (bounds.bottom - dragY) : 0; if (deltaX !== 0 || deltaY !== 0) { this._props.Document[this._props.panYFieldKey] = NumCast(this._props.Document[this._props.panYFieldKey]) + deltaY / 2; this._props.Document[this._props.panXFieldKey] = NumCast(this._props.Document[this._props.panXFieldKey]) + deltaX / 2; } } e.stopPropagation(); }; setRef = (r: HTMLDivElement | null) => { r?.addEventListener('dashDragMovePause', this.onDragMovePause as EventListenerOrEventListenerObject); this.MarqueeRef = r; }; render() { return (
e.preventDefault()} onScroll={e => (e.currentTarget.scrollTop = (e.currentTarget.scrollLeft = 0))} // prettier-ignore onClick={this.onClick} onPointerDown={this.onPointerDown}> {this._visible ? this.marqueeDiv : null} {this.props.children}
); } }