aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
authorbobzel <zzzman@gmail.com>2024-10-14 19:55:32 -0400
committerbobzel <zzzman@gmail.com>2024-10-14 19:55:32 -0400
commit29b83f023442c313ca5cf95f70f6430f101060e6 (patch)
tree9847ff4419e50f5bc8b7c1512a07b72275cd3b40 /src
parenta60c12ddef3db4123dffb2c91b446d20633f523a (diff)
reorganized comparisonBox related components -- moved stuff down into Docs.Crete and CurrentUserUtils. changed Doc.Copy to copy Doc's in fields tagged with cloneOnCopy. Changed ComparisonBox to support hover for slide or flip views. Fixed pointerEfvents for hover in comparisonBox
Diffstat (limited to 'src')
-rw-r--r--src/client/documents/DocUtils.ts2
-rw-r--r--src/client/documents/Documents.ts43
-rw-r--r--src/client/util/CurrentUserUtils.ts4
-rw-r--r--src/client/views/collections/CollectionCarousel3DView.tsx33
-rw-r--r--src/client/views/collections/CollectionCarouselView.tsx58
-rw-r--r--src/client/views/collections/CollectionSubView.tsx2
-rw-r--r--src/client/views/collections/CollectionView.tsx6
-rw-r--r--src/client/views/collections/FlashcardPracticeUI.tsx36
-rw-r--r--src/client/views/nodes/ComparisonBox.scss3
-rw-r--r--src/client/views/nodes/ComparisonBox.tsx196
-rw-r--r--src/client/views/nodes/formattedText/FormattedTextBox.tsx20
-rw-r--r--src/fields/Doc.ts2
12 files changed, 223 insertions, 182 deletions
diff --git a/src/client/documents/DocUtils.ts b/src/client/documents/DocUtils.ts
index 5f54f9d0a..19f3c89ef 100644
--- a/src/client/documents/DocUtils.ts
+++ b/src/client/documents/DocUtils.ts
@@ -103,7 +103,6 @@ export namespace DocUtils {
return false;
}
const facetKeys = Object.keys(filterFacets).filter(fkey => fkey !== 'cookies' && fkey !== ClientUtils.noDragDocsFilter.split(Doc.FilterSep)[0]);
- // eslint-disable-next-line no-restricted-syntax
for (const facetKey of facetKeys) {
const facet = filterFacets[facetKey];
@@ -288,7 +287,6 @@ export namespace DocUtils {
return doc;
}
export function AssignDocField(doc: Doc, field: string, creator: (reqdOpts: DocumentOptions, items?: Doc[]) => Doc, reqdOpts: DocumentOptions, items?: Doc[], scripts?: { [key: string]: string }, funcs?: { [key: string]: string }) {
- // eslint-disable-next-line no-return-assign
return DocUtils.AssignScripts(DocUtils.AssignOpts(DocCast(doc[field]), reqdOpts, items) ?? (doc[field] = creator(reqdOpts, items)), scripts, funcs);
}
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index f71b9f879..99af1f1a9 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -18,6 +18,7 @@ import { PointData } from '../../pen-gestures/GestureTypes';
import { DocServer } from '../DocServer';
import { dropActionType } from '../util/DropActionTypes';
import { CollectionViewType, DocumentType } from './DocumentTypes';
+import { Id } from '../../fields/FieldSymbols';
class EmptyBox {
public static LayoutString() {
@@ -360,6 +361,7 @@ export class DocumentOptions {
isFolder?: BOOLt = new BoolInfo('is document a tree view folder');
_isTimelineLabel?: BOOLt = new BoolInfo('is document a timeline label');
_isLightbox?: BOOLt = new BoolInfo('whether a collection acts as a lightbox by opening lightbox links by hiding all other documents in collection besides link target');
+ cloneOnCopy?: BOOLt = new BoolInfo('if this Doc is a field of another Doc, then it should be copied when the other Doc is copied');
mapPin?: DOCt = new DocInfo('pin associated with a config anchor', false);
config_latitude?: NUMt = new NumInfo('latitude of a map', false);
@@ -420,6 +422,12 @@ export class DocumentOptions {
flexGap?: NUMt = new NumInfo('Linear view flex gap');
flexDirection?: 'unset' | 'row' | 'column' | 'row-reverse' | 'column-reverse';
+ // Comparison
+ data_revealOp?: STRt = new StrInfo("visual reveal type for front and back of comparison - 'slide' or 'flip' ");
+ data_revealOp_hover?: BOOLt = new BoolInfo('reveal back of comparison manually or by hovering');
+ data_front?: DOCt = new DocInfo('contents of front of flashcard/comparison');
+ data_back?: DOCt = new DocInfo('contents of back of flashcard/comparison');
+
link?: string;
link_description?: string; // added for links
link_relationship?: string; // type of relatinoship a link represents
@@ -784,8 +792,39 @@ export namespace Docs {
return InstanceFromProto(Prototypes.get(DocumentType.SCREENSHOT), '', options);
}
- export function ComparisonDocument(text: string, options: DocumentOptions = { title: 'Comparison Box' }) {
- return InstanceFromProto(Prototypes.get(DocumentType.COMPARISON), text, options);
+ export function ComparisonDocument(title: string, options: DocumentOptions) {
+ return InstanceFromProto(Prototypes.get(DocumentType.COMPARISON), '', options);
+ }
+ /**
+ * Creates a text box where the supplied text (and optional iimage) will be vertically
+ * and horizontally centered. If text_placeholder is set to true, then the text will be
+ * treated as placeholder text and automatically selected when the text box is selected.
+ * @param title name of text box
+ * @param text text to display in text box
+ * @param opts metadata fields to set on text box
+ * @param img optional image to add to text box
+ * @returns
+ */
+ export function CenteredTextCreator(title: string, text: string, opts: DocumentOptions, img?: Doc) {
+ return TextDocument(RichTextField.textToRtf(text, img?.[Id]), {
+ title, //
+ _layout_autoHeight: true,
+ _layout_centered: true,
+ text_align: 'center',
+ _layout_fitWidth: true,
+ ...opts,
+ });
+ }
+
+ export function FlashcardDocument(title: string, front?: Doc, back?: Doc, options: DocumentOptions = { title: 'Flashcard' }) {
+ return InstanceFromProto(Prototypes.get(DocumentType.COMPARISON), '', {
+ data_front: front ?? CenteredTextCreator('question', 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards', { text_placeholder: true, cloneOnCopy: true }, undefined),
+ data_back: back ?? CenteredTextCreator('answer', 'answer here', { text_placeholder: true, cloneOnCopy: true }, undefined),
+ _layout_fitWidth: true,
+ _layout_isFlashcard: true,
+ title,
+ ...options,
+ });
}
export function DiagramDocument(options: DocumentOptions = { title: '' }) {
return InstanceFromProto(Prototypes.get(DocumentType.DIAGRAM), undefined, options);
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index 798cdf5a9..555ccdd88 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -369,14 +369,14 @@ pie title Minerals in my tap water
creator:(opts:DocumentOptions)=> Doc // how to create the empty thing if it doesn't exist
}[] = [
{key: "Note", creator: opts => Docs.Create.TextDocument("", opts), opts: { _width: 200, _layout_autoHeight: true }},
- {key: "Flashcard", creator: opts => Docs.Create.ComparisonDocument("", opts), opts: { _layout_isFlashcard: true, _layout_fitWidth: true, _width: 300, _height: 300}},
+ {key: "Flashcard", creator: opts => Docs.Create.FlashcardDocument("", undefined, undefined, opts),opts: { _width: 300, _height: 300}},
{key: "Image", creator: opts => Docs.Create.ImageDocument("", opts), opts: { _width: 400, _height:400 }},
{key: "Equation", creator: opts => Docs.Create.EquationDocument("",opts), opts: { _width: 300, _height: 35, }},
{key: "Noteboard", creator: opts => Docs.Create.NoteTakingDocument([], opts), opts: { _width: 250, _height: 200, _layout_fitWidth: true}},
{key: "Simulation", creator: opts => Docs.Create.SimulationDocument(opts), opts: { _width: 300, _height: 300, }},
{key: "Collection", creator: opts => Docs.Create.FreeformDocument([], opts), opts: { _width: 150, _height: 100, _layout_fitWidth: true }},
{key: "Webpage", creator: opts => Docs.Create.WebDocument("",opts), opts: { _width: 400, _height: 512, _nativeWidth: 850, data_useCors: true, }},
- {key: "Comparison", creator: opts => Docs.Create.ComparisonDocument("",opts), opts: { _width: 300, _height: 300 }},
+ {key: "Comparison", creator: opts => Docs.Create.ComparisonDocument("", opts), opts: { _width: 300, _height: 300 }},
{key: "Diagram", creator: Docs.Create.DiagramDocument, opts: { _width: 300, _height: 300, _type_collection: CollectionViewType.Freeform, layout_diagramEditor: CollectionView.LayoutString("data") }, scripts: { onPaint: `toggleDetail(documentView, "diagramEditor","")`}},
{key: "Audio", creator: opts => Docs.Create.AudioDocument(nullAudio, opts),opts: { _width: 200, _height: 100, }},
{key: "Audio", creator: opts => Docs.Create.AudioDocument(nullAudio, opts),opts: { _width: 200, _height: 100, _layout_fitWidth: true, }},
diff --git a/src/client/views/collections/CollectionCarousel3DView.tsx b/src/client/views/collections/CollectionCarousel3DView.tsx
index 05be376ca..e9ace733e 100644
--- a/src/client/views/collections/CollectionCarousel3DView.tsx
+++ b/src/client/views/collections/CollectionCarousel3DView.tsx
@@ -15,6 +15,7 @@ import { DocumentView } from '../nodes/DocumentView';
import { FocusViewOptions } from '../nodes/FocusViewOptions';
import './CollectionCarousel3DView.scss';
import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView';
+import { computedFn } from 'mobx-utils';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { CAROUSEL3D_CENTER_SCALE, CAROUSEL3D_SIDE_SCALE, CAROUSEL3D_TOP } = require('../global/globalCssVariables.module.scss');
@@ -53,18 +54,22 @@ export class CollectionCarousel3DView extends CollectionSubView() {
centerScale = Number(CAROUSEL3D_CENTER_SCALE);
sideScale = Number(CAROUSEL3D_SIDE_SCALE);
- panelWidth = () => this._props.PanelWidth() / 3;
- panelHeight = () => this._props.PanelHeight() * this.sideScale;
+ panelWidth = () => this._props.PanelWidth() / 3 / this.nativeScaling();
+ panelHeight = () => (this._props.PanelHeight() * this.sideScale) / this.nativeScaling();
onChildDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick);
isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this._props.isAnyChildContentActive();
- isChildContentActive = () =>
- this._props.isContentActive?.() === false
- ? false
- : this._props.isDocumentActive?.() && (this._props.childDocumentsActive?.() || BoolCast(this.Document.childDocumentsActive))
- ? true
- : this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false
+ isChildContentActive = computedFn(
+ (doc: Doc) => () =>
+ this._props.isContentActive?.() === false
? false
- : undefined;
+ : this._props.isDocumentActive?.() && (this._props.childDocumentsActive?.() || BoolCast(this.Document.childDocumentsActive))
+ ? true
+ : this._props.isContentActive?.() && this.curDoc() === doc
+ ? true
+ : this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false
+ ? false
+ : undefined
+ );
contentScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1);
childScreenLeftToLocal = () =>
this.contentScreenToLocalXf()
@@ -110,7 +115,7 @@ export class CollectionCarousel3DView extends CollectionSubView() {
LayoutTemplateString={this._props.childLayoutString}
focus={this.focus}
ScreenToLocalTransform={dxf}
- isContentActive={this.isChildContentActive}
+ isContentActive={this.isChildContentActive(child)}
isDocumentActive={this._props.childDocumentsActive?.() || this.Document._childDocumentsActive ? this._props.isDocumentActive : this.isContentActive}
PanelWidth={this.panelWidth}
PanelHeight={this.panelHeight}
@@ -125,7 +130,6 @@ export class CollectionCarousel3DView extends CollectionSubView() {
}
changeSlide = (direction: number) => {
- DocumentView.DeselectAll();
this.layoutDoc._carousel_index = !this.curDoc() ? 0 : (NumCast(this.layoutDoc._carousel_index) + direction + this.carouselItems.length) % (this.carouselItems.length || 1);
};
@@ -205,9 +209,10 @@ export class CollectionCarousel3DView extends CollectionSubView() {
docViewProps = () => ({
...this._props, //
isDocumentActive: this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive,
- isContentActive: this.isChildContentActive,
+ isContentActive: this._props.isContentActive,
ScreenToLocalTransform: this.contentScreenToLocalXf,
});
+ nativeScaling = () => this._props.NativeDimScaling?.() || 1;
render() {
return (
<div
@@ -216,6 +221,10 @@ export class CollectionCarousel3DView extends CollectionSubView() {
style={{
background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) as string,
color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string,
+ transformOrigin: 'top left',
+ transform: `scale(${this.nativeScaling()})`,
+ width: `${100 / this.nativeScaling()}%`,
+ height: `${100 / this.nativeScaling()}%`,
}}>
<div className="carousel-wrapper" style={{ transform: `translateX(${this.translateX}px)` }}>
{this.content}
diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx
index ef66a2c83..ff587b199 100644
--- a/src/client/views/collections/CollectionCarouselView.tsx
+++ b/src/client/views/collections/CollectionCarouselView.tsx
@@ -58,18 +58,12 @@ export class CollectionCarouselView extends CollectionSubView() {
/**
* Goes to the next Doc in the stack subject to the currently selected filter option.
*/
- advance = (e?: React.MouseEvent) => {
- e?.stopPropagation();
- this.move(1);
- };
+ advance = () => this.move(1);
/**
* Goes to the previous Doc in the stack subject to the currently selected filter option.
*/
- goback = (e: React.MouseEvent) => {
- e.stopPropagation();
- this.move(-1);
- };
+ goback = () => this.move(-1);
curDoc = () => this.carouselItems[this.carouselIndex]?.layout;
@@ -78,24 +72,23 @@ export class CollectionCarouselView extends CollectionSubView() {
const childValue = doc?.['caption_' + property] ? this._props.styleProvider?.(doc, captionProps, property) : undefined;
return childValue ?? this._props.styleProvider?.(this.layoutDoc, captionProps, property);
};
- contentPanelWidth = () => this._props.PanelWidth() - 2 * NumCast(this.layoutDoc.xMargin);
- contentPanelHeight = () => this._props.PanelHeight() - (StrCast(this.layoutDoc._layout_showCaption) ? 50 : 0) - 2 * NumCast(this.layoutDoc.yMargin);
+ contentPanelWidth = () => (this._props.PanelWidth() - 2 * NumCast(this.layoutDoc.xMargin)) / this.nativeScaling();
+ contentPanelHeight = () => (this._props.PanelHeight() - (StrCast(this.layoutDoc._layout_showCaption) ? 50 : 0) - 2 * NumCast(this.layoutDoc.yMargin)) / this.nativeScaling();
onContentDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick);
onContentClick = () => ScriptCast(this.layoutDoc.onChildClick);
captionWidth = () => this._props.PanelWidth() - 2 * this.captionMarginX;
contentScreenToLocalXf = () =>
this._props
- .ScreenToLocalTransform()
- .translate(-NumCast(this.layoutDoc.xMargin), -NumCast(this.layoutDoc.yMargin))
- .scale(this._props.NativeDimScaling?.() || 1);
+ .ScreenToLocalTransform() //
+ .translate(-NumCast(this.layoutDoc.xMargin) / this.nativeScaling(), -NumCast(this.layoutDoc.yMargin) / this.nativeScaling());
isChildContentActive = () =>
this._props.isContentActive?.() === false
? false
- : this._props.isDocumentActive?.() && (this._props.childDocumentsActive?.() || BoolCast(this.Document.childDocumentsActive))
+ : this._props.isContentActive()
? true
: this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false
? false
- : undefined;
+ : undefined; // prettier-ignore
onPassiveWheel = (e: WheelEvent) => e.stopPropagation();
renderDoc = (doc: Doc, showCaptions: boolean, overlayFunc?: (r: DocumentView | null) => void) => {
return (
@@ -202,6 +195,8 @@ export class CollectionCarouselView extends CollectionSubView() {
);
}
+ nativeScaling = () => this._props.NativeDimScaling?.() || 1;
+
docViewProps = () => ({
...this._props, //
isDocumentActive: this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive,
@@ -212,20 +207,25 @@ export class CollectionCarouselView extends CollectionSubView() {
render() {
return (
- <div
- className="collectionCarouselView-outer"
- ref={this.createDashEventsTarget}
- style={{
- background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) as string,
- color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string,
- width: `calc(100% - ${NumCast(this.layoutDoc._xMargin)}px)`,
- height: `calc(100% - ${NumCast(this.layoutDoc._yMargin)}px)`,
- left: NumCast(this.layoutDoc._xMargin),
- top: NumCast(this.layoutDoc._yMargin),
- }}>
- {this.content}
- {this.flashCardUI(this.curDoc, this.docViewProps, this.answered)}
- {this.navButtons}
+ <div>
+ <div
+ className="collectionCarouselView-outer"
+ ref={this.createDashEventsTarget}
+ style={{
+ background: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BackgroundColor) as string,
+ color: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string,
+ left: NumCast(this.layoutDoc._xMargin),
+ top: NumCast(this.layoutDoc._yMargin),
+ transformOrigin: 'top left',
+ transform: `scale(${this.nativeScaling()})`,
+ width: `calc(${100 / this.nativeScaling()}% - ${(2 * NumCast(this.layoutDoc._xMargin)) / this.nativeScaling()}px)`,
+ height: `calc(${100 / this.nativeScaling()}% - ${(2 * NumCast(this.layoutDoc._yMargin)) / this.nativeScaling()}px)`,
+ position: 'relative',
+ }}>
+ {this.content}
+ {this.flashCardUI(this.curDoc, this.docViewProps, this.answered)}
+ {this.navButtons}
+ </div>
</div>
);
}
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index f85b0b433..ab5b70a85 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -523,7 +523,7 @@ export function CollectionSubView<X>() {
/**
* How much the content of the collection is being scaled based on its nesting and its fit-to-width settings
*/
- @computed get contentScaling() { return this.ScreenToLocalBoxXf().Scale * (this._props.NativeDimScaling?.() ?? 1); } // prettier-ignore
+ @computed get contentScaling() { return this.ScreenToLocalBoxXf().Scale; } // prettier-ignore
/**
* The maximum size a UI widget can be in collection coordinates based on not wanting the widget to visually obscure too much of the collection
* This takes the desired screen space size and converts into collection coordinates. It then returns the smaller of the converted
diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx
index 7418d4360..6f0833a22 100644
--- a/src/client/views/collections/CollectionView.tsx
+++ b/src/client/views/collections/CollectionView.tsx
@@ -83,7 +83,7 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr
return viewField;
}
- screenToLocalTransform = () => (this._props.renderDepth ? this.ScreenToLocalBoxXf() : this.ScreenToLocalBoxXf().scale(this._props.PanelWidth() / this.bodyPanelWidth()));
+ screenToLocalTransform = this.ScreenToLocalBoxXf;
// prettier-ignore
private renderSubView = (type: CollectionViewType | undefined, props: SubCollectionViewProps) => {
TraceMobx();
@@ -202,8 +202,6 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr
}
};
- bodyPanelWidth = () => this._props.PanelWidth();
-
childLayoutTemplate = () => this._props.childLayoutTemplate?.() || Cast(this.Document.childLayoutTemplate, Doc, null);
isContentActive = () => this._isContentActive;
@@ -221,7 +219,7 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr
removeDocument: this.removeDocument,
isContentActive: this.isContentActive,
isAnyChildContentActive: this.isAnyChildContentActive,
- PanelWidth: this.bodyPanelWidth,
+ PanelWidth: this._props.PanelWidth,
PanelHeight: this._props.PanelHeight,
ScreenToLocalTransform: this.screenToLocalTransform,
childLayoutTemplate: this.childLayoutTemplate,
diff --git a/src/client/views/collections/FlashcardPracticeUI.tsx b/src/client/views/collections/FlashcardPracticeUI.tsx
index 45e040653..79eb7f107 100644
--- a/src/client/views/collections/FlashcardPracticeUI.tsx
+++ b/src/client/views/collections/FlashcardPracticeUI.tsx
@@ -1,7 +1,7 @@
import { IconProp } from '@fortawesome/fontawesome-svg-core';
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Tooltip } from '@mui/material';
-import { IconButton, MultiToggle, Type } from 'browndash-components';
+import { MultiToggle, Type } from 'browndash-components';
import { computed, makeObservable } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
@@ -25,8 +25,8 @@ enum practiceVal {
}
export enum flashcardRevealOp {
- HOVER = 'hover',
FLIP = 'flip',
+ SLIDE = 'slide',
}
interface PracticeUIProps {
@@ -153,20 +153,28 @@ export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProp
selectedItems={this.practiceMode}
onSelectionChange={(val: (string | number) | (string | number)[]) => togglePracticeMode(val as practiceMode)}
/>
- <IconButton
- tooltip="hover over card to reveal answer"
- type={Type.TERT}
- text={StrCast(this._props.layoutDoc.revealOp)}
+ <MultiToggle
+ tooltip="How to reveal flashcard answer"
+ type={Type.PRIM}
color={SnappingManager.userColor}
background={SnappingManager.userVariantColor}
- icon={<FontAwesomeIcon color={SnappingManager.userColor} icon={this._props.layoutDoc.revealOp === flashcardRevealOp.HOVER ? 'hand-point-up' : 'question'} size="sm" />}
- label={StrCast(this._props.layoutDoc.revealOp)}
- onPointerDown={e =>
- setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => {
- this._props.layoutDoc.revealOp = this._props.layoutDoc.revealOp === flashcardRevealOp.HOVER ? flashcardRevealOp.FLIP : flashcardRevealOp.HOVER;
- this._props.layoutDoc.childDocumentsActive = this._props.layoutDoc.revealOp === 'hover' ? true : undefined;
- })
- }
+ multiSelect={false}
+ isToggle={false}
+ toggleStatus={!!this.practiceMode}
+ label={StrCast(this._props.layoutDoc.revealOp, flashcardRevealOp.FLIP)}
+ items={[
+ ['reveal', StrCast(this._props.layoutDoc.revealOp) === flashcardRevealOp.SLIDE ? 'expand' : 'question', StrCast(this._props.layoutDoc.revealOp, flashcardRevealOp.FLIP)],
+ ['trigger', this._props.layoutDoc.revealOp_hover ? 'hand-point-up' : 'hand', this._props.layoutDoc.revealOp_hover ? 'show on hover' : 'show on click'],
+ ].map(([item, icon, tooltip]) => ({
+ icon: <FontAwesomeIcon className={`FlashcardPracticeUI-${item}`} color={setColor(item as practiceMode)} icon={icon as IconProp} size="sm" />,
+ tooltip: tooltip,
+ val: item,
+ }))}
+ selectedItems={this._props.layoutDoc.revealOp_hover ? ['reveal', 'trigger'] : 'reveal'}
+ onSelectionChange={(val: (string | number) | (string | number)[]) => {
+ if (val === 'reveal') this._props.layoutDoc.revealOp = this._props.layoutDoc.revealOp === flashcardRevealOp.SLIDE ? flashcardRevealOp.FLIP : flashcardRevealOp.SLIDE;
+ if (val === 'trigger') this._props.layoutDoc.revealOp_hover = !this._props.layoutDoc.revealOp_hover;
+ }}
/>
</div>
);
diff --git a/src/client/views/nodes/ComparisonBox.scss b/src/client/views/nodes/ComparisonBox.scss
index c328ef4bf..d1cc48051 100644
--- a/src/client/views/nodes/ComparisonBox.scss
+++ b/src/client/views/nodes/ComparisonBox.scss
@@ -246,8 +246,7 @@
pointer-events: none;
}
-.comparisonBox-interactive {
- pointer-events: unset;
+.comparisonBox-slide {
cursor: ew-resize;
.slide-bar {
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index 80ef126dc..38ce5f2f7 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -35,22 +35,21 @@ import { FormattedTextBox } from './formattedText/FormattedTextBox';
const API_URL = 'https://api.unsplash.com/search/photos';
/**
- * This view serves three distinct functions depending on the metadata field layout_isFlashcard
- * 1) it provides a before/after animated sliding transition between two Docs
- * 2) it provides a question/answer switch between two Docs (flashcard)
- * 3) it provides a quiz view that displays a question and a user answer that can be "scored" by GPT
+ * This view serves two distinct functions depending on the revealOp field ('slide' or 'flip)
+ * 1) ('slide') - provides a before/after animated sliding transition between two Docs
+ * 2) ('flip') - provides a question/answer flip between two Docs
+ * And a third function that overrides the first two if the doc's container has its 'practiceMode' set to 'quiz'
+ * 3) ('quiz') - it provides a quiz view that displays a question and a user answer that can be "scored" by GPT
+ * NOTE: this should probably be changed to passing down a prop to the flashcard telling it to render as a quiz.
*
* In each case, the two docs are stored in the <fieldKey>_front and <fieldKey>_back fields
*
- * In the case of the flashcard, there is an icon that allows the user to choose between a
- * hover and a flip action to switch between cards. The transition is stored in the 'revealOp' field.
- * In addition, if a flashcard is created without data in the front/back fields, this will
- * create Text documents with placeholder text indicating to the user how to fill in the cards.
- * One option is to allow the user to enter a topic and, by clicking on the flashcard stack button,
- * convert the comparision box into a stack of comparison boxes filled in by GPT about the topic.
+ * For 'flip' and 'slide', the trigger can either be clicking, or hovering as determined by the revealOp_hover field.
+ * For 'quiz' the data of both Docs are shown in a single-view quiz display.
*
- * Quiz mode is activated when the parent collection has its 'quiz' field set when it renders a flashcard.
- * NOTE: this should probably be changed to passing down a prop to the flashcard telling it to render as a quiz.
+ * Users can create a stack of flashcards all at once (only) from an empty flashcard by entering a topic into the front card
+ * and clicking on the flashcard stack button. This will convert the comparision box into a stack of comparison boxes
+ * filled in by GPT about the topic.
*
*/
@@ -67,7 +66,6 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
*/
public static createFlashcard(tuple: string, frontKey: string, backKey: string, useDoc?: Doc) {
const [ktoken, atoken] = [ComparisonBox.ktoken, ComparisonBox.atoken];
- const newDoc = useDoc ?? Docs.Create.ComparisonDocument('', { _layout_isFlashcard: true, _width: 300, _height: 300 });
const question = (tuple.includes(ktoken) ? tuple.split(ktoken)[0] : tuple).split(atoken)[0];
const rest = tuple.replace(question, '');
// prettier-ignore
@@ -78,9 +76,14 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
rest.replace(atoken,""); // finally if there's no keyword, just get rid of answer token and take what's left
const keyword = rest.replace(atoken, '').replace(answer, '').replace(ktoken, '').trim();
const fillInFlashcard = (img?: Doc) => {
- newDoc[DocData][frontKey] = FormattedTextBox.centeredTextCreator('question', question, img);
- newDoc[DocData][backKey] = FormattedTextBox.centeredTextCreator('answer', answer);
- return newDoc;
+ const front = Docs.Create.CenteredTextCreator('question', question, {}, img);
+ const back = Docs.Create.CenteredTextCreator('answer', answer, {});
+ if (useDoc) {
+ useDoc[DocData][frontKey] = front;
+ useDoc[DocData][backKey] = back;
+ return useDoc;
+ }
+ return Docs.Create.FlashcardDocument('flashcard', front, back, { _width: 300, _height: 300 });
};
return keyword && keyword.toLowerCase() !== 'none' ? ComparisonBox.fetchImages(keyword).then(img => fillInFlashcard(img)) : fillInFlashcard();
}
@@ -98,6 +101,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
.map(tuple => ComparisonBox.createFlashcard(tuple, front, back))
).then(docs => {
return Docs.Create.CarouselDocument(docs, {
+ title: 'flashcard deck',
_width: width,
_height: height,
_layout_fitWidth: false,
@@ -116,12 +120,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
private _sideBtnWidth = 35;
private _closeRef = React.createRef<HTMLDivElement>();
private _disposers: { [key: string]: DragManager.DragDropDisposer | undefined } = {};
- private _reactDisposer: IReactionDisposer | undefined;
+ private _reactDisposer: { [key: string]: IReactionDisposer } = {};
@observable private _inputValue = '';
@observable private _outputValue = '';
@observable private _loading = false;
- @observable private _isEmpty = false;
@observable private _childActive = false;
@observable private _animating = '';
@observable private _listening = false;
@@ -135,18 +138,28 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
componentDidMount() {
this._props.setContentViewBox?.(this);
- this._reactDisposer = reaction(
- () => this._props.isSelected(), // when this reaction should update
+ this._reactDisposer.select = reaction(
+ () => this._props.isSelected(),
selected => {
- if (selected && this.isFlashcard) this.activateContent();
+ if (selected && this.revealOp !== flashcardRevealOp.SLIDE) this.activateContent();
!selected && (this._childActive = false);
}, // what it should update to
{ fireImmediately: true }
);
+ this._reactDisposer.hover = reaction(
+ () => this._props.isContentActive(),
+ hover => {
+ if (!hover) {
+ this.revealOp === flashcardRevealOp.FLIP && this.animateFlipping(this.frontKey);
+ this.revealOp === flashcardRevealOp.SLIDE && this.animateSliding(this._props.PanelWidth() - 3);
+ }
+ }, // what it should update to
+ { fireImmediately: true }
+ );
}
componentWillUnmount() {
- this._reactDisposer?.();
+ Object.values(this._reactDisposer).forEach(disposer => disposer?.());
}
protected createDropTarget = (ele: HTMLDivElement | null, fieldKey: string) => {
@@ -169,7 +182,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
return undefined;
}, 'internal drop');
- @computed get isQuizMode() { return DocCast(this.Document.embedContainer)?.practiceMode === practiceMode.QUIZ; } // prettier-ignore
+ @computed get containerDoc() { return this._props.docViewPath().slice(-2)[0]?.Document; } // prettier-ignore
+ @computed get isQuizMode() { return this.containerDoc?.practiceMode === practiceMode.QUIZ; } // prettier-ignore
@computed get isFlashcard() { return BoolCast(this.Document.layout_isFlashcard); } // prettier-ignore
@computed get frontKey() { return this._props.fieldKey + '_front'; } // prettier-ignore
@computed get backKey() { return this._props.fieldKey + '_back'; } // prettier-ignore
@@ -178,10 +192,10 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
@computed get revealOpKey() { return `_${this._props.fieldKey}_revealOp`; } // prettier-ignore
@computed get clipHeightKey() { return `_${this._props.fieldKey}_clipHeight`; } // prettier-ignore
@computed get clipWidthKey() { return `_${this._props.fieldKey}_clipWidth`; } // prettier-ignore
- @computed get clipWidth() { return NumCast(this.layoutDoc[this.clipWidthKey], 50); } // prettier-ignore
+ @computed get clipWidth() { return NumCast(this.layoutDoc[this.clipWidthKey], this.isFlashcard ? 100: 50); } // prettier-ignore
@computed get clipHeight() { return NumCast(this.layoutDoc[this.clipHeightKey], 200); } // prettier-ignore
- @computed get revealOp() { return StrCast(this.layoutDoc[this.revealOpKey], StrCast(this._props.docViewPath().slice(-2)[0]?.Document.revealOp)); } // prettier-ignore
- set revealOp(value:string) { this.layoutDoc[this.revealOpKey] = value; } // prettier-ignore
+ @computed get revealOp() { return StrCast(this.layoutDoc[this.revealOpKey], StrCast(this.containerDoc?.revealOp, this.isFlashcard ? flashcardRevealOp.FLIP : flashcardRevealOp.SLIDE)) as flashcardRevealOp; } // prettier-ignore
+ @computed get revealOpHover() { return BoolCast(this.layoutDoc[this.revealOpKey+"_hover"], BoolCast(this.containerDoc?.revealOp_hover)); } // prettier-ignore
@computed get loading() { return this._loading; } // prettier-ignore
set loading(value) { runInAction(() => { this._loading = value; })} // prettier-ignore
@@ -193,13 +207,13 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
onPointerDown={e =>
setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => {
if (!this.revealOp || this.revealOp === flashcardRevealOp.FLIP) {
- this.flipFlashcard();
+ this.animateFlipping();
}
})
}
style={{
- background: this.revealOp === flashcardRevealOp.HOVER ? 'gray' : this._renderSide === this.backKey ? 'white' : 'black',
- color: this.revealOp === flashcardRevealOp.HOVER ? 'black' : this._renderSide === this.backKey ? 'black' : 'white',
+ background: this.revealOpHover ? 'gray' : this._renderSide === this.backKey ? 'white' : 'black',
+ color: this.revealOpHover ? 'black' : this._renderSide === this.backKey ? 'black' : 'white',
display: 'inline-block',
}}>
<FontAwesomeIcon icon="turn-up" size="xl" />
@@ -223,7 +237,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
@computed get flashcardMenu() {
return SnappingManager.HideDecorations ? null : (
<div className="comparisonBox-bottomMenu" style={{ transform: `scale(${this.uiBtnScaling})` }}>
- {this.revealOp === flashcardRevealOp.HOVER || !this._props.isSelected() ? null : this.overlayAlternateIcon}
+ {this.revealOpHover || !this._props.isSelected() ? null : this.overlayAlternateIcon}
{!this._props.isSelected() || this._renderSide === this.frontKey ? null : (
<Tooltip title={<div className="dash-tooltip">Ask GPT to create an answer for the question on the front</div>}>
<div className="comparisonBox-button" onPointerDown={() => this.askGPT(GPTCallType.CHATCARD)}>
@@ -296,13 +310,11 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
moveDoc = (doc: Doc, addDocument: (document: Doc | Doc[]) => boolean, which: string) => this.remDoc(doc, which) && addDocument(doc);
addDoc = (doc: Doc, which: string) => {
- if (this.dataDoc[which] && !this._isEmpty) return false;
this.dataDoc[which] = doc;
return true;
};
remDoc = (doc: Doc, which: string) => {
if (this.dataDoc[which] === doc) {
- this._isEmpty = true;
this.dataDoc[which] = undefined;
return true;
}
@@ -334,6 +346,26 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
moveDocBack = (docs: Doc | Doc[], targetCol: Doc | undefined, addDoc: (doc: Doc | Doc[]) => boolean) => toList(docs).reduce((res, doc: Doc) => res && this.moveDoc(doc, addDoc, this.backKey), true);
remDocFront = (docs: Doc | Doc[]) => toList(docs).reduce((res, doc) => res && this.remDoc(doc, this.frontKey), true);
remDocBack = (docs: Doc | Doc[]) => toList(docs).reduce((res, doc) => res && this.remDoc(doc, this.backKey), true);
+ animateSliding = action((targetWidth: number) => {
+ this._animating = `all ${this._slideTiming}ms`; // on click, animate slider movement to the targetWidth
+ this.layoutDoc[this.clipWidthKey] = (targetWidth * 100) / this._props.PanelWidth();
+ setTimeout(action(() => {this._animating = ''; }), this._slideTiming); // prettier-ignore
+ });
+
+ _flipAnim: NodeJS.Timeout | undefined;
+ animateFlipping = action((side?: string) => {
+ if (side !== this._renderSide) {
+ this._renderSide = side ?? (this._renderSide === this.frontKey ? this.backKey : this.frontKey); // switches to new front
+ this._animating = '0'; // reveals old front on the bottom layer by making top layer transparent
+ setTimeout(
+ action(() => {
+ this._animating = `all ${this._slideTiming * 5}ms`; // makes new front fade in
+ clearTimeout(this._flipAnim);
+ this._flipAnim = setTimeout( action(() => { this._animating = ''; }), this._slideTiming * 5 ); // prettier-ignore
+ })
+ );
+ }
+ });
registerSliding = (e: React.PointerEvent<HTMLDivElement>, targetWidth: number) => {
if (e.button !== 2) {
@@ -351,13 +383,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
}),
false,
undefined,
- action(() => {
- if (!this._childActive) {
- this._animating = `all ${this._slideTiming}ms`; // on click, animate slider movement to the targetWidth
- this.layoutDoc[this.clipWidthKey] = (targetWidth * 100) / this._props.PanelWidth();
- setTimeout( action(() => {this._animating = ''; }), this._slideTiming); // prettier-ignore
- }
- })
+ action(() => !this._childActive && this.animateSliding(targetWidth))
);
}
};
@@ -584,16 +610,6 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
}
};
- @action
- flipFlashcard = () => {
- this._renderSide = this._renderSide === this.frontKey ? this.backKey : this.frontKey;
- };
-
- @action
- hoverFlip = (side: string) => {
- if (this.revealOp === flashcardRevealOp.HOVER) this._renderSide = side;
- };
-
flashcardContextMenu = () => {
const appearance = ContextMenu.Instance.findByDescription('Appearance...');
const appearanceItems = appearance?.subitems ?? [];
@@ -680,16 +696,8 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
};
displayBox = (which: string, cover: number) => (
- <div
- className={`${which === this.frontKey ? 'before' : 'after'}Box-cont`}
- key={which}
- style={{ width: this._props.PanelWidth() }}
- onPointerDown={e => {
- this.registerSliding(e, cover);
- this.isFlashcard && this.activateContent();
- }}
- ref={ele => this.createDropTarget(ele, which)}>
- {!this._isEmpty ? this.displayDoc(which) : null}
+ <div className={`${which === this.frontKey ? 'before' : 'after'}Box-cont`} key={which} style={{ width: this._props.PanelWidth() }} onPointerDown={e => this.registerSliding(e, cover)} ref={ele => this.createDropTarget(ele, which)}>
+ {this.displayDoc(which)}
</div>
);
@@ -727,7 +735,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
<button className="submit-buttonpronunciation" onClick={this.evaluatePronunciation}>
Evaluate Pronunciation
</button>
- <button className="submit-buttonsubmit" type="button" onClick={this._renderSide === this.backKey ? this.flipFlashcard : this.handleRenderGPTClick}>
+ <button className="submit-buttonsubmit" type="button" onClick={this._renderSide === this.backKey ? () => this.animateFlipping(this.frontKey) : this.handleRenderGPTClick}>
{this._renderSide === this.backKey ? 'Redo the Question' : 'Submit'}
</button>
</div>
@@ -736,40 +744,33 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
);
// if flashcard is rendered that has no data, then add some placeholders for question and answer
- addPlaceholdersForEmptyFlashcard = () => {
- if (this.dataDoc.data) {
- if (!this.dataDoc[this.backKey] || !this.dataDoc[this.frontKey]) ComparisonBox.createFlashcard(StrCast(this.dataDoc.data), this.frontKey, this.backKey, this.Document);
- } else {
- // add text box to each side when comparison box is first created
- if (!this.dataDoc[this.backKey] && !this._isEmpty) {
- this.dataDoc[this.backKey] = FormattedTextBox.centeredTextCreator('answer', 'answer here', undefined, true);
- }
-
- if (!this.dataDoc[this.frontKey] && !this._isEmpty) {
- this.dataDoc[this.frontKey] = FormattedTextBox.centeredTextCreator('question', 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards', undefined, true);
- }
- }
- };
-
- renderAsFlashcard = () => (
+ // addPlaceholdersForEmptyFlashcard = () => {
+ // if (this.dataDoc.data) {
+ // if (!this.dataDoc[this.backKey] || !this.dataDoc[this.frontKey]) ComparisonBox.createFlashcard(StrCast(this.dataDoc.data), this.frontKey, this.backKey, this.Document);
+ // }
+ // };
+
+ // render a button that flips between front and back
+ renderAsFlip = () => (
<div
- className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}`} /* change className to easily disable/enable pointer events in CSS */
- onContextMenu={this.flashcardContextMenu}
- onMouseEnter={() => this.hoverFlip(this.backKey)}
- onMouseLeave={() => this.hoverFlip(this.frontKey)}>
- {this.displayBox(this._renderSide, this._props.PanelWidth() - 3)}
- {this.loading ? (
- <div className="loading-spinner">
- <ReactLoading type="spin" height={30} width={30} color="blue" />
- </div>
- ) : null}
+ style={{ display: 'flex', pointerEvents: this.revealOpHover && this._props.isContentActive() ? 'unset' : undefined }} //
+ onMouseEnter={() => this.revealOpHover && this.animateFlipping(this.backKey)}
+ onMouseLeave={() => this.revealOpHover && this.animateFlipping(this.frontKey)}>
+ <div style={{ position: 'absolute', width: '100%', height: '100%', transition: this._animating === '0' ? undefined : this._animating, opacity: this._animating === '0' ? 1 : 0 }}>
+ {this.displayBox(this._renderSide === this.backKey ? this.frontKey : this.backKey, 0)}
+ </div>
+ <div style={{ position: 'absolute', width: '100%', height: '100%', transition: this._animating === '0' ? undefined : this._animating, opacity: this._animating === '0' ? 0 : 1 }}>{this.displayBox(this._renderSide, 0)}</div>
{this.flashcardMenu}
</div>
);
- // render a comparison box that compares items side by side
+ // render a slider that reveals front and back as slider is dragged horizonally
renderAsBeforeAfter = () => (
- <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}` /* change className to easily disable/enable pointer events in CSS */}>
+ <div
+ className="comparisonBox-slide"
+ style={{ display: 'flex', pointerEvents: this.revealOpHover && this._props.isContentActive() ? 'unset' : undefined }}
+ onMouseEnter={() => this.revealOpHover && this.animateSliding(0)}
+ onMouseLeave={() => this.revealOpHover && this.animateSliding(this._props.PanelWidth() - 3)}>
{this.displayBox(this.backKey, this._props.PanelWidth() - 3)}
<div className="clip-div" style={{ width: this.clipWidth + '%', transition: this._animating, background: StrCast(this.layoutDoc._backgroundColor, 'gray') }}>
{this.displayBox(this.frontKey, 0)}
@@ -789,11 +790,20 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
);
render() {
- this.isFlashcard && this.addPlaceholdersForEmptyFlashcard();
- return this.isFlashcard ?
- this.isQuizMode ? this.renderAsQuiz(this.frontText) :
- this.renderAsFlashcard() :
- this.renderAsBeforeAfter(); // prettier-ignore
+ const renderMode = new Map<flashcardRevealOp, () => JSX.Element>([
+ [flashcardRevealOp.FLIP, this.renderAsFlip],
+ [flashcardRevealOp.SLIDE, this.renderAsBeforeAfter]]); // prettier-ignore
+ if (this.isQuizMode) this.renderAsQuiz(this.frontText);
+ return (
+ <div className="comparisonBox" style={{ pointerEvents: this._props.isContentActive() ? 'unset' : undefined }} onContextMenu={this.flashcardContextMenu}>
+ {renderMode.get(this.revealOp)?.() ?? null}
+ {this.loading ? (
+ <div className="loading-spinner">
+ <ReactLoading type="spin" height={30} width={30} color="blue" />
+ </div>
+ ) : null}
+ </div>
+ );
}
}
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 9d3a899f5..29be8d285 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -76,26 +76,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
public static LayoutString(fieldStr: string) {
return FieldView.LayoutString(FormattedTextBox, fieldStr);
}
- /**
- * Creates a text box where the supplied text (and optional iimage) will be vertically
- * and horizontally centered. If text_placeholder is set to true, then the text will be
- * treated as placeholder text and automatically selected when the text box is selected.
- * @param title name of text box
- * @param text text to display in textbox
- * @param img optional image to add to text box
- * @param text_placeholder makes the text automatially select
- * @returns
- */
- public static centeredTextCreator(title: string, text: string, img?: Doc, text_placeholder?: boolean) {
- return Docs.Create.TextDocument(RichTextField.textToRtf(text, img?.[Id]), {
- title, //
- _layout_autoHeight: true,
- _layout_centered: true,
- text_align: 'center',
- text_placeholder,
- _layout_fitWidth: true,
- });
- }
public static MakeConfig(rules?: RichTextRules, props?: FormattedTextBoxProps) {
return {
schema,
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index 45dfe233f..6ec195910 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -1003,7 +1003,7 @@ export namespace Doc {
} else if (field instanceof ObjectField) {
const docAtKey = doc[key];
copy[key] =
- docAtKey instanceof Doc && key.includes('layout[')
+ docAtKey instanceof Doc && (key.includes('layout[') || docAtKey.cloneOnCopy)
? new ProxyField(Doc.MakeCopy(docAtKey)) // copy the expanded render template
: ObjectField.MakeCopy(field);
} else if (field instanceof Promise) {