import { Colors } from '@dash/components'; import { action, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { DashColor, OmitKeys } from '../../../ClientUtils'; import { numberRange } from '../../../Utils'; import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { TransitionTimer } from '../../../fields/DocSymbols'; import { InkField } from '../../../fields/InkField'; import { List } from '../../../fields/List'; import { listSpec } from '../../../fields/Schema'; import { ComputedField } from '../../../fields/ScriptField'; import { Cast, NumCast, StrCast } from '../../../fields/Types'; import { TraceMobx } from '../../../fields/util'; import { DragManager } from '../../util/DragManager'; import { ScriptingGlobals } from '../../util/ScriptingGlobals'; import { DocComponent } from '../DocComponent'; import { StyleProp } from '../StyleProp'; import './CollectionFreeFormDocumentView.scss'; import { DocumentView, DocumentViewProps } from './DocumentView'; import { FieldViewProps } from './FieldView'; import { OpenWhere } from './OpenWhere'; export enum GroupActive { // flags for whether a view is activate because of its relationship to a group group = 'group', // this is a group that is activated because it's on an active canvas, but is not part of some other group child = 'child', // this is a group child that is activated because its containing group is activated inactive = 'inactive', // this is a group child but it is not active } /// Ugh, typescript has no run-time way of iterating through the keys of an interface. so we need /// manaully keep this list of keys in synch wih the fields of the freeFormProps interface const freeFormPropsKeys = ['x', 'y', 'z', 'width', 'height', 'zIndex', 'autoDim', 'rotation', 'color', 'backgroundColor', 'opacity', 'highlight', 'transition']; interface freeFormProps { x: number; y: number; z: number; width: number; height: number; zIndex?: number; autoDim?: number; // 1 means use Panel Width/Height, 0 means use width/height rotation?: number; color?: string; backgroundColor?: string; opacity?: number; highlight?: boolean; transition?: string; } export interface CollectionFreeFormDocumentViewProps extends DocumentViewProps { RenderCutoffProvider: (doc: Doc) => boolean; isAnyChildContentActive: () => boolean; reactParent: React.Component; } @observer export class CollectionFreeFormDocumentView extends DocComponent() { get displayName() { // this makes mobx trace() statements more descriptive return 'CollectionFreeFormDocumentView(' + this.Document.title + ')'; } // prettier-ignore public static CollectionFreeFormDocViewClassName = DragManager.dragClassName; public static animFields: { key: string; val?: number }[] = [ { key: 'x' }, { key: 'y' }, { key: 'opacity', val: 1 }, { key: '_height' }, { key: '_width' }, { key: '_rotation', val: 0 }, { key: '_layout_scrollTop' }, { key: '_currentFrame' }, { key: 'freeform_scale', val: 1 }, { key: 'freeform_panX' }, { key: 'freeform_panY' }, ]; // fields that are configured to be animatable using animation frames public static animStringFields = ['backgroundColor', 'borderColor', 'color', 'fillColor']; // fields that are configured to be animatable using animation frames public static animDataFields = (doc: Doc) => (Doc.LayoutFieldKey(doc) ? [Doc.LayoutFieldKey(doc)] : []); // fields that are configured to be animatable using animation frames public static from(dv?: DocumentView): CollectionFreeFormDocumentView | undefined { return dv?._props.reactParent instanceof CollectionFreeFormDocumentView ? dv._props.reactParent : undefined; } constructor(props: CollectionFreeFormDocumentViewProps & freeFormProps) { super(props); makeObservable(this); } get WrapperKeys() { // each of these keys is set by the CollectionView and passed via props. however, if any one of these props changes // (or any other prop), then it's as if they all change. // Anything that accesses these props will then invalidate unncessarily. // To avoid this, we copy these prop values into local observables. Now when 'props' changes, nothing invalidates. // Instead, we copy each values into its observable which ohnly triggers invalidations if the new value is different // than the old value, and then only things that access that observable will invalidate. return freeFormPropsKeys .map(key => ({upper:key[0].toUpperCase() + key.substring(1), lower:key})); // prettier-ignore } @observable X = this.props.x; @observable Y = this.props.y; @observable Z = this.props.z; @observable ZIndex = this.props.zIndex; @observable Rotation = this.props.rotation; @observable Opacity = this.props.opacity; @observable BackgroundColor = this.props.backgroundColor; @observable Color = this.props.color; @observable Highlight = this.props.highlight; @observable Width = this.props.width; @observable Height = this.props.height; @observable AutoDim = this.props.autoDim; @observable Transition = this.props.transition; componentDidMount(): void { if (this.props.transition && !this.Document[TransitionTimer]) { const num = Number(this.props.transition.match(/([0-9.]+)s/)?.[1]) * 1000 || Number(this.props.transition.match(/([0-9.]+)ms/)?.[1]); this.Document[TransitionTimer] = setTimeout( action(() => { this.Document[TransitionTimer] = this.Transition = undefined; }), num ); } } componentDidUpdate(prevProps: Readonly>) { super.componentDidUpdate(prevProps); this.WrapperKeys.forEach( action(keys => { (this as unknown as { [key: string]: unknown })[keys.upper] = (this.props as { [key: string]: unknown })[keys.lower]; }) ); } // this way, downstream code only invalidates when it uses a specific prop, not when any prop changes DataTransition = () => this.Transition || StrCast(this.Document.dataTransition); // prettier-ignore RenderCutoffProvider = this.props.RenderCutoffProvider; // needed for type checking PanelWidth = () => this._props.autoDim ? this._props.PanelWidth?.() : this.Width; // prettier-ignore PanelHeight = () => this._props.autoDim ? this._props.PanelHeight?.() : this.Height; // prettier-ignore styleProvider = (doc: Doc | undefined, props: Opt, property: string) => { const overrideProp = () => { switch (property.split(':')[0]) { case StyleProp.Opacity: return this.Opacity; case StyleProp.BackgroundColor: return this.BackgroundColor; case StyleProp.Color: return this.Color; default: return undefined; }}; // prettier-ignore // only override values for this specific document, not any children return (doc === this.layoutDoc ? overrideProp() : undefined) ?? this._props.styleProvider?.(doc, props, property); }; public static getValues(doc: Doc, time: number, fillIn: boolean = true) { return CollectionFreeFormDocumentView.animFields.reduce( (p, val) => { p[val.key] = Cast(doc[`${val.key}_indexed`], listSpec('number'), fillIn ? [NumCast(doc[val.key], val.val)] : []).reduce( (prev, v, i) => ((i <= Math.round(time) && v !== undefined) || prev === undefined ? v : prev), undefined as unknown as number ); return p; }, {} as { [val: string]: Opt } ); } public static getStringValues(doc: Doc, time: number) { return CollectionFreeFormDocumentView.animStringFields.reduce( (p, val) => { p[val] = Cast(doc[`${val}_indexed`], listSpec('string'), [StrCast(doc[val])]).reduce((prev, v, i) => ((i <= Math.round(time) && v !== undefined) || prev === undefined ? v : prev), undefined as unknown as string); return p; }, {} as { [val: string]: Opt } ); } public static setStringValues(time: number, d: Doc, vals: { [val: string]: Opt }) { const timecode = Math.round(time); Object.keys(vals).forEach(val => { const findexed = Cast(d[`${val}_indexed`], listSpec('string'), []).slice(); findexed[timecode] = vals[val] || ''; d[`${val}_indexed`] = new List(findexed); }); } public static setValues(time: number, d: Doc, vals: { [val: string]: Opt }) { const timecode = Math.round(time); Object.keys(vals).forEach(val => { const findexed = Cast(d[`${val}_indexed`], listSpec('number'), []).slice(); findexed[timecode] = vals[val] as unknown as number; d[`${val}_indexed`] = new List(findexed); }); } public static gotoKeyFrame(doc: Doc, newFrame: number) { if (doc) { const childDocs = DocListCast(doc[Doc.LayoutFieldKey(doc)]); const currentFrame = Cast(doc._currentFrame, 'number', null); if (currentFrame === undefined) { doc._currentFrame = 0; this.setupKeyframes(childDocs, 0); } this.updateKeyframe(undefined, [...childDocs, doc], currentFrame || 0); doc._currentFrame = newFrame === undefined ? 0 : Math.max(0, newFrame); } } public static updateKeyframe(timer: NodeJS.Timeout | undefined, docs: Doc[], time: number) { const newTimer = DocumentView.SetViewTransition(docs, 'all', 1000, timer, true); const timecode = Math.round(time); docs.forEach(doc => { this.animFields.forEach(val => { const findexed = Cast(doc[`${val.key}_indexed`], listSpec('number'), null); findexed?.length <= timecode + 1 && findexed.push(undefined as unknown as number); }); this.animStringFields.forEach(val => { const findexed = Cast(doc[`${val}_indexed`], listSpec('string'), null); findexed?.length <= timecode + 1 && findexed.push(undefined as unknown as string); }); this.animDataFields(doc).forEach(val => { const findexed = Cast(doc[`${val}_indexed`], listSpec(InkField), null); findexed?.length <= timecode + 1 && findexed.push(undefined as unknown as InkField); }); }); return newTimer; } public static setupKeyframes(docs: Doc[], currTimecode: number, makeAppear: boolean = false) { docs.forEach(doc => { if (doc.appearFrame === undefined) doc.appearFrame = currTimecode; if (!doc.opacity_indexed) { // opacity is unlike other fields because it's value should not be undefined before it appears to enable it to fade-in doc.opacity_indexed = new List(numberRange(currTimecode + 1).map(t => (!doc.z && makeAppear && t < NumCast(doc.appearFrame) ? 0 : 1))); } CollectionFreeFormDocumentView.animFields.forEach(val => { doc[val.key] = ComputedField.MakeInterpolatedNumber(val.key, 'activeFrame', doc, currTimecode, val.val); }); CollectionFreeFormDocumentView.animStringFields.forEach(val => { doc[val] = ComputedField.MakeInterpolatedString(val, 'activeFrame', doc, currTimecode); }); CollectionFreeFormDocumentView.animDataFields(doc).forEach(val => { doc[val] = ComputedField.MakeInterpolatedDataField(val, 'activeFrame', doc, currTimecode); }); const targetDoc = doc; // data fields, like rtf 'text' exist on the data doc, so // doc !== targetDoc && (targetDoc.embedContainer = doc.embedContainer); // the computed fields don't see the layout doc -- need to copy the embedContainer to the data doc (HACK!!!) and set the activeFrame on the data doc (HACK!!!) targetDoc.activeFrame = ComputedField.MakeFunction('this.embedContainer?._currentFrame||0'); targetDoc.dataTransition = 'inherit'; }); } float = () => { const topDoc = this.Document; const containerDocView = this._props.containerViewPath?.().lastElement(); const screenXf = containerDocView?.screenToContentsTransform(); if (screenXf) { DocumentView.DeselectAll(); if (topDoc.z) { [topDoc.x, topDoc.y] = screenXf.inverse().transformPoint(NumCast(topDoc.x), NumCast(topDoc.y)); topDoc.z = 0; this._props.removeDocument?.(topDoc); this._props.addDocTab(topDoc, OpenWhere.inParentFromScreen); } else { const spt = this.screenToLocalTransform().inverse().transformPoint(0, 0); [topDoc.x, topDoc.y] = screenXf.transformPoint(spt[0], spt[1]); topDoc.z = 1; } setTimeout(() => DocumentView.SelectView(DocumentView.getDocumentView(topDoc, containerDocView), false), 0); } }; nudge = (x: number, y: number) => { const [locX, locY] = this._props.ScreenToLocalTransform().transformDirection(x, y); this.Document.x = this.X + locX; this.Document.y = this.Y + locY; }; screenToLocalTransform = () => this._props .ScreenToLocalTransform() .translate(-this.X, -this.Y) .rotateDeg(-(this.Rotation || 0)); returnThis = () => this; /// this indicates whether the doc view is activated because of its relationshop to a group // 'group' - this is a group that is activated because it's on an active canvas, but is not part of some other group // 'child' - this is a group child that is activated because its containing group is activated // 'inactive' - this is a group child but it is not active // undefined - this is not activated by a group isGroupActive = () => { if (this._props.isAnyChildContentActive()) return undefined; const backColor = this.BackgroundColor; const isGroup = this.dataDoc.isGroup && (!backColor || backColor === 'transparent'); return isGroup ? (this._props.isDocumentActive?.() ? GroupActive.group : this._props.isGroupActive?.() ? GroupActive.child : GroupActive.inactive) : this._props.isGroupActive?.() ? GroupActive.child : undefined; // prettier-ignore }; localRotation = () => this._props.rotation; render() { TraceMobx(); return (
{this.RenderCutoffProvider(this.Document) ? (
) : ( val.lower)).omit} // prettier-ignore Document={this._props.Document} renderDepth={this._props.renderDepth} isContentActive={this._props.isContentActive} childFilters={this._props.childFilters} childFiltersByRanges={this._props.childFilters} pinToPres={this._props.pinToPres} addDocTab={this._props.addDocTab} searchFilterDocs={this._props.searchFilterDocs} focus={this._props.focus} whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged} reactParent={this} DataTransition={this.DataTransition} LocalRotation={this.localRotation} styleProvider={this.styleProvider} ScreenToLocalTransform={this.screenToLocalTransform} isGroupActive={this.isGroupActive} PanelWidth={this.PanelWidth} PanelHeight={this.PanelHeight} /> )}
); } } // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function gotoFrame(doc: Doc, newFrame: number) { CollectionFreeFormDocumentView.gotoKeyFrame(doc, newFrame); });