diff options
Diffstat (limited to 'src/client/views/nodes')
29 files changed, 837 insertions, 457 deletions
diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx index 0ae4ed62c..a0a64ab59 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.tsx +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.tsx @@ -33,7 +33,6 @@ export interface CollectionFreeFormDocumentViewWrapperProps extends DocumentView opacity?: number; highlight?: boolean; transition?: string; - dataTransition?: string; RenderCutoffProvider: (doc: Doc) => boolean; CollectionFreeFormView: CollectionFreeFormView; } @@ -56,7 +55,6 @@ export class CollectionFreeFormDocumentViewWrapper extends ObservableReactCompon @observable Height = this.props.height; @observable AutoDim = this.props.autoDim; @observable Transition = this.props.transition; - @observable DataTransition = this.props.dataTransition; CollectionFreeFormView = this.props.CollectionFreeFormView; // needed for type checking RenderCutoffProvider = this.props.RenderCutoffProvider; // needed for type checking @@ -83,7 +81,6 @@ export class CollectionFreeFormDocumentViewWrapper extends ObservableReactCompon w_Height = () => this.Height; // prettier-ignore w_AutoDim = () => this.AutoDim; // prettier-ignore w_Transition = () => this.Transition; // prettier-ignore - w_DataTransition = () => this.DataTransition; // prettier-ignore PanelWidth = () => this._props.autoDim ? this._props.PanelWidth?.() : this.Width; // prettier-ignore PanelHeight = () => this._props.autoDim ? this._props.PanelHeight?.() : this.Height; // prettier-ignore @@ -117,7 +114,6 @@ export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps { w_Transition: () => string | undefined; w_Width: () => number; w_Height: () => number; - w_DataTransition: () => string | undefined; PanelWidth: () => number; PanelHeight: () => number; RenderCutoffProvider: (doc: Doc) => boolean; @@ -288,14 +284,21 @@ export class CollectionFreeFormDocumentView extends DocComponent<CollectionFreeF width: this._props.PanelWidth(), height: this._props.PanelHeight(), transform: `translate(${this._props.w_X()}px, ${this._props.w_Y()}px) rotate(${NumCast(this._props.w_Rotation?.())}deg)`, - transition: this._props.w_Transition?.() ?? (this._props.w_DataTransition?.() || this._props.w_Transition?.()), + transition: this._props.w_Transition?.() || StrCast(this.Document.dataTransition), zIndex: this._props.w_ZIndex?.(), display: this._props.w_Width?.() ? undefined : 'none', }}> {this._props.RenderCutoffProvider(this.Document) ? ( <div style={{ position: 'absolute', width: this._props.PanelWidth(), height: this._props.PanelHeight(), background: 'lightGreen' }} /> ) : ( - <DocumentView {...passOnProps} CollectionFreeFormDocumentView={this.returnThis} styleProvider={this.styleProvider} ScreenToLocalTransform={this.screenToLocalTransform} isGroupActive={this.isGroupActive} /> + <DocumentView + {...passOnProps} + DataTransition={this._props.w_Transition} + CollectionFreeFormDocumentView={this.returnThis} + styleProvider={this.styleProvider} + ScreenToLocalTransform={this.screenToLocalTransform} + isGroupActive={this.isGroupActive} + /> )} </div> ); diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.scss b/src/client/views/nodes/DataVizBox/DataVizBox.scss index a3132dc6e..6b5738790 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.scss +++ b/src/client/views/nodes/DataVizBox/DataVizBox.scss @@ -29,6 +29,10 @@ } } + .liveSchema-checkBox { + margin-bottom: -35px; + } + .dataviz-sidebar { position: absolute; right: 0; diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index 8365f4770..66a08f13e 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -1,6 +1,6 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Colors, Toggle, ToggleType, Type } from 'browndash-components'; -import { ObservableMap, action, computed, makeObservable, observable, runInAction } from 'mobx'; +import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { emptyFunction, returnEmptyString, returnFalse, returnOne, setupMoveUpEvents } from '../../../../Utils'; @@ -11,7 +11,7 @@ import { listSpec } from '../../../../fields/Schema'; import { Cast, CsvCast, DocCast, NumCast, StrCast } from '../../../../fields/Types'; import { CsvField } from '../../../../fields/URLField'; import { TraceMobx } from '../../../../fields/util'; -import { Docs } from '../../../documents/Documents'; +import { DocUtils, Docs } from '../../../documents/Documents'; import { DocumentManager } from '../../../util/DocumentManager'; import { UndoManager, undoable } from '../../../util/UndoManager'; import { ViewBoxAnnotatableComponent, ViewBoxInterface } from '../../DocComponent'; @@ -27,6 +27,7 @@ import { Histogram } from './components/Histogram'; import { LineChart } from './components/LineChart'; import { PieChart } from './components/PieChart'; import { TableBox } from './components/TableBox'; +import { Checkbox } from '@mui/material'; export enum DataVizView { TABLE = 'table', @@ -40,9 +41,9 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im private _mainCont: React.RefObject<HTMLDivElement> = React.createRef(); private _marqueeref = React.createRef<MarqueeAnnotator>(); private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef(); + private _disposers: { [name: string]: IReactionDisposer } = {}; anchorMenuClick?: () => undefined | ((anchor: Doc) => void); crop: ((region: Doc | undefined, addCrop?: boolean) => Doc | undefined) | undefined; - @observable _schemaDataVizChildren: any = undefined; @observable _marqueeing: number[] | undefined = undefined; @observable _savedAnnotations = new ObservableMap<number, HTMLDivElement[]>(); @@ -85,6 +86,10 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im // all datasets that have been retrieved from the server stored as a map from the dataset url to an array of records static dataset = new ObservableMap<string, { [key: string]: string }[]>(); + // when a dataset comes from schema view, this stores the original dataset to refer back to + // href : dataset + static datasetSchemaOG = new ObservableMap<string, { [key: string]: string }[]>(); + private _vizRenderer: LineChart | Histogram | PieChart | undefined; private _sidebarRef = React.createRef<SidebarAnnos>(); @@ -103,10 +108,13 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im return Cast(this.dataDoc[this.fieldKey], CsvField); } @computed.struct get axes() { - return StrListCast(this.layoutDoc.dataViz_axes); + return StrListCast(this.layoutDoc._dataViz_axes); } - - selectAxes = (axes: string[]) => (this.layoutDoc.dataViz_axes = new List<string>(axes)); + selectAxes = (axes: string[]) => (this.layoutDoc._dataViz_axes = new List<string>(axes)); + @computed.struct get titleCol() { + return StrCast(this.layoutDoc._dataViz_titleCol); + } + selectTitleCol = (titleCol: string) => (this.layoutDoc._dataViz_titleCol = titleCol); @action // pinned / linked anchor doc includes selected rows, graph titles, and graph colors restoreView = (data: Doc) => { @@ -256,12 +264,73 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im componentDidMount() { this._props.setContentViewBox?.(this); if (!DataVizBox.dataset.has(CsvCast(this.dataDoc[this.fieldKey]).url.href)) this.fetchData(); + this._disposers.datavis = reaction( + () => { + if (this.layoutDoc.dataViz_schemaLive==undefined) this.layoutDoc.dataViz_schemaLive = true; + const getFrom = DocCast(this.layoutDoc.dataViz_asSchema); + const keys = Cast(getFrom?.schema_columnKeys, listSpec('string'))?.filter(key => key != 'text'); + if (!keys) return; + const children = DocListCast(getFrom[Doc.LayoutFieldKey(getFrom)]); + var current: { [key: string]: string }[] = []; + children + .filter(child => child) + .forEach(child => { + const row: { [key: string]: string } = {}; + keys.forEach(key => { + var cell = child[key]; + if (cell && (cell as string)) cell = cell.toString().replace(/\,/g, ''); + row[key] = StrCast(cell); + }); + current.push(row); + }); + if (!this.layoutDoc._dataViz_schemaOG){ // makes a copy of the original table for the "live" toggle + let csvRows = []; + csvRows.push(keys.join(',')); + for (let i = 0; i < children.length-1; i++) { + let eachRow = []; + for (let j = 0; j < keys.length; j++) { + var cell = children[i][keys[j]]; + if (cell && (cell as string)) cell = cell.toString().replace(/\,/g, ''); + eachRow.push(cell); + } + csvRows.push(eachRow); + } + const blob = new Blob([csvRows.join('\n')], { type: 'text/csv' }); + const options = { x: 0, y: 0, title: 'schemaTable for static dataviz', _width: 300, _height: 100, type: 'text/csv' }; + const file = new File([blob], 'schemaTable for static dataviz', options); + const loading = Docs.Create.LoadingDocument(file, options); + DocUtils.uploadFileToDoc(file, {}, loading); + this.layoutDoc._dataViz_schemaOG = loading; + } + const ogDoc = this.layoutDoc._dataViz_schemaOG as Doc + const ogHref = CsvCast(ogDoc[this.fieldKey])? CsvCast(ogDoc[this.fieldKey]).url.href : undefined; + const href = CsvCast(this.Document[this.fieldKey]).url.href + if (ogHref && !DataVizBox.datasetSchemaOG.has(href)){ // sets original dataset to the var + const lastRow = current.pop(); + DataVizBox.datasetSchemaOG.set(href, current); + current.push(lastRow!); + fetch('/csvData?uri=' + ogHref) + .then(res => res.json().then(action(res => !res.errno && DataVizBox.datasetSchemaOG.set(href, res)))); + } + return current; + }, + current => { + if (current) { + const href = CsvCast(this.Document[this.fieldKey]).url.href; + if (this.layoutDoc.dataViz_schemaLive) DataVizBox.dataset.set(href, current); + else DataVizBox.dataset.set(href, DataVizBox.datasetSchemaOG.get(href)!); + } + }, + { fireImmediately: true } + ); } fetchData = () => { - DataVizBox.dataset.set(CsvCast(this.dataDoc[this.fieldKey]).url.href, []); // assign temporary dataset as a lock to prevent duplicate server requests - fetch('/csvData?uri=' + this.dataUrl?.url.href) // - .then(res => res.json().then(action(res => !res.errno && DataVizBox.dataset.set(CsvCast(this.dataDoc[this.fieldKey]).url.href, res)))); + if (!this.Document.dataViz_asSchema) { + DataVizBox.dataset.set(CsvCast(this.dataDoc[this.fieldKey]).url.href, []); // assign temporary dataset as a lock to prevent duplicate server requests + fetch('/csvData?uri=' + this.dataUrl?.url.href) // + .then(res => res.json().then(action(res => !res.errno && DataVizBox.dataset.set(CsvCast(this.dataDoc[this.fieldKey]).url.href, res)))); + } }; // toggles for user to decide which chart type to view the data in @@ -272,6 +341,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im layoutDoc: this.layoutDoc, records: this.records, axes: this.axes, + titleCol: this.titleCol, //width: this.SidebarShown? this._props.PanelWidth()*.9/1.2: this._props.PanelWidth() * 0.9, height: (this._props.PanelHeight() / scale - 32) /* height of 'change view' button */ * 0.9, width: ((this._props.PanelWidth() - this.sidebarWidth()) / scale) * 0.9, @@ -279,7 +349,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im }; if (!this.records.length) return 'no data/visualization'; switch (this.dataVizView) { - case DataVizView.TABLE: return <TableBox {...sharedProps} docView={this.DocumentView} selectAxes={this.selectAxes} />; + case DataVizView.TABLE: return <TableBox {...sharedProps} docView={this.DocumentView} selectAxes={this.selectAxes} selectTitleCol={this.selectTitleCol}/>; case DataVizView.LINECHART: return <LineChart {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => (this._vizRenderer = r ?? undefined)} vizBox={this} />; case DataVizView.HISTOGRAM: return <Histogram {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => (this._vizRenderer = r ?? undefined)} />; case DataVizView.PIECHART: return <PieChart {...sharedProps} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => (this._vizRenderer = r ?? undefined)} @@ -328,32 +398,11 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im }; @action - updateSchemaViz = () => { - const getFrom = DocCast(this.layoutDoc.dataViz_asSchema); - const keys = Cast(getFrom.schema_columnKeys, listSpec('string'))?.filter(key => key != 'text'); - if (!keys) return; - const children = DocListCast(getFrom[Doc.LayoutFieldKey(getFrom)]); - var current: { [key: string]: string }[] = []; - for (let i = 0; i < children.length; i++) { - var row: { [key: string]: string } = {}; - if (children[i]) { - for (let j = 0; j < keys.length; j++) { - var cell = children[i][keys[j]]; - if (cell && (cell as string)) cell = cell.toString().replace(/\,/g, ''); - row[keys[j]] = StrCast(cell); - } - } - current.push(row); - } - DataVizBox.dataset.set(CsvCast(this.Document[this.fieldKey]).url.href, current); - }; + changeLiveSchemaCheckbox = () => { + this.layoutDoc.dataViz_schemaLive = !this.layoutDoc.dataViz_schemaLive + } render() { - if (this.layoutDoc && this.layoutDoc.dataViz_asSchema) { - this._schemaDataVizChildren = DocListCast(DocCast(this.layoutDoc.dataViz_asSchema)[Doc.LayoutFieldKey(DocCast(this.layoutDoc.dataViz_asSchema))]).length; - this.updateSchemaViz(); - } - const scale = this._props.NativeDimScaling?.() || 1; return !this.records.length ? ( // displays how to get data into the DataVizBox if its empty @@ -378,27 +427,15 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() im <Toggle text={'PIE CHART'} toggleType={ToggleType.BUTTON} type={Type.SEC} color={'black'} onClick={e => (this.layoutDoc._dataViz = DataVizView.PIECHART)} toggleStatus={this.layoutDoc._dataViz == -DataVizView.PIECHART} /> </div> - {/* <CollectionFreeFormView - ref={this._ffref} - {...this._props} - setContentView={emptyFunction} - renderDepth={this._props.renderDepth - 1} - fieldKey={this.annotationKey} - styleProvider={this._props.styleProvider} - isAnnotationOverlay={true} - annotationLayerHostsContent={false} - PanelWidth={this._props.PanelWidth} - PanelHeight={this._props.PanelHeight} - select={emptyFunction} - isAnyChildContentActive={returnFalse} - whenChildContentsActiveChanged={this.whenChildContentsActiveChanged} - removeDocument={this.removeDocument} - moveDocument={this.moveDocument} - addDocument={this.addDocument}> - {this.renderVizView} - </CollectionFreeFormView> */} + {(this.layoutDoc && this.layoutDoc.dataViz_asSchema)?( + <div className={'liveSchema-checkBox'} style={{ width: this._props.width }}> + <Checkbox color="primary" onChange={this.changeLiveSchemaCheckbox} checked={this.layoutDoc.dataViz_schemaLive as boolean} /> + Display Live Updates to Canvas + </div> + ) : null} {this.renderVizView} + <div className="dataviz-sidebar" style={{ width: `${this.sidebarWidthPercent}`, backgroundColor: `${this.sidebarColor}` }} onPointerDown={this.onPointerDown}> <SidebarAnnos ref={this._sidebarRef} diff --git a/src/client/views/nodes/DataVizBox/components/Chart.scss b/src/client/views/nodes/DataVizBox/components/Chart.scss index 2f7dd0487..41ce637ac 100644 --- a/src/client/views/nodes/DataVizBox/components/Chart.scss +++ b/src/client/views/nodes/DataVizBox/components/Chart.scss @@ -19,8 +19,6 @@ margin-bottom: -20px; } .asHistogram-checkBox { - // display: flex; - // flex-direction: row; align-items: left; align-self: left; align-content: left; @@ -93,7 +91,6 @@ display: flex; flex-direction: column; cursor: default; - margin-top: 30px; height: calc(100% - 40px); // bcz: hack 40px is the size of the button rows .tableBox-container { overflow: scroll; diff --git a/src/client/views/nodes/DataVizBox/components/Histogram.tsx b/src/client/views/nodes/DataVizBox/components/Histogram.tsx index 4a1fb2ed1..6672603f3 100644 --- a/src/client/views/nodes/DataVizBox/components/Histogram.tsx +++ b/src/client/views/nodes/DataVizBox/components/Histogram.tsx @@ -20,6 +20,7 @@ export interface HistogramProps { Document: Doc; layoutDoc: Doc; axes: string[]; + titleCol: string; records: { [key: string]: any }[]; width: number; height: number; @@ -63,17 +64,17 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { if (this._props.axes.length < 1) return []; if (this._props.axes.length < 2) { var ax0 = this._props.axes[0]; - if (/\d/.test(this._props.records[0][ax0])) { + if (!/[A-Za-z-:]/.test(this._props.records[0][ax0])){ this.numericalXData = true; } return this._tableData.map(record => ({ [ax0]: record[this._props.axes[0]] })); } var ax0 = this._props.axes[0]; var ax1 = this._props.axes[1]; - if (/\d/.test(this._props.records[0][ax0])) { + if (!/[A-Za-z-:]/.test(this._props.records[0][ax0])) { this.numericalXData = true; } - if (/\d/.test(this._props.records[0][ax1])) { + if (!/[A-Za-z-:]/.test(this._props.records[0][ax1])) { this.numericalYData = true; } return this._tableData.map(record => ({ [ax0]: record[this._props.axes[0]], [ax1]: record[this._props.axes[1]] })); @@ -89,9 +90,6 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { @computed get parentViz() { return DocCast(this._props.Document.dataViz_parentViz); - // return LinkManager.Instance.getAllRelatedLinks(this._props.Document) // out of all links - // .filter(link => link.link_anchor_1 == this._props.Document.dataViz_parentViz) // get links where this chart doc is the target of the link - // .map(link => DocCast(link.link_anchor_1)); // then return the source of the link } @computed get rangeVals(): { xMin?: number; xMax?: number; yMin?: number; yMax?: number } { @@ -438,9 +436,9 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { this.updateBarColors(); this._histogramData; var curSelectedBarName = ''; - var titleAccessor: any = ''; - if (this._props.axes.length == 2) titleAccessor = 'dataViz_histogram_title' + this._props.axes[0] + '-' + this._props.axes[1]; - else if (this._props.axes.length > 0) titleAccessor = 'dataViz_histogram_title' + this._props.axes[0]; + var titleAccessor: any = 'dataViz_histogram_title'; + if (this._props.axes.length == 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; + else if (this._props.axes.length > 0) titleAccessor = titleAccessor + this._props.axes[0]; if (!this._props.layoutDoc[titleAccessor]) this._props.layoutDoc[titleAccessor] = this.defaultGraphTitle; if (!this._props.layoutDoc.dataViz_histogram_defaultColor) this._props.layoutDoc.dataViz_histogram_defaultColor = '#69b3a2'; if (!this._props.layoutDoc.dataViz_histogram_barColors) this._props.layoutDoc.dataViz_histogram_barColors = new List<string>(); @@ -454,6 +452,18 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { : '' ); selected = selected.substring(0, selected.length - 2) + ' }'; + if (this._props.titleCol!="" && (!this._currSelected["frequency"] || this._currSelected["frequency"]<10)){ + selected+= "\n" + this._props.titleCol + ": " + this._tableData.forEach(each => { + if (this._currSelected[this._props.axes[0]]==each[this._props.axes[0]]) { + if (this._props.axes[1]){ + if (this._currSelected[this._props.axes[1]]==each[this._props.axes[1]]) selected+= each[this._props.titleCol] + ", "; + } + else selected+= each[this._props.titleCol] + ", "; + } + }) + selected = selected.slice(0,-1).slice(0,-1); + } } var selectedBarColor; var barColors = StrListCast(this._props.layoutDoc.histogramBarColors).map(each => each.split('::')); diff --git a/src/client/views/nodes/DataVizBox/components/LineChart.tsx b/src/client/views/nodes/DataVizBox/components/LineChart.tsx index 2a9a8b354..e093ec648 100644 --- a/src/client/views/nodes/DataVizBox/components/LineChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/LineChart.tsx @@ -28,6 +28,7 @@ export interface LineChartProps { Document: Doc; layoutDoc: Doc; axes: string[]; + titleCol: string; records: { [key: string]: any }[]; width: number; height: number; @@ -46,7 +47,7 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { private _disposers: { [key: string]: IReactionDisposer } = {}; private _lineChartRef: React.RefObject<HTMLDivElement> = React.createRef(); private _lineChartSvg: d3.Selection<SVGGElement, unknown, null, undefined> | undefined; - @observable _currSelected: SelectedDataPoint | undefined = undefined; + @observable _currSelected: any | undefined = undefined; // TODO: nda - some sort of mapping that keeps track of the annotated points so we can easily remove when annotations list updates constructor(props: any) { super(props); @@ -235,21 +236,16 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { } } - // TODO: nda - can use d3.create() to create html element instead of appending drawChart = (dataSet: any[][], rangeVals: { xMin?: number; xMax?: number; yMin?: number; yMax?: number }, width: number, height: number) => { // clearing tooltip and the current chart d3.select(this._lineChartRef.current).select('svg').remove(); d3.select(this._lineChartRef.current).select('.tooltip').remove(); - const { xMin, xMax, yMin, yMax } = rangeVals; + var { xMin, xMax, yMin, yMax } = rangeVals; if (xMin === undefined || xMax === undefined || yMin === undefined || yMax === undefined) { return; } - // creating the x and y scales - const xScale = scaleCreatorNumerical(xMin, xMax, 0, width); - const yScale = scaleCreatorNumerical(0, yMax, height, 0); - // adding svg const margin = this._props.margin; const svg = (this._lineChartSvg = d3 @@ -261,24 +257,71 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { .append('g') .attr('transform', `translate(${margin.left}, ${margin.top})`)); + var validSecondData; + if (this._props.axes.length>2){ // for when there are 2 lines on the chart + var next = this._tableData.map(record => ({ x: Number(record[this._props.axes[0]]), y: Number(record[this._props.axes[2]]) })).sort((a, b) => (a.x < b.x ? -1 : 1)); + validSecondData = next.filter(d => { + if (!d.x || Number.isNaN(d.x) || !d.y || Number.isNaN(d.y)) return false; + return true; + }); + var secondDataRange = minMaxRange([validSecondData]); + if (secondDataRange.xMax!>xMax) xMax = secondDataRange.xMax; + if (secondDataRange.yMax!>yMax) yMax = secondDataRange.yMax; + if (secondDataRange.xMin!<xMin) xMin = secondDataRange.xMin; + if (secondDataRange.yMin!<yMin) yMin = secondDataRange.yMin; + } + + // creating the x and y scales + const xScale = scaleCreatorNumerical(xMin!, xMax!, 0, width); + const yScale = scaleCreatorNumerical(0, yMax!, height, 0); + const lineGen = createLineGenerator(xScale, yScale); + // create x and y grids xGrid(svg.append('g'), height, xScale); yGrid(svg.append('g'), width, yScale); xAxisCreator(svg.append('g'), height, xScale); yAxisCreator(svg.append('g'), width, yScale); + if (validSecondData) { + drawLine(svg.append('path'), validSecondData, lineGen, true); + this.drawDataPoints(validSecondData, 0, xScale, yScale); + svg.append('path').attr("stroke", "red"); + + // legend + var color = d3.scaleOrdinal() + .range(["black", "blue"]) + .domain([this._props.axes[1], this._props.axes[2]]) + svg.selectAll("mydots") + .data([this._props.axes[1], this._props.axes[2]]) + .enter() + .append("circle") + .attr("cx", 5) + .attr("cy", function(d,i){ return -30 + i*15}) + .attr("r", 7) + .style("fill", function(d){ return color(d)}) + svg.selectAll("mylabels") + .data([this._props.axes[1], this._props.axes[2]]) + .enter() + .append("text") + .attr("x", 25) + .attr("y", function(d,i){ return -30 + i*15}) + .style("fill", function(d){ return color(d)}) + .text(function(d){ return d}) + .attr("text-anchor", "left") + .style("alignment-baseline", "middle") + } + // get valid data points const data = dataSet[0]; - const lineGen = createLineGenerator(xScale, yScale); var validData = data.filter(d => { - var valid = true; Object.keys(data[0]).map(key => { - if (!d[key] || Number.isNaN(d[key])) valid = false; + if (!d[key] || Number.isNaN(d[key])) return false; }); - return valid; + return true; }); + // draw the plot line - drawLine(svg.append('path'), validData, lineGen); + drawLine(svg.append('path'), validData, lineGen, false); // draw the datapoint circle this.drawDataPoints(validData, 0, xScale, yScale); @@ -291,7 +334,7 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { const xPos = d3.pointer(e)[0]; const x0 = Math.min(data.length - 1, bisect(data, xScale.invert(xPos - 5))); // shift x by -5 so that you can reach points on the left-side axis const d0 = data[x0]; - if (!d0) return; + if (d0) this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); this.updateTooltip(higlightFocusPt, xScale, d0, yScale, tooltip); }); @@ -327,7 +370,7 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { svg.append('text') .attr('transform', 'rotate(-90)' + ' ' + 'translate( 0, ' + -10 + ')') .attr('x', -(height / 2)) - .attr('y', -20) + .attr('y', -30) .attr('height', 20) .attr('width', 20) .style('text-anchor', 'middle') @@ -351,11 +394,22 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { } render() { - var titleAccessor: any = ''; - if (this._props.axes.length == 2) titleAccessor = 'dataViz_lineChart_title' + this._props.axes[0] + '-' + this._props.axes[1]; - else if (this._props.axes.length > 0) titleAccessor = 'dataViz_lineChart_title' + this._props.axes[0]; + var titleAccessor: any = 'dataViz_lineChart_title'; + if (this._props.axes.length == 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; + else if (this._props.axes.length > 0) titleAccessor = titleAccessor + this._props.axes[0]; if (!this._props.layoutDoc[titleAccessor]) this._props.layoutDoc[titleAccessor] = this.defaultGraphTitle; const selectedPt = this._currSelected ? `{ ${this._props.axes[0]}: ${this._currSelected.x} ${this._props.axes[1]}: ${this._currSelected.y} }` : 'none'; + var selectedTitle = ""; + if (this._currSelected && this._props.titleCol){ + selectedTitle+= "\n" + this._props.titleCol + ": " + this._tableData.forEach(each => { + var mapThisEntry = false; + if (this._currSelected.x==each[this._props.axes[0]] && this._currSelected.y==each[this._props.axes[1]]) mapThisEntry = true; + else if (this._currSelected.y==each[this._props.axes[0]] && this._currSelected.x==each[this._props.axes[1]]) mapThisEntry = true; + if (mapThisEntry) selectedTitle += each[this._props.titleCol] + ", "; + }) + selectedTitle = selectedTitle.slice(0,-1).slice(0,-1); + } if (this._lineChartData.length > 0 || !this.parentViz || this.parentViz.length == 0) { return this._props.axes.length >= 2 && /\d/.test(this._props.records[0][this._props.axes[0]]) && /\d/.test(this._props.records[0][this._props.axes[1]]) ? ( <div className="chart-container" style={{ width: this._props.width + this._props.margin.right }}> @@ -375,9 +429,9 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { {selectedPt != 'none' ? ( <div className={'selected-data'}> {`Selected: ${selectedPt}`} + {`${selectedTitle}`} <Button onClick={e => { - console.log('test plzz'); this._props.vizBox.sidebarBtnDown; this._props.vizBox.sidebarAddDocument; }}></Button> diff --git a/src/client/views/nodes/DataVizBox/components/PieChart.tsx b/src/client/views/nodes/DataVizBox/components/PieChart.tsx index 1259a13ff..fc23f47de 100644 --- a/src/client/views/nodes/DataVizBox/components/PieChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/PieChart.tsx @@ -19,6 +19,7 @@ export interface PieChartProps { Document: Doc; layoutDoc: Doc; axes: string[]; + titleCol: string; records: { [key: string]: any }[]; width: number; height: number; @@ -331,22 +332,34 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { }; render() { - var titleAccessor: any = ''; - if (this._props.axes.length == 2) titleAccessor = 'dataViz_pie_title' + this._props.axes[0] + '-' + this._props.axes[1]; - else if (this._props.axes.length > 0) titleAccessor = 'dataViz_pie_title' + this._props.axes[0]; + var titleAccessor: any = 'dataViz_pie_title'; + if (this._props.axes.length == 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; + else if (this._props.axes.length > 0) titleAccessor = titleAccessor + this._props.axes[0]; if (!this._props.layoutDoc[titleAccessor]) this._props.layoutDoc[titleAccessor] = this.defaultGraphTitle; if (!this._props.layoutDoc.dataViz_pie_sliceColors) this._props.layoutDoc.dataViz_pie_sliceColors = new List<string>(); var selected: string; var curSelectedSliceName = ''; if (this._currSelected) { + selected = '{ '; const sliceTitle = this._currSelected[this._props.axes[0]]; curSelectedSliceName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/\%/g, '').replace(/\#/g, '').replace(/\</g, '') : sliceTitle; - selected = '{ '; Object.keys(this._currSelected).map(key => { key != '' ? (selected += key + ': ' + this._currSelected[key] + ', ') : ''; }); selected = selected.substring(0, selected.length - 2); selected += ' }'; + if (this._props.titleCol!="" && (!this._currSelected["frequency"] || this._currSelected["frequency"]<10)){ + selected+= "\n" + this._props.titleCol + ": " + this._tableData.forEach(each => { + if (this._currSelected[this._props.axes[0]]==each[this._props.axes[0]]) { + if (this._props.axes[1]){ + if (this._currSelected[this._props.axes[1]]==each[this._props.axes[1]]) selected+= each[this._props.titleCol] + ", "; + } + else selected+= each[this._props.titleCol] + ", "; + } + }) + selected = selected.slice(0,-1).slice(0,-1); + } } else selected = 'none'; var selectedSliceColor; var sliceColors = StrListCast(this._props.layoutDoc.dataViz_pie_sliceColors).map(each => each.split('::')); diff --git a/src/client/views/nodes/DataVizBox/components/TableBox.tsx b/src/client/views/nodes/DataVizBox/components/TableBox.tsx index ed44d9269..1b239b5e5 100644 --- a/src/client/views/nodes/DataVizBox/components/TableBox.tsx +++ b/src/client/views/nodes/DataVizBox/components/TableBox.tsx @@ -18,7 +18,9 @@ interface TableBoxProps { layoutDoc: Doc; records: { [key: string]: any }[]; selectAxes: (axes: string[]) => void; + selectTitleCol: (titleCol: string) => void; axes: string[]; + titleCol: string; width: number; height: number; margin: { @@ -83,14 +85,12 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { return this._props.docView?.()?.screenToViewTransform().Scale || 1; } @computed get rowHeight() { - console.log('scale = ' + this.viewScale + ' table = ' + this._tableHeight + ' ids = ' + this._tableDataIds.length); return (this.viewScale * this._tableHeight) / this._tableDataIds.length; } @computed get startID() { return this.rowHeight ? Math.max(Math.floor(this._scrollTop / this.rowHeight) - 1, 0) : 0; } @computed get endID() { - console.log('start = ' + this.startID + ' container = ' + this._tableContainerHeight + ' scale = ' + this.viewScale + ' row = ' + this.rowHeight); return Math.ceil(this.startID + (this._tableContainerHeight * this.viewScale) / (this.rowHeight || 1)); } @action handleScroll = () => { @@ -155,11 +155,18 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { }, emptyFunction, action(e => { - const newAxes = this._props.axes; - if (newAxes.includes(col)) newAxes.splice(newAxes.indexOf(col), 1); - else if (newAxes.length > 1) newAxes[1] = col; - else newAxes.push(col); - this._props.selectAxes(newAxes); + if (e.shiftKey){ + if (this._props.titleCol == col) this._props.titleCol = ""; + else this._props.titleCol = col; + this._props.selectTitleCol(this._props.titleCol); + } + else{ + const newAxes = this._props.axes; + if (newAxes.includes(col)) newAxes.splice(newAxes.indexOf(col), 1); + else if (newAxes.length > 2) newAxes[newAxes.length-1] = col; + else newAxes.push(col); + this._props.selectAxes(newAxes); + } }) ); }; @@ -213,8 +220,15 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { <th key={this.columns.indexOf(col)} style={{ - color: this._props.axes.slice().reverse().lastElement() === col ? 'darkgreen' : this._props.axes.lastElement() === col ? 'darkred' : undefined, - background: this._props.axes.slice().reverse().lastElement() === col ? '#E3fbdb' : this._props.axes.lastElement() === col ? '#Fbdbdb' : undefined, + color: this._props.axes.slice().reverse().lastElement() === col ? 'darkgreen' + : (this._props.axes.length>2 && this._props.axes.lastElement() === col) ? 'darkred' + : (this._props.axes.lastElement()===col || (this._props.axes.length>2 && this._props.axes[1]==col))? 'darkblue' : undefined, + background: this._props.axes.slice().reverse().lastElement() === col ? '#E3fbdb' + : (this._props.axes.length>2 && this._props.axes.lastElement() === col) ? '#Fbdbdb' + : (this._props.axes.lastElement()===col || (this._props.axes.length>2 && this._props.axes[1]==col))? '#c6ebf7' : undefined, + // blue: #ADD8E6 + // green: #E3fbdb + // red: #Fbdbdb fontWeight: 'bolder', border: '3px solid black', }} @@ -236,7 +250,11 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { background: NumListCast(this._props.layoutDoc.dataViz_highlitedRows).includes(rowId) ? 'lightYellow' : NumListCast(this._props.layoutDoc.dataViz_selectedRows).includes(rowId) ? 'lightgrey' : '', }}> {this.columns.map(col => { - const colSelected = this._props.axes.length > 1 ? this._props.axes[0] == col || this._props.axes[1] == col : this._props.axes.length > 0 ? this._props.axes[0] == col : false; + var colSelected = false; + if (this._props.axes.length>2) colSelected = this._props.axes[0]==col || this._props.axes[1]==col || this._props.axes[2]==col; + else if (this._props.axes.length>1) colSelected = this._props.axes[0]==col || this._props.axes[1]==col; + else if (this._props.axes.length>0) colSelected = this._props.axes[0]==col; + if (this._props.titleCol==col) colSelected = true; return ( <td key={this.columns.indexOf(col)} style={{ border: colSelected ? '3px solid black' : '1px solid black', fontWeight: colSelected ? 'bolder' : 'normal' }}> <div className="tableBox-cell">{this._props.records[rowId][col]}</div> diff --git a/src/client/views/nodes/DataVizBox/utils/D3Utils.ts b/src/client/views/nodes/DataVizBox/utils/D3Utils.ts index 10bfb0c64..336935d23 100644 --- a/src/client/views/nodes/DataVizBox/utils/D3Utils.ts +++ b/src/client/views/nodes/DataVizBox/utils/D3Utils.ts @@ -61,6 +61,6 @@ export const yGrid = (g: d3.Selection<SVGGElement, unknown, null, undefined>, wi ); }; -export const drawLine = (p: d3.Selection<SVGPathElement, unknown, null, undefined>, dataPts: DataPoint[], lineGen: d3.Line<DataPoint>) => { - p.datum(dataPts).attr('fill', 'none').attr('stroke', 'rgba(53, 162, 235, 0.5)').attr('stroke-width', 2).attr('class', 'line').attr('d', lineGen); +export const drawLine = (p: d3.Selection<SVGPathElement, unknown, null, undefined>, dataPts: DataPoint[], lineGen: d3.Line<DataPoint>, extra: boolean) => { + p.datum(dataPts).attr('fill', 'none').attr('stroke', 'rgba(53, 162, 235, 0.5)').attr('stroke-width', 2).attr('stroke', extra? 'blue' : 'black').attr('class', 'line').attr('d', lineGen); }; diff --git a/src/client/views/nodes/DocumentLinksButton.tsx b/src/client/views/nodes/DocumentLinksButton.tsx index d1805308d..2a68d2bf6 100644 --- a/src/client/views/nodes/DocumentLinksButton.tsx +++ b/src/client/views/nodes/DocumentLinksButton.tsx @@ -157,7 +157,7 @@ export class DocumentLinksButton extends ObservableReactComponent<DocumentLinksB startLink = DocumentLinksButton.StartLinkView?.ComponentView?.getAnchor?.(true) || startLink; const linkDoc = DocUtils.MakeLink(startLink, endLink, { link_relationship: DocumentLinksButton.AnnotationId ? 'hypothes.is annotation' : undefined }); - LinkManager.currentLink = linkDoc; + LinkManager.Instance.currentLink = linkDoc; if (linkDoc) { if (DocumentLinksButton.AnnotationId && DocumentLinksButton.AnnotationUri) { diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 8eb354e1e..d131f72d5 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -20,13 +20,14 @@ import { DocServer } from '../../DocServer'; import { Networking } from '../../Network'; import { GooglePhotos } from '../../apis/google_docs/GooglePhotosClientUtils'; import { CollectionViewType, DocumentType } from '../../documents/DocumentTypes'; -import { DocOptions, DocUtils, Docs, FInfo } from '../../documents/Documents'; +import { DocOptions, DocUtils, Docs } from '../../documents/Documents'; import { DictationManager } from '../../util/DictationManager'; import { DocumentManager } from '../../util/DocumentManager'; import { DragManager, dropActionType } from '../../util/DragManager'; import { FollowLinkScript } from '../../util/LinkFollower'; import { LinkManager } from '../../util/LinkManager'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; +import { SearchUtil } from '../../util/SearchUtil'; import { SelectionManager } from '../../util/SelectionManager'; import { SettingsManager } from '../../util/SettingsManager'; import { SharingManager } from '../../util/SharingManager'; @@ -103,9 +104,11 @@ export interface DocumentViewProps extends FieldViewSharedProps { dontHideOnDrag?: boolean; suppressSetHeight?: boolean; onClickScriptDisable?: 'never' | 'always'; // undefined = only when selected + DataTransition?: () => string | undefined; NativeWidth?: () => number; NativeHeight?: () => number; contextMenuItems?: () => { script: ScriptField; filter?: ScriptField; label: string; icon: string }[]; + dragConfig?: (data: DragManager.DocumentDragData) => void; dragStarting?: () => void; dragEnding?: () => void; } @@ -114,7 +117,6 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document // this makes mobx trace() statements more descriptive public get displayName() { return 'DocumentViewInternal(' + this.Document.title + ')'; } // prettier-ignore public static SelectAfterContextMenu = true; // whether a document should be selected after it's contextmenu is triggered. - /** * This function is filled in by MainView to allow non-viewBox views to add Docs as tabs without * needing to know about/reference MainView @@ -236,16 +238,15 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document runInAction(() => (this._mounted = true)); this.setupHandlers(); this._disposers.contentActive = reaction( - () => { + () => // true - if the document has been activated directly or indirectly (by having its children selected) // false - if its pointer events are explicitly turned off or if it's container tells it that it's inactive // undefined - it is not active, but it should be responsive to actions that might activate it or its contents (eg clicking) - return this._props.isContentActive() === false || this._props.pointerEvents?.() === 'none' + this._props.isContentActive() === false || this._props.pointerEvents?.() === 'none' ? false : Doc.ActiveTool !== InkTool.None || SnappingManager.CanEmbed || this.rootSelected() || this.Document.forceActive || this._componentView?.isAnyChildContentActive?.() || this._props.isContentActive() ? true - : undefined; - }, + : undefined, active => (this._isContentActive = active), { fireImmediately: true } ); @@ -289,7 +290,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document dragData.moveDocument = this._props.moveDocument; dragData.draggedViews = [docView]; dragData.canEmbed = this.Document.dragAction ?? this._props.dragAction ? true : false; - this._componentView?.dragConfig?.(dragData); + (this._props.dragConfig ?? this._componentView?.dragConfig)?.(dragData); DragManager.StartDocumentDrag( selected.map(dv => dv.ContentDiv!), dragData, @@ -312,20 +313,23 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document let stopPropagate = true; let preventDefault = true; !this.layoutDoc._keepZWhenDragged && this._props.bringToFront?.(this.Document); + const scriptProps = { + this: this.Document, + _readOnly_: false, + scriptContext: this._props.scriptContext, + documentView, + clientX: e.clientX, + clientY: e.clientY, + shiftKey: e.shiftKey, + altKey: e.altKey, + metaKey: e.metaKey, + value: undefined, + }; if (this._doubleTap) { const defaultDblclick = this._props.defaultDoubleClick?.() || this.Document.defaultDoubleClick; if (this.onDoubleClickHandler?.script) { - const { clientX, clientY, shiftKey, altKey, ctrlKey } = e; // or we could call e.persist() to capture variables - // prettier-ignore - const func = () => this.onDoubleClickHandler.script.run( { - this: this.Document, - scriptContext: this._props.scriptContext, - documentView, - clientX, clientY, altKey, shiftKey, ctrlKey, - value: undefined, - }, console.log ); - UndoManager.RunInBatch(() => (func().result?.select === true ? this._props.select(false) : ''), 'on double click'); - } else if (!Doc.IsSystem(this.Document) && (defaultDblclick === undefined || defaultDblclick === 'default')) { + UndoManager.RunInBatch(() => this.onDoubleClickHandler.script.run(scriptProps, console.log).result?.select && this._props.select(false), 'on double click: ' + this.Document.title); + } else if (!Doc.IsSystem(this.Document) && defaultDblclick !== 'ignore') { UndoManager.RunInBatch(() => LightboxView.Instance.AddDocTab(this.Document, OpenWhere.lightbox), 'double tap'); SelectionManager.DeselectAll(); Doc.UnBrushDoc(this.Document); @@ -338,33 +342,14 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document } else { let clickFunc: undefined | (() => any); if (!this.disableClickScriptFunc && this.onClickHandler?.script) { - const { clientX, clientY, shiftKey, altKey, metaKey } = e; - const func = () => { - // replace default add doc func with this view's add doc func. - // to allow override behaviors for how to display links to undisplayed documents. - // e.g., if this document is part of a labeled 'lightbox' container, then documents will be shown in place - // instead of in the global lightbox + clickFunc = undoable(() => { + // use this view's add doc func to override method for following links to undisplayed documents. + // e.g., if this document is part of a labeled 'lightbox' container, then documents will be shown in this container of in the global lightbox const oldFunc = DocumentViewInternal.addDocTabFunc; DocumentViewInternal.addDocTabFunc = this._props.addDocTab; - this.onClickHandler?.script.run( - { - this: this.Document, - _readOnly_: false, - scriptContext: this._props.scriptContext, - documentView, - clientX, - clientY, - shiftKey, - altKey, - metaKey, - }, - console.log - ).result?.select === true - ? this._props.select(false) - : ''; + this.onClickHandler?.script.run(scriptProps, console.log).result?.select && this._props.select(false); DocumentViewInternal.addDocTabFunc = oldFunc; - }; - clickFunc = () => UndoManager.RunInBatch(func, 'click ' + this.Document.title); + }, 'click ' + this.Document.title); } else { // onDragStart implies a button doc that we don't want to select when clicking. RootDocument & isTemplateForField implies we're clicking on part of a template instance and we want to select the whole template, not the part if ((this.layoutDoc.onDragStart || this._props.TemplateDataDocument) && !(e.ctrlKey || e.button > 0)) { @@ -376,7 +361,6 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document this._singleClickFunc = // prettier-ignore clickFunc ?? (() => (sendToBack ? documentView._props.bringToFront?.(this.Document, true) : - this._componentView?.select?.(e.ctrlKey || e.metaKey, e.shiftKey) ?? this._props.select(e.ctrlKey||e.shiftKey, e.metaKey))); const waitFordblclick = this._props.waitForDoubleClickToClick?.() ?? this.Document.waitForDoubleClickToClick; if ((clickFunc && waitFordblclick !== 'never') || waitFordblclick === 'always') { @@ -412,10 +396,9 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document !Doc.IsInMyOverlay(this.layoutDoc) ) { e.stopPropagation(); - // don't preventDefault anymore. Goldenlayout, PDF text selection and RTF text selection all need it to go though - //if (this._props.isSelected(true) && this.Document.type !== DocumentType.PDF && this.layoutDoc._type_collection !== CollectionViewType.Docking) e.preventDefault(); + // don't preventDefault. Goldenlayout, PDF text selection and RTF text selection all need it to go though - // listen to move events if document content isn't active or document is draggable + // listen to move events when document content isn't active or document is always draggable if (!this.layoutDoc._lockedPosition && (!this.isContentActive() || BoolCast(this.layoutDoc._dragWhenActive, this._props.dragWhenActive))) { document.addEventListener('pointermove', this.onPointerMove); } @@ -474,11 +457,11 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document deleteClicked = undoable(() => this._props.removeDocument?.(this.Document), 'delete doc'); setToggleDetail = undoable( - () => - (this.Document.onClick = ScriptField.MakeScript( + (defaultLayout: string, scriptFieldKey: 'onClick') => + (this.Document[scriptFieldKey] = ScriptField.MakeScript( `toggleDetail(documentView, "${StrCast(this.Document.layout_fieldKey) .replace('layout_', '') - .replace(/^layout$/, 'detail')}")`, + .replace(/^layout$/, 'detail')}", "${defaultLayout}")`, { documentView: 'any' } )), 'set toggle detail' @@ -489,26 +472,24 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document if (this.Document === Doc.ActiveDashboard) { e.stopPropagation(); e.preventDefault(); - alert( - (e.target as any)?.closest?.('*.lm_content') - ? "You can't perform this move most likely because you didn't drag the document's title bar to enable embedding in a different document." - : 'Linking to document tabs not yet supported. Drop link on document content.' - ); + alert((e.target as any)?.closest?.('*.lm_content') ? "You can't perform this move most likely because you didn't drag the document's title bar to enable embedding in a different document." : 'Linking to document tabs not yet supported.'); return true; } - const linkdrag = de.complete.annoDragData ?? de.complete.linkDragData; + const annoData = de.complete.annoDragData; + const linkdrag = annoData ?? de.complete.linkDragData; if (linkdrag) { linkdrag.linkSourceDoc = linkdrag.linkSourceGetAnchor(); if (linkdrag.linkSourceDoc && linkdrag.linkSourceDoc !== this.Document) { - if (de.complete.annoDragData && !de.complete.annoDragData.dropDocument) { - de.complete.annoDragData.dropDocument = de.complete.annoDragData.dropDocCreator(undefined); + if (annoData && !annoData.dropDocument) { + annoData.dropDocument = annoData.dropDocCreator(undefined); } - if (de.complete.annoDragData || this.Document !== linkdrag.linkSourceDoc.embedContainer) { - const dropDoc = de.complete.annoDragData?.dropDocument ?? this._componentView?.getAnchor?.(true) ?? this.Document; - de.complete.linkDocument = DocUtils.MakeLink(linkdrag.linkSourceDoc, dropDoc, {}, undefined, [de.x, de.y - 50]); - if (de.complete.linkDocument) { - de.complete.linkDocument.layout_isSvg = true; - this._docView?.CollectionFreeFormView?.addDocument(de.complete.linkDocument); + if (annoData || this.Document !== linkdrag.linkSourceDoc.embedContainer) { + const dropDoc = annoData?.dropDocument ?? this._componentView?.getAnchor?.(true) ?? this.Document; + const linkDoc = DocUtils.MakeLink(linkdrag.linkSourceDoc, dropDoc, {}, undefined, [de.x, de.y - 50]); + if (linkDoc) { + de.complete.linkDocument = linkDoc; + linkDoc.layout_isSvg = true; + DocumentManager.LinkCommonAncestor(linkDoc)?.ComponentView?.addDocument?.(linkDoc); } } e.stopPropagation(); @@ -518,25 +499,6 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document return false; }, 'drop doc'); - makeIntoPortal = undoable(() => { - const portalLink = this._allLinks.find(d => d.link_anchor_1 === this.Document && d.link_relationship === 'portal to:portal from'); - if (!portalLink) { - DocUtils.MakeLink( - this.Document, - Docs.Create.FreeformDocument([], { - _width: NumCast(this.layoutDoc._width) + 10, - _height: Math.max(NumCast(this.layoutDoc._height), NumCast(this.layoutDoc._width) + 10), - _isLightbox: true, - _layout_fitWidth: true, - title: StrCast(this.Document.title) + ' [Portal]', - }), - { link_relationship: 'portal to:portal from' } - ); - } - this.Document.followLinkLocation = OpenWhere.lightbox; - this.Document.onClick = FollowLinkScript(); - }, 'make into portal'); - importDocument = () => { const input = document.createElement('input'); input.type = 'file'; @@ -627,17 +589,13 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document const existingOnClick = cm.findByDescription('OnClick...'); const onClicks: ContextMenuProps[] = existingOnClick && 'subitems' in existingOnClick ? existingOnClick.subitems : []; - onClicks.push({ description: 'Enter Portal', event: this.makeIntoPortal, icon: 'window-restore' }); + onClicks.push({ description: 'Enter Portal', event: undoable(e => DocUtils.makeIntoPortal(this.Document, this.layoutDoc, this._allLinks), 'make into portal'), icon: 'window-restore' }); !Doc.noviceMode && onClicks.push({ description: 'Toggle Detail', event: this.setToggleDetail, icon: 'concierge-bell' }); if (!this.Document.annotationOn) { - const options = cm.findByDescription('Options...'); - const optionItems: ContextMenuProps[] = options && 'subitems' in options ? options.subitems : []; - !options && cm.addItem({ description: 'Options...', subitems: optionItems, icon: 'compass' }); - onClicks.push({ description: this.onClickHandler ? 'Remove Click Behavior' : 'Follow Link', event: () => this.toggleFollowLink(false, false), icon: 'link' }); !Doc.noviceMode && onClicks.push({ description: 'Edit onClick Script', event: () => UndoManager.RunInBatch(() => DocUtils.makeCustomViewClicked(this.Document, undefined, 'onClick'), 'edit onClick'), icon: 'terminal' }); - !existingOnClick && cm.addItem({ description: 'OnClick...', noexpand: true, subitems: onClicks, icon: 'mouse-pointer' }); + cm.addItem({ description: 'OnClick...', noexpand: true, subitems: onClicks, icon: 'mouse-pointer' }); } else if (LinkManager.Links(this.Document).length) { onClicks.push({ description: 'Restore On Click default', event: () => this.noOnClick(), icon: 'link' }); onClicks.push({ description: 'Follow Link on Click', event: () => this.followLinkOnClick(), icon: 'link' }); @@ -768,7 +726,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document }; removeLinkByHiding = (link: Doc) => () => (link.link_displayLine = false); - allLinkEndpoints = () => { + @computed get allLinkEndpoints() { // the small blue dots that mark the endpoints of links if (this._componentView instanceof KeyValueBox || this._props.hideLinkAnchors || this.layoutDoc.layout_hideLinkAnchors || this._props.dontRegisterView || this.layoutDoc.layout_unrendered) return null; return this.filteredLinks.map(link => ( @@ -792,9 +750,9 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document /> </div> )); - }; + } - viewBoxContents = () => { + @computed get viewBoxContents() { TraceMobx(); const isInk = this.layoutDoc._layout_isSvg && !this._props.LayoutTemplateString; const noBackground = this.Document.isGroup && !this._props.LayoutTemplateString?.includes(KeyValueBox.name) && (!this.layoutDoc.backgroundColor || this.layoutDoc.backgroundColor === 'transparent'); @@ -818,12 +776,12 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document rootSelected={this.rootSelected} onClickScript={this.onClickFunc} setTitleFocus={this.setTitleFocus} - hideClickBehaviors={BoolCast(this.layoutDoc.hideClickBehaviors)} + hideClickBehaviors={BoolCast(this.Document.hideClickBehaviors)} /> - {this.layoutDoc.layout_hideAllLinks ? null : this.allLinkEndpoints()} + {this.layoutDoc.layout_hideAllLinks ? null : this.allLinkEndpoints} </div> ); - }; + } captionStyleProvider = (doc: Opt<Doc>, props: Opt<FieldViewProps>, property: string) => this._props?.styleProvider?.(doc, props, property + ':caption'); fieldsDropdown = (reqdFields: string[], dropdownWidth: number, placeholder: string, onChange: (val: string | number) => void, onClose: () => void) => { @@ -856,7 +814,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document * setting layout_showTitle using the format: field1[;field2[...][:hover]] * from the UI, this is done by clicking the title field and prefixin the format with '#'. eg., #field1[;field2;...][:hover] **/ - titleView = () => { + @computed get titleView() { const showTitle = this.layout_showTitle?.split(':')[0]; const showTitleHover = this.layout_showTitle?.includes(':hover'); @@ -930,9 +888,9 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document </div> </div> ); - }; + } - captionView = () => { + @computed get captionView() { return !this.layout_showCaption ? null : ( <div className="documentView-captionWrapper" @@ -955,7 +913,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document /> </div> ); - }; + } renderDoc = (style: object) => { TraceMobx(); @@ -975,15 +933,15 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document fontFamily: StrCast(this.Document._text_fontFamily, 'inherit'), fontSize: Cast(this.Document._text_fontSize, 'string', null), transform: this._animateScalingTo ? `scale(${this._animateScalingTo})` : undefined, - transition: !this._animateScalingTo ? StrCast(this.Document.dataTransition) : `transform ${this.animateScaleTime() / 1000}s ease-${this._animateScalingTo < 1 ? 'in' : 'out'}`, + transition: !this._animateScalingTo ? this._props.DataTransition?.() : `transform ${this.animateScaleTime() / 1000}s ease-${this._animateScalingTo < 1 ? 'in' : 'out'}`, }}> {this._props.hideTitle || (!showTitle && !this.layout_showCaption) ? ( - this.viewBoxContents() + this.viewBoxContents ) : ( <div className="documentView-styleWrapper"> - {this.titleView()} - {this.viewBoxContents()} - {this.captionView()} + {this.titleView} + {this.viewBoxContents} + {this.captionView} </div> )} {this.widgetDecorations ?? null} @@ -1117,6 +1075,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { private _disposers: { [name: string]: IReactionDisposer } = {}; private _viewTimer: NodeJS.Timeout | undefined; private _animEffectTimer: NodeJS.Timeout | undefined; + public Guid = Utils.GenerateGuid(); // a unique id associated with the main <div>. used by LinkBox's Xanchor to find the arrowhead locations. @computed public static get exploreMode() { return () => (SnappingManager.ExploreMode ? ScriptField.MakeScript('CollectionBrowseClick(documentView, clientX, clientY)', { documentView: 'any', clientX: 'number', clientY: 'number' })! : undefined); @@ -1193,6 +1152,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { } componentWillUnmount() { + this._viewTimer && clearTimeout(this._viewTimer); runInAction(() => this.Document[DocViews].delete(this)); Object.values(this._disposers).forEach(disposer => disposer?.()); !BoolCast(this.Document.dontRegisterView, this._props.dontRegisterView) && DocumentManager.Instance.RemoveView(this); @@ -1216,21 +1176,23 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { return this._props.LayoutTemplateString?.includes('link_anchor_2') ? DocCast(this.Document['link_anchor_2']) : this._props.LayoutTemplateString?.includes('link_anchor_1') ? DocCast(this.Document['link_anchor_1']) : undefined; } - @computed get getBounds() { - if (!this._docViewInternal?._contentDiv || Doc.AreProtosEqual(this.Document, Doc.UserDoc())) { + @computed get getBounds(): Opt<{ left: number; top: number; right: number; bottom: number; transition?: string }> { + if (!this.ContentDiv || Doc.AreProtosEqual(this.Document, Doc.UserDoc())) { return undefined; } - if (this._docViewInternal._componentView?.screenBounds?.()) { - return this._docViewInternal._componentView.screenBounds(); + if (this.ComponentView?.screenBounds?.()) { + return this.ComponentView.screenBounds(); } const xf = this.screenToContentsTransform().scale(this.nativeScaling).inverse(); const [[left, top], [right, bottom]] = [xf.transformPoint(0, 0), xf.transformPoint(this.panelWidth, this.panelHeight)]; if (this._props.LayoutTemplateString?.includes(LinkAnchorBox.name)) { - const docuBox = this._docViewInternal._contentDiv.getElementsByClassName('linkAnchorBox-cont'); - if (docuBox.length) return { ...docuBox[0].getBoundingClientRect(), center: undefined }; + const docuBox = this.ContentDiv.getElementsByClassName('linkAnchorBox-cont'); + if (docuBox.length) return { ...docuBox[0].getBoundingClientRect(), transition: undefined }; } - return { left, top, right, bottom }; + // transition is returned so that the bounds will 'update' at the end of an animated transition. This is needed by xAnchor in LinkBox + const transition = this.docViewPath().find((parent: DocumentView) => parent.DataTransition?.() || parent.ComponentView?.viewTransition?.()); + return { left, top, right, bottom, transition: transition?.DataTransition?.() || transition?.ComponentView?.viewTransition?.() }; } @computed get nativeWidth() { @@ -1254,9 +1216,8 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { this.layoutDoc._viewTransition = undefined; }; public noOnClick = () => this._docViewInternal?.noOnClick(); - public makeIntoPortal = () => this._docViewInternal?.makeIntoPortal(); public toggleFollowLink = (zoom?: boolean, setTargetToggle?: boolean): void => this._docViewInternal?.toggleFollowLink(zoom, setTargetToggle); - public setToggleDetail = () => this._docViewInternal?.setToggleDetail(); + public setToggleDetail = (defaultLayout = '', scriptFieldKey = 'onClick') => this._docViewInternal?.setToggleDetail(defaultLayout, scriptFieldKey); public onContextMenu = (e?: React.MouseEvent, pageX?: number, pageY?: number) => this._docViewInternal?.onContextMenu?.(e, pageX, pageY); public cleanupPointerEvents = () => this._docViewInternal?.cleanupPointerEvents(); public startDragging = (x: number, y: number, dropAction: dropActionType, hideSource = false) => this._docViewInternal?.startDragging(x, y, dropAction, hideSource); @@ -1376,6 +1337,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { } } }; + DataTransition = () => this._props.DataTransition?.() || StrCast(this.Document.dataTransition); ShouldNotScale = () => this.shouldNotScale; NativeWidth = () => this.effectiveNativeWidth; NativeHeight = () => this.effectiveNativeHeight; @@ -1428,7 +1390,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { const yshift = Math.abs(this.Yshift) <= 0.001 ? this._props.PanelHeight() : undefined; return ( - <div className="contentFittingDocumentView" onPointerEnter={action(() => (this._isHovering = true))} onPointerLeave={action(() => (this._isHovering = false))}> + <div id={this.Guid} className="contentFittingDocumentView" onPointerEnter={action(() => (this._isHovering = true))} onPointerLeave={action(() => (this._isHovering = false))}> {!this.Document || !this._props.PanelWidth() ? null : ( <div className="contentFittingDocumentView-previewDoc" @@ -1441,6 +1403,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { <DocumentViewInternal {...this._props} fieldKey={this.LayoutFieldKey} + DataTransition={this.DataTransition} DocumentView={this.selfView} docViewPath={this.docViewPath} PanelWidth={this.PanelWidth} @@ -1504,13 +1467,13 @@ ScriptingGlobals.add(function deiconifyViewToLightbox(documentView: DocumentView LightboxView.Instance.AddDocTab(documentView.Document, OpenWhere.lightbox, 'layout'); //, 0); }); -ScriptingGlobals.add(function toggleDetail(dv: DocumentView, detailLayoutKeySuffix: string) { - if (dv.Document.layout_fieldKey === 'layout_' + detailLayoutKeySuffix) dv.switchViews(false, 'layout'); +ScriptingGlobals.add(function toggleDetail(dv: DocumentView, detailLayoutKeySuffix: string, defaultLayout = '') { + if (dv.Document.layout_fieldKey === 'layout_' + detailLayoutKeySuffix) dv.switchViews(defaultLayout ? true : false, defaultLayout, undefined, true); else dv.switchViews(true, detailLayoutKeySuffix, undefined, true); }); ScriptingGlobals.add(function updateLinkCollection(linkCollection: Doc, linkSource: Doc) { - const collectedLinks = DocListCast(Doc.GetProto(linkCollection).data); + const collectedLinks = DocListCast(linkCollection[DocData].data); let wid = NumCast(linkSource._width); let embedding: Doc | undefined; const links = LinkManager.Links(linkSource); @@ -1529,3 +1492,29 @@ ScriptingGlobals.add(function updateLinkCollection(linkCollection: Doc, linkSour embedding && DocServer.UPDATE_SERVER_CACHE(); // if a new embedding was made, update the client's server cache so that it will not come back as a promise return links; }); +ScriptingGlobals.add(function updateTagsCollection(collection: Doc) { + const tag = StrCast(collection.title).split('-->')[1]; + const matchedTags = Array.from(SearchUtil.SearchCollection(Doc.MyFilesystem, tag, false, ['tags']).keys()); + const collectionDocs = DocListCast(collection[DocData].data).concat(collection); + let wid = 100; + let created = false; + const matchedDocs = matchedTags + .filter(tagDoc => !Doc.AreProtosEqual(collection, tagDoc)) + .reduce((aset, tagDoc) => { + let embedding = Array.from(aset).find(doc => Doc.AreProtosEqual(tagDoc, doc)) ?? collectionDocs.find(doc => Doc.AreProtosEqual(tagDoc, doc)); + if (!embedding) { + embedding = Doc.MakeEmbedding(tagDoc); + embedding.x = wid; + embedding.y = 0; + embedding._lockedPosition = false; + wid += NumCast(tagDoc._width); + created = true; + } + Doc.SetContainer(embedding, collection); + aset.add(embedding); + return aset; + }, new Set<Doc>()); + + created && (collection[DocData].data = new List<Doc>(Array.from(matchedDocs))); + return true; +}); diff --git a/src/client/views/nodes/FontIconBox/FontIconBox.scss b/src/client/views/nodes/FontIconBox/FontIconBox.scss index db2ffa756..2db285910 100644 --- a/src/client/views/nodes/FontIconBox/FontIconBox.scss +++ b/src/client/views/nodes/FontIconBox/FontIconBox.scss @@ -1,5 +1,15 @@ @import '../../global/globalCssVariables.module.scss'; +// bcz: something's messed up with the IconButton css. this mostly fixes the fit-all button, the color buttons, the undo +/- expander and the dropdown doc type list (eg 'text') +.iconButton-container { + width: unset !important; + min-width: 30px !important; + height: unset !important; + min-height: 30px; + .color { + height: 3px !important; + } +} .menuButton { height: 100%; display: flex; diff --git a/src/client/views/nodes/FontIconBox/FontIconBox.tsx b/src/client/views/nodes/FontIconBox/FontIconBox.tsx index cf07d98be..3577cc8d9 100644 --- a/src/client/views/nodes/FontIconBox/FontIconBox.tsx +++ b/src/client/views/nodes/FontIconBox/FontIconBox.tsx @@ -73,7 +73,7 @@ export class FontIconBox extends ViewBoxBaseComponent<ButtonProps>() { }; specificContextMenu = (): void => { - if (!Doc.noviceMode) { + if (!Doc.noviceMode && Cast(this.layoutDoc.dragFactory, Doc, null)) { const cm = ContextMenu.Instance; cm.addItem({ description: 'Show Template', event: this.showTemplate, icon: 'tag' }); cm.addItem({ description: 'Use as Render Template', event: this.dragAsTemplate, icon: 'tag' }); @@ -366,7 +366,7 @@ export class FontIconBox extends ViewBoxBaseComponent<ButtonProps>() { ); } - render() { + renderButton = () => { const color = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color); const tooltip = StrCast(this.Document.toolTip); const scriptFunc = () => ScriptCast(this.Document.onClick)?.script.run({ this: this.Document, self: this.Document, _readOnly_: false }); @@ -381,7 +381,7 @@ export class FontIconBox extends ViewBoxBaseComponent<ButtonProps>() { case ButtonType.ColorButton: return this.colorButton; case ButtonType.MultiToggleButton: return this.multiToggleButton; case ButtonType.ToggleButton: return this.toggleButton; - case ButtonType.ClickButton: + case ButtonType.ClickButton:return <IconButton {...btnProps} size={Size.MEDIUM} color={color} />; case ButtonType.ToolButton: return <IconButton {...btnProps} size={Size.LARGE} color={color} />; case ButtonType.TextButton: return <Button {...btnProps} color={color} background={SettingsManager.userBackgroundColor} text={StrCast(this.dataDoc.buttonText)}/>; @@ -389,5 +389,13 @@ export class FontIconBox extends ViewBoxBaseComponent<ButtonProps>() { background={SettingsManager.userBackgroundColor} size={Size.LARGE} tooltipPlacement='right' onPointerDown={scriptFunc} />; } return this.defaultButton; + }; + + render() { + return ( + <div style={{ margin: 'auto', width: '100%' }} onContextMenu={this.specificContextMenu}> + {this.renderButton()} + </div> + ); } } diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx index 10eeff08d..fd3074a88 100644 --- a/src/client/views/nodes/LabelBox.tsx +++ b/src/client/views/nodes/LabelBox.tsx @@ -88,7 +88,7 @@ export class LabelBox extends ViewBoxBaseComponent<LabelBoxProps>() { @observable _mouseOver = false; @computed get hoverColor() { - return this._mouseOver ? StrCast(this.layoutDoc._hoverBackgroundColor) : this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor); + return this._mouseOver ? StrCast(this.layoutDoc._hoverBackgroundColor) : this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor); } getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { diff --git a/src/client/views/nodes/LinkBox.scss b/src/client/views/nodes/LinkBox.scss index 767f0291b..484fb301e 100644 --- a/src/client/views/nodes/LinkBox.scss +++ b/src/client/views/nodes/LinkBox.scss @@ -5,3 +5,28 @@ .linkBox-container { width: 100%; } + +.linkBox { + transition: inherit; + pointer-events: none; + position: absolute; + width: 100%; + height: 100%; + path { + transition: inherit; + fill: transparent; + } + svg { + transition: inherit; + overflow: visible; + } + text { + cursor: default; + text-anchor: middle; + font-size: 12; + stroke: black; + } + circle { + cursor: default; + } +} diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx index 8b6293806..decdbb240 100644 --- a/src/client/views/nodes/LinkBox.tsx +++ b/src/client/views/nodes/LinkBox.tsx @@ -1,14 +1,17 @@ -import { Bezier } from 'bezier-js'; -import { computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; +import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; +import Xarrow from 'react-xarrows'; +import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { DocCast, NumCast, StrCast } from '../../../fields/Types'; -import { aggregateBounds, emptyFunction, returnAlways, returnFalse, Utils } from '../../../Utils'; +import { DashColor, emptyFunction, lightOrDark, returnFalse } from '../../../Utils'; import { DocumentManager } from '../../util/DocumentManager'; -import { Transform } from '../../util/Transform'; -import { CollectionFreeFormView } from '../collections/collectionFreeForm'; +import { LinkManager } from '../../util/LinkManager'; +import { SnappingManager } from '../../util/SnappingManager'; import { ViewBoxBaseComponent } from '../DocComponent'; +import { EditableView } from '../EditableView'; +import { LightboxView } from '../LightboxView'; import { StyleProp } from '../StyleProvider'; import { ComparisonBox } from './ComparisonBox'; import { FieldView, FieldViewProps } from './FieldView'; @@ -19,152 +22,178 @@ export class LinkBox extends ViewBoxBaseComponent<FieldViewProps>() { public static LayoutString(fieldKey: string = 'link') { return FieldView.LayoutString(LinkBox, fieldKey); } + disposer: IReactionDisposer | undefined; + @observable _forceAnimate = 0; // forces xArrow to animate when a transition animation is detected on something that affects an anchor + @observable _hide = false; // don't render if anchor is not visible since that breaks xAnchor constructor(props: FieldViewProps) { super(props); makeObservable(this); } + @computed get anchor1() { return this.anchor(1); } // prettier-ignore + @computed get anchor2() { return this.anchor(2); } // prettier-ignore - onClickScriptDisable = returnAlways; - @computed get anchor1() { - const anchor1 = DocCast(this.dataDoc.link_anchor_1); - const anchor_1 = anchor1?.layout_unrendered ? DocCast(anchor1.annotationOn) : anchor1; - return DocumentManager.Instance.getDocumentView(anchor_1, this.DocumentView?.().containerViewPath?.().lastElement()); - } - @computed get anchor2() { - const anchor2 = DocCast(this.dataDoc.link_anchor_2); - const anchor_2 = anchor2?.layout_unrendered ? DocCast(anchor2.annotationOn) : anchor2; - return DocumentManager.Instance.getDocumentView(anchor_2, this.DocumentView?.().containerViewPath?.().lastElement()); - } - screenBounds = () => { - if (this.layoutDoc._layout_isSvg && this.anchor1 && this.anchor2 && this.anchor1.CollectionFreeFormView) { - const a_invXf = this.anchor1.screenToViewTransform().inverse(); - const b_invXf = this.anchor2.screenToViewTransform().inverse(); - const a_scrBds = { tl: a_invXf.transformPoint(0, 0), br: a_invXf.transformPoint(NumCast(this.anchor1.Document._width), NumCast(this.anchor1.Document._height)) }; - const b_scrBds = { tl: b_invXf.transformPoint(0, 0), br: b_invXf.transformPoint(NumCast(this.anchor2.Document._width), NumCast(this.anchor2.Document._height)) }; - - const pts = [] as number[][]; - pts.push([(a_scrBds.tl[0] + a_scrBds.br[0]) / 2, (a_scrBds.tl[1] + a_scrBds.br[1]) / 2]); - pts.push(Utils.getNearestPointInPerimeter(a_scrBds.tl[0], a_scrBds.tl[1], a_scrBds.br[0] - a_scrBds.tl[0], a_scrBds.br[1] - a_scrBds.tl[1], (b_scrBds.tl[0] + b_scrBds.br[0]) / 2, (b_scrBds.tl[1] + b_scrBds.br[1]) / 2)); - pts.push(Utils.getNearestPointInPerimeter(b_scrBds.tl[0], b_scrBds.tl[1], b_scrBds.br[0] - b_scrBds.tl[0], b_scrBds.br[1] - b_scrBds.tl[1], (a_scrBds.tl[0] + a_scrBds.br[0]) / 2, (a_scrBds.tl[1] + a_scrBds.br[1]) / 2)); - pts.push([(b_scrBds.tl[0] + b_scrBds.br[0]) / 2, (b_scrBds.tl[1] + b_scrBds.br[1]) / 2]); - const agg = aggregateBounds( - pts.map(pt => ({ x: pt[0], y: pt[1] })), - 0, - 0 - ); - return { left: agg.x, top: agg.y, right: agg.r, bottom: agg.b, center: undefined }; - } - return undefined; + anchor = (which: number) => { + const anch = DocCast(this.dataDoc['link_anchor_' + which]); + const anchor = anch?.layout_unrendered ? DocCast(anch.annotationOn) : anch; + return DocumentManager.Instance.getDocumentView(anchor, this.DocumentView?.().containerViewPath?.().lastElement()); }; - disposer: IReactionDisposer | undefined; + componentWillUnmount() { + this.disposer?.(); + } componentDidMount() { this._props.setContentViewBox?.(this); this.disposer = reaction( - () => { - if (this.layoutDoc._layout_isSvg && (this.anchor1 || this.anchor2)?.CollectionFreeFormView) { - const a = (this.anchor1 ?? this.anchor2)!; - const b = (this.anchor2 ?? this.anchor1)!; - - const parxf = this.DocumentView?.().containerViewPath?.().lastElement().ComponentView as CollectionFreeFormView; - const this_xf = parxf?.screenToFreeformContentsXf ?? Transform.Identity; //this.ScreenToLocalTransform(); - const a_invXf = a.screenToViewTransform().inverse(); - const b_invXf = b.screenToViewTransform().inverse(); - const a_scrBds = { tl: a_invXf.transformPoint(0, 0), br: a_invXf.transformPoint(NumCast(a.Document._width), NumCast(a.Document._height)) }; - const b_scrBds = { tl: b_invXf.transformPoint(0, 0), br: b_invXf.transformPoint(NumCast(b.Document._width), NumCast(b.Document._height)) }; - const a_bds = { tl: this_xf.transformPoint(a_scrBds.tl[0], a_scrBds.tl[1]), br: this_xf.transformPoint(a_scrBds.br[0], a_scrBds.br[1]) }; - const b_bds = { tl: this_xf.transformPoint(b_scrBds.tl[0], b_scrBds.tl[1]), br: this_xf.transformPoint(b_scrBds.br[0], b_scrBds.br[1]) }; - - const ppt1 = [(a_bds.tl[0] + a_bds.br[0]) / 2, (a_bds.tl[1] + a_bds.br[1]) / 2]; - const pt1 = Utils.getNearestPointInPerimeter(a_bds.tl[0], a_bds.tl[1], a_bds.br[0] - a_bds.tl[0], a_bds.br[1] - a_bds.tl[1], (b_bds.tl[0] + b_bds.br[0]) / 2, (b_bds.tl[1] + b_bds.br[1]) / 2); - const pt2 = Utils.getNearestPointInPerimeter(b_bds.tl[0], b_bds.tl[1], b_bds.br[0] - b_bds.tl[0], b_bds.br[1] - b_bds.tl[1], (a_bds.tl[0] + a_bds.br[0]) / 2, (a_bds.tl[1] + a_bds.br[1]) / 2); - const ppt2 = [(b_bds.tl[0] + b_bds.br[0]) / 2, (b_bds.tl[1] + b_bds.br[1]) / 2]; - - const pts = [ppt1, pt1, pt2, ppt2].map(pt => [pt[0], pt[1]]); - const [lx, rx, ty, by] = [Math.min(pt1[0], pt2[0]), Math.max(pt1[0], pt2[0]), Math.min(pt1[1], pt2[1]), Math.max(pt1[1], pt2[1])]; - return { pts, lx, rx, ty, by }; - } - return undefined; - }, - params => { - this.renderProps = params; - if (params) { - if ( - Math.abs(params.lx - NumCast(this.layoutDoc.x)) > 1e-5 || - Math.abs(params.ty - NumCast(this.layoutDoc.y)) > 1e-5 || - Math.abs(params.rx - params.lx - NumCast(this.layoutDoc._width)) > 1e-5 || - Math.abs(params.by - params.ty - NumCast(this.layoutDoc._height)) > 1e-5 - ) { - this.layoutDoc.x = params?.lx; - this.layoutDoc.y = params?.ty; - this.layoutDoc._width = params.rx - params?.lx; - this.layoutDoc._height = params?.by - params?.ty; - } - } else { - this.layoutDoc._width = Math.max(50, NumCast(this.layoutDoc._width)); - this.layoutDoc._height = Math.max(50, NumCast(this.layoutDoc._height)); - } + () => ({ drag: SnappingManager.IsDragging }), + ({ drag }) => { + !LightboxView.Contains(this.DocumentView?.()) && + setTimeout( + // need to wait for drag manager to set 'hidden' flag on dragged DOM elements + action(() => { + const a = this.anchor1, + b = this.anchor2; + let a1 = a && document.getElementById(a.Guid); + let a2 = b && document.getElementById(b.Guid); + // test whether the anchors themselves are hidden,... + if (!a1 || !a2 || (a?.ContentDiv as any)?.hidden || (b?.ContentDiv as any)?.hidden) this._hide = true; + else { + // .. or whether and of their DOM parents are hidden + for (; a1 && !a1.hidden; a1 = a1.parentElement); + for (; a2 && !a2.hidden; a2 = a2.parentElement); + this._hide = a1 || a2 ? true : false; + } + }) + ); }, { fireImmediately: true } ); } - componentWillUnmount(): void { - this.disposer?.(); - } - @observable renderProps: { lx: number; rx: number; ty: number; by: number; pts: number[][] } | undefined = undefined; + render() { - if (this.renderProps) { + if (this._hide) return null; + const a = this.anchor1; + const b = this.anchor2; + this._forceAnimate; + const docView = this._props.docViewPath().lastElement(); + + if (a && b && !LightboxView.Contains(docView)) { + // text selection bounds are not directly observable, so we have to + // force an update when anything that could affect them changes (text edits causing reflow, scrolling) + a.Document[a.LayoutFieldKey]; + b.Document[b.LayoutFieldKey]; + a.Document.layout_scrollTop; + b.Document.layout_scrollTop; + + const axf = a.screenToViewTransform(); // these force re-render when a or b moves (so do NOT remove) + const bxf = b.screenToViewTransform(); + const scale = docView?.screenToViewTransform().Scale ?? 1; + const at = a.getBounds?.transition; // these force re-render when a or b change size and at the end of an animated transition + const bt = b.getBounds?.transition; // inquring getBounds() also causes text anchors to update whether or not they reflow (any size change triggers an invalidation) + + // if there's an element in the DOM with a classname containing a link anchor's id (eg a hypertext <a>), + // then that DOM element is a hyperlink source for the current anchor and we want to place our link box at it's top right + // otherwise, we just use the computed nearest point on the document boundary to the target Document + const targetAhyperlink = Array.from(document.getElementsByClassName(DocCast(this.dataDoc.link_anchor_1)[Id])).lastElement(); + const targetBhyperlink = Array.from(document.getElementsByClassName(DocCast(this.dataDoc.link_anchor_2)[Id])).lastElement(); + + const aid = targetAhyperlink?.id || a.Document[Id]; + const bid = targetBhyperlink?.id || b.Document[Id]; + if (!document.getElementById(aid) || !document.getElementById(bid)) { + setTimeout(action(() => (this._forceAnimate = this._forceAnimate + 0.01))); + return null; + } + + if (at || bt) setTimeout(action(() => (this._forceAnimate = this._forceAnimate + 0.01))); // this forces an update during a transition animation const highlight = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Highlighting); const highlightColor = highlight?.highlightIndex ? highlight?.highlightColor : undefined; + const color = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color); + const fontFamily = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontFamily); + const fontSize = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontSize); + const fontColor = (c => (c !== 'transparent' ? c : undefined))(StrCast(this.layoutDoc.link_fontColor)); + const { stroke_markerScale, stroke_width, stroke_startMarker, stroke_endMarker, stroke_dash } = this.Document; - const bez = new Bezier(this.renderProps.pts.map(p => ({ x: p[0], y: p[1] }))); - const text = bez.get(0.5); - const linkDesc = StrCast(this.dataDoc.link_description) || 'description'; - const strokeWidth = NumCast(this.dataDoc.stroke_width, 4); - const dash = StrCast(this.Document.stroke_dash); - const strokeDasharray = dash && Number(dash) ? String(strokeWidth * Number(dash)) : undefined; - const { pts, lx, ty, rx, by } = this.renderProps; + const strokeWidth = NumCast(stroke_width, 4); + const linkDesc = StrCast(this.dataDoc.link_description) || ' '; + const labelText = linkDesc.substring(0, 50) + (linkDesc.length > 50 ? '...' : ''); return ( - <div style={{ transition: 'inherit', pointerEvents: 'none', position: 'absolute', width: '100%', height: '100%' }}> - <svg width={Math.max(100, rx - lx)} height={Math.max(100, by - ty)} style={{ transition: 'inherit', overflow: 'visible' }}> - <defs> - <filter x="0" y="0" width="1" height="1" id={`${this.Document[Id] + 'background'}`}> - <feFlood floodColor={`${StrCast(this.layoutDoc._backgroundColor, 'lightblue')}`} result="bg" /> - <feMerge> - <feMergeNode in="bg" /> - <feMergeNode in="SourceGraphic" /> - </feMerge> - </filter> - </defs> - <path - className="collectionfreeformlinkview-linkLine" - style={{ - pointerEvents: this._props.pointerEvents?.() === 'none' ? 'none' : 'visibleStroke', // - stroke: highlightColor ?? 'lightblue', - strokeDasharray, - strokeWidth, - transition: 'inherit', - }} - d={`M ${pts[1][0] - lx} ${pts[1][1] - ty} C ${pts[1][0] + pts[1][0] - pts[0][0] - lx} ${pts[1][1] + pts[1][1] - pts[0][1] - ty}, - ${pts[2][0] + pts[2][0] - pts[3][0] - lx} ${pts[2][1] + pts[2][1] - pts[3][1] - ty}, ${pts[2][0] - lx} ${pts[2][1] - ty}`} + <> + {!highlightColor ? null : ( + <Xarrow + divContainerStyle={{ transform: `scale(${scale})` }} + start={aid} + end={bid} // + strokeWidth={strokeWidth + Math.max(2, strokeWidth * 0.1)} + showHead={stroke_startMarker ? true : false} + showTail={stroke_endMarker ? true : false} + headSize={NumCast(stroke_markerScale, 3)} + tailSize={NumCast(stroke_markerScale, 3)} + tailShape={stroke_endMarker === 'dot' ? 'circle' : 'arrow1'} + headShape={stroke_startMarker === 'dot' ? 'circle' : 'arrow1'} + color={highlightColor} /> - <text - filter={`url(#${this.Document[Id] + 'background'})`} - style={{ pointerEvents: this._props.pointerEvents?.() === 'none' ? 'none' : 'all', textAnchor: 'middle', fontSize: '12', stroke: 'black' }} - x={text.x - lx} - y={text.y - ty}> - <tspan> </tspan> - <tspan dy="2">{linkDesc.substring(0, 50) + (linkDesc.length > 50 ? '...' : '')}</tspan> - <tspan dy="2"> </tspan> - </text> - </svg> - </div> + )} + <Xarrow + divContainerStyle={{ transform: `scale(${scale})` }} + start={aid} + end={bid} // + strokeWidth={strokeWidth} + dashness={Number(stroke_dash) ? true : false} + showHead={stroke_startMarker ? true : false} + showTail={stroke_endMarker ? true : false} + headSize={NumCast(stroke_markerScale, 3)} + tailSize={NumCast(stroke_markerScale, 3)} + tailShape={stroke_endMarker === 'dot' ? 'circle' : 'arrow1'} + headShape={stroke_startMarker === 'dot' ? 'circle' : 'arrow1'} + color={color} + labels={ + <div + style={{ + borderRadius: '8px', + pointerEvents: this._props.isDocumentActive?.() ? 'all' : undefined, + fontSize, + fontFamily /*, fontStyle: 'italic'*/, + color: fontColor || lightOrDark(DashColor(color).fade(0.5).toString()), + paddingLeft: 4, + paddingRight: 4, + paddingTop: 3, + paddingBottom: 3, + background: DashColor((!docView?.isSelected() && highlightColor) || color) + .fade(0.5) + .toString(), + }}> + <EditableView + key="editableView" + oneLine + contents={labelText} + height={fontSize + 4} + fontSize={fontSize} + GetValue={() => linkDesc} + SetValue={action(val => { + this.Document[DocData].link_description = val; + return true; + })} + /> + + {/* <EditableText + placeholder={labelText} + background={color} + color={fontColor || lightOrDark(DashColor(color).fade(0.5).toString())} + type={Type.PRIM} + val={StrCast(this.Document[DocData].link_description)} + setVal={action(val => (this.Document[DocData].link_description = val))} + fillWidth + /> */} + </div> + } + passProps={{}} + /> + </> ); } return ( <div className={`linkBox-container${this._props.isContentActive() ? '-interactive' : ''}`} style={{ background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) }}> <ComparisonBox - {...this._props} // + {...this.props} // fieldKey="link_anchor" setHeight={emptyFunction} dontRegisterView={true} diff --git a/src/client/views/nodes/LinkDescriptionPopup.tsx b/src/client/views/nodes/LinkDescriptionPopup.tsx index 13f0ac4fc..1645d0813 100644 --- a/src/client/views/nodes/LinkDescriptionPopup.tsx +++ b/src/client/views/nodes/LinkDescriptionPopup.tsx @@ -1,10 +1,11 @@ -import { action, makeObservable, observable } from 'mobx'; +import { action, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { DocData } from '../../../fields/DocSymbols'; import { LinkManager } from '../../util/LinkManager'; import './LinkDescriptionPopup.scss'; import { TaskCompletionBox } from './TaskCompletedBox'; +import { StrCast } from '../../../fields/Types'; @observer export class LinkDescriptionPopup extends React.Component<{}> { @@ -31,21 +32,26 @@ export class LinkDescriptionPopup extends React.Component<{}> { onDismiss = (add: boolean) => { this.display = false; if (add) { - LinkManager.currentLink && (LinkManager.currentLink[DocData].link_description = this.description); + LinkManager.Instance.currentLink && (LinkManager.Instance.currentLink[DocData].link_description = this.description); } + this.description = ''; }; @action onClick = (e: PointerEvent) => { if (this.popupRef && !!!this.popupRef.current?.contains(e.target as any)) { this.display = false; + this.description = ''; TaskCompletionBox.taskCompleted = false; } }; - @action componentDidMount() { document.addEventListener('pointerdown', this.onClick, true); + reaction( + () => this.display, + display => display && (this.description = StrCast(LinkManager.Instance.currentLink?.link_description)) + ); } componentWillUnmount() { @@ -65,8 +71,10 @@ export class LinkDescriptionPopup extends React.Component<{}> { className="linkDescriptionPopup-input" onKeyDown={e => e.stopPropagation()} onKeyPress={e => e.key === 'Enter' && this.onDismiss(true)} - placeholder={'(Optional) Enter link description...'} - onChange={e => this.descriptionChanged(e)}></input> + value={this.description} + placeholder={this.description || '(Optional) Enter link description...'} + onChange={e => this.descriptionChanged(e)} + /> <div className="linkDescriptionPopup-btn"> <div className="linkDescriptionPopup-btn-dismiss" onPointerDown={e => this.onDismiss(false)}> {' '} diff --git a/src/client/views/nodes/LinkDocPreview.tsx b/src/client/views/nodes/LinkDocPreview.tsx index 4b242649a..ae25ff179 100644 --- a/src/client/views/nodes/LinkDocPreview.tsx +++ b/src/client/views/nodes/LinkDocPreview.tsx @@ -152,8 +152,8 @@ export class LinkDocPreview extends ObservableReactComponent<LinkDocPreviewProps returnFalse, emptyFunction, action(() => { - LinkManager.currentLink = this._linkDoc; - LinkManager.currentLinkAnchor = this._linkSrc; + LinkManager.Instance.currentLink = this._linkDoc; + LinkManager.Instance.currentLinkAnchor = this._linkSrc; this._props.DocumentView?.().select(false); if ((SettingsManager.Instance.propertiesWidth ?? 0) < 100) { SettingsManager.Instance.propertiesWidth = 250; @@ -184,7 +184,7 @@ export class LinkDocPreview extends ObservableReactComponent<LinkDocPreviewProps LinkFollower.FollowLink(this._linkDoc, this._linkSrc, false); } else if (this._props.hrefs?.length) { const webDoc = - Array.from(SearchUtil.SearchCollection(Doc.MyFilesystem, this._props.hrefs[0]).keys()).lastElement() ?? + Array.from(SearchUtil.SearchCollection(Doc.MyFilesystem, this._props.hrefs[0], false).keys()).lastElement() ?? Docs.Create.WebDocument(this._props.hrefs[0], { title: this._props.hrefs[0], _nativeWidth: 850, _width: 200, _height: 400, data_useCors: true }); DocumentManager.Instance.showDocument(webDoc, { openLocation: OpenWhere.lightbox, diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 5a07540da..2c5398e40 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -14,7 +14,7 @@ import { listSpec } from '../../../fields/Schema'; import { Cast, NumCast, StrCast, WebCast } from '../../../fields/Types'; import { ImageField, WebField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; -import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, getWordAtPoint, lightOrDark, returnFalse, returnOne, returnZero, setupMoveUpEvents, smoothScroll, Utils } from '../../../Utils'; +import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, emptyFunction, getWordAtPoint, lightOrDark, returnFalse, returnOne, returnZero, setupMoveUpEvents, smoothScroll, stringHash, Utils } from '../../../Utils'; import { Docs, DocUtils } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; @@ -83,7 +83,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() implem return this.webField?.toString() || ''; } @computed get _urlHash() { - return this._url ? WebBox.urlHash(this._url) + '' : ''; + return ""+ (stringHash(this._url)??''); } @computed get scrollHeight() { return Math.max(NumCast(this.layoutDoc._height), this._scrollHeight); @@ -624,15 +624,6 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() implem return false; }; - static urlHash = (s: string) => { - const split = s.split(''); - return Math.abs( - split.reduce((a: any, b: any) => { - a = (a << 5) - a + b.charCodeAt(0); - return a & a; - }, 0) - ); - }; @action submitURL = (preview?: boolean, dontUpdateIframe?: boolean) => { try { @@ -1154,5 +1145,5 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() implem } } ScriptingGlobals.add(function urlHash(url: string) { - return url ? WebBox.urlHash(url) : 0; + return stringHash(url); }); diff --git a/src/client/views/nodes/formattedText/DashDocCommentView.tsx b/src/client/views/nodes/formattedText/DashDocCommentView.tsx index b7d2a24c2..a72ed1813 100644 --- a/src/client/views/nodes/formattedText/DashDocCommentView.tsx +++ b/src/client/views/nodes/formattedText/DashDocCommentView.tsx @@ -3,6 +3,8 @@ import * as ReactDOM from 'react-dom/client'; import { Doc } from '../../../../fields/Doc'; import { DocServer } from '../../../DocServer'; import * as React from 'react'; +import { IReactionDisposer, computed, reaction } from 'mobx'; +import { NumCast } from '../../../../fields/Types'; // creates an inline comment in a note when '>>' is typed. // the comment sits on the right side of the note and vertically aligns with its anchor in the text. @@ -10,8 +12,10 @@ import * as React from 'react'; export class DashDocCommentView { dom: HTMLDivElement; // container for label and value root: any; + node: any; constructor(node: any, view: any, getPos: any) { + this.node = node; this.dom = document.createElement('div'); this.dom.style.width = node.attrs.width; this.dom.style.height = node.attrs.height; @@ -32,10 +36,14 @@ export class DashDocCommentView { }; this.root = ReactDOM.createRoot(this.dom); - this.root.render(<DashDocCommentViewInternal view={view} getPos={getPos} docId={node.attrs.docId} />); + this.root.render(<DashDocCommentViewInternal view={view} getPos={getPos} setHeight={this.setHeight} docId={node.attrs.docId} />); (this as any).dom = this.dom; } + setHeight = (hgt: number) => { + !this.node.attrs.reflow && DocServer.GetRefField(this.node.attrs.docId).then(doc => doc instanceof Doc && (this.dom.style.height = hgt + '')); + }; + destroy() { this.root.unmount(); } @@ -51,9 +59,15 @@ interface IDashDocCommentViewInternal { docId: string; view: any; getPos: any; + setHeight: (height: number) => void; } export class DashDocCommentViewInternal extends React.Component<IDashDocCommentViewInternal> { + _reactionDisposer: IReactionDisposer | undefined; + + @computed get _dashDoc() { + return DocServer.GetRefField(this.props.docId); + } constructor(props: any) { super(props); this.onPointerLeaveCollapsed = this.onPointerLeaveCollapsed.bind(this); @@ -61,15 +75,32 @@ export class DashDocCommentViewInternal extends React.Component<IDashDocCommentV this.onPointerUpCollapsed = this.onPointerUpCollapsed.bind(this); this.onPointerDownCollapsed = this.onPointerDownCollapsed.bind(this); } + componentDidMount(): void { + this._reactionDisposer?.(); + this._dashDoc.then( + doc => + doc instanceof Doc && + (this._reactionDisposer = reaction( + () => NumCast((doc as Doc)._height), + hgt => this.props.setHeight(hgt), + { + fireImmediately: true, + } + )) + ); + } + componentWillUnmount(): void { + this._reactionDisposer?.(); + } onPointerLeaveCollapsed(e: any) { - DocServer.GetRefField(this.props.docId).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowUnhighlight()); + this._dashDoc.then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowUnhighlight()); e.preventDefault(); e.stopPropagation(); } onPointerEnterCollapsed(e: any) { - DocServer.GetRefField(this.props.docId).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc, false)); + this._dashDoc.then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc, false)); e.preventDefault(); e.stopPropagation(); } @@ -82,7 +113,7 @@ export class DashDocCommentViewInternal extends React.Component<IDashDocCommentV const tr = this.props.view.state.tr.setNodeMarkup(target.pos, undefined, { ...target.node.attrs, hidden: target.node.attrs.hidden ? false : true }); this.props.view.dispatch(tr.setSelection(TextSelection.create(tr.doc, this.props.getPos() + (expand ? 2 : 1)))); // update the attrs setTimeout(() => { - expand && DocServer.GetRefField(this.props.docId).then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)); + expand && this._dashDoc.then(async dashDoc => dashDoc instanceof Doc && Doc.linkFollowHighlight(dashDoc)); try { this.props.view.dispatch(this.props.view.state.tr.setSelection(TextSelection.create(this.props.view.state.tr.doc, this.props.getPos() + (expand ? 2 : 1)))); } catch (e) {} diff --git a/src/client/views/nodes/formattedText/DashFieldView.scss b/src/client/views/nodes/formattedText/DashFieldView.scss index 3426ba1a7..7a0ff8776 100644 --- a/src/client/views/nodes/formattedText/DashFieldView.scss +++ b/src/client/views/nodes/formattedText/DashFieldView.scss @@ -5,6 +5,16 @@ display: inline-flex; align-items: center; + select { + display: none; + } + + &:hover { + select { + display: unset; + } + } + .dashFieldView-enumerables { width: 10px; height: 10px; diff --git a/src/client/views/nodes/formattedText/DashFieldView.tsx b/src/client/views/nodes/formattedText/DashFieldView.tsx index dc9914637..b49e7dcf0 100644 --- a/src/client/views/nodes/formattedText/DashFieldView.tsx +++ b/src/client/views/nodes/formattedText/DashFieldView.tsx @@ -1,24 +1,27 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; -import { action, computed, IReactionDisposer, makeObservable, observable } from 'mobx'; +import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import * as ReactDOM from 'react-dom/client'; -import { Doc } from '../../../../fields/Doc'; +import Select from 'react-select'; +import { Doc, DocListCast, Field } from '../../../../fields/Doc'; import { List } from '../../../../fields/List'; import { listSpec } from '../../../../fields/Schema'; import { SchemaHeaderField } from '../../../../fields/SchemaHeaderField'; -import { Cast } from '../../../../fields/Types'; +import { Cast, StrCast } from '../../../../fields/Types'; import { emptyFunction, returnFalse, returnZero, setupMoveUpEvents } from '../../../../Utils'; import { DocServer } from '../../../DocServer'; import { CollectionViewType } from '../../../documents/DocumentTypes'; import { Transform } from '../../../util/Transform'; +import { undoable, undoBatch } from '../../../util/UndoManager'; import { AntimodeMenu, AntimodeMenuProps } from '../../AntimodeMenu'; import { SchemaTableCell } from '../../collections/collectionSchema/SchemaTableCell'; import { ObservableReactComponent } from '../../ObservableReactComponent'; import { OpenWhere } from '../DocumentView'; import './DashFieldView.scss'; import { FormattedTextBox } from './FormattedTextBox'; +import { FilterPanel } from '../../FilterPanel'; export class DashFieldView { dom: HTMLDivElement; // container for label and value @@ -97,7 +100,7 @@ export class DashFieldViewInternal extends ObservableReactComponent<IDashFieldVi _reactionDisposer: IReactionDisposer | undefined; _textBoxDoc: Doc; _fieldKey: string; - _fieldStringRef = React.createRef<HTMLSpanElement>(); + _fieldRef = React.createRef<HTMLDivElement>(); @observable _dashDoc: Doc | undefined = undefined; @observable _expanded = false; @@ -114,6 +117,13 @@ export class DashFieldViewInternal extends ObservableReactComponent<IDashFieldVi } } + componentDidMount() { + this._reactionDisposer = reaction( + () => (this._dashDoc ? Field.toKeyValueString(this._dashDoc, this._props.fieldKey) : undefined), + keyvalue => keyvalue && this._props.tbox.tryUpdateDoc(true) + ); + } + componentWillUnmount() { this._reactionDisposer?.(); } @@ -173,10 +183,22 @@ export class DashFieldViewInternal extends ObservableReactComponent<IDashFieldVi }); }; + @undoBatch + selectVal = (event: React.ChangeEvent<HTMLSelectElement> | undefined) => { + event && this._dashDoc && (this._dashDoc[this._fieldKey] = event.target.value); + }; + + @computed get values() { + const vals = FilterPanel.gatherFieldValues(DocListCast(Doc.ActiveDashboard?.data), this._fieldKey, []); + + return vals.strings.map(facet => ({ value: facet, label: facet })); + } + render() { return ( <div className="dashFieldView" + ref={this._fieldRef} style={{ width: this._props.width, height: this._props.height, @@ -184,11 +206,17 @@ export class DashFieldViewInternal extends ObservableReactComponent<IDashFieldVi }}> {this._props.hideKey ? null : ( <span className="dashFieldView-labelSpan" title="click to see related tags" onPointerDown={this.onPointerDownLabelSpan}> - {this._fieldKey} + {(this._textBoxDoc === this._dashDoc ? '' : this._dashDoc?.title + ':') + this._fieldKey} </span> )} - {this._props.fieldKey.startsWith('#') ? null : this.fieldValueContent} + {!this.values.length ? null : ( + <select onChange={this.selectVal} style={{ height: '10px', width: '15px', fontSize: '12px', background: 'transparent' }}> + {this.values.map(val => ( + <option value={val.value}>{val.label}</option> + ))} + </select> + )} </div> ); } diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 460f2a1c6..40acf164f 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx +++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx @@ -13,7 +13,7 @@ import { EditorView } from 'prosemirror-view'; import * as React from 'react'; import { BsMarkdownFill } from 'react-icons/bs'; import { DateField } from '../../../../fields/DateField'; -import { Doc, DocListCast, Field, Opt } from '../../../../fields/Doc'; +import { Doc, DocListCast, Field, Opt, StrListCast } from '../../../../fields/Doc'; import { AclAdmin, AclAugment, AclEdit, AclSelfEdit, DocCss, DocData, ForceServerWrite, UpdatingFromServer } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { InkTool } from '../../../../fields/InkField'; @@ -68,6 +68,7 @@ import { RichTextRules } from './RichTextRules'; import { schema } from './schema_rts'; import { SummaryView } from './SummaryView'; import { isDarkMode } from '../../../util/reportManager/reportManagerUtils'; +import Select from 'react-select'; // import * as applyDevTools from 'prosemirror-dev-tools'; @observer export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps>() implements ViewBoxInterface { @@ -326,13 +327,26 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps } catch (e) {} }; + leafText = (node: Node) => { + if (node.type === this._editorView?.state.schema.nodes.dashField) { + const refDoc = !node.attrs.docId ? this.Document : (DocServer.GetCachedRefField(node.attrs.docId as string) as Doc); + return Field.toJavascriptString(refDoc[node.attrs.fieldKey as string] as Field); + } + return ''; + }; dispatchTransaction = (tx: Transaction) => { if (this._editorView && (this._editorView as any).docView) { const state = this._editorView.state.apply(tx); this._editorView.updateState(state); + this.tryUpdateDoc(false); + } + }; + tryUpdateDoc = (force: boolean) => { + if (this._editorView && (this._editorView as any).docView) { + const state = this._editorView.state; const dataDoc = Doc.IsDelegateField(DocCast(this.layoutDoc.proto), this.fieldKey) ? DocCast(this.layoutDoc.proto) : this.dataDoc; - const newText = state.doc.textBetween(0, state.doc.content.size, ' \n'); + const newText = state.doc.textBetween(0, state.doc.content.size, ' \n', this.leafText); const newJson = JSON.stringify(state.toJSON()); const prevData = Cast(this.layoutDoc[this.fieldKey], RichTextField, null); // the actual text in the text box const templateData = this.Document !== this.layoutDoc ? prevData : undefined; // the default text stored in a layout template @@ -348,16 +362,18 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps accumTags.push(node.attrs.fieldKey); } }); - dataDoc.tags = accumTags.length ? new List<string>(Array.from(new Set<string>(accumTags))) : undefined; + if (accumTags.some(atag => !StrListCast(dataDoc.tags).includes(atag))) { + dataDoc.tags = new List<string>(Array.from(new Set<string>(accumTags))); + } let unchanged = true; - if (this._applyingChange !== this.fieldKey && removeSelection(newJson) !== removeSelection(prevData?.Data)) { + if (this._applyingChange !== this.fieldKey && (force || removeSelection(newJson) !== removeSelection(prevData?.Data))) { this._applyingChange = this.fieldKey; const textChange = newText !== prevData?.Text; textChange && (dataDoc[this.fieldKey + '_modificationDate'] = new DateField(new Date(Date.now()))); - if ((!prevData && !protoData) || newText || (!newText && !templateData)) { + if ((!prevData && !protoData) || newText || (!newText && !protoData)) { // if no template, or there's text that didn't come from the layout template, write it to the document. (if this is driven by a template, then this overwrites the template text which is intended) - if ((this._finishingLink || this._props.isContentActive() || this._inDrop) && removeSelection(newJson) !== removeSelection(prevData?.Data)) { + if (force || ((this._finishingLink || this._props.isContentActive() || this._inDrop) && removeSelection(newJson) !== removeSelection(prevData?.Data))) { const numstring = NumCast(dataDoc[this.fieldKey], null); dataDoc[this.fieldKey] = numstring !== undefined ? Number(newText) : new RichTextField(newJson, newText); dataDoc[this.fieldKey + '_noTemplate'] = true; // mark the data field as being split from the template if it has been edited @@ -474,14 +490,29 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps }; // creates links between terms in a document and published documents (myPublishedDocs) that have titles starting with an '@' + /** + * Searches the text for occurences of any strings that match the names of 'published' documents. These document + * names will begin with an '@' prefix. However, valid matches within the text can have any of the following formats: + * name, @<name>, or ^@<name> + * The last of these is interpreted as an include directive when converting the text into evaluated code in the paint + * function of a freeform view that is driven by the text box's text. The include directive will copy the code of the published + * document into the code being evaluated. + */ hyperlinkTerm = (tr: any, target: Doc, newAutoLinks: Set<Doc>) => { const editorView = this._editorView; if (editorView && (editorView as any).docView && !Doc.AreProtosEqual(target, this.Document)) { const autoLinkTerm = StrCast(target.title).replace(/^@/, ''); var alink: Doc | undefined; this.findInNode(editorView, editorView.state.doc, autoLinkTerm).forEach(sel => { - const splitter = editorView.state.schema.marks.splitter.create({ id: Utils.GenerateGuid() }); - if (!sel.$anchor.pos || editorView.state.doc.textBetween(sel.$anchor.pos - 1, sel.$to.pos).trim() === autoLinkTerm) { + if ( + !sel.$anchor.pos || + autoLinkTerm === + editorView.state.doc + .textBetween(sel.$anchor.pos - 1, sel.$to.pos) + .trim() + .replace(/[\^@]+/, '') + ) { + const splitter = editorView.state.schema.marks.splitter.create({ id: Utils.GenerateGuid() }); tr = tr.addMark(sel.from, sel.to, splitter); tr.doc.nodesBetween(sel.from, sel.to, (node: any, pos: number, parent: any) => { if (node.firstChild === null && !node.marks.find((m: Mark) => m.type.name === schema.marks.noAutoLinkAnchor.name) && node.marks.find((m: Mark) => m.type.name === schema.marks.splitter.name)) { @@ -654,12 +685,22 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps let index = 0, foundAt; const ep = this.getNodeEndpoints(pm.state.doc, node); - const regexp = new RegExp(find.replace('*', ''), 'i'); + const regexp = new RegExp(find, 'i'); if (regexp) { - while (ep && (foundAt = node.textContent.slice(index).search(regexp)) > -1) { - const sel = new TextSelection(pm.state.doc.resolve(ep.from + index + foundAt + 1), pm.state.doc.resolve(ep.from + index + foundAt + find.length + 1)); - ret.push(sel); - index = index + foundAt + find.length; + var blockOffset = 0; + for (var i = 0; i < node.childCount; i++) { + var textContent = ''; + while (i < node.childCount && node.child(i).type === pm.state.schema.nodes.text) { + textContent += node.child(i).textContent; + i++; + } + while (ep && (foundAt = textContent.slice(index).search(regexp)) > -1) { + const sel = new TextSelection(pm.state.doc.resolve(ep.from + index + blockOffset + foundAt + 1), pm.state.doc.resolve(ep.from + index + blockOffset + foundAt + find.length + 1)); + ret.push(sel); + index = index + foundAt + find.length; + } + blockOffset += textContent.length; + if (i < node.childCount) blockOffset += node.child(i).nodeSize; } } } else { @@ -1171,8 +1212,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps this._cachedLinks = LinkManager.Links(this.Document); this._disposers.breakupDictation = reaction(() => Doc.RecordingEvent, this.breakupDictation); this._disposers.layout_autoHeight = reaction( - () => this.layout_autoHeight, - layout_autoHeight => layout_autoHeight && this.tryUpdateScrollHeight() + () => ({ autoHeight: this.layout_autoHeight, fontSize: this.fontSize }), + (autoHeight, fontSize) => setTimeout(() => autoHeight && this.tryUpdateScrollHeight()) ); this._disposers.highlights = reaction( () => Array.from(FormattedTextBox._globalHighlights).slice(), @@ -1537,15 +1578,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps this._downX = e.clientX; this._downY = e.clientY; this._downTime = Date.now(); - this._hadDownFocus = this.ProseRef?.children[0].className.includes('focused') ?? false; FormattedTextBoxComment.textBox = this; if (e.button === 0 && this._props.rootSelected?.() && !e.altKey && !e.ctrlKey && !e.metaKey) { if (e.clientX < this.ProseRef!.getBoundingClientRect().right) { // stop propagation if not in sidebar, otherwise nested boxes will lose focus to outer boxes. e.stopPropagation(); // if the text box's content is active, then it consumes all down events document.addEventListener('pointerup', this.onSelectEnd); + (this.ProseRef?.children?.[0] as any).focus(); } } + this._hadDownFocus = this.ProseRef?.children[0].className.includes('focused') ?? false; if (e.button === 2 || (e.button === 0 && e.ctrlKey)) { e.preventDefault(); } @@ -1706,7 +1748,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps FormattedTextBox.LiveTextUndo = undefined; const state = this._editorView!.state; - const curText = state.doc.textBetween(0, state.doc.content.size, ' \n'); if (StrCast(this.Document.title).startsWith('@') && !this.dataDoc.title_custom) { UndoManager.RunInBatch(() => { this.dataDoc.title_custom = true; @@ -2002,7 +2043,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FieldViewProps } render() { TraceMobx(); - const scale = (this._props.NativeDimScaling?.() || 1) * NumCast(this.layoutDoc._freeform_scale, 1); + const scale = this._props.NativeDimScaling?.() || 1; // * NumCast(this.layoutDoc._freeform_scale, 1); const rounded = StrCast(this.layoutDoc.layout_borderRounding) === '100%' ? '-rounded' : ''; setTimeout(() => !this._props.isContentActive() && FormattedTextBoxComment.textBox === this && FormattedTextBoxComment.Hide); const paddingX = NumCast(this.layoutDoc._xMargin, this._props.xPadding || 0); diff --git a/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts b/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts index 08bad2d57..47527847b 100644 --- a/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts +++ b/src/client/views/nodes/formattedText/ProsemirrorExampleTransfer.ts @@ -303,7 +303,7 @@ export function buildKeymap<S extends Schema<any>>(schema: S, props: any, mapKey const marks = state.storedMarks || (state.selection.$to.parentOffset && state.selection.$from.marks()); const cr = state.selection.$from.node().textContent.endsWith('\n'); - if (cr || !newlineInCode(state, dispatch as any)) { + if (/*cr ||*/ !newlineInCode(state, dispatch as any)) { if ( !splitListItem(schema.nodes.list_item)(state as any, (tx2: Transaction) => { const tx3 = updateBullets(tx2, schema); diff --git a/src/client/views/nodes/formattedText/RichTextMenu.tsx b/src/client/views/nodes/formattedText/RichTextMenu.tsx index 5858c3b11..cd0cdaa74 100644 --- a/src/client/views/nodes/formattedText/RichTextMenu.tsx +++ b/src/client/views/nodes/formattedText/RichTextMenu.tsx @@ -141,12 +141,14 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> { const activeSizes = active.activeSizes; const activeColors = active.activeColors; const activeHighlights = active.activeHighlights; + const refDoc = SelectionManager.Views.lastElement()?.layoutDoc ?? Doc.UserDoc(); + const refField = (pfx => (pfx ? pfx + '_' : ''))(SelectionManager.Views.lastElement()?.LayoutFieldKey); this.activeListType = this.getActiveListStyle(); this._activeAlignment = this.getActiveAlignment(); - this._activeFontFamily = !activeFamilies.length ? StrCast(this.TextView?.Document._text_fontFamily, StrCast(Doc.UserDoc().fontFamily, 'Arial')) : activeFamilies.length === 1 ? String(activeFamilies[0]) : 'various'; - this._activeFontSize = !activeSizes.length ? StrCast(this.TextView?.Document.fontSize, StrCast(Doc.UserDoc().fontSize, '10px')) : activeSizes[0]; - this._activeFontColor = !activeColors.length ? StrCast(this.TextView?.Document.fontColor, StrCast(Doc.UserDoc().fontColor, 'black')) : activeColors.length > 0 ? String(activeColors[0]) : '...'; + this._activeFontFamily = !activeFamilies.length ? StrCast(this.TextView?.Document._text_fontFamily, StrCast(refDoc[refField + 'fontFamily'], 'Arial')) : activeFamilies.length === 1 ? String(activeFamilies[0]) : 'various'; + this._activeFontSize = !activeSizes.length ? StrCast(this.TextView?.Document.fontSize, StrCast(refDoc[refField + 'fontSize'], '10px')) : activeSizes[0]; + this._activeFontColor = !activeColors.length ? StrCast(this.TextView?.Document.fontColor, StrCast(refDoc[refField + 'fontColor'], 'black')) : activeColors.length > 0 ? String(activeColors[0]) : '...'; this._activeHighlightColor = !activeHighlights.length ? '' : activeHighlights.length > 0 ? String(activeHighlights[0]) : '...'; // update link in current selection @@ -358,8 +360,8 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> { this.setMark(fmark, this.view.state, (tx: any) => this.view!.dispatch(tx.addStoredMark(fmark)), true); this.view.focus(); } - } else if (SelectionManager.Views.some(dv => dv.ComponentView instanceof EquationBox)) { - SelectionManager.Views.forEach(dv => (dv.Document._text_fontSize = fontSize)); + } else if (SelectionManager.Views.length) { + SelectionManager.Views.forEach(dv => (dv.layoutDoc[dv.LayoutFieldKey + '_fontSize'] = fontSize)); } else Doc.UserDoc().fontSize = fontSize; this.updateMenu(this.view, undefined, this.props, this.layoutDoc); }; @@ -369,6 +371,8 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> { const fmark = this.view.state.schema.marks.pFontFamily.create({ family: family }); this.setMark(fmark, this.view.state, (tx: any) => this.view!.dispatch(tx.addStoredMark(fmark)), true); this.view.focus(); + } else if (SelectionManager.Views.length) { + SelectionManager.Views.forEach(dv => (dv.layoutDoc[dv.LayoutFieldKey + '_fontFamily'] = family)); } else Doc.UserDoc().fontFamily = family; this.updateMenu(this.view, undefined, this.props, this.layoutDoc); }; @@ -387,6 +391,8 @@ export class RichTextMenu extends AntimodeMenu<AntimodeMenuProps> { const colorMark = this.view.state.schema.mark(this.view.state.schema.marks.pFontColor, { color }); this.setMark(colorMark, this.view.state, (tx: any) => this.view!.dispatch(tx.addStoredMark(colorMark)), true); this.view.focus(); + } else if (SelectionManager.Views.length) { + SelectionManager.Views.forEach(dv => (dv.layoutDoc[dv.LayoutFieldKey + '_fontColor'] = color)); } else Doc.UserDoc().fontColor = color; this.updateMenu(this.view, undefined, this.props, this.layoutDoc); } diff --git a/src/client/views/nodes/formattedText/RichTextRules.ts b/src/client/views/nodes/formattedText/RichTextRules.ts index 456ed4732..9bd41f42c 100644 --- a/src/client/views/nodes/formattedText/RichTextRules.ts +++ b/src/client/views/nodes/formattedText/RichTextRules.ts @@ -4,8 +4,7 @@ import { Doc, StrListCast } from '../../../../fields/Doc'; import { DocData } from '../../../../fields/DocSymbols'; import { Id } from '../../../../fields/FieldSymbols'; import { List } from '../../../../fields/List'; -import { ComputedField } from '../../../../fields/ScriptField'; -import { NumCast } from '../../../../fields/Types'; +import { NumCast, StrCast } from '../../../../fields/Types'; import { Utils } from '../../../../Utils'; import { DocServer } from '../../../DocServer'; import { Docs, DocUtils } from '../../../documents/Documents'; @@ -13,6 +12,9 @@ import { FormattedTextBox } from './FormattedTextBox'; import { wrappingInputRule } from './prosemirrorPatches'; import { RichTextMenu } from './RichTextMenu'; import { schema } from './schema_rts'; +import { CollectionView } from '../../collections/CollectionView'; +import { CollectionViewType } from '../../../documents/DocumentTypes'; +import { ContextMenu } from '../../ContextMenu'; export class RichTextRules { public Document: Doc; @@ -66,7 +68,21 @@ export class RichTextRules { ), // ``` create code block - textblockTypeInputRule(/^```$/, schema.nodes.code_block), + new InputRule(/^```$/, (state, match, start, end) => { + let $start = state.doc.resolve(start); + if (!$start.node(-1).canReplaceWith($start.index(-1), $start.indexAfter(-1), schema.nodes.code_block)) return null; + + // this enables text with code blocks to be used as a 'paint' function via a styleprovider button that is added to Docs that have an onPaint script + this.TextBox.layoutDoc.type_collection = CollectionViewType.Freeform; // make it a freeform when rendered as a collection since those are the only views that know about the paint function + const paintedField = 'layout_' + this.TextBox.fieldKey + 'Painted'; // make a layout field key for storing the CollectionView jsx string pointing to the textbox's text + this.TextBox.dataDoc[paintedField] = CollectionView.LayoutString(this.TextBox.fieldKey); + const layoutFieldKey = StrCast(this.TextBox.layoutDoc.layout_fieldKey); // save the current layout fieldkey + this.TextBox.layoutDoc.layout_fieldKey = paintedField; // setup the paint layout field key + this.TextBox.DocumentView?.().setToggleDetail(layoutFieldKey.replace('layout_', '').replace('layout', ''), 'onPaint'); // create the script to toggle between the painted and regular view + this.TextBox.layoutDoc.layout_fieldKey = layoutFieldKey; // restore the layout field key to text + + return state.tr.delete(start, end).setBlockType(start, start, schema.nodes.code_block); + }), // %<font-size> set the font size new InputRule(new RegExp(/%([0-9]+)\s$/), (state, match, start, end) => { @@ -75,6 +91,32 @@ export class RichTextRules { }), //Create annotation to a field on the text document + new InputRule(new RegExp(/>::$/), (state, match, start, end) => { + const creator = (doc: Doc) => { + const textDoc = this.Document[DocData]; + const numInlines = NumCast(textDoc.inlineTextCount); + textDoc.inlineTextCount = numInlines + 1; + const node = (state.doc.resolve(start) as any).nodeAfter; + const newNode = schema.nodes.dashComment.create({ docId: doc[Id], reflow: false }); + const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: 'dashDoc', docId: doc[Id], float: 'right' }); + const sm = state.storedMarks || undefined; + this.TextBox.EditorView?.dispatch( + node + ? this.TextBox.EditorView.state.tr + .insert(start, newNode) + .replaceRangeWith(start + 1, end + 2, dashDoc) + .insertText(' ', start + 2) + .setStoredMarks([...node.marks, ...(sm ? sm : [])]) + : this.TextBox.EditorView.state.tr + ); + }; + DocUtils.addDocumentCreatorMenuItems(creator, creator, 200, 200); + const cm = ContextMenu.Instance; + cm.displayMenu(200, 200, undefined, true); + + return null; + }), + //Create annotation to a field on the text document new InputRule(new RegExp(/>>$/), (state, match, start, end) => { const textDoc = this.Document[DocData]; const numInlines = NumCast(textDoc.inlineTextCount); @@ -98,7 +140,7 @@ export class RichTextRules { textDoc[inlineLayoutKey] = FormattedTextBox.LayoutString(inlineFieldKey); // create a layout string for the layout key that will render the annotation text textDoc[inlineFieldKey] = ''; // set a default value for the annotation const node = (state.doc.resolve(start) as any).nodeAfter; - const newNode = schema.nodes.dashComment.create({ docId: textDocInline[Id] }); + const newNode = schema.nodes.dashComment.create({ docId: textDocInline[Id], reflow: true }); const dashDoc = schema.nodes.dashDoc.create({ width: 75, height: 35, title: 'dashDoc', docId: textDocInline[Id], float: 'right' }); const sm = state.storedMarks || undefined; const replaced = node @@ -222,7 +264,7 @@ export class RichTextRules { return null; }), - // stop using active style + // toggle alternate text UI %/ new InputRule(new RegExp(/%\//), (state, match, start, end) => { setTimeout(this.TextBox.cycleAlternateText); return state.tr.deleteRange(start, end); @@ -246,49 +288,57 @@ export class RichTextRules { // [[fieldKey]] => show field // [[fieldKey=value]] => show field and also set its value // [[fieldKey:docTitle]] => show field of doc - new InputRule(new RegExp(/\[\[([a-zA-Z_\? \-0-9]*)(=[a-zA-Z_@\? /\-0-9]*)?(:[a-zA-Z_@:\.\? \-0-9]+)?\]\]$/), (state, match, start, end) => { - const fieldKey = match[1]; - const docTitle = match[3]?.replace(':', ''); - const value = match[2]?.substring(1); - const linkToDoc = (target: Doc) => { - const rstate = this.TextBox.EditorView?.state; - const selection = rstate?.selection.$from.pos; - if (rstate) { - this.TextBox.EditorView?.dispatch(rstate.tr.setSelection(new TextSelection(rstate.doc.resolve(start), rstate.doc.resolve(end - 3)))); - } + new InputRule( + new RegExp(/\[\[([a-zA-Z_\? \-0-9]*)(=[a-z,A-Z_@\? /\-0-9]*)?(:[a-zA-Z_@:\.\? \-0-9]+)?\]\]$/), + (state, match, start, end) => { + const fieldKey = match[1]; + const docTitle = match[3]?.replace(':', ''); + const value = match[2]?.substring(1); + const linkToDoc = (target: Doc) => { + const rstate = this.TextBox.EditorView?.state; + const selection = rstate?.selection.$from.pos; + if (rstate) { + this.TextBox.EditorView?.dispatch(rstate.tr.setSelection(new TextSelection(rstate.doc.resolve(start), rstate.doc.resolve(end - 3)))); + } - DocUtils.MakeLink(this.TextBox.getAnchor(true), target, { link_relationship: 'portal to:portal from' }); + DocUtils.MakeLink(this.TextBox.getAnchor(true), target, { link_relationship: 'portal to:portal from' }); - const fstate = this.TextBox.EditorView?.state; - if (fstate && selection) { - this.TextBox.EditorView?.dispatch(fstate.tr.setSelection(new TextSelection(fstate.doc.resolve(selection)))); - } - }; - const getTitledDoc = (docTitle: string) => { - if (!DocServer.FindDocByTitle(docTitle)) { - Doc.AddToMyPublished(Docs.Create.TextDocument('', { title: docTitle, _width: 400, _layout_autoHeight: true })); - } - const titledDoc = DocServer.FindDocByTitle(docTitle); - return titledDoc ? Doc.BestEmbedding(titledDoc) : titledDoc; - }; - if (!fieldKey) { - if (docTitle) { - const target = getTitledDoc(docTitle); - if (target) { - setTimeout(() => linkToDoc(target)); - return state.tr.deleteRange(end - 1, end).deleteRange(start, start + 3); + const fstate = this.TextBox.EditorView?.state; + if (fstate && selection) { + this.TextBox.EditorView?.dispatch(fstate.tr.setSelection(new TextSelection(fstate.doc.resolve(selection)))); } + }; + const getTitledDoc = (docTitle: string) => { + if (!DocServer.FindDocByTitle(docTitle)) { + Doc.AddToMyPublished(Docs.Create.TextDocument('', { title: docTitle, _width: 400, _layout_autoHeight: true })); + } + const titledDoc = DocServer.FindDocByTitle(docTitle); + return titledDoc ? Doc.BestEmbedding(titledDoc) : titledDoc; + }; + if (!fieldKey) { + if (docTitle) { + const target = getTitledDoc(docTitle); + if (target) { + setTimeout(() => linkToDoc(target)); + return state.tr.deleteRange(end - 1, end).deleteRange(start, start + 3); + } + } + return state.tr; } - return state.tr; - } - if (value !== '' && value !== undefined) { - const num = value.match(/^[0-9.]$/); - this.Document[DocData][fieldKey] = value === 'true' ? true : value === 'false' ? false : num ? Number(value) : value; - } - const target = getTitledDoc(docTitle); - const fieldView = state.schema.nodes.dashField.create({ fieldKey, docId: target?.[Id], hideKey: false }); - return state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end))).replaceSelectionWith(fieldView, true); - }), + if (value?.includes(',')) { + const values = value.split(','); + const strs = values.some(v => !v.match(/^[-]?[0-9.]$/)); + this.Document[DocData][fieldKey] = strs ? new List<string>(values) : new List<number>(values.map(v => Number(v))); + } else if (value !== '' && value !== undefined) { + const num = value.match(/^[0-9.]$/); + this.Document[DocData][fieldKey] = value === 'true' ? true : value === 'false' ? false : num ? Number(value) : value; + } + const target = getTitledDoc(docTitle); + const fieldView = state.schema.nodes.dashField.create({ fieldKey, docId: target?.[Id], hideKey: false }); + return state.tr.setSelection(new TextSelection(state.doc.resolve(start), state.doc.resolve(end))).replaceSelectionWith(fieldView, true); + }, + { inCode: true } + ), // create a text display of a metadata field on this or another document, or create a hyperlink portal to another document // wiki:title diff --git a/src/client/views/nodes/formattedText/marks_rts.ts b/src/client/views/nodes/formattedText/marks_rts.ts index a342285b0..a141ef041 100644 --- a/src/client/views/nodes/formattedText/marks_rts.ts +++ b/src/client/views/nodes/formattedText/marks_rts.ts @@ -1,6 +1,7 @@ import * as React from 'react'; import { DOMOutputSpec, Fragment, MarkSpec, Node, NodeSpec, Schema, Slice } from 'prosemirror-model'; import { Doc } from '../../../../fields/Doc'; +import { Utils } from '../../../../Utils'; const emDOM: DOMOutputSpec = ['em', 0]; const strongDOM: DOMOutputSpec = ['strong', 0]; @@ -44,7 +45,7 @@ export const marks: { [index: string]: MarkSpec } = { toDOM(node: any) { const targethrefs = node.attrs.allAnchors.reduce((p: string, item: { href: string; title: string; anchorId: string }) => (p ? p + ' ' + item.href : item.href), ''); const anchorids = node.attrs.allAnchors.reduce((p: string, item: { href: string; title: string; anchorId: string }) => (p ? p + ' ' + item.anchorId : item.anchorId), ''); - return ['a', { class: anchorids, 'data-targethrefs': targethrefs, /*'data-noPreview': 'true', */ 'data-linkdoc': node.attrs.linkDoc, title: node.attrs.title, style: `background: lightBlue` }, 0]; + return ['a', { id: Utils.GenerateGuid(), class: anchorids, 'data-targethrefs': targethrefs, /*'data-noPreview': 'true', */ 'data-linkdoc': node.attrs.linkDoc, title: node.attrs.title, style: `background: lightBlue` }, 0]; }, }, noAutoLinkAnchor: { @@ -104,7 +105,7 @@ export const marks: { [index: string]: MarkSpec } = { node.attrs.title, ], ] - : ['a', { class: anchorids, 'data-targethrefs': targethrefs, title: node.attrs.title, 'data-noPreview': node.attrs.noPreview, style: `text-decoration: underline; cursor: default` }, 0]; + : ['a', { id: '' + Utils.GenerateGuid(), class: anchorids, 'data-targethrefs': targethrefs, title: node.attrs.title, 'data-noPreview': node.attrs.noPreview, style: `text-decoration: underline; cursor: default` }, 0]; }, }, diff --git a/src/client/views/nodes/formattedText/nodes_rts.ts b/src/client/views/nodes/formattedText/nodes_rts.ts index d023020e1..4706a97fa 100644 --- a/src/client/views/nodes/formattedText/nodes_rts.ts +++ b/src/client/views/nodes/formattedText/nodes_rts.ts @@ -2,6 +2,8 @@ import * as React from 'react'; import { DOMOutputSpec, Node, NodeSpec } from 'prosemirror-model'; import { listItem, orderedList } from 'prosemirror-schema-list'; import { ParagraphNodeSpec, toParagraphDOM, getParagraphNodeAttrs } from './ParagraphNodeSpec'; +import { DocServer } from '../../../DocServer'; +import { Doc, Field } from '../../../../fields/Doc'; const blockquoteDOM: DOMOutputSpec = ['blockquote', 0], hrDOM: DOMOutputSpec = ['hr'], @@ -177,6 +179,7 @@ export const nodes: { [index: string]: NodeSpec } = { dashComment: { attrs: { docId: { default: '' }, + reflow: { default: true }, }, inline: true, group: 'inline', @@ -264,6 +267,18 @@ export const nodes: { [index: string]: NodeSpec } = { hideKey: { default: false }, editable: { default: true }, }, + leafText: node => Field.toString((DocServer.GetCachedRefField(node.attrs.docId as string) as Doc)?.[node.attrs.fieldKey as string] as Field), + group: 'inline', + draggable: false, + toDOM(node) { + const attrs = { style: `width: ${node.attrs.width}, height: ${node.attrs.height}` }; + return ['div', { ...node.attrs, ...attrs }]; + }, + }, + + paintButton: { + inline: true, + attrs: {}, group: 'inline', draggable: false, toDOM(node) { diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx index b8b60d2a9..1b1b65e46 100644 --- a/src/client/views/nodes/trails/PresBox.tsx +++ b/src/client/views/nodes/trails/PresBox.tsx @@ -3,8 +3,8 @@ import { Tooltip } from '@mui/material'; import { action, computed, IReactionDisposer, makeObservable, observable, ObservableSet, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; -import { Doc, DocListCast, FieldResult, NumListCast, Opt, StrListCast } from '../../../../fields/Doc'; -import { Animation, DocData } from '../../../../fields/DocSymbols'; +import { Doc, DocListCast, Field, FieldResult, NumListCast, Opt, StrListCast } from '../../../../fields/Doc'; +import { Animation, DocData, TransitionTimer } from '../../../../fields/DocSymbols'; import { Copy, Id } from '../../../../fields/FieldSymbols'; import { InkField } from '../../../../fields/InkField'; import { List } from '../../../../fields/List'; @@ -13,7 +13,7 @@ import { listSpec } from '../../../../fields/Schema'; import { ComputedField, ScriptField } from '../../../../fields/ScriptField'; import { BoolCast, Cast, DocCast, NumCast, StrCast } from '../../../../fields/Types'; import { AudioField } from '../../../../fields/URLField'; -import { emptyFunction, emptyPath, lightOrDark, returnFalse, returnOne, setupMoveUpEvents, StopEvent } from '../../../../Utils'; +import { emptyFunction, emptyPath, lightOrDark, returnFalse, returnOne, setupMoveUpEvents, StopEvent, stringHash } from '../../../../Utils'; import { DocServer } from '../../../DocServer'; import { Docs } from '../../../documents/Documents'; import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes'; @@ -518,7 +518,8 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { bestTarget.rotation = NumCast(activeItem.config_rotation, NumCast(bestTarget.rotation)); bestTarget.width = NumCast(activeItem.config_width, NumCast(bestTarget.width)); bestTarget.height = NumCast(activeItem.config_height, NumCast(bestTarget.height)); - setTimeout(() => (bestTarget._dataTransition = undefined), transTime + 10); + bestTarget[TransitionTimer] && clearTimeout(bestTarget[TransitionTimer]); + bestTarget[TransitionTimer] = setTimeout(() => (bestTarget[TransitionTimer] = bestTarget._dataTransition = undefined), transTime + 10); changed = true; } } @@ -544,7 +545,8 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { else { const bestTargetData = bestTarget[DocData]; const current = bestTargetData[fkey]; - bestTargetData[fkey + '_' + Date.now()] = current instanceof ObjectField ? current[Copy]() : current; + const hash = bestTargetData[fkey] ? stringHash(Field.toString(bestTargetData[fkey] as Field)) : undefined; + if (hash) bestTargetData[fkey + '_' + hash] = current instanceof ObjectField ? current[Copy]() : current; bestTargetData[fkey] = activeItem.config_data instanceof ObjectField ? activeItem.config_data[Copy]() : activeItem.config_data; } bestTarget[fkey + '_usePath'] = activeItem.config_usePath; |
