aboutsummaryrefslogtreecommitdiff
path: root/src
diff options
context:
space:
mode:
Diffstat (limited to 'src')
-rw-r--r--src/ClientUtils.ts21
-rw-r--r--src/client/apis/gpt/GPT.ts26
-rw-r--r--src/client/documents/DocUtils.ts7
-rw-r--r--src/client/documents/Documents.ts17
-rw-r--r--src/client/util/CurrentUserUtils.ts6
-rw-r--r--src/client/util/DictationManager.ts2
-rw-r--r--src/client/util/Import & Export/ImageUtils.ts1
-rw-r--r--src/client/views/ContextMenu.scss7
-rw-r--r--src/client/views/ContextMenu.tsx7
-rw-r--r--src/client/views/InkTranscription.tsx36
-rw-r--r--src/client/views/MainView.tsx1
-rw-r--r--src/client/views/MarqueeAnnotator.tsx29
-rw-r--r--src/client/views/PropertiesView.tsx11
-rw-r--r--src/client/views/SidebarAnnos.tsx1
-rw-r--r--src/client/views/StyleProp.ts2
-rw-r--r--src/client/views/StyleProvider.tsx10
-rw-r--r--src/client/views/StyleProviderQuiz.scss38
-rw-r--r--src/client/views/StyleProviderQuiz.tsx398
-rw-r--r--src/client/views/collections/CollectionCardDeckView.scss1
-rw-r--r--src/client/views/collections/CollectionCardDeckView.tsx138
-rw-r--r--src/client/views/collections/CollectionCarousel3DView.scss1
-rw-r--r--src/client/views/collections/CollectionCarousel3DView.tsx65
-rw-r--r--src/client/views/collections/CollectionCarouselView.scss42
-rw-r--r--src/client/views/collections/CollectionCarouselView.tsx191
-rw-r--r--src/client/views/collections/CollectionSubView.tsx50
-rw-r--r--src/client/views/collections/FlashcardPracticeUI.scss72
-rw-r--r--src/client/views/collections/FlashcardPracticeUI.tsx201
-rw-r--r--src/client/views/collections/TabDocView.tsx1
-rw-r--r--src/client/views/collections/TreeView.tsx2
-rw-r--r--src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx2
-rw-r--r--src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx24
-rw-r--r--src/client/views/collections/collectionFreeForm/MarqueeView.tsx4
-rw-r--r--src/client/views/nodes/AudioBox.tsx19
-rw-r--r--src/client/views/nodes/ComparisonBox.scss203
-rw-r--r--src/client/views/nodes/ComparisonBox.tsx787
-rw-r--r--src/client/views/nodes/DocumentContentsView.tsx1
-rw-r--r--src/client/views/nodes/DocumentView.tsx22
-rw-r--r--src/client/views/nodes/FieldView.tsx4
-rw-r--r--src/client/views/nodes/ImageBox.tsx75
-rw-r--r--src/client/views/nodes/LabelBox.scss35
-rw-r--r--src/client/views/nodes/LabelBox.tsx12
-rw-r--r--src/client/views/nodes/PDFBox.tsx5
-rw-r--r--src/client/views/nodes/VideoBox.tsx2
-rw-r--r--src/client/views/nodes/formattedText/FormattedTextBox.scss15
-rw-r--r--src/client/views/nodes/formattedText/FormattedTextBox.tsx208
-rw-r--r--src/client/views/nodes/formattedText/FormattedTextBoxComment.scss8
-rw-r--r--src/client/views/pdf/AnchorMenu.tsx56
-rw-r--r--src/client/views/pdf/Annotation.scss2
-rw-r--r--src/client/views/pdf/PDFViewer.scss23
-rw-r--r--src/client/views/pdf/PDFViewer.tsx31
-rw-r--r--src/client/views/smartdraw/SmartDrawHandler.tsx4
-rw-r--r--src/fields/Doc.ts11
-rw-r--r--src/fields/RichTextField.ts9
53 files changed, 2212 insertions, 734 deletions
diff --git a/src/ClientUtils.ts b/src/ClientUtils.ts
index 3066499d8..e7aee1c2a 100644
--- a/src/ClientUtils.ts
+++ b/src/ClientUtils.ts
@@ -675,6 +675,27 @@ export function dateRangeStrToDates(dateStr: string) {
return { start: new Date(dateRangeParts[0]), end: new Date(dateRangeParts[1]) };
}
+/**
+ * converts the image to base url formate
+ * @param imageUrl imageurl taken from the collection icon
+ */
+export async function imageUrlToBase64(imageUrl: string): Promise<string> {
+ try {
+ const response = await fetch(imageUrl);
+ const blob = await response.blob();
+
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(blob);
+ reader.onloadend = () => resolve(reader.result as string);
+ reader.onerror = error => reject(error);
+ });
+ } catch (error) {
+ console.error('Error:', error);
+ throw error;
+ }
+}
+
function replaceCanvases(oldDiv: HTMLElement, newDiv: HTMLElement) {
if (oldDiv.childNodes && newDiv) {
for (let i = 0; i < oldDiv.childNodes.length; i++) {
diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts
index eeac57a5e..66c49abc7 100644
--- a/src/client/apis/gpt/GPT.ts
+++ b/src/client/apis/gpt/GPT.ts
@@ -12,6 +12,8 @@ enum GPTCallType {
DESCRIBE = 'describe',
MERMAID = 'mermaid',
DATA = 'data',
+ STACK = 'stack',
+ PRONUNCIATION = 'pronunciation',
DRAW = 'draw',
COLOR = 'color',
RUBRIC = 'rubric',
@@ -36,7 +38,13 @@ const callTypeMap: { [type: string]: GPTCallOpts } = {
// newest model: gpt-4
summary: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Summarize the text given in simpler terms.' },
edit: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Reword the text.' },
- flashcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Make flashcards out of this text with each question and answer labeled. Do not label each flashcard and do not include asterisks: ' },
+ flashcard: { model: 'gpt-4-turbo', maxTokens: 512, temp: 0.5, prompt: 'Make flashcards out of this text with each question and answer labeled as question and answer. Do not label each flashcard and do not include asterisks: ' },
+ stack: {
+ model: 'gpt-4o',
+ maxTokens: 2048,
+ temp: 0.7,
+ prompt: 'Create a stack of flashcards out of this text with each question and answer labeled as question and answer. For some questions, ask "what is this image of" but tailored to stacks theme and the image and write a keyword that represents the image and label it "keyword". Otherwise, write none. Do not label each flashcard and do not include asterisks.',
+ },
completion: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: "You are a helpful assistant. Answer the user's prompt." },
mermaid: {
@@ -63,7 +71,13 @@ const callTypeMap: { [type: string]: GPTCallOpts } = {
model: 'gpt-4-turbo',
maxTokens: 1024,
temp: 0,
- prompt: 'List unique differences between the content of the UserAnswer and Rubric. Before each difference, label it and provide any additional information the UserAnswer missed and explain it in second person without separating it into UserAnswer and Rubric content and additional information. If there are no differences, say correct',
+ prompt: 'List unique differences between the content of the UserAnswer and Rubric. Before each difference, label it and provide any additional information the UserAnswer missed and explain it in second person without separating it into UserAnswer and Rubric content and additional information. If the Rubric is incorrect, explain why. If there are no differences, say correct. If it is empty, say there is nothing for me to evaluate. If it is comparing two words, look for spelling and not capitalization and not punctuation.',
+ },
+ pronunciation: {
+ model: 'gpt-4-turbo',
+ maxTokens: 1024,
+ temp: 0.1, //0.3
+ prompt: "BRIEFLY (<50 words) describe any differences between the rubric and the user's answer answer in second person. If there are no differences, say correct",
},
template: {
model: 'gpt-4-turbo',
@@ -112,7 +126,7 @@ let lastResp = '';
* @returns AI Output
*/
const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: string, dontCache?: boolean) => {
- const inputText = [GPTCallType.SUMMARY, GPTCallType.FLASHCARD, GPTCallType.QUIZ].includes(callType) ? inputTextIn + '.' : inputTextIn;
+ const inputText = [GPTCallType.SUMMARY, GPTCallType.FLASHCARD, GPTCallType.QUIZ, GPTCallType.STACK].includes(callType) ? inputTextIn + '.' : inputTextIn;
const opts: GPTCallOpts = callTypeMap[callType];
if (lastCall === inputText && dontCache !== true) return lastResp;
try {
@@ -131,6 +145,7 @@ const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: s
max_tokens: opts.maxTokens,
});
lastResp = response.choices[0].message.content ?? '';
+ console.log('RESP:' + lastResp);
return lastResp;
} catch (err) {
console.log(err);
@@ -168,7 +183,7 @@ const gptGetEmbedding = async (src: string): Promise<number[]> => {
return [];
}
};
-const gptImageLabel = async (src: string): Promise<string> => {
+const gptImageLabel = async (src: string, prompt: string): Promise<string> => {
try {
const response = await openai.chat.completions.create({
model: 'gpt-4o',
@@ -176,7 +191,7 @@ const gptImageLabel = async (src: string): Promise<string> => {
{
role: 'user',
content: [
- { type: 'text', text: 'Give three labels to describe this image.' },
+ { type: 'text', text: prompt },
{
type: 'image_url',
image_url: {
@@ -189,6 +204,7 @@ const gptImageLabel = async (src: string): Promise<string> => {
],
});
if (response.choices[0].message.content) {
+ console.log(response.choices[0].message.content);
return response.choices[0].message.content;
}
return 'Missing labels';
diff --git a/src/client/documents/DocUtils.ts b/src/client/documents/DocUtils.ts
index 1130a9ae8..5f54f9d0a 100644
--- a/src/client/documents/DocUtils.ts
+++ b/src/client/documents/DocUtils.ts
@@ -35,11 +35,6 @@ import { Docs, DocumentOptions } from './Documents';
import { DocumentView } from '../views/nodes/DocumentView';
import { CollectionFreeFormView } from '../views/collections/collectionFreeForm';
-// eslint-disable-next-line @typescript-eslint/no-var-requires, @typescript-eslint/no-require-imports
-const { DFLT_IMAGE_NATIVE_DIM } = require('../views/global/globalCssVariables.module.scss'); // prettier-ignore
-
-const defaultNativeImageDim = Number(DFLT_IMAGE_NATIVE_DIM.replace('px', ''));
-
export namespace DocUtils {
function matchFieldValue(doc: Doc, key: string, valueIn: unknown): boolean {
let value = valueIn;
@@ -629,7 +624,7 @@ export namespace DocUtils {
export function assignImageInfo(result: Upload.FileInformation, protoIn: Doc) {
const proto = protoIn;
if (Upload.isImageInformation(result)) {
- const maxNativeDim = Math.min(Math.max(result.nativeHeight, result.nativeWidth), defaultNativeImageDim);
+ const maxNativeDim = Math.max(result.nativeHeight, result.nativeWidth);
const exifRotation = StrCast(result.exifData?.data?.Orientation).toLowerCase();
proto.data_nativeOrientation = result.exifData?.data?.image?.Orientation ?? (exifRotation.includes('rotate 90') || exifRotation.includes('rotate 270') ? 5 : undefined);
proto.data_nativeWidth = result.nativeWidth < result.nativeHeight ? (maxNativeDim * result.nativeWidth) / result.nativeHeight : maxNativeDim;
diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts
index 951632d71..5f2a592ae 100644
--- a/src/client/documents/Documents.ts
+++ b/src/client/documents/Documents.ts
@@ -259,6 +259,7 @@ export class DocumentOptions {
layout_hideResizeHandles?: BOOLt = new BoolInfo('whether to hide the resize handles when selected');
layout_hideLinkButton?: BOOLt = new BoolInfo('whether the blue link counter button should be hidden');
layout_hideDecorationTitle?: BOOLt = new BoolInfo('whether to suppress the document decortations title when selected');
+ layout_hideDecorations?: BOOLt = new BoolInfo('whether to suppress all document decortations when selected');
_layout_hideContextMenu?: BOOLt = new BoolInfo('whether the context menu can be shown');
layout_diagramEditor?: STRt = new StrInfo('specify the JSX string for a diagram editor view');
layout_hideContextMenu?: BOOLt = new BoolInfo('whether the context menu can be shown');
@@ -304,6 +305,7 @@ export class DocumentOptions {
_text_fontSize?: string;
_text_fontFamily?: string;
_text_fontWeight?: string;
+ text_align?: STRt = new StrInfo('horizontal text alignment default');
fontSize?: string;
_pivotField?: string; // field key used to determine headings for sections in stacking, masonry, pivot views
@@ -734,6 +736,9 @@ export namespace Docs {
updateCachedAcls(dataDoc);
updateCachedAcls(viewDoc);
+ if (data instanceof List) {
+ data.map(item => item instanceof Doc && Doc.SetContainer(item, viewDoc));
+ }
return viewDoc;
}
@@ -923,15 +928,13 @@ export namespace Docs {
}
export function CalendarDocument(options: DocumentOptions, documents: Array<Doc>) {
- const inst = InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), {
+ return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), {
_layout_nativeDimEditable: true,
_layout_reflowHorizontal: true,
_layout_reflowVertical: true,
...options,
_type_collection: CollectionViewType.Calendar,
});
- documents.forEach(d => Doc.SetContainer(d, inst));
- return inst;
}
// shouldn't ever need to create a KVP document-- instead set the LayoutTemplateString to be a KeyValueBox for the DocumentView (see addDocTab in TabDocView)
@@ -940,9 +943,7 @@ export namespace Docs {
// }
export function FreeformDocument(documents: Array<Doc>, options: DocumentOptions, id?: string) {
- const inst = InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { ...options, _type_collection: CollectionViewType.Freeform }, id);
- documents.forEach(d => Doc.SetContainer(d, inst));
- return inst;
+ return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { ...options, _type_collection: CollectionViewType.Freeform }, id);
}
export function ConfigDocument(options: DocumentOptions, id?: string) {
@@ -1047,9 +1048,7 @@ export namespace Docs {
}
export function DockDocument(documents: Array<Doc>, config: string, options: DocumentOptions, id?: string) {
- const ret = InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { treeView_FreezeChildren: 'remove|add', ...options, type_collection: CollectionViewType.Docking, dockingConfig: config }, id);
- documents.map(c => Doc.SetContainer(c, ret));
- return ret;
+ return InstanceFromProto(Prototypes.get(DocumentType.COL), new List(documents), { treeView_FreezeChildren: 'remove|add', ...options, type_collection: CollectionViewType.Docking, dockingConfig: config }, id);
}
export function DelegateDocument(proto: Doc, options: DocumentOptions = {}) {
diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts
index 7670827f8..274bc79be 100644
--- a/src/client/util/CurrentUserUtils.ts
+++ b/src/client/util/CurrentUserUtils.ts
@@ -369,7 +369,7 @@ 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, _width: 300, _height: 300}},
+ {key: "Flashcard", creator: opts => Docs.Create.ComparisonDocument("", opts), opts: { _layout_isFlashcard: true, _layout_fitWidth: true, _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}},
@@ -725,8 +725,6 @@ pie title Minerals in my tap water
{ title: "Fit All", icon: "object-group", toolTip: "Fit Docs to View (double click to make sticky)",btnType: ButtonType.ToggleButton, ignoreClick:true, expertMode: false, toolType:"viewAll", funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}', onDoubleClick: '{ return showFreeform(this.toolType, _readOnly_, true);}'}}, // Only when floating document is selected in freeform
{ title: "Clusters", icon: "braille", toolTip: "Show Doc Clusters", btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"clusters", funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform
{ title: "Cards", icon: "brain", toolTip: "Flashcards", btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"flashcards", funcs: {}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform
- { title: "Arrange", icon:"arrow-down-short-wide",toolTip:"Auto Arrange", btnType: ButtonType.ToggleButton, ignoreClick: true, expertMode: false, toolType:"arrange", funcs: {hidden: 'IsNoviceMode()'}, scripts: { onClick: '{ return showFreeform(this.toolType, _readOnly_);}'}}, // Only when floating document is selected in freeform
-
]
}
static textTools():Button[] {
@@ -1004,12 +1002,10 @@ pie title Minerals in my tap water
doc.fontColor ?? (doc.fontColor = "black");
doc.fontHighlight ?? (doc.fontHighlight = "");
doc.defaultAclPrivate ?? (doc.defaultAclPrivate = false);
- doc.savedFilters ?? (doc.savedFilters = new List<Doc>());
doc.userBackgroundColor ?? (doc.userBackgroundColor = Colors.DARK_GRAY);
doc.userVariantColor ?? (doc.userVariantColor = Colors.MEDIUM_BLUE);
doc.userColor ?? (doc.userColor = Colors.LIGHT_GRAY);
doc.userTheme ?? (doc.userTheme = ColorScheme.Dark);
- doc.filterDocCount = 0;
doc.treeView_FreezeChildren = "remove|add";
doc.activePage = doc.activeDashboard === undefined ? 'home': doc.activePage;
diff --git a/src/client/util/DictationManager.ts b/src/client/util/DictationManager.ts
index a0e1413b6..831afe538 100644
--- a/src/client/util/DictationManager.ts
+++ b/src/client/util/DictationManager.ts
@@ -349,7 +349,7 @@ export namespace DictationManager {
const head = 3;
const anchor = head + prompt.length;
const proseMirrorState = `{"doc":{"type":"doc","content":[{"type":"ordered_list","content":[{"type":"list_item","content":[{"type":"paragraph","content":[{"type":"text","text":"${prompt}"}]}]}]}]},"selection":{"type":"text","anchor":${anchor},"head":${head}}}`;
- proto.data = new RichTextField(proseMirrorState);
+ proto.data = new RichTextField(proseMirrorState, prompt);
proto.backgroundColor = '#eeffff';
target.props.addDocTab(newBox, OpenWhere.addRight);
},
diff --git a/src/client/util/Import & Export/ImageUtils.ts b/src/client/util/Import & Export/ImageUtils.ts
index 266e05f08..8d4eefa7e 100644
--- a/src/client/util/Import & Export/ImageUtils.ts
+++ b/src/client/util/Import & Export/ImageUtils.ts
@@ -1,4 +1,3 @@
-/* eslint-disable @typescript-eslint/no-namespace */
import { ClientUtils } from '../../../ClientUtils';
import { Doc } from '../../../fields/Doc';
import { DocData } from '../../../fields/DocSymbols';
diff --git a/src/client/views/ContextMenu.scss b/src/client/views/ContextMenu.scss
index af0f717fe..88e147a03 100644
--- a/src/client/views/ContextMenu.scss
+++ b/src/client/views/ContextMenu.scss
@@ -116,6 +116,13 @@
cursor: pointer;
}
+.contextMenu-itemSelected {
+ background: white;
+ color: black;
+ // background: lightgoldenrodyellow;
+ border-style: none;
+}
+
.contextMenu-group {
// width: 11vw; //10vw
height: 30px; //2vh
diff --git a/src/client/views/ContextMenu.tsx b/src/client/views/ContextMenu.tsx
index 4b67ef704..2eb3e5565 100644
--- a/src/client/views/ContextMenu.tsx
+++ b/src/client/views/ContextMenu.tsx
@@ -179,7 +179,7 @@ export class ContextMenu extends ObservableReactComponent<{ noexpand?: boolean }
@computed get menuItems() {
if (!this._searchString) {
- return this._items.map((item, ind) => <ContextMenuItem key={item.description + ind} {...item} noexpand={this.itemsNeedSearch ? true : item.noexpand} closeMenu={this.closeMenu} />);
+ return this._items.map((item, ind) => <ContextMenuItem key={item.description + ind} {...item} selected={ind === this._selectedIndex} noexpand={this.itemsNeedSearch ? true : item.noexpand} closeMenu={this.closeMenu} />);
}
return this.filteredItems.map((value, index) =>
Array.isArray(value) ? (
@@ -238,6 +238,11 @@ export class ContextMenu extends ObservableReactComponent<{ noexpand?: boolean }
}
@action
+ setLangIndex = (ind: number) => {
+ this._selectedIndex = ind;
+ };
+
+ @action
onKeyDown = (e: React.KeyboardEvent) => {
if (e.key === 'ArrowDown') {
if (this._selectedIndex < this.flatItems.length - 1) {
diff --git a/src/client/views/InkTranscription.tsx b/src/client/views/InkTranscription.tsx
index 24d53a8c8..c5a9d3ba4 100644
--- a/src/client/views/InkTranscription.tsx
+++ b/src/client/views/InkTranscription.tsx
@@ -1,19 +1,19 @@
import * as iink from 'iink-ts';
import { action, observable } from 'mobx';
import * as React from 'react';
+import { imageUrlToBase64 } from '../../ClientUtils';
+import { aggregateBounds } from '../../Utils';
import { Doc, DocListCast } from '../../fields/Doc';
import { InkData, InkField, InkTool } from '../../fields/InkField';
import { Cast, DateCast, ImageCast, NumCast } from '../../fields/Types';
-import { aggregateBounds } from '../../Utils';
+import { ImageField, URLField } from '../../fields/URLField';
+import { gptHandwriting } from '../apis/gpt/GPT';
import { DocumentType } from '../documents/DocumentTypes';
-import { CollectionFreeFormView, MarqueeView } from './collections/collectionFreeForm';
-import { InkingStroke } from './InkingStroke';
-import './InkTranscription.scss';
import { Docs } from '../documents/Documents';
+import './InkTranscription.scss';
+import { InkingStroke } from './InkingStroke';
+import { CollectionFreeFormView, MarqueeView } from './collections/collectionFreeForm';
import { DocumentView } from './nodes/DocumentView';
-import { ImageField } from '../../fields/URLField';
-import { gptHandwriting } from '../apis/gpt/GPT';
-import { URLField } from '../../fields/URLField';
/**
* Class component that handles inking in writing mode
*/
@@ -260,7 +260,7 @@ export class InkTranscription extends React.Component {
const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`;
let response;
try {
- const hrefBase64 = await this.imageUrlToBase64(hrefComplete);
+ const hrefBase64 = await imageUrlToBase64(hrefComplete);
response = await gptHandwriting(hrefBase64);
} catch {
console.error('Error getting image');
@@ -291,26 +291,6 @@ export class InkTranscription extends React.Component {
}
return undefined;
}
- /**
- * converts the image to base url formate
- * @param imageUrl imageurl taken from the collection icon
- */
- imageUrlToBase64 = async (imageUrl: string): Promise<string> => {
- try {
- const response = await fetch(imageUrl);
- const blob = await response.blob();
-
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.readAsDataURL(blob);
- reader.onloadend = () => resolve(reader.result as string);
- reader.onerror = error => reject(error);
- });
- } catch (error) {
- console.error('Error:', error);
- throw error;
- }
- };
/**
* Creates the ink grouping once the user leaves the writing mode.
diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx
index e469531b0..7779d339f 100644
--- a/src/client/views/MainView.tsx
+++ b/src/client/views/MainView.tsx
@@ -573,6 +573,7 @@ export class MainView extends ObservableReactComponent<object> {
fa.faRobot,
fa.faSatellite,
fa.faStar,
+ fa.faFilePen,
fa.faCloud,
fa.faBolt,
fa.faLightbulb,
diff --git a/src/client/views/MarqueeAnnotator.tsx b/src/client/views/MarqueeAnnotator.tsx
index 90323086c..7266875c5 100644
--- a/src/client/views/MarqueeAnnotator.tsx
+++ b/src/client/views/MarqueeAnnotator.tsx
@@ -34,6 +34,7 @@ export interface MarqueeAnnotatorProps {
getPageFromScroll?: (top: number) => number;
finishMarquee: (x?: number, y?: number) => void;
anchorMenuClick?: () => undefined | ((anchor: Doc) => void);
+ anchorMenuFlashcard?: () => Promise<String>;
anchorMenuCrop?: (anchor: Doc | undefined, addCrop: boolean) => Doc | undefined;
highlightDragSrcColor?: string;
}
@@ -46,10 +47,10 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP
makeObservable(this);
}
- @observable private _width: number = 0;
- @observable private _height: number = 0;
- @computed get top() { return Math.min(this._start.y, this._start.y + this._height); } // prettier-ignore
- @computed get left() { return Math.min(this._start.x, this._start.x + this._width);} // prettier-ignore
+ @observable Width: number = 0;
+ @observable Height: number = 0;
+ @computed get top() { return Math.min(this._start.y, this._start.y + this.Height); } // prettier-ignore
+ @computed get left() { return Math.min(this._start.x, this._start.x + this.Width);} // prettier-ignore
static clearAnnotations = action((savedAnnotations: ObservableMap<number, HTMLDivElement[]>) => {
AnchorMenu.Instance.Status = 'marquee';
@@ -167,7 +168,7 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP
@action
public onInitiateSelection(down: number[]) {
- this._width = this._height = 0;
+ this.Width = this.Height = 0;
this._start = this.getTransformedScreenPt(down);
document.removeEventListener('pointermove', this.onSelectMove);
@@ -241,15 +242,15 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP
@action
onMove = (pt: number[]) => {
const movLoc = this.getTransformedScreenPt(pt);
- this._width = movLoc.x - this._start.x;
- this._height = movLoc.y - this._start.y;
+ this.Width = movLoc.x - this._start.x;
+ this.Height = movLoc.y - this._start.y;
};
@action
onSelectMove = (e: PointerEvent) => {
const movLoc = this.getTransformedScreenPt([e.clientX, e.clientY]);
- this._width = movLoc.x - this._start.x;
- this._height = movLoc.y - this._start.y;
+ this.Width = movLoc.x - this._start.x;
+ this.Height = movLoc.y - this._start.y;
// e.stopPropagation(); // overlay documents are all 'active', yet they can be dragged. if we stop propagation, then they can be marqueed but not dragged. if we don't stop, then they will be marqueed and dragged, but the marquee will be zero width since the doc will move along with the cursor.
};
@@ -280,11 +281,11 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP
AnchorMenu.Instance.jumpTo(x, y);
}
this.props.finishMarquee(this.isEmpty ? x : undefined, this.isEmpty ? y : undefined);
- this._width = this._height = 0;
+ this.Width = this.Height = 0;
};
get isEmpty() {
- return Math.abs(this._width) <= 10 && Math.abs(this._height) <= 10;
+ return Math.abs(this.Width) <= 10 && Math.abs(this.Height) <= 10;
}
render() {
@@ -294,9 +295,9 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP
style={{
left: `${this.left}px`,
top: `${this.top}px`,
- width: `${Math.abs(this._width)}px`,
- height: `${Math.abs(this._height)}px`,
- border: `${this._width === 0 ? '' : '2px dashed black'}`,
+ width: `${Math.abs(this.Width)}px`,
+ height: `${Math.abs(this.Height)}px`,
+ border: `${this.Width === 0 ? '' : '2px dashed black'}`,
}}
/>
);
diff --git a/src/client/views/PropertiesView.tsx b/src/client/views/PropertiesView.tsx
index 789333995..c539b1d0a 100644
--- a/src/client/views/PropertiesView.tsx
+++ b/src/client/views/PropertiesView.tsx
@@ -139,6 +139,9 @@ export class PropertiesView extends ObservableReactComponent<PropertiesViewProps
Object.values(this._disposers).forEach(disposer => disposer?.());
}
+ @computed get isText() {
+ return this.selectedDoc?.type === DocumentType.RTF;
+ }
@computed get isInk() {
return this.selectedDoc?.type === DocumentType.INK;
}
@@ -146,7 +149,9 @@ export class PropertiesView extends ObservableReactComponent<PropertiesViewProps
return this.selectedDoc?.isGroup;
}
@computed get isStack() {
- return [CollectionViewType.Masonry, CollectionViewType.Multicolumn, CollectionViewType.Multirow, CollectionViewType.Stacking, CollectionViewType.NoteTaking].includes(this.selectedDoc?.type_collection as CollectionViewType);
+ return [CollectionViewType.Masonry, CollectionViewType.Multicolumn, CollectionViewType.Multirow, CollectionViewType.Stacking, CollectionViewType.NoteTaking, CollectionViewType.Carousel].includes(
+ this.selectedDoc?.type_collection as CollectionViewType
+ );
}
rtfWidth = () => (!this.selectedLayoutDoc ? 0 : Math.min(NumCast(this.selectedLayoutDoc?._width), this._props.width - 20));
@@ -1207,8 +1212,8 @@ export class PropertiesView extends ObservableReactComponent<PropertiesViewProps
// prettier-ignore
<div className="transform-editor">
{!this.isStack ? null : this.getNumber('Gap', ' px', 0, 200, NumCast(this.selectedDoc!.gridGap), this.setVal((doc: Doc, val: number) => { doc.gridGap = val; })) }
- {!this.isStack ? null : this.getNumber('xMargin', ' px', 0, 500, NumCast(this.selectedDoc!.xMargin), this.setVal((doc: Doc, val: number) => { doc.xMargin = val; })) }
- {!this.isStack ? null : this.getNumber('yMargin', ' px', 0, 500, NumCast(this.selectedDoc!.yMargin), this.setVal((doc: Doc, val: number) => { doc.yMargin = val; })) }
+ {!this.isStack && !this.isText? null : this.getNumber('xMargin', ' px', 0, 500, NumCast(this.selectedDoc!.xMargin), this.setVal((doc: Doc, val: number) => { doc.xMargin = val; })) }
+ {!this.isStack && !this.isText? null : this.getNumber('yMargin', ' px', 0, 500, NumCast(this.selectedDoc!.yMargin), this.setVal((doc: Doc, val: number) => { doc.yMargin = val; })) }
{!this.isGroup ? null : this.getNumber('Padding', ' px', 0, 500, NumCast(this.selectedDoc!.xPadding), this.setVal((doc: Doc, val: number) => { doc.xPadding = doc.yPadding = val; })) }
{this.isInk ? this.controlPointsButton : null}
{this.getNumber('Width', ' px', 0, Math.max(1000, this.shapeWid), this.shapeWid, this.setVal((doc: Doc, val:number) => {this.shapeWid = val}), 1000, 1)}
diff --git a/src/client/views/SidebarAnnos.tsx b/src/client/views/SidebarAnnos.tsx
index dd60bfa65..1f3ad8444 100644
--- a/src/client/views/SidebarAnnos.tsx
+++ b/src/client/views/SidebarAnnos.tsx
@@ -232,7 +232,6 @@ export class SidebarAnnos extends ObservableReactComponent<FieldViewProps & Extr
<div style={{ width: '100%', height: `calc(100% - 38px)`, position: 'relative' }}>
<CollectionStackingView
- // eslint-disable-next-line react/jsx-props-no-spreading
{...this._props}
setContentViewBox={emptyFunction}
NativeWidth={returnZero}
diff --git a/src/client/views/StyleProp.ts b/src/client/views/StyleProp.ts
index dd5b98cfe..44d3bf757 100644
--- a/src/client/views/StyleProp.ts
+++ b/src/client/views/StyleProp.ts
@@ -21,4 +21,6 @@ export enum StyleProp {
FontFamily = 'fontFamily', // font family of text
FontWeight = 'fontWeight', // font weight of text
Highlighting = 'highlighting', // border highlighting
+ ContextMenuItems = 'contextMenuItems', // menu items to add to context menu
+ AnchorMenuItems = 'anchorMenuItems',
}
diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx
index 1e98695d1..02e0a34d8 100644
--- a/src/client/views/StyleProvider.tsx
+++ b/src/client/views/StyleProvider.tsx
@@ -21,6 +21,7 @@ import { TreeSort } from './collections/TreeSort';
import { Colors } from './global/globalEnums';
import { DocumentView, DocumentViewProps } from './nodes/DocumentView';
import { FieldViewProps } from './nodes/FieldView';
+import { styleProviderQuiz } from './StyleProviderQuiz';
import { StyleProp } from './StyleProp';
import './StyleProvider.scss';
import { TagsView } from './TagsView';
@@ -100,6 +101,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
containerViewPath,
childFilters,
hideCaptions,
+ hideFilterStatus,
showTitle,
childFiltersByRanges,
renderDepth,
@@ -121,6 +123,12 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
const color = () => styleProvider?.(doc, props, StyleProp.Color) as string;
const opacity = () => styleProvider?.(doc, props, StyleProp.Opacity);
const layoutShowTitle = () => styleProvider?.(doc, props, StyleProp.ShowTitle) as string;
+
+ // bcz: For now, this is how to add custom-stylings (like a Quiz styling) for app-specific purposes. The quiz styling will short-circuit
+ // the regular stylings for items that it controls (eg., things with a quiz field, or images)
+ const quizProp = styleProviderQuiz.quizStyleProvider(doc, props, property);
+ if (quizProp !== undefined) return quizProp;
+
// prettier-ignore
switch (property.split(':')[0]) {
case StyleProp.TreeViewIcon: {
@@ -339,7 +347,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps &
: childFilters?.().filter(f => ClientUtils.IsRecursiveFilter(f) && f !== ClientUtils.noDragDocsFilter).length || childFiltersByRanges?.().length
? 'orange' // 'inheritsFilter'
: undefined;
- return !showFilterIcon ? null : (
+ return !showFilterIcon || hideFilterStatus ? null : (
<div className="styleProvider-filter">
<Dropdown
type={Type.TERT}
diff --git a/src/client/views/StyleProviderQuiz.scss b/src/client/views/StyleProviderQuiz.scss
new file mode 100644
index 000000000..84b3f1fef
--- /dev/null
+++ b/src/client/views/StyleProviderQuiz.scss
@@ -0,0 +1,38 @@
+.loading-spinner {
+ position: absolute;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ width: 100%;
+ z-index: 200;
+ font-size: 20px;
+ font-weight: bold;
+ color: #17175e;
+}
+
+.check-icon {
+ position: absolute;
+ right: 40;
+ bottom: 10;
+ color: green;
+ display: inline-block;
+ font-size: 20px;
+ overflow: hidden;
+}
+
+.redo-icon {
+ position: absolute;
+ right: 10;
+ bottom: 10;
+ color: black;
+ display: inline-block;
+ font-size: 20px;
+ overflow: hidden;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/client/views/StyleProviderQuiz.tsx b/src/client/views/StyleProviderQuiz.tsx
new file mode 100644
index 000000000..1f2ad1485
--- /dev/null
+++ b/src/client/views/StyleProviderQuiz.tsx
@@ -0,0 +1,398 @@
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Tooltip } from '@mui/material';
+import axios from 'axios';
+import * as React from 'react';
+import { returnFalse, setupMoveUpEvents } from '../../ClientUtils';
+import { emptyFunction } from '../../Utils';
+import { Doc, DocListCast, Opt } from '../../fields/Doc';
+import { DocData } from '../../fields/DocSymbols';
+import { List } from '../../fields/List';
+import { NumCast, StrCast } from '../../fields/Types';
+import { GPTCallType, gptAPICall, gptImageLabel } from '../apis/gpt/GPT';
+import { Docs } from '../documents/Documents';
+import { ContextMenu } from './ContextMenu';
+import { ContextMenuProps } from './ContextMenuItem';
+import { StyleProp } from './StyleProp';
+import { AnchorMenu } from './pdf/AnchorMenu';
+import { DocumentViewProps } from './nodes/DocumentView';
+import { FieldViewProps } from './nodes/FieldView';
+import { ImageBox } from './nodes/ImageBox';
+import { ImageUtility } from './nodes/generativeFill/generativeFillUtils/ImageHandler';
+import './StyleProviderQuiz.scss';
+
+export namespace styleProviderQuiz {
+ enum quizMode {
+ SMART = 'smart',
+ NORMAL = 'normal',
+ NONE = 'none',
+ }
+
+ async function selectUrlToBase64(blob: Blob): Promise<string> {
+ try {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.readAsDataURL(blob);
+ reader.onloadend = () => resolve(reader.result as string);
+ reader.onerror = error => reject(error);
+ });
+ } catch (error) {
+ console.error('Error:', error);
+ throw error;
+ }
+ }
+ /**
+ * Creates label boxes over text on the image to be filled in.
+ * @param boxes
+ * @param texts
+ */
+ async function createBoxes(img: ImageBox, boxes: [[[number, number]]], texts: [string]) {
+ img.Document._quizBoxes = new List<Doc>([]);
+ for (let i = 0; i < boxes.length; i++) {
+ const coords = boxes[i] ? boxes[i] : [];
+ const width = coords[1][0] - coords[0][0];
+ const height = coords[2][1] - coords[0][1];
+ const text = texts[i];
+
+ const newCol = Docs.Create.LabelDocument({
+ _width: width,
+ _height: height,
+ _layout_fitWidth: true,
+ title: '',
+ });
+ const scaling = 1 / (img._props.NativeDimScaling?.() || 1);
+ newCol.x = coords[0][0] + NumCast(img.marqueeref.current?.left) * scaling;
+ newCol.y = coords[0][1] + NumCast(img.marqueeref.current?.top) * scaling;
+
+ newCol.zIndex = 1000;
+ newCol.forceActive = true;
+ newCol.quiz = text;
+ newCol[DocData].textTransform = 'none';
+ Doc.AddDocToList(img.Document, '_quizBoxes', newCol);
+ img.addDocument(newCol);
+ // img._loading = false;
+ }
+ }
+
+ /**
+ * Calls backend to find any text on an image. Gets the text and the
+ * coordinates of the text and creates label boxes at those locations.
+ * @param quiz
+ * @param i
+ */
+ async function pushInfo(imgBox: ImageBox, quiz: quizMode, i?: string) {
+ imgBox.Document._quizMode = quiz;
+ const quizBoxes = DocListCast(imgBox.Document.quizBoxes);
+ if (!quizBoxes.length) {
+ imgBox.Loading = true;
+
+ const img = {
+ file: i ? i : imgBox.paths[0],
+ drag: i ? 'drag' : 'full',
+ smart: quiz,
+ };
+ const response = await axios.post('http://localhost:105/labels/', img, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ if (response.data['boxes'].length != 0) {
+ createBoxes(imgBox, response.data['boxes'], response.data['text']);
+ } else {
+ imgBox.Loading = false;
+ }
+ } else quizBoxes.forEach(box => (box.hidden = false));
+ }
+
+ async function createCanvas(img: ImageBox) {
+ const canvas = document.createElement('canvas');
+ const scaling = 1 / (img._props.NativeDimScaling?.() || 1);
+ const w = AnchorMenu.Instance.marqueeWidth * scaling;
+ const h = AnchorMenu.Instance.marqueeHeight * scaling;
+ canvas.width = w;
+ canvas.height = h;
+ const ctx = canvas.getContext('2d'); // draw image to canvas. scale to target dimensions
+ if (ctx) {
+ img.imageRef && ctx.drawImage(img.imageRef, NumCast(img.marqueeref.current?.left) * scaling, NumCast(img.marqueeref.current?.top) * scaling, w, h, 0, 0, w, h);
+ }
+ const blob = await ImageUtility.canvasToBlob(canvas);
+ return selectUrlToBase64(blob);
+ }
+ /**
+ * Create flashcards from an image.
+ */
+ async function getImageDesc(img: ImageBox) {
+ img.Loading = true;
+ try {
+ const hrefBase64 = await createCanvas(img);
+ const response = await gptImageLabel(hrefBase64, 'Make flashcards out of this image with each question and answer labeled as "question" and "answer". Do not label each flashcard and do not include asterisks: ');
+ AnchorMenu.Instance.transferToFlashcard(response, NumCast(img.layoutDoc['x']), NumCast(img.layoutDoc['y']));
+ } catch (error) {
+ console.log('Error', error);
+ }
+ img.Loading = false;
+ }
+
+ /**
+ * Calls the createCanvas and pushInfo methods to convert the
+ * image to a form that can be passed to GPT and find the locations
+ * of the text.
+ */
+ async function makeLabels(img: ImageBox) {
+ try {
+ const hrefBase64 = await createCanvas(img);
+ pushInfo(img, quizMode.NORMAL, hrefBase64);
+ } catch (error) {
+ console.log('Error', error);
+ }
+ }
+
+ /**
+ * Determines whether two words should be considered
+ * the same, allowing minor typos.
+ * @param str1
+ * @param str2
+ * @returns
+ */
+ function levenshteinDistance(str1: string, str2: string) {
+ const len1 = str1.length;
+ const len2 = str2.length;
+ const dp = Array.from(Array(len1 + 1), () => Array(len2 + 1).fill(0));
+
+ if (len1 === 0) return len2;
+ if (len2 === 0) return len1;
+
+ for (let i = 0; i <= len1; i++) dp[i][0] = i;
+ for (let j = 0; j <= len2; j++) dp[0][j] = j;
+
+ for (let i = 1; i <= len1; i++) {
+ for (let j = 1; j <= len2; j++) {
+ const cost = str1[i - 1] === str2[j - 1] ? 0 : 1;
+ dp[i][j] = Math.min(
+ dp[i - 1][j] + 1, // deletion
+ dp[i][j - 1] + 1, // insertion
+ dp[i - 1][j - 1] + cost // substitution
+ );
+ }
+ }
+
+ return dp[len1][len2];
+ }
+
+ /**
+ * Different algorithm for determining string similarity.
+ * @param str1
+ * @param str2
+ * @returns
+ */
+ function jaccardSimilarity(str1: string, str2: string) {
+ const set1 = new Set(str1.split(' '));
+ const set2 = new Set(str2.split(' '));
+
+ const intersection = new Set([...set1].filter(x => set2.has(x)));
+ const union = new Set([...set1, ...set2]);
+
+ return intersection.size / union.size;
+ }
+
+ /**
+ * Averages the jaccardSimilarity and levenshteinDistance scores
+ * to determine string similarity for the labelboxes answers and
+ * the users response.
+ * @param str1
+ * @param str2
+ * @returns
+ */
+ function stringSimilarity(str1: string, str2: string) {
+ const levenshteinDist = levenshteinDistance(str1, str2);
+ const levenshteinScore = 1 - levenshteinDist / Math.max(str1.length, str2.length);
+
+ const jaccardScore = jaccardSimilarity(str1, str2);
+
+ // Combine the scores with a higher weight on Jaccard similarity
+ return 0.5 * levenshteinScore + 0.5 * jaccardScore;
+ }
+ /**
+ * Returns whether two strings are similar
+ * @param input
+ * @param target
+ * @returns
+ */
+ function compareWords(input: string, target: string) {
+ const distance = stringSimilarity(input.toLowerCase(), target.toLowerCase());
+ return distance >= 0.7;
+ }
+
+ /**
+ * GPT returns a hex color for what color the label box should be based on
+ * the correctness of the users answer.
+ * @param inputString
+ * @returns
+ */
+ function extractHexAndSentences(inputString: string) {
+ // Regular expression to match a hexadecimal number at the beginning followed by a period and sentences
+ const regex = /^#([0-9A-Fa-f]+)\.\s*(.+)$/s;
+ const match = inputString.match(regex);
+
+ if (match) {
+ const hexNumber = match[1];
+ const sentences = match[2].trim();
+ return { hexNumber, sentences };
+ } else {
+ return { error: 'The input string does not match the expected format.' };
+ }
+ }
+ function imgQuizBoxes(img: ImageBox) {
+ return DocListCast(img.Document.quizBoxes);
+ }
+ function imgQuizMode(img: ImageBox) {
+ return StrCast(img.Document._quizMode);
+ }
+
+ /**
+ * Check whether the contents of the label boxes on an image are correct.
+ */
+ function check(img: ImageBox) {
+ //this._loading = true;
+ imgQuizBoxes(img).forEach(async doc => {
+ const input = StrCast(doc[DocData].title);
+ if (imgQuizMode(img) == quizMode.SMART && input) {
+ const questionText = 'Question: What was labeled in this image?';
+ const rubricText = ' Rubric: ' + StrCast(doc.quiz);
+ const queryText =
+ questionText +
+ ' UserAnswer: ' +
+ input +
+ '. ' +
+ rubricText +
+ '. One sentence and evaluate based on meaning, not wording. Provide a hex color at the beginning with a period after it on a scale of green (minor details missed) to red (big error) for how correct the answer is. Example: "#FFFFFF. Pasta is delicious."';
+ const response = await gptAPICall(queryText, GPTCallType.QUIZ);
+ const hexSent = extractHexAndSentences(response);
+ doc.quiz = hexSent.sentences?.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric');
+ doc.backgroundColor = '#' + hexSent.hexNumber;
+ } else {
+ const match = compareWords(input, StrCast(doc.quiz).trim());
+ doc.backgroundColor = match ? '#11c249' : '#eb2d2d';
+ }
+ });
+ //this._loading = false;
+ }
+
+ function redo(img: ImageBox) {
+ imgQuizBoxes(img).forEach(doc => {
+ doc[DocData].title = '';
+ doc.backgroundColor = '#e4e4e4';
+ });
+ }
+
+ /**
+ * Get rid of all the label boxes on the images.
+ */
+ function exitQuizMode(img: ImageBox) {
+ img.Document._quizMode = quizMode.NONE;
+ DocListCast(img.Document._quizBoxes).forEach(box => {
+ box.hidden = true;
+ });
+ }
+
+ export function quizStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & DocumentViewProps>, property: string) {
+ const editLabelAnswer = (qdoc: Doc) => {
+ // when click the pencil, set the text to the quiz content. when click off, set the quiz text to that and set textbox to nothing.
+ if (!qdoc._editLabel) {
+ qdoc.title = StrCast(qdoc.quiz);
+ } else {
+ qdoc.quiz = StrCast(qdoc.title);
+ qdoc.title = '';
+ }
+ qdoc._editLabel = !qdoc._editLabel;
+ };
+ const editAnswer = (qdoc: Opt<Doc>) => {
+ return (
+ <Tooltip
+ title={
+ <div className="answer-tooltip" style={{ minWidth: '150px' }}>
+ {qdoc?._editLabel ? 'save' : 'edit correct answer'}
+ </div>
+ }>
+ <div className="answer-tool-tip" onPointerDown={e => setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => qdoc && editLabelAnswer(qdoc))}>
+ <FontAwesomeIcon className="edit-icon" color={qdoc?._editLabel ? 'white' : 'black'} icon="pencil" size="sm" />
+ </div>
+ </Tooltip>
+ );
+ };
+ const answerIcon = (qdoc: Opt<Doc>) => {
+ return (
+ <Tooltip
+ title={
+ <div className="answer-tooltip" style={{ minWidth: '150px' }}>
+ {StrCast(qdoc?.quiz ?? '')}
+ </div>
+ }>
+ <div className="answer-tool-tip">
+ <FontAwesomeIcon className="q-icon" icon="circle" color="white" />
+ <FontAwesomeIcon className="answer-icon" icon="question" />
+ </div>
+ </Tooltip>
+ );
+ };
+ const checkIcon = (img: ImageBox) => (
+ <Tooltip title={<div className="dash-tooltip">Check</div>}>
+ <div className="check-icon" onPointerDown={() => check(img)}>
+ <FontAwesomeIcon icon="circle-check" size="lg" />
+ </div>
+ </Tooltip>
+ );
+ const redoIcon = (img: ImageBox) => (
+ <Tooltip title={<div className="dash-tooltip">Redo</div>}>
+ <div className="redo-icon" onPointerDown={() => redo(img)}>
+ <FontAwesomeIcon icon="redo-alt" size="lg" />
+ </div>
+ </Tooltip>
+ );
+
+ const imgBox = props?.DocumentView?.().ComponentView as ImageBox;
+ switch (property) {
+ case StyleProp.Decorations:
+ {
+ if (doc?.quiz) {
+ // this should only be set on Labels that are part of an image quiz
+ return (
+ <>
+ {editAnswer(doc?.[DocData])}
+ {answerIcon(doc)}
+ </>
+ );
+ } else if (imgBox?.Document._quizMode && imgBox.Document._quizMode !== quizMode.NONE) {
+ return (
+ <>
+ {checkIcon(imgBox)}
+ {redoIcon(imgBox)}
+ </>
+ );
+ }
+ }
+ break;
+ case StyleProp.ContextMenuItems:
+ if (imgBox) {
+ const quizes: ContextMenuProps[] = [];
+ quizes.push({
+ description: 'Smart Check',
+ event: doc?.quizMode == quizMode.NONE ? () => pushInfo(imgBox, quizMode.SMART) : () => exitQuizMode(imgBox),
+ icon: 'pen-to-square',
+ });
+ quizes.push({
+ description: 'Normal',
+ event: doc?.quizMode == quizMode.NONE ? () => pushInfo(imgBox, quizMode.NORMAL) : () => exitQuizMode(imgBox),
+ icon: 'pencil',
+ });
+ ContextMenu.Instance?.addItem({ description: 'Quiz Mode', subitems: quizes, icon: 'file-pen' });
+ }
+ break;
+ case StyleProp.AnchorMenuItems:
+ if (imgBox) {
+ AnchorMenu.Instance.gptFlashcards = () => getImageDesc(imgBox);
+ AnchorMenu.Instance.makeLabels = () => makeLabels(props?.DocumentView?.().ComponentView as ImageBox);
+ }
+ }
+ return undefined;
+ }
+}
diff --git a/src/client/views/collections/CollectionCardDeckView.scss b/src/client/views/collections/CollectionCardDeckView.scss
index d1731c244..0637cd4e9 100644
--- a/src/client/views/collections/CollectionCardDeckView.scss
+++ b/src/client/views/collections/CollectionCardDeckView.scss
@@ -6,6 +6,7 @@
position: relative;
background-color: white;
overflow: hidden;
+ display: flex;
button {
border-radius: 50%;
diff --git a/src/client/views/collections/CollectionCardDeckView.tsx b/src/client/views/collections/CollectionCardDeckView.tsx
index 3122aa587..286df30aa 100644
--- a/src/client/views/collections/CollectionCardDeckView.tsx
+++ b/src/client/views/collections/CollectionCardDeckView.tsx
@@ -1,11 +1,14 @@
import { IReactionDisposer, ObservableMap, action, computed, makeObservable, observable, reaction } from 'mobx';
import { observer } from 'mobx-react';
+import { computedFn } from 'mobx-utils';
import * as React from 'react';
-import { ClientUtils, DashColor, returnFalse, returnZero } from '../../../ClientUtils';
+import { ClientUtils, DashColor, imageUrlToBase64, returnFalse, returnNever, returnZero } from '../../../ClientUtils';
+import { emptyFunction } from '../../../Utils';
import { Doc } from '../../../fields/Doc';
import { DocData } from '../../../fields/DocSymbols';
import { Id } from '../../../fields/FieldSymbols';
import { List } from '../../../fields/List';
+import { ScriptField } from '../../../fields/ScriptField';
import { BoolCast, DateCast, DocCast, NumCast, RTFCast, ScriptCast, StrCast } from '../../../fields/Types';
import { URLField } from '../../../fields/URLField';
import { gptImageLabel } from '../../apis/gpt/GPT';
@@ -17,11 +20,10 @@ import { Transform } from '../../util/Transform';
import { undoable } from '../../util/UndoManager';
import { StyleProp } from '../StyleProp';
import { TagItem } from '../TagsView';
-import { DocumentView } from '../nodes/DocumentView';
+import { DocumentView, DocumentViewProps } from '../nodes/DocumentView';
import { GPTPopup, GPTPopupMode } from '../pdf/GPTPopup/GPTPopup';
import './CollectionCardDeckView.scss';
import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView';
-import { computedFn } from 'mobx-utils';
enum cardSortings {
Time = 'time',
@@ -45,29 +47,14 @@ export class CollectionCardView extends CollectionSubView() {
private _disposers: { [key: string]: IReactionDisposer } = {};
private _textToDoc = new Map<string, Doc>();
private _dropped = false; // set when a card doc has just moved and the drop method has been called - prevents the pointerUp method from hiding doc decorations (which needs to be done when clicking on a card to animate it to front/center)
+ private _clickScript = () => ScriptField.MakeScript('scriptContext._curDoc=this', { scriptContext: 'any' })!;
@observable _forceChildXf = 0;
@observable _hoveredNodeIndex = -1;
@observable _docRefs = new ObservableMap<Doc, DocumentView>();
@observable _maxRowCount = 10;
@observable _docDraggedIndex: number = -1;
-
- static imageUrlToBase64 = async (imageUrl: string): Promise<string> => {
- try {
- const response = await fetch(imageUrl);
- const blob = await response.blob();
-
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.readAsDataURL(blob);
- reader.onloadend = () => resolve(reader.result as string);
- reader.onerror = error => reject(error);
- });
- } catch (error) {
- console.error('Error:', error);
- throw error;
- }
- };
+ @observable _curDoc: Doc | undefined = undefined;
constructor(props: SubCollectionViewProps) {
super(props);
@@ -133,25 +120,25 @@ export class CollectionCardView extends CollectionSubView() {
/**
* The child documents to be rendered-- everything other than ink/link docs (which are marks as being svg's)
*/
- @computed get childDocsWithoutLinks() {
- return this.childDocs.filter(l => !l.layout_isSvg);
+ @computed get childCards() {
+ return this.childLayoutPairs.filter(pair => !pair.layout.layout_isSvg);
}
/**
* how much to scale down the contents of the view so that everything will fit
*/
@computed get fitContentScale() {
- const length = Math.min(this.childDocsWithoutLinks.length, this._maxRowCount);
+ const length = Math.min(this.childCards.length, this._maxRowCount);
return (this.childPanelWidth() * length) / this._props.PanelWidth();
}
/**
* When in quiz mode, randomly selects a document
*/
- quizMode = () => {
+ quizMode = action(() => {
const randomIndex = Math.floor(Math.random() * this.childDocs.length);
- DocumentView.getDocumentView(this.childDocs[randomIndex])?.select(false);
- };
+ this._curDoc = this.childDocs[randomIndex];
+ });
/**
* Number of rows of cards to be rendered
@@ -294,7 +281,12 @@ export class CollectionCardView extends CollectionSubView() {
);
@computed get sortedDocs() {
- return this.sort(this.childDocsWithoutLinks, this.cardSort, BoolCast(this.Document.cardSort_isDesc), this._docDraggedIndex);
+ return this.sort(
+ this.childCards.map(card => card.layout),
+ this.cardSort,
+ BoolCast(this.Document.cardSort_isDesc),
+ this._docDraggedIndex
+ );
}
/**
@@ -344,14 +336,16 @@ export class CollectionCardView extends CollectionSubView() {
return docs;
};
- 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._curDoc === doc
+ ? true
+ : this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false
+ ? false
+ : undefined
+ ); // prettier-ignore
displayDoc = (doc: Doc, screenToLocalTransform: () => Transform) => (
<DocumentView
@@ -370,11 +364,14 @@ export class CollectionCardView extends CollectionSubView() {
isDocumentActive={this._props.childDocumentsActive?.() || this.Document._childDocumentsActive ? this._props.isDocumentActive : this.isContentActive}
PanelWidth={this.childPanelWidth}
PanelHeight={this.childPanelHeight}
+ waitForDoubleClickToClick={returnNever}
+ scriptContext={this}
+ onClickScript={this._curDoc === doc ? undefined : this._clickScript}
dontCenter="y" // Don't center it vertically, because the grid it's in is already doing that and we don't want to do it twice.
dragAction={(this.Document.childDragAction ?? this._props.childDragAction) as dropActionType}
showTags={BoolCast(this.layoutDoc.showChildTags)}
whenChildContentsActiveChanged={this._props.whenChildContentsActiveChanged}
- isContentActive={this.isChildContentActive}
+ isContentActive={this.isChildContentActive(doc)}
dontHideOnDrag
/>
);
@@ -442,12 +439,14 @@ export class CollectionCardView extends CollectionSubView() {
default: return StrCast(doc.title);
} // prettier-ignore
};
- const docTextPromises = this.childDocsWithoutLinks.map(async doc => {
- const docText = (await docToText(doc)) ?? '';
- doc.gptInputText = docText;
- this._textToDoc.set(docText.replace(/\n/g, ' ').trim(), doc);
- return `======${docText.replace(/\n/g, ' ').trim()}======`;
- });
+ const docTextPromises = this.childCards
+ .map(pair => pair.layout)
+ .map(async doc => {
+ const docText = (await docToText(doc)) ?? '';
+ doc.gptInputText = docText;
+ this._textToDoc.set(docText.replace(/\n/g, ' ').trim(), doc);
+ return `======${docText.replace(/\n/g, ' ').trim()}======`;
+ });
return Promise.all<string>(docTextPromises);
};
@@ -462,8 +461,8 @@ export class CollectionCardView extends CollectionSubView() {
const hrefParts = href.split('.');
const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`;
try {
- const hrefBase64 = await CollectionCardView.imageUrlToBase64(hrefComplete);
- const response = await gptImageLabel(hrefBase64);
+ const hrefBase64 = await imageUrlToBase64(hrefComplete);
+ const response = await gptImageLabel(hrefBase64, 'Give three to five labels to describe this image.');
image[DocData].description = response.trim();
return response; // Return the response from gptImageLabel
} catch (error) {
@@ -561,7 +560,7 @@ export class CollectionCardView extends CollectionSubView() {
cardPointerUp = action((doc: Doc) => {
// if a card doc has just moved, or a card is selected and in front, then ignore this event
- if (this.isSelected(doc) || this._dropped) {
+ if (this._curDoc === doc || this._dropped) {
this._dropped = false;
} else {
// otherwise, turn off documentDecorations becase we're in a selection transition and want to avoid artifacts.
@@ -581,21 +580,16 @@ export class CollectionCardView extends CollectionSubView() {
* Actually renders all the cards
*/
@computed get renderCards() {
- if (!this.childDocsWithoutLinks.length) {
- return (
- <span className="no-card-span" style={{ width: ` ${this._props.PanelWidth()}px`, height: ` ${this._props.PanelHeight()}px` }}>
- Sorry ! There are no cards in this group
- </span>
- );
+ if (!this.childCards.length) {
+ return null;
}
// Map sorted documents to their rendered components
return this.sortedDocs.map((doc, index) => {
const calcRowIndex = this.overflowIndexCalc(index);
const amCards = this.overflowAmCardsCalc(index);
- const view = DocumentView.getDocumentView(doc, this.DocumentView?.());
- const childScreenToLocal = this.childScreenToLocal(doc, index, calcRowIndex, !!view?.IsContentActive, amCards);
+ const childScreenToLocal = this.childScreenToLocal(doc, index, calcRowIndex, doc === this._curDoc, amCards);
const translateIfSelected = () => {
const indexInRow = index % this._maxRowCount;
@@ -610,15 +604,15 @@ export class CollectionCardView extends CollectionSubView() {
return (
<div
key={doc[Id]}
- className={`card-item${view?.IsContentActive ? '-active' : this.isAnyChildContentActive() ? '-inactive' : ''}`}
+ className={`card-item${doc === this._curDoc ? '-active' : this.isAnyChildContentActive() ? '-inactive' : ''}`}
onPointerUp={() => this.cardPointerUp(doc)}
style={{
width: this.childPanelWidth(),
height: 'max-content',
- transform: `translateY(${this.calculateTranslateY(!!view?.IsContentActive, index, amCards, calcRowIndex)}px)
- translateX(calc(${view?.IsContentActive ? translateIfSelected() : 0}% + ${this.translateOverflowX(index, amCards)}px))
- rotate(${!view?.IsContentActive ? this.rotate(amCards, calcRowIndex) : 0}deg)
- scale(${view?.IsContentActive ? `${Math.min(hscale, vscale) * 100}%` : this._hoveredNodeIndex === index ? 1.1 : 1})`,
+ transform: `translateY(${this.calculateTranslateY(doc === this._curDoc, index, amCards, calcRowIndex)}px)
+ translateX(calc(${doc === this._curDoc ? translateIfSelected() : 0}% + ${this.translateOverflowX(index, amCards)}px))
+ rotate(${doc !== this._curDoc ? this.rotate(amCards, calcRowIndex) : 0}deg)
+ scale(${doc === this._curDoc ? `${Math.min(hscale, vscale) * 100}%` : this._hoveredNodeIndex === index ? 1.1 : 1})`,
}} // prettier-ignore
onPointerEnter={() => this.setHoveredNodeIndex(index)}
onPointerLeave={() => this.setHoveredNodeIndex(-1)}>
@@ -628,13 +622,35 @@ export class CollectionCardView extends CollectionSubView() {
});
}
- render() {
- const isEmpty = this.childDocsWithoutLinks.length === 0;
+ contentScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1);
+ docViewProps = (): DocumentViewProps => ({
+ ...this._props, //
+ isDocumentActive: this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive,
+ isContentActive: emptyFunction,
+ ScreenToLocalTransform: this.contentScreenToLocalXf,
+ });
+ answered = action(() => {
+ this._curDoc = this.curDoc ? this.filteredChildDocs()[(this.filteredChildDocs().findIndex(this.curDoc) + 1) % (this.filteredChildDocs().length || 1)] : undefined;
+ });
+ curDoc = () => this._curDoc;
+ render() {
+ const isEmpty = this.childCards.length === 0;
return (
<div
className="collectionCardView-outer"
ref={(ele: HTMLDivElement | null) => this.createDashEventsTarget(ele)}
+ onPointerDown={action(() => {
+ this._curDoc = undefined;
+ SnappingManager.SetHideDecorations(true);
+ setTimeout(
+ action(() => {
+ SnappingManager.SetHideDecorations(false);
+ this._forceChildXf++;
+ }),
+ 1000
+ );
+ })}
onPointerLeave={action(() => (this._docDraggedIndex = -1))}
onPointerMove={e => this.onPointerMove(...this._props.ScreenToLocalTransform().transformPoint(e.clientX, e.clientY))}
onDrop={this.onExternalDrop.bind(this)}
@@ -646,11 +662,13 @@ export class CollectionCardView extends CollectionSubView() {
className="card-wrapper"
style={{
...(!isEmpty && { transform: `scale(${1 / this.fitContentScale})` }),
- ...(!isEmpty && { height: `${100 * this.fitContentScale}%` }),
+ ...{ height: `${100 * (isEmpty ? 1 : this.fitContentScale)}%` },
+ ...{ width: `${100 * (isEmpty ? 1 : this.fitContentScale)}%` },
gridAutoRows: `${100 / this.numRows}%`,
}}>
{this.renderCards}
</div>
+ {this.flashCardUI(this.curDoc, this.docViewProps, this.answered)}
</div>
);
}
diff --git a/src/client/views/collections/CollectionCarousel3DView.scss b/src/client/views/collections/CollectionCarousel3DView.scss
index a556d0fa7..42e112906 100644
--- a/src/client/views/collections/CollectionCarousel3DView.scss
+++ b/src/client/views/collections/CollectionCarousel3DView.scss
@@ -4,6 +4,7 @@
position: relative;
background-color: white;
overflow: hidden;
+ display: flex;
}
.carousel-wrapper {
diff --git a/src/client/views/collections/CollectionCarousel3DView.tsx b/src/client/views/collections/CollectionCarousel3DView.tsx
index c5da8e037..f2ba90c78 100644
--- a/src/client/views/collections/CollectionCarousel3DView.tsx
+++ b/src/client/views/collections/CollectionCarousel3DView.tsx
@@ -6,31 +6,28 @@ import { returnZero } from '../../../ClientUtils';
import { Utils } from '../../../Utils';
import { Doc, DocListCast, Opt } from '../../../fields/Doc';
import { Id } from '../../../fields/FieldSymbols';
-import { DocCast, NumCast, ScriptCast, StrCast } from '../../../fields/Types';
+import { BoolCast, DocCast, NumCast, ScriptCast, StrCast } from '../../../fields/Types';
import { DocumentType } from '../../documents/DocumentTypes';
import { DragManager } from '../../util/DragManager';
+import { Transform } from '../../util/Transform';
import { StyleProp } from '../StyleProp';
import { DocumentView } from '../nodes/DocumentView';
import { FocusViewOptions } from '../nodes/FocusViewOptions';
import './CollectionCarousel3DView.scss';
import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView';
-import { Transform } from '../../util/Transform';
// eslint-disable-next-line @typescript-eslint/no-require-imports
const { CAROUSEL3D_CENTER_SCALE, CAROUSEL3D_SIDE_SCALE, CAROUSEL3D_TOP } = require('../global/globalCssVariables.module.scss');
@observer
export class CollectionCarousel3DView extends CollectionSubView() {
- @computed get scrollSpeed() {
- return this.layoutDoc._autoScrollSpeed ? NumCast(this.layoutDoc._autoScrollSpeed) : 1000; // default scroll speed
- }
+ private _dropDisposer?: DragManager.DragDropDisposer;
+
constructor(props: SubCollectionViewProps) {
super(props);
makeObservable(this);
}
- private _dropDisposer?: DragManager.DragDropDisposer;
-
componentWillUnmount() {
this._dropDisposer?.();
}
@@ -42,8 +39,11 @@ export class CollectionCarousel3DView extends CollectionSubView() {
}
};
+ @computed get scrollSpeed() {
+ return this.layoutDoc._autoScrollSpeed ? NumCast(this.layoutDoc._autoScrollSpeed) : 1000; // default scroll speed
+ }
@computed get carouselItems() {
- return this.childLayoutPairs.filter(pair => pair.layout.type !== DocumentType.LINK);
+ return this.childLayoutPairs.filter(pair => !pair.layout.layout_isSvg);
}
centerScale = Number(CAROUSEL3D_CENTER_SCALE);
@@ -52,23 +52,25 @@ export class CollectionCarousel3DView extends CollectionSubView() {
panelHeight = () => this._props.PanelHeight() * this.sideScale;
onChildDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick);
isContentActive = () => this._props.isSelected() || this._props.isContentActive() || this._props.isAnyChildContentActive();
- isChildContentActive = () => !!this.isContentActive();
+ 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
+ ? false
+ : undefined;
+ contentScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1);
childScreenLeftToLocal = () =>
- this._props
- .ScreenToLocalTransform()
- .scale(this._props.NativeDimScaling?.() || 1)
+ this.contentScreenToLocalXf()
.translate(-(this.panelWidth() - this.panelWidth() * this.sideScale) / 2, -(this.panelHeight() - this.panelHeight() * this.sideScale) / 2 - (Number(CAROUSEL3D_TOP) / 100) * this._props.PanelHeight())
.scale(1 / this.sideScale);
childScreenRightToLocal = () =>
- this._props
- .ScreenToLocalTransform()
- .scale(this._props.NativeDimScaling?.() || 1)
+ this.contentScreenToLocalXf()
.translate(-2 * this.panelWidth() - (this.panelWidth() - this.panelWidth() * this.sideScale) / 2, -(this.panelHeight() - this.panelHeight() * this.sideScale) / 2 - (Number(CAROUSEL3D_TOP) / 100) * this._props.PanelHeight())
.scale(1 / this.sideScale);
childCenterScreenToLocal = () =>
- this._props
- .ScreenToLocalTransform()
- .scale(this._props.NativeDimScaling?.() || 1)
+ this.contentScreenToLocalXf()
.translate(
-this.panelWidth() + ((this.centerScale - 1) * this.panelWidth()) / 2, // Focused Doc is shifted right by 1/3 panel width then left by increased size percent of center * 1/2 * panel width / 3
-((Number(CAROUSEL3D_TOP) / 100) * this._props.PanelHeight()) + ((this.centerScale - 1) * this.panelHeight()) / 2
@@ -87,11 +89,11 @@ export class CollectionCarousel3DView extends CollectionSubView() {
@computed get content() {
const currentIndex = NumCast(this.layoutDoc._carousel_index);
- const displayDoc = (childPair: { layout: Doc; data: Doc }, dxf: () => Transform) => (
+ const displayDoc = (child: Doc, dxf: () => Transform) => (
<DocumentView
{...this._props}
- Document={childPair.layout}
- TemplateDataDocument={childPair.data}
+ Document={child}
+ TemplateDataDocument={undefined}
// suppressSetHeight={true}
NativeWidth={returnZero}
NativeHeight={returnZero}
@@ -110,16 +112,16 @@ export class CollectionCarousel3DView extends CollectionSubView() {
/>
);
- return this.carouselItems.map((childPair, index) => (
- <div key={childPair.layout[Id]} className={`collectionCarousel3DView-item${index === currentIndex ? '-active' : ''} ${index}`} style={{ width: this.panelWidth() }}>
- {displayDoc(childPair, index < currentIndex ? this.childScreenLeftToLocal : index === currentIndex ? this.childCenterScreenToLocal : this.childScreenRightToLocal)}
+ return this.carouselItems.map((child, index) => (
+ <div key={child.layout[Id]} className={`collectionCarousel3DView-item${index === currentIndex ? '-active' : ''} ${index}`} style={{ width: this.panelWidth() }}>
+ {displayDoc(child.layout, index < currentIndex ? this.childScreenLeftToLocal : index === currentIndex ? this.childCenterScreenToLocal : this.childScreenRightToLocal)}
</div>
));
}
changeSlide = (direction: number) => {
DocumentView.DeselectAll();
- this.layoutDoc._carousel_index = (NumCast(this.layoutDoc._carousel_index) + direction + this.carouselItems.length) % this.carouselItems.length;
+ this.layoutDoc._carousel_index = !this.curDoc() ? 0 : (NumCast(this.layoutDoc._carousel_index) + direction + this.carouselItems.length) % (this.carouselItems.length || 1);
};
onArrowClick = (direction: number) => {
@@ -192,6 +194,14 @@ export class CollectionCarousel3DView extends CollectionSubView() {
return this.panelWidth() * (1 - index);
}
+ curDoc = () => this.carouselItems[NumCast(this.layoutDoc._carousel_index)]?.layout;
+ answered = (correct: boolean) => (!correct || !this.curDoc()) && this.changeSlide(1);
+ docViewProps = () => ({
+ ...this._props, //
+ isDocumentActive: this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive,
+ isContentActive: this.isChildContentActive,
+ ScreenToLocalTransform: this.contentScreenToLocalXf,
+ });
render() {
return (
<div
@@ -205,7 +215,10 @@ export class CollectionCarousel3DView extends CollectionSubView() {
{this.content}
</div>
{this.buttons}
- <div className="dot-bar">{this.dots}</div>
+ <div className="dot-bar" style={{ transform: `scale(${this.uiBtnScaling})` }}>
+ {this.dots}
+ </div>
+ {this.flashCardUI(this.curDoc, this.docViewProps, this.answered)}
</div>
);
}
diff --git a/src/client/views/collections/CollectionCarouselView.scss b/src/client/views/collections/CollectionCarouselView.scss
index 01b20d6d3..544b3e262 100644
--- a/src/client/views/collections/CollectionCarouselView.scss
+++ b/src/client/views/collections/CollectionCarouselView.scss
@@ -1,5 +1,8 @@
.collectionCarouselView-outer {
height: 100%;
+ position: relative;
+ overflow: hidden;
+ display: flex;
.collectionCarouselView-caption {
height: 50;
display: inline-block;
@@ -10,15 +13,11 @@
display: inline-block;
width: 100%;
user-select: none;
+ position: absolute;
+ top: 0;
+ left: 0;
}
}
-.collectionCarouselView-addFlashcards {
- justify-content: center;
- align-items: center;
- height: 100%;
- z-index: -1;
- pointer-events: none;
-}
.collectionCarouselView-recentlyMissed {
color: red;
z-index: 999;
@@ -28,15 +27,11 @@
pointer-events: none;
}
.carouselView-back,
-.carouselView-fwd,
-.carouselView-star,
-.carouselView-remove,
-.carouselView-check {
+.carouselView-fwd {
position: absolute;
display: flex;
- top: 42.5%;
width: 30;
- height: 15%;
+ height: 30;
align-items: center;
border-radius: 5px;
justify-content: center;
@@ -47,24 +42,15 @@
}
}
.carouselView-fwd {
- right: 20;
+ top: calc(50% - 15px);
+ right: 0;
+ transform-origin: right top;
}
.carouselView-back {
- left: 20;
-}
-.carouselView-star {
- top: 0;
- right: 20;
-}
-.carouselView-remove {
- top: 80%;
- left: 52%;
-}
-.carouselView-check {
- top: 80%;
- right: 52%;
+ top: calc(50% - 15px);
+ left: 0;
+ transform-origin: top left;
}
-
.carouselView-back:hover,
.carouselView-fwd:hover {
background: lightgray;
diff --git a/src/client/views/collections/CollectionCarouselView.tsx b/src/client/views/collections/CollectionCarouselView.tsx
index 8b3a699ed..aa447c7bf 100644
--- a/src/client/views/collections/CollectionCarouselView.tsx
+++ b/src/client/views/collections/CollectionCarouselView.tsx
@@ -1,64 +1,33 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
-import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx';
+import { action, computed, makeObservable, observable } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
import { StopEvent, returnOne, returnZero } from '../../../ClientUtils';
import { Doc, Opt } from '../../../fields/Doc';
import { BoolCast, DocCast, NumCast, ScriptCast, StrCast } from '../../../fields/Types';
-import { DocumentType } from '../../documents/DocumentTypes';
import { DragManager } from '../../util/DragManager';
-import { ContextMenu } from '../ContextMenu';
import { StyleProp } from '../StyleProp';
-import { TagItem } from '../TagsView';
import { DocumentView } from '../nodes/DocumentView';
import { FieldViewProps } from '../nodes/FieldView';
import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox';
import './CollectionCarouselView.scss';
import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView';
-enum cardMode {
- PRACTICE = 'practice',
- STAR = 'star',
- QUIZ = 'quiz',
-}
-enum practiceVal {
- MISSED = 'missed',
- CORRECT = 'correct',
-}
@observer
export class CollectionCarouselView extends CollectionSubView() {
private _dropDisposer?: DragManager.DragDropDisposer;
- get practiceField() { return this.fieldKey + "_practice"; } // prettier-ignore
- get starField() { return "#star"; } // prettier-ignore
_fadeTimer: NodeJS.Timeout | undefined;
- _resetter: IReactionDisposer | undefined;
+ @observable _last_index = this.carouselIndex;
+ @observable _last_opacity = 1;
constructor(props: SubCollectionViewProps) {
super(props);
makeObservable(this);
}
- @observable _last_index = this.carouselIndex;
- @observable _last_opacity = 1;
-
- componentDidMount() {
- this._resetter = reaction(
- // automatically reset practice fields when all cards have been marked as correct
- () => this.carouselItems.length,
- itemsCount => {
- if (this.layoutDoc.filterOp === cardMode.PRACTICE && !itemsCount) {
- this.layoutDoc.filterOp = undefined; // if all of the cards are correct, show all cards and exit practice mode
- this.carouselItems.forEach(item => { // reset all the practice values
- item[this.practiceField] = undefined;
- });
- }
- } // prettier-ignore
- );
- }
componentWillUnmount() {
this._dropDisposer?.();
- this._resetter?.();
}
protected createDashEventsTarget = (ele: HTMLDivElement | null) => {
@@ -68,30 +37,24 @@ export class CollectionCarouselView extends CollectionSubView() {
}
};
- @computed get marginX() { return NumCast(this.layoutDoc.caption_xMargin, 50); } // prettier-ignore
+ @computed get captionMarginX(){ return NumCast(this.layoutDoc.caption_xMargin, 50); } // prettier-ignore
@computed get carouselIndex() { return NumCast(this.layoutDoc._carousel_index) % this.carouselItems.length; } // prettier-ignore
- @computed get carouselItems() {
- return this.childDocs
- .filter(doc => doc.type !== DocumentType.LINK)
- .filter(doc => {
- switch (StrCast(this.layoutDoc.filterOp)) {
- case cardMode.STAR: return !!doc[this.starField]; // show only cards that are starred
- case cardMode.PRACTICE: return doc[this.practiceField] !== practiceVal.CORRECT;// show only cards that aren't marked as correct
- default: return true;
- } // prettier-ignore
- });
- }
+ @computed get carouselItems() { return this.childLayoutPairs.filter(pair => !pair.layout.layout_isSvg); } // prettier-ignore
+ /**
+ * Move forward or backward the specified number of Docs
+ * @param dir signed number indicating Docs to move forward or backward
+ */
move = action((dir: number) => {
this._last_index = this.carouselIndex;
- this.layoutDoc._carousel_index = (this.carouselIndex + dir + this.carouselItems.length) % this.carouselItems.length;
+ this.layoutDoc._carousel_index = this.carouselItems.length ? (this.carouselIndex + dir + this.carouselItems.length) % this.carouselItems.length : 0;
});
/**
* Goes to the next Doc in the stack subject to the currently selected filter option.
*/
- advance = (e: React.MouseEvent) => {
- e.stopPropagation();
+ advance = (e?: React.MouseEvent) => {
+ e?.stopPropagation();
this.move(1);
};
@@ -103,48 +66,23 @@ export class CollectionCarouselView extends CollectionSubView() {
this.move(-1);
};
- /*
- * Stars the document when the star button is pressed.
- */
- star = (e: React.MouseEvent) => {
- e.stopPropagation();
- const curDoc = this.carouselItems[this.carouselIndex];
- if (curDoc) {
- if (TagItem.docHasTag(curDoc, this.starField)) TagItem.removeTagFromDoc(curDoc, this.starField);
- else TagItem.addTagToDoc(curDoc, this.starField);
- }
- };
-
- /*
- * Sets a flashcard to either missed or correct depending on if they got the question right in practice mode.
- */
- setPracticeVal = (e: React.MouseEvent, val: string) => {
- e.stopPropagation();
- const curDoc = this.carouselItems[this.carouselIndex];
- curDoc && (curDoc[this.practiceField] = val);
- this.advance(e);
- };
+ curDoc = () => this.carouselItems[this.carouselIndex]?.layout;
captionStyleProvider = (doc: Doc | undefined, captionProps: Opt<FieldViewProps>, property: string) => {
// first look for properties on the document in the carousel, then fallback to properties on the container
const childValue = doc?.['caption_' + property] ? this._props.styleProvider?.(doc, captionProps, property) : undefined;
return childValue ?? this._props.styleProvider?.(this.layoutDoc, captionProps, property);
};
- panelHeight = () => this._props.PanelHeight() - (StrCast(this.layoutDoc._layout_showCaption) ? 50 : 0);
+ 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);
onContentDoubleClick = () => ScriptCast(this.layoutDoc.onChildDoubleClick);
onContentClick = () => ScriptCast(this.layoutDoc.onChildClick);
- captionWidth = () => this._props.PanelWidth() - 2 * this.marginX;
- specificMenu = (): void => {
- const cm = ContextMenu.Instance;
- const revealOptions = cm.findByDescription('Filter Flashcards');
- const revealItems = revealOptions?.subitems ?? [];
- revealItems.push({description: 'All', event: () => {this.layoutDoc.filterOp = undefined;}, icon: 'layer-group',}); // prettier-ignore
- revealItems.push({description: 'Star', event: () => {this.layoutDoc.filterOp = cardMode.STAR;}, icon: 'star',}); // prettier-ignore
- revealItems.push({description: 'Practice Mode', event: () => {this.layoutDoc.filterOp = cardMode.PRACTICE;}, icon: 'check',}); // prettier-ignore
- revealItems.push({description: 'Quiz Cards', event: () => {this.layoutDoc.filterOp = cardMode.QUIZ;}, icon: 'pencil',}); // prettier-ignore
- !revealOptions && cm.addItem({ description: 'Filter Flashcards', addDivider: false, noexpand: true, subitems: revealItems, icon: 'layer-group' });
- };
-
+ 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);
isChildContentActive = () =>
this._props.isContentActive?.() === false
? false
@@ -153,9 +91,6 @@ export class CollectionCarouselView extends CollectionSubView() {
: this._props.childDocumentsActive?.() === false || this.Document.childDocumentsActive === false
? false
: undefined;
-
- childScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1);
-
renderDoc = (doc: Doc, showCaptions: boolean, overlayFunc?: (r: DocumentView | null) => void) => {
return (
<DocumentView
@@ -165,10 +100,10 @@ export class CollectionCarouselView extends CollectionSubView() {
NativeWidth={returnZero}
NativeHeight={returnZero}
fitWidth={this._props.childLayoutFitWidth}
+ hideFilterStatus={true}
showTags={BoolCast(this.layoutDoc.showChildTags)}
containerViewPath={this.childContainerViewPath}
setContentViewBox={undefined}
- ScreenToLocalTransform={this.childScreenToLocalXf}
onDoubleClickScript={this.onContentDoubleClick}
onClickScript={this.onContentClick}
isDocumentActive={this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive}
@@ -178,8 +113,13 @@ export class CollectionCarouselView extends CollectionSubView() {
LayoutTemplate={this._props.childLayoutTemplate}
LayoutTemplateString={this._props.childLayoutString}
TemplateDataDocument={DocCast(Doc.Layout(doc).resolvedDataDoc)}
- xPadding={35}
- PanelHeight={this.panelHeight}
+ childFilters={this.childDocFilters}
+ hideDecorations={BoolCast(this.layoutDoc.layout_hideDecorations)}
+ addDocument={this._props.addDocument}
+ ScreenToLocalTransform={this.contentScreenToLocalXf}
+ PanelWidth={this.contentPanelWidth}
+ PanelHeight={this.contentPanelHeight}
+ screenXPadding={this.screenXPadding}
/>
);
};
@@ -188,9 +128,9 @@ export class CollectionCarouselView extends CollectionSubView() {
*/
@computed get overlay() {
const fadeTime = 500;
- const lastDoc = this.carouselItems?.[this._last_index];
+ const lastDoc = this.carouselItems?.[this._last_index]?.layout;
return !lastDoc || this.carouselIndex === this._last_index ? null : (
- <div className="collectionCarouselView-image" style={{ opacity: this._last_opacity, position: 'absolute', top: 0, left: 0, transition: `opacity ${fadeTime}ms` }}>
+ <div className="collectionCarouselView-image" style={{ opacity: this._last_opacity, transition: `opacity ${fadeTime}ms` }}>
{this.renderDoc(
lastDoc,
false, // hide captions if the carousel is configured to show the captions
@@ -211,15 +151,18 @@ export class CollectionCarouselView extends CollectionSubView() {
</div>
);
}
+ @computed get renderedDoc() {
+ const carouselShowsCaptions = StrCast(this.layoutDoc._layout_showCaption);
+ return this.renderDoc(this.curDoc(), !!carouselShowsCaptions);
+ }
+
@computed get content() {
- const index = this.carouselIndex;
- const curDoc = this.carouselItems?.[index];
const captionProps = { ...this._props, NativeScaling: returnOne, PanelWidth: this.captionWidth, fieldKey: 'caption', setHeight: undefined, setContentView: undefined };
const carouselShowsCaptions = StrCast(this.layoutDoc._layout_showCaption);
- return !curDoc ? null : (
+ return !this.curDoc() ? null : (
<>
<div className="collectionCarouselView-image" key="image">
- {this.renderDoc(curDoc, !!carouselShowsCaptions)}
+ {this.renderedDoc}
{this.overlay}
</div>
{!carouselShowsCaptions ? null : (
@@ -229,68 +172,54 @@ export class CollectionCarouselView extends CollectionSubView() {
onWheel={StopEvent}
style={{
borderRadius: this._props.styleProvider?.(this.layoutDoc, captionProps, StyleProp.BorderRounding) as string,
- marginRight: this.marginX,
- marginLeft: this.marginX,
- width: `calc(100% - ${this.marginX * 2}px)`,
+ marginRight: this.captionMarginX,
+ marginLeft: this.captionMarginX,
+ width: `calc(100% - ${this.captionMarginX * 2}px)`,
}}>
- <FormattedTextBox key={index} xPadding={10} yPadding={10} {...captionProps} fieldKey={carouselShowsCaptions} styleProvider={this.captionStyleProvider} Document={curDoc} TemplateDataDocument={undefined} />
+ <FormattedTextBox xPadding={10} yPadding={10} {...captionProps} fieldKey={carouselShowsCaptions} styleProvider={this.captionStyleProvider} Document={this.curDoc()} TemplateDataDocument={undefined} />
</div>
)}
</>
);
}
- @computed get buttons() {
- if (!this.carouselItems?.[this.carouselIndex]) return null;
- return (
+
+ @computed get navButtons() {
+ return !this.curDoc() ? null : (
<>
- <div key="back" className="carouselView-back" onClick={this.goback}>
+ <div key="back" className="carouselView-back" style={{ transform: `scale(${this.uiBtnScaling})` }} onClick={this.goback}>
<FontAwesomeIcon icon="chevron-left" size="2x" />
</div>
- <div key="fwd" className="carouselView-fwd" onClick={this.advance}>
+ <div key="fwd" className="carouselView-fwd" style={{ transform: `scale(${this.uiBtnScaling})` }} onClick={this.advance}>
<FontAwesomeIcon icon="chevron-right" size="2x" />
</div>
- <div key="remove" className="carouselView-remove" onClick={e => this.setPracticeVal(e, practiceVal.MISSED)} style={{ visibility: this.layoutDoc.filterOp === cardMode.PRACTICE ? 'visible' : 'hidden' }}>
- <FontAwesomeIcon icon="xmark" color="red" size="1x" />
- </div>
- <div key="check" className="carouselView-check" onClick={e => this.setPracticeVal(e, practiceVal.CORRECT)} style={{ visibility: this.layoutDoc.filterOp === cardMode.PRACTICE ? 'visible' : 'hidden' }}>
- <FontAwesomeIcon icon="check" color="green" size="1x" />
- </div>
</>
);
}
- /**
- * Prompts user to add more flashcaards if they are in practice mode but there are no flashcards
- */
- renderAddFlashcards = () => <p
- className="collectionCarouselView-addFlashcards"
- style={{display: !this.carouselItems?.[this.carouselIndex] && this.layoutDoc.filterOp === cardMode.PRACTICE ? 'flex' : 'none'}}>
- Add flashcards!
- </p> // prettier-ignore
-
- /**
- * Displays message that a flashcard was recently missed if it had previously been marked as wrong.
- * */
- renderRecentlyMissed = () => <p
- className="collectionCarouselView-recentlyMissed"
- style={{display: this.carouselItems?.[this.carouselIndex]?.[this.practiceField] === practiceVal.MISSED ? 'block' : 'none'}}>
- Recently missed!
- </p> // prettier-ignore
+ docViewProps = () => ({
+ ...this._props, //
+ isDocumentActive: this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this._props.isContentActive,
+ isContentActive: this.isChildContentActive,
+ ScreenToLocalTransform: this.contentScreenToLocalXf,
+ });
+ answered = () => this.advance();
render() {
return (
<div
className="collectionCarouselView-outer"
ref={this.createDashEventsTarget}
- onContextMenu={this.specificMenu}
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.renderAddFlashcards()}
- {this.renderRecentlyMissed()}
- {this.Document._chromeHidden ? null : this.buttons}
+ {this.flashCardUI(this.curDoc, this.docViewProps, this.answered)}
+ {this.navButtons}
</div>
);
}
diff --git a/src/client/views/collections/CollectionSubView.tsx b/src/client/views/collections/CollectionSubView.tsx
index 581201a20..f85b0b433 100644
--- a/src/client/views/collections/CollectionSubView.tsx
+++ b/src/client/views/collections/CollectionSubView.tsx
@@ -9,7 +9,7 @@ import { Id } from '../../../fields/FieldSymbols';
import { List } from '../../../fields/List';
import { listSpec } from '../../../fields/Schema';
import { ScriptField } from '../../../fields/ScriptField';
-import { BoolCast, Cast, ScriptCast, StrCast } from '../../../fields/Types';
+import { BoolCast, Cast, NumCast, ScriptCast, StrCast } from '../../../fields/Types';
import { WebField } from '../../../fields/URLField';
import { GetEffectiveAcl, TraceMobx } from '../../../fields/util';
import { GestureUtils } from '../../../pen-gestures/GestureUtils';
@@ -25,7 +25,8 @@ import { SnappingManager } from '../../util/SnappingManager';
import { UndoManager } from '../../util/UndoManager';
import { ViewBoxBaseComponent } from '../DocComponent';
import { FieldViewProps } from '../nodes/FieldView';
-import { DocumentView } from '../nodes/DocumentView';
+import { DocumentView, DocumentViewProps } from '../nodes/DocumentView';
+import { FlashcardPracticeUI } from './FlashcardPracticeUI';
export interface CollectionViewProps extends React.PropsWithChildren<FieldViewProps> {
isAnnotationOverlay?: boolean; // is the collection an annotation overlay (eg an overlay on an image/video/etc)
@@ -119,7 +120,8 @@ export function CollectionSubView<X>() {
pair =>
// filter out any documents that have a proto that we don't have permissions to
!pair.layout?.hidden && pair.layout && (!pair.layout.proto || (pair.layout.proto instanceof Doc && GetEffectiveAcl(pair.layout.proto) !== AclPrivate))
- );
+ )
+ .filter(pair => !this._filterFunc?.(pair.layout!));
return validPairs.map(({ data, layout }) => ({ data: data as Doc, layout: layout! })); // this mapping is a bit of a hack to coerce types
}
/**
@@ -515,6 +517,48 @@ export function CollectionSubView<X>() {
alert('Document upload failed - possibly an unsupported file type.');
}
};
+
+ protected _sideBtnWidth = 35;
+ @observable _filterFunc: ((doc: Doc) => boolean) | undefined = undefined;
+ /**
+ * 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
+ /**
+ * 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
+ * size or a fraction of the collection view.
+ */
+ @computed get maxWidgetSize() { return Math.min(this._sideBtnWidth * this.contentScaling, 0.25 * NumCast(this.layoutDoc.width, 1)); } // prettier-ignore
+ /**
+ * This computes a scale factor for UI elements so that they shrink and grow as the collection does in screen space.
+ * Note, the scale factor does not allow for elements to grow larger than their native screen space size.
+ */
+ @computed get uiBtnScaling() { return this.maxWidgetSize / this._sideBtnWidth; } // prettier-ignore
+
+ screenXPadding = () => (this.uiBtnScaling * this._sideBtnWidth - NumCast(this.layoutDoc.xMargin)) / this._props.ScreenToLocalTransform().Scale;
+ filteredChildDocs = () => this.childLayoutPairs.map(pair => pair.layout);
+ childDocsFunc = () => this.childDocs;
+ @action setFilterFunc = (func?: (doc: Doc) => boolean) => { this._filterFunc = func; }; // prettier-ignore
+
+ public flashCardUI = (curDoc: () => Doc | undefined, docViewProps: () => DocumentViewProps, answered?: (correct: boolean) => void) => {
+ return (
+ <FlashcardPracticeUI
+ setFilterFunc={this.setFilterFunc}
+ fieldKey={this.fieldKey}
+ sideBtnWidth={this._sideBtnWidth}
+ allChildDocs={this.childDocsFunc}
+ filteredChildDocs={this.filteredChildDocs}
+ advance={answered}
+ curDoc={curDoc}
+ layoutDoc={this.layoutDoc}
+ uiBtnScaling={this.uiBtnScaling}
+ ScreenToLocalBoxXf={this.ScreenToLocalBoxXf}
+ renderDepth={this._props.renderDepth}
+ docViewProps={docViewProps}
+ />
+ );
+ };
}
return CollectionSubViewInternal;
diff --git a/src/client/views/collections/FlashcardPracticeUI.scss b/src/client/views/collections/FlashcardPracticeUI.scss
new file mode 100644
index 000000000..4ed27793d
--- /dev/null
+++ b/src/client/views/collections/FlashcardPracticeUI.scss
@@ -0,0 +1,72 @@
+.FlashcardPracticeUI-remove,
+.FlashcardPracticeUI-check {
+ position: absolute;
+ display: flex;
+ width: 30;
+ height: 30;
+ align-items: center;
+ border-radius: 5px;
+ justify-content: center;
+ color: rgba(255, 255, 255, 0.5);
+ background: rgba(0, 0, 0, 0.1);
+ &:hover {
+ color: white;
+ }
+}
+.FlashcardPracticeUI-practice {
+ position: absolute;
+ width: 100%;
+ pointer-events: none;
+ .FlashcardPracticeUI-remove {
+ left: 52%;
+ pointer-events: all;
+ }
+ .FlashcardPracticeUI-check {
+ right: 52%;
+ pointer-events: all;
+ }
+}
+.FlashcardPracticeUI-menu {
+ position: absolute;
+ flex-direction: column;
+ align-items: center;
+ display: flex;
+ top: 0px;
+ left: 0px;
+ width: 30;
+ transform-origin: top left;
+ border-radius: 5px;
+ color: rgba(255, 255, 255, 0.5);
+ pointer-events: all;
+ background: rgba(0, 0, 0, 0.1);
+ .FlashcardPracticeUI-practiceModes {
+ width: 100%;
+ display: flex;
+ flex-direction: column;
+ top: 0;
+ position: relative;
+ .FlashcardPracticeUI-quiz,
+ .FlashcardPracticeUI-practice {
+ position: relative;
+ display: flex;
+ height: 20px;
+ align-items: center;
+ margin: auto;
+ padding: 3px;
+ &:hover {
+ color: white;
+ }
+ & > svg {
+ height: 100%;
+ width: 100%;
+ }
+ }
+ }
+}
+.FlashcardPracticeUI-message {
+ z-index: 100;
+ position: relative;
+ margin: auto;
+ align-content: center;
+ width: max-content;
+}
diff --git a/src/client/views/collections/FlashcardPracticeUI.tsx b/src/client/views/collections/FlashcardPracticeUI.tsx
new file mode 100644
index 000000000..7697d308b
--- /dev/null
+++ b/src/client/views/collections/FlashcardPracticeUI.tsx
@@ -0,0 +1,201 @@
+import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
+import { Tooltip } from '@mui/material';
+import { computed, makeObservable } from 'mobx';
+import { observer } from 'mobx-react';
+import * as React from 'react';
+import { returnFalse, returnZero, setupMoveUpEvents } from '../../../ClientUtils';
+import { Doc, DocListCast } from '../../../fields/Doc';
+import { BoolCast, NumCast, StrCast } from '../../../fields/Types';
+import { Transform } from '../../util/Transform';
+import { ObservableReactComponent } from '../ObservableReactComponent';
+import { DocumentView, DocumentViewProps } from '../nodes/DocumentView';
+import './FlashcardPracticeUI.scss';
+import { IconButton, MultiToggle, Type } from 'browndash-components';
+import { SnappingManager } from '../../util/SnappingManager';
+import { IconProp } from '@fortawesome/fontawesome-svg-core';
+import { emptyFunction } from '../../../Utils';
+
+export enum practiceMode {
+ PRACTICE = 'practice',
+ QUIZ = 'quiz',
+}
+enum practiceVal {
+ MISSED = 'missed',
+ CORRECT = 'correct',
+}
+
+interface PracticeUIProps {
+ fieldKey: string;
+ layoutDoc: Doc;
+ filteredChildDocs: () => Doc[];
+ allChildDocs: () => Doc[];
+ curDoc: () => Doc | undefined;
+ advance?: (correct: boolean) => void;
+ renderDepth: number;
+ sideBtnWidth: number;
+ uiBtnScaling: number;
+ ScreenToLocalBoxXf: () => Transform;
+ docViewProps: () => DocumentViewProps;
+ setFilterFunc: (func?: (doc: Doc) => boolean) => void;
+}
+@observer
+export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProps> {
+ constructor(props: PracticeUIProps) {
+ super(props);
+ makeObservable(this);
+ this._props.setFilterFunc(this.tryFilterOut);
+ }
+
+ componentWillUnmount(): void {
+ this._props.setFilterFunc(undefined);
+ }
+
+ get practiceField() { return this._props.fieldKey + "_practice"; } // prettier-ignore
+
+ @computed get filterDoc() { return DocListCast(Doc.MyContextMenuBtns.data).find(doc => doc.title === 'Filter'); } // prettier-ignore
+ @computed get practiceMode() { return this._props.allChildDocs().some(doc => doc._layout_isFlashcard) ? StrCast(this._props.layoutDoc.practiceMode) : ''; } // prettier-ignore
+
+ btnHeight = () => NumCast(this.filterDoc?.height) * Math.min(1, this._props.ScreenToLocalBoxXf().Scale);
+ btnWidth = () => (!this.filterDoc ? 1 : (this.btnHeight() * NumCast(this.filterDoc._width)) / NumCast(this.filterDoc._height));
+
+ /**
+ * Sets the practice mode answer style for flashcards
+ * @param mode practiceMode or undefined for no practice
+ */
+ setPracticeMode = (mode: practiceMode | undefined) => {
+ this._props.layoutDoc.practiceMode = mode;
+ this._props.allChildDocs().map(doc => (doc[this.practiceField] = undefined));
+ };
+
+ @computed get emptyMessage() {
+ const cardCount = this._props.filteredChildDocs().length;
+ const practiceMessage = this.practiceMode && !Doc.hasDocFilter(this._props.layoutDoc, 'tags', Doc.FilterAny) && !cardCount ? 'Finished! Click here to view all flashcards.' : '';
+ const filterMessage = practiceMessage
+ ? ''
+ : Doc.hasDocFilter(this._props.layoutDoc, 'tags', Doc.FilterAny) && !cardCount
+ ? 'No tagged items. Click here to view all flash cards.'
+ : this.practiceMode && !cardCount
+ ? 'No flashcards to show! Click here to leave practice mode'
+ : '';
+ return !practiceMessage && !filterMessage ? null : (
+ <p
+ className="FlashcardPracticeUI-message"
+ style={{ transform: `scale(${this._props.uiBtnScaling})` }}
+ onClick={() => {
+ if (filterMessage || practiceMessage) {
+ this.setPracticeMode(undefined);
+ Doc.setDocFilter(this._props.layoutDoc, 'tags', Doc.FilterAny, 'remove');
+ }
+ }}>
+ {filterMessage || practiceMessage}
+ </p>
+ );
+ }
+
+ @computed get practiceButtons() {
+ /*
+ * Sets a flashcard to either missed or correct depending on if they got the question right in practice mode.
+ */
+ const setPracticeVal = (e: React.MouseEvent, val: string) => {
+ e.stopPropagation();
+ const curDoc = this._props.curDoc();
+ curDoc && (curDoc[this.practiceField] = val);
+ this._props.advance?.(val === practiceVal.CORRECT);
+ };
+
+ return this.practiceMode == practiceMode.PRACTICE && this._props.curDoc() ? (
+ <div className="FlashcardPracticeUI-practice" style={{ transform: `scale(${this._props.uiBtnScaling})`, bottom: `${this._props.sideBtnWidth}px`, height: `${this._props.sideBtnWidth}px` }}>
+ <Tooltip title="Incorrect. View again later.">
+ <div key="remove" className="FlashcardPracticeUI-remove" onPointerDown={e => setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => setPracticeVal(e, practiceVal.MISSED))}>
+ <FontAwesomeIcon icon="xmark" color="red" size="1x" />
+ </div>
+ </Tooltip>
+ <Tooltip title="Correct">
+ <div key="check" className="FlashcardPracticeUI-check" onPointerDown={e => setupMoveUpEvents(this, e, returnFalse, emptyFunction, () => setPracticeVal(e, practiceVal.CORRECT))}>
+ <FontAwesomeIcon icon="check" color="green" size="1x" />
+ </div>
+ </Tooltip>
+ </div>
+ ) : null;
+ }
+ @computed get practiceModesMenu() {
+ const setColor = (mode: practiceMode) => (StrCast(this.practiceMode) === mode ? 'white' : 'lightgray');
+ const togglePracticeMode = (mode: practiceMode) => this.setPracticeMode(mode === this.practiceMode ? undefined : mode);
+
+ return !this._props.allChildDocs().some(doc => doc._layout_isFlashcard) ? null : (
+ <div
+ className="FlashcardPracticeUI-practiceModes"
+ style={{
+ transform: `translateY(${(this.btnHeight() * (1 - Math.min(1, this._props.uiBtnScaling))) / this._props.ScreenToLocalBoxXf().Scale}px)`,
+ }}>
+ <MultiToggle
+ tooltip="Practice flashcards one at a time"
+ type={Type.PRIM}
+ color={SnappingManager.userColor}
+ background={SnappingManager.userVariantColor}
+ multiSelect={false}
+ isToggle={false}
+ toggleStatus={!!this.practiceMode}
+ label="Practice"
+ items={[
+ [practiceMode.QUIZ, 'file-pen', 'Practice flashcards using GPT'],
+ [practiceMode.PRACTICE, 'check', this.practiceMode === practiceMode.PRACTICE ? 'Exit practice mode' : 'Practice flashcards manually'],
+ ].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.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)}
+ color={SnappingManager.userColor}
+ background={SnappingManager.userVariantColor}
+ icon={<FontAwesomeIcon color={SnappingManager.userColor} icon={this._props.layoutDoc.revealOp === '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 === 'hover' ? 'flip' : 'hover';
+ this._props.layoutDoc.childDocumentsActive = this._props.layoutDoc.revealOp === 'hover' ? true : undefined;
+ })
+ }
+ />
+ </div>
+ );
+ }
+ tryFilterOut = (doc: Doc) => (this.practiceMode && BoolCast(doc?._layout_isFlashcard) && doc[this.practiceField] === practiceVal.CORRECT ? true : false); // show only cards that aren't marked as correct
+ render() {
+ return (
+ <>
+ {this.emptyMessage}
+ {this.practiceButtons}
+ {this._props.layoutDoc._chromeHidden ? null : (
+ <div className="FlashcardPracticeUI-menu" style={{ height: this.btnHeight(), width: this.btnHeight(), transform: `scale(${this._props.uiBtnScaling})` }}>
+ {!this.filterDoc ? null : (
+ <DocumentView
+ {...this._props.docViewProps()}
+ Document={this.filterDoc}
+ TemplateDataDocument={undefined}
+ PanelWidth={this.btnWidth}
+ PanelHeight={this.btnHeight}
+ NativeWidth={returnZero}
+ NativeHeight={returnZero}
+ hideDecorations={BoolCast(this._props.layoutDoc.layout_hideDecorations)}
+ hideCaptions={true}
+ hideFilterStatus={true}
+ renderDepth={this._props.renderDepth + 1}
+ fitWidth={undefined}
+ showTags={false}
+ setContentViewBox={undefined}
+ />
+ )}
+ {this.practiceModesMenu}
+ </div>
+ )}
+ </>
+ );
+ }
+}
diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx
index f56ea9d76..5bfdee1f5 100644
--- a/src/client/views/collections/TabDocView.tsx
+++ b/src/client/views/collections/TabDocView.tsx
@@ -478,7 +478,6 @@ export class TabDocView extends ObservableReactComponent<TabDocViewProps> {
componentDidMount() {
new ResizeObserver(
action(entries => {
- // eslint-disable-next-line no-restricted-syntax
for (const entry of entries) {
this._panelWidth = entry.contentRect.width;
this._panelHeight = entry.contentRect.height;
diff --git a/src/client/views/collections/TreeView.tsx b/src/client/views/collections/TreeView.tsx
index 2f604aaa5..9284a36a2 100644
--- a/src/client/views/collections/TreeView.tsx
+++ b/src/client/views/collections/TreeView.tsx
@@ -38,7 +38,7 @@ import { CollectionView } from './CollectionView';
import { TreeSort } from './TreeSort';
import './TreeView.scss';
-// eslint-disable-next-line @typescript-eslint/no-require-imports
+// eslint-disable-next-line @typescript-eslint/no-require-imports
const { TREE_BULLET_WIDTH } = require('../global/globalCssVariables.module.scss'); // prettier-ignore
export interface TreeViewProps {
diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
index 4043091d5..d2bc8f2c2 100644
--- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
+++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx
@@ -138,7 +138,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection
return this._props.layoutEngine?.() || StrCast(this.layoutDoc._layoutEngine);
}
@computed get childPointerEvents() {
- return falseSnappingManager.IsResizing
+ return SnappingManager.IsResizing
? 'none'
: (this._props.childPointerEvents?.() ??
(this._props.viewDefDivClick || //
diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
index 033d1590d..583f2e656 100644
--- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
+++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx
@@ -6,6 +6,7 @@ import 'ldrs/ring';
import { action, computed, makeObservable, observable, reaction } from 'mobx';
import { observer } from 'mobx-react';
import React from 'react';
+import { imageUrlToBase64 } from '../../../../ClientUtils';
import { Utils, numberRange } from '../../../../Utils';
import { Doc, NumListCast, Opt } from '../../../../fields/Doc';
import { DocData } from '../../../../fields/DocSymbols';
@@ -22,13 +23,13 @@ import { MainView } from '../../MainView';
import { DocumentView } from '../../nodes/DocumentView';
import { FieldView, FieldViewProps } from '../../nodes/FieldView';
import { OpenWhere } from '../../nodes/OpenWhere';
-import { CollectionCardView } from '../CollectionCardDeckView';
import './ImageLabelBox.scss';
import { MarqueeOptionsMenu } from './MarqueeOptionsMenu';
export class ImageInformationItem {}
export class ImageLabelBoxData {
+ // eslint-disable-next-line no-use-before-define
static _instance: ImageLabelBoxData;
@observable _docs: Doc[] = [];
@observable _labelGroups: string[] = [];
@@ -47,8 +48,8 @@ export class ImageLabelBoxData {
};
@action
- addLabel = (label: string) => {
- label = label.toUpperCase().trim();
+ addLabel = (labelIn: string) => {
+ const label = labelIn.toUpperCase().trim();
if (label.length > 0) {
if (!this._labelGroups.includes(label)) {
this._labelGroups = [...this._labelGroups, label.startsWith('#') ? label : '#' + label];
@@ -68,9 +69,10 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
public static LayoutString(fieldKey: string) {
return FieldView.LayoutString(ImageLabelBox, fieldKey);
}
+ // eslint-disable-next-line no-use-before-define
+ public static Instance: ImageLabelBox;
private _dropDisposer?: DragManager.DragDropDisposer;
- public static Instance: ImageLabelBox;
private _inputRef = React.createRef<HTMLInputElement>();
@observable _loading: boolean = false;
private _currentLabel: string = '';
@@ -99,7 +101,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
}
@observable _displayImageInformation: boolean = false;
- constructor(props: any) {
+ constructor(props: FieldViewProps) {
super(props);
makeObservable(this);
ring.register();
@@ -165,9 +167,9 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
const imageInfos = this._selectedImages.map(async doc => {
if (!doc[DocData].tags_chat) {
const [name, type] = ImageCast(doc[Doc.LayoutFieldKey(doc)]).url.href.split('.');
- return CollectionCardView.imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
+ return imageUrlToBase64(`${name}_o.${type}`).then(hrefBase64 =>
!hrefBase64 ? undefined :
- gptImageLabel(hrefBase64).then(labels =>
+ gptImageLabel(hrefBase64,'Give three labels to describe this image.').then(labels =>
({ doc, labels }))) ; // prettier-ignore
}
});
@@ -178,13 +180,13 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
const labels = imageInfo.labels.split('\n');
labels.forEach(label => {
- label =
+ const hashLabel =
'#' +
label
.replace(/^\d+\.\s*|-|f\*/, '')
.replace(/^#/, '')
.trim();
- (imageInfo.doc[DocData].tags_chat as List<string>).push(label);
+ (imageInfo.doc[DocData].tags_chat as List<string>).push(hashLabel);
});
}
});
@@ -214,7 +216,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
// most similar one.
this._selectedImages.forEach(doc => {
const embedLists = numberRange((doc[DocData].tags_chat as List<string>).length).map(n => Array.from(NumListCast(doc[DocData][`tags_embedding_${n + 1}`])));
- const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map((l, index) => (embedding && similarity(Array.from(embedding), l)!) || 0));
+ const bestEmbedScore = (embedding: Opt<number[]>) => Math.max(...embedLists.map(l => (embedding && similarity(Array.from(embedding), l)!) || 0));
const {label: mostSimilarLabelCollect} =
this._labelGroups.map(label => ({ label, similarityScore: bestEmbedScore(labelToEmbedding.get(label)) }))
.reduce((prev, cur) => cur.similarityScore < 0.3 || cur.similarityScore <= prev.similarityScore ? prev: cur,
@@ -243,7 +245,7 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
if (this._selectedImages.length === 0) {
return (
<div className="searchBox-container" style={{ pointerEvents: 'all', color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor }} ref={ele => this.createDropTarget(ele!)}>
- <p style={{ fontSize: 'large' }}>In order to classify and sort images, marquee select the desired images and press the 'Classify and Sort Images' button. Then, add the desired groups for the images to be put in.</p>
+ <p style={{ fontSize: 'large' }}>In order to classify and sort images, marquee select the desired images and press the &apos;Classify and Sort Images&apos; button. Then, add the desired groups for the images to be put in.</p>
</div>
);
}
diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
index 10709cc00..c865c681d 100644
--- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
+++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx
@@ -436,14 +436,14 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps
* Classifies images and assigns the labels as document fields.
*/
@undoBatch
- classifyImages = action(async () => {
+ classifyImages = async () => {
const groupButton = DocListCast(Doc.MyLeftSidebarMenu.data).find(d => d.target === Doc.MyImageGrouper);
if (groupButton) {
this._selectedDocs = this.marqueeSelect(false, DocumentType.IMG);
ImageLabelBoxData.Instance.setData(this._selectedDocs);
MainView.Instance.expandFlyout(groupButton);
}
- });
+ };
/**
* Groups images to most similar labels.
diff --git a/src/client/views/nodes/AudioBox.tsx b/src/client/views/nodes/AudioBox.tsx
index 59349da8b..25e76e2a6 100644
--- a/src/client/views/nodes/AudioBox.tsx
+++ b/src/client/views/nodes/AudioBox.tsx
@@ -27,6 +27,7 @@ import './AudioBox.scss';
import { DocumentView } from './DocumentView';
import { FieldView, FieldViewProps } from './FieldView';
import { OpenWhere } from './OpenWhere';
+import axios from 'axios';
/**
* AudioBox
@@ -257,6 +258,8 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
const [{ result }] = await Networking.UploadFilesToServer({ file: file as Blob & { name: string; lastModified: number; webkitRelativePath: string } });
if (!(result instanceof Error)) {
this.Document[this.fieldKey] = new AudioField(result.accessPaths.agnostic.client);
+ this.Document.url = result.accessPaths.agnostic.client;
+ await this.pushInfo();
}
};
this._recordStart = new Date().getTime();
@@ -284,14 +287,27 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
}
};
+ pushInfo = async () => {
+ const audio = {
+ file: this.path,
+ };
+ const response = await axios.post('http://localhost:105/recognize/', audio, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ this.Document[DocData].phoneticTranscription = response.data['transcription'];
+ };
+
// context menu
specificContextMenu = (): void => {
const funcs: ContextMenuProps[] = [];
+
funcs.push({
description: (this.layoutDoc.hideAnchors ? "Don't hide" : 'Hide') + ' anchors',
event: () => { this.layoutDoc.hideAnchors = !this.layoutDoc.hideAnchors; }, // prettier-ignore
icon: 'expand-arrows-alt',
- });
+ }); //
funcs.push({
description: (this.layoutDoc.dontAutoFollowLinks ? '' : "Don't") + ' follow links when encountered',
event: () => { this.layoutDoc.dontAutoFollowLinks = !this.layoutDoc.dontAutoFollowLinks}, // prettier-ignore
@@ -705,7 +721,6 @@ export class AudioBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
ref={action((r: CollectionStackedTimeline | null) => {
this._stackedTimeline = r;
})}
- // eslint-disable-next-line react/jsx-props-no-spreading
{...this._props}
dataFieldKey={this.fieldKey}
fieldKey={this.annotationKey}
diff --git a/src/client/views/nodes/ComparisonBox.scss b/src/client/views/nodes/ComparisonBox.scss
index 08d9e6010..c328ef4bf 100644
--- a/src/client/views/nodes/ComparisonBox.scss
+++ b/src/client/views/nodes/ComparisonBox.scss
@@ -5,42 +5,142 @@
width: 100%;
height: 100%;
position: relative;
+ background: gray;
z-index: 0;
pointer-events: none;
display: flex;
+ flex-direction: column;
p {
+ // bcz: what is this styling for? if text in the comparison box is colored, then this causes it to render with a black outline
color: rgb(0, 0, 0);
-webkit-text-stroke-color: black;
-webkit-text-stroke-width: 0.2px;
}
-
.input-box {
- position: relative;
+ position: absolute;
+ top: 50;
padding: 10px;
width: 100%;
- height: 100%;
+ height: 70%;
display: flex;
}
.submit-button {
- position: relative;
+ position: absolute;
padding-bottom: 10px;
+ padding-top: 5px;
padding-left: 5px;
padding-right: 5px;
- width: 100%;
- height: 15%;
+ border-radius: 2px;
+ height: 17%;
+ bottom: 0;
+ overflow: hidden;
display: flex;
+ width: 100%;
- button {
- flex: 1;
- position: relative;
+ &.schema-header-button {
+ color: gray;
+ margin: 3px;
+ cursor: pointer;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+
+ svg {
+ width: 15px;
+ }
}
+
+ &.pronunciation {
+ width: 40%;
+ align-items: center;
+ justify-content: center;
+ }
+ &.submit {
+ width: 40%;
+ }
+ &.record {
+ width: 20%;
+ float: left;
+ border-radius: 2px;
+ }
+ .submit-buttonrecord {
+ border-radius: 2px;
+ }
+ .submit-buttonpronunciation {
+ display: inline-flex;
+ align-items: center;
+ }
+ .submit-buttonschema-header-button {
+ position: absolute;
+ top: 5px;
+ left: 11px;
+ z-index: 10;
+ width: 5px;
+ height: 5px;
+ cursor: pointer;
+ }
+ .submit-buttonsubmit {
+ border-radius: 2px;
+ margin-bottom: 3px;
+ width: 100%;
+ display: inline-flex;
+ align-items: center;
+ }
+ }
+
+ .dropbtn {
+ background-color: #3498db;
+ color: white;
+ padding: 16px;
+ font-size: 16px;
+ border: none;
+ }
+
+ .dropup {
+ position: absolute;
+ display: inline-block;
+ margin-top: 150px;
+ bottom: 0;
}
+
+ .dropup-content {
+ display: none;
+ position: absolute;
+ background-color: #f1f1f1;
+ min-width: 160px;
+ bottom: 40px;
+ z-index: 1000;
+ }
+
+ .dropup-content a {
+ color: black;
+ padding: 12px 16px;
+ text-decoration: none;
+ display: block;
+ }
+
+ .dropup-content a:hover {
+ background-color: #ccc;
+ }
+
+ .dropup:hover .dropup-content {
+ display: block;
+ }
+
+ .dropup:hover .dropbtn {
+ background-color: #2980b9;
+ }
+
textarea {
flex: 1;
padding: 10px;
- position: relative;
resize: none;
+ position: 'absolute';
+ width: '91%';
+ height: '80%';
+ z-index: '-1';
+ overscroll-behavior: contain;
}
.clip-div {
@@ -117,6 +217,33 @@
opacity: 0.5;
}
}
+
+ .loading-spinner {
+ display: flex;
+ position: absolute;
+ justify-content: center;
+ align-items: center;
+ height: 90%;
+ width: 93%;
+ font-size: 20px;
+ font-weight: bold;
+ color: #0b0a0a;
+ }
+
+ @keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+ }
+}
+
+.comparisonBox-explain {
+ position: absolute;
+ top: 10px;
+ left: 10px;
+ z-index: 200;
+ background: #dfdfdf;
+ pointer-events: none;
}
.comparisonBox-interactive {
@@ -128,28 +255,8 @@
display: flex;
}
}
- // .input-box {
- // position: relative;
- // padding: 10px;
- // }
- // input[type='text'] {
- // flex: 1;
- // position: relative;
- // margin-right: 10px;
- // width: 100px;
- // }
}
-// .quiz-card {
-// position: relative;
-
-// input[type='text'] {
-// flex: 1;
-// position: relative;
-// margin-right: 10px;
-// width: 100px;
-// }
-// }
.QuizCard {
width: 100%;
height: 100%;
@@ -166,8 +273,6 @@
align-items: center;
justify-content: center;
.QuizCardBox {
- /* existing code */
-
.DIYNodeBox-iframe {
height: 100%;
width: 100%;
@@ -216,24 +321,20 @@
}
}
}
-
- .loading-circle {
- position: relative;
- width: 50px;
- height: 50px;
- border-radius: 50%;
- border: 3px solid #ccc;
- border-top-color: #333;
- animation: spin 1s infinite linear;
- }
-
- @keyframes spin {
- 0% {
- transform: rotate(0deg);
- }
- 100% {
- transform: rotate(360deg);
- }
- }
+ }
+}
+.comparisonBox-bottomMenu {
+ transform-origin: bottom right;
+ width: max-content;
+ justify-content: space-between;
+ height: max-content;
+ position: absolute;
+ bottom: 0;
+ right: 2;
+ flex-direction: row-reverse;
+ display: flex;
+ cursor: pointer;
+ .comparisonBox-button {
+ padding-right: 8px;
}
}
diff --git a/src/client/views/nodes/ComparisonBox.tsx b/src/client/views/nodes/ComparisonBox.tsx
index c1446a77a..ccbe98257 100644
--- a/src/client/views/nodes/ComparisonBox.tsx
+++ b/src/client/views/nodes/ComparisonBox.tsx
@@ -1,50 +1,180 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Tooltip } from '@mui/material';
-import { action, computed, makeObservable, observable } from 'mobx';
+import axios from 'axios';
+import { IReactionDisposer, action, computed, makeObservable, observable, reaction, runInAction } from 'mobx';
import { observer } from 'mobx-react';
import * as React from 'react';
-import { returnFalse, returnNone, returnZero, setupMoveUpEvents } from '../../../ClientUtils';
+import ReactLoading from 'react-loading';
+import { imageUrlToBase64, returnFalse, returnNone, returnTrue, returnZero, setupMoveUpEvents } from '../../../ClientUtils';
import { emptyFunction } from '../../../Utils';
import { Doc, Opt } from '../../../fields/Doc';
import { DocData } from '../../../fields/DocSymbols';
+import { Id } from '../../../fields/FieldSymbols';
import { RichTextField } from '../../../fields/RichTextField';
import { DocCast, NumCast, RTFCast, StrCast, toList } from '../../../fields/Types';
-import { GPTCallType, gptAPICall } from '../../apis/gpt/GPT';
-import { DocUtils } from '../../documents/DocUtils';
-import { DocumentType } from '../../documents/DocumentTypes';
+import { nullAudio } from '../../../fields/URLField';
+import { GPTCallType, gptAPICall, gptImageLabel } from '../../apis/gpt/GPT';
+import { DocUtils, FollowLinkScript } from '../../documents/DocUtils';
+import { CollectionViewType, DocumentType } from '../../documents/DocumentTypes';
import { Docs } from '../../documents/Documents';
import { DragManager } from '../../util/DragManager';
import { dropActionType } from '../../util/DropActionTypes';
+import { SnappingManager } from '../../util/SnappingManager';
import { undoable } from '../../util/UndoManager';
+import { ContextMenu } from '../ContextMenu';
import { ViewBoxAnnotatableComponent } from '../DocComponent';
import { PinDocView, PinProps } from '../PinFuncs';
import { StyleProp } from '../StyleProp';
+import '../pdf/GPTPopup/GPTPopup.scss';
import './ComparisonBox.scss';
import { DocumentView } from './DocumentView';
import { FieldView, FieldViewProps } from './FieldView';
import { FormattedTextBox } from './formattedText/FormattedTextBox';
+import { practiceMode } from '../collections/FlashcardPracticeUI';
+
+const API_URL = 'https://api.unsplash.com/search/photos';
@observer
export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
public static LayoutString(fieldKey: string) {
return FieldView.LayoutString(ComparisonBox, fieldKey);
}
- private _disposers: (DragManager.DragDropDisposer | undefined)[] = [undefined, undefined];
+ private SpeechRecognition = window.SpeechRecognition || window.webkitSpeechRecognition;
private _closeRef = React.createRef<HTMLDivElement>();
- @observable _inputValue = '';
- @observable _outputValue = '';
- @observable _loading = false;
- @observable _errorMessage = '';
- @observable _outputMessage = '';
- @observable _animating = '';
-
+ private _disposers: (DragManager.DragDropDisposer | undefined)[] = [undefined, undefined];
+ private _reactDisposer: IReactionDisposer | undefined;
constructor(props: FieldViewProps) {
super(props);
makeObservable(this);
}
+ @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;
+ @observable private _frontSide = false;
+ @observable recognition = new this.SpeechRecognition();
+
+ @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 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 overlayAlternateIcon() {
+ return (
+ <Tooltip title={<div className="dash-tooltip">flip</div>}>
+ <div
+ className="comparisonBox-alternateButton ccomparisonBox-button"
+ onPointerDown={e =>
+ setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => {
+ if (!this.revealOp || this.revealOp === 'flip') {
+ this.flipFlashcard();
+ }
+ })
+ }
+ style={{
+ background: this.revealOp === 'hover' ? 'gray' : this._frontSide ? 'white' : 'black',
+ color: this.revealOp === 'hover' ? 'black' : this._frontSide ? 'black' : 'white',
+ display: 'inline-block',
+ }}>
+ <FontAwesomeIcon icon="turn-up" size="xl" />
+ </div>
+ </Tooltip>
+ );
+ }
+
+ _sideBtnWidth = 35;
+ /**
+ * How much the content of the view is being scaled based on its nesting and its fit-to-width settings
+ */
+ @computed get viewScaling() { return this.ScreenToLocalBoxXf().Scale; } // prettier-ignore
+ /**
+ * The maximum size a UI widget can be scaled so that it won't be bigger in screen pixels than its normal 35 pixel size.
+ */
+ @computed get maxWidgetSize() { return Math.min(this._sideBtnWidth * this.viewScaling, 0.25 * Math.min(NumCast(this.Document.width), NumCast(this.Document.height))); } // prettier-ignore
+ /**
+ * How much to reactively scale a UI element so that it is as big as it can be (up to its normal 35pixel size) without being too big for the Doc content
+ */
+ @computed get uiBtnScaling() { return Math.max(this.maxWidgetSize / this._sideBtnWidth, 1) * Math.min(1, this.viewScaling)* (this._props.NativeDimScaling?.() ?? 1); } // prettier-ignore
+
+ @computed get flashcardMenu() {
+ return SnappingManager.HideDecorations ? null : (
+ <div className="comparisonBox-bottomMenu" style={{ transform: `scale(${this.uiBtnScaling})` }}>
+ {this.revealOp === 'hover' || !this._props.isSelected() ? null : this.overlayAlternateIcon}
+ {!this._props.isSelected() ? null : (
+ <>
+ {!this._frontSide ? null : (
+ <Tooltip
+ title={
+ <div className="dash-tooltip">{
+ !this._frontSide ? "Flip to front side to use GPT":
+ "Ask GPT to create an answer on the back side of the flashcard based on your question on the front"}
+ </div> // prettier-ignore
+ }>
+ <div className="comparisonBox-button" onPointerDown={() => (this._frontSide ? this.findImageTags() : null)}>
+ <FontAwesomeIcon icon="lightbulb" size="xl" />
+ </div>
+ </Tooltip>
+ )}
+ {DocCast(this.Document.embedContainer)?.type_collection !== CollectionViewType.Freeform ? null : (
+ <Tooltip title={<div className="dash-tooltip">Create new flashcard stack based on text</div>}>
+ <div className="comparisonBox-button" onClick={this.gptFlashcardPile}>
+ <FontAwesomeIcon icon="layer-group" size="xl" />
+ </div>
+ </Tooltip>
+ )}
+ </>
+ )}
+ </div>
+ );
+ }
+
+ @action handleInputChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
+ this._inputValue = e.target.value;
+ };
+
+ @action activateContent = () => {
+ this._childActive = true;
+ };
+
+ @action handleRenderClick = () => {
+ this._frontSide = !this._frontSide;
+ };
+
+ @action handleRenderGPTClick = () => {
+ const phonTrans = DocCast(this.Document.audio) ? DocCast(this.Document.audio).phoneticTranscription : undefined;
+ if (phonTrans) {
+ this._inputValue = StrCast(phonTrans);
+ this.askGPTPhonemes(this._inputValue);
+ } else if (this._inputValue) this.askGPT(GPTCallType.QUIZ);
+ this._frontSide = false;
+ this._outputValue = '';
+ };
+
+ onPointerMove = ({ movementX }: PointerEvent) => {
+ const width = movementX * this.ScreenToLocalBoxXf().Scale + (this.clipWidth / 100) * this._props.PanelWidth();
+ if (width && width > 5 && width < this._props.PanelWidth()) {
+ this.layoutDoc[this.clipWidthKey] = (width * 100) / this._props.PanelWidth();
+ }
+ return false;
+ };
+
componentDidMount() {
this._props.setContentViewBox?.(this);
+ this._reactDisposer = reaction(
+ () => this._props.isSelected(), // when this reaction should update
+ selected => !selected && (this._childActive = false) // what it should update to
+ );
+ }
+
+ componentWillUnmount() {
+ this._reactDisposer?.();
}
protected createDropTarget = (ele: HTMLDivElement | null, fieldKey: string, disposerId: number) => {
@@ -54,62 +184,19 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
}
};
- @computed get revealOp() { return this.layoutDoc[`_${this.fieldKey}_revealOp`] as ('flip'|'hover'|undefined); } // prettier-ignore
- @computed get clipWidth() { return NumCast(this.layoutDoc[`_${this.fieldKey}_clipWidth`], 50); } // prettier-ignore
- set clipWidth(width: number) { this.layoutDoc[`_${this.fieldKey}_clipWidth`] = width; } // prettier-ignore
- @computed get useAlternate() { return this.layoutDoc[`_${this.fieldKey}_usePath`] === 'alternate'; } // prettier-ignore
- set useAlternate(alt: boolean) { this.layoutDoc[`_${this.fieldKey}_usePath`] = alt ? 'alternate' : undefined; } // prettier-ignore
-
- animateClipWidth = action((clipWidth: number, duration = 200 /* ms */) => {
- this._animating = `all ${duration}ms`; // turn on clip animation transition, then turn it off at end of animation
- setTimeout(action(() => { this._animating = ''; }), duration); // prettier-ignore
- this.clipWidth = clipWidth;
- });
-
- internalDrop = undoable((e: Event, dropEvent: DragManager.DropEvent, fieldKey: string) => {
+ private internalDrop = undoable((e: Event, dropEvent: DragManager.DropEvent, fieldKey: string) => {
if (dropEvent.complete.docDragData) {
const { droppedDocuments } = dropEvent.complete.docDragData;
const added = dropEvent.complete.docDragData.moveDocument?.(droppedDocuments, this.Document, (doc: Doc | Doc[]) => this.addDoc(toList(doc).lastElement(), fieldKey));
Doc.SetContainer(droppedDocuments.lastElement(), this.dataDoc);
!added && e.preventDefault();
e.stopPropagation(); // prevent parent Doc from registering new position so that it snaps back into place
+ // this.childActive = false;
return added;
}
return undefined;
}, 'internal drop');
- registerSliding = (e: React.PointerEvent<HTMLDivElement>, targetWidth: number) => {
- if (e.button !== 2) {
- setupMoveUpEvents(
- this,
- e,
- this.onPointerMove,
- emptyFunction,
- action((clickEv, doubleTap) => {
- if (doubleTap) {
- this._isAnyChildContentActive = true;
- if (!this.dataDoc[this.fieldKey + '_1'] && !this.dataDoc[this.fieldKey]) this.dataDoc[this.fieldKey + '_1'] = DocUtils.copyDragFactory(Doc.UserDoc().emptyNote as Doc);
- if (!this.dataDoc[this.fieldKey + '_2'] && !this.dataDoc[this.fieldKey + '_alternate']) this.dataDoc[this.fieldKey + '_2'] = DocUtils.copyDragFactory(Doc.UserDoc().emptyNote as Doc);
- // DocumentView.addViewRenderedCb(DocCast(this.dataDoc[this.fieldKey + '_1']), dv => {
- // dv?.select(false);
- // });
- }
- }),
- true,
- undefined,
- () => !this._isAnyChildContentActive && this.animateClipWidth((targetWidth * 100) / this._props.PanelWidth())
- );
- }
- };
-
- onPointerMove = ({ movementX }: PointerEvent) => {
- const width = movementX * this.ScreenToLocalBoxXf().Scale + (this.clipWidth / 100) * this._props.PanelWidth();
- if (width > 5 && width < this._props.PanelWidth()) {
- this.clipWidth = (width * 100) / this._props.PanelWidth();
- }
- return false;
- };
-
getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => {
const anchor = Docs.Create.ConfigDocument({
title: 'CompareAnchor:' + this.Document.title,
@@ -127,20 +214,18 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
};
clearDoc = undoable((fieldKey: string) => {
- delete this.dataDoc[fieldKey];
- this.dataDoc[fieldKey] = 'empty';
+ this.dataDoc[fieldKey] = undefined;
}, 'clear doc');
- // clearDoc = (fieldKey: string) => delete this.dataDoc[fieldKey];
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.dataDoc[which] !== 'empty') return false;
+ 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.dataDoc[which] = 'empty';
+ this._isEmpty = true;
this.dataDoc[which] = undefined;
return true;
}
@@ -173,114 +258,404 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
remDoc1 = (docs: Doc | Doc[]) => toList(docs).reduce((res, doc) => res && this.remDoc(doc, this.fieldKey + '_1'), true);
remDoc2 = (docs: Doc | Doc[]) => toList(docs).reduce((res, doc) => res && this.remDoc(doc, this.fieldKey + '_2'), true);
+ registerSliding = (e: React.PointerEvent<HTMLDivElement>, targetWidth: number) => {
+ if (e.button !== 2) {
+ setupMoveUpEvents(
+ this,
+ e,
+ this.onPointerMove,
+ emptyFunction,
+ action((moveEv, doubleTap) => {
+ if (doubleTap) {
+ this._childActive = true;
+ if (!this.dataDoc[this.fieldKey + '_1'] && !this.dataDoc[this.fieldKey]) this.dataDoc[this.fieldKey + '_1'] = DocUtils.copyDragFactory(Doc.UserDoc().emptyNote as Doc);
+ if (!this.dataDoc[this.fieldKey + '_2'] && !this.dataDoc[this.fieldKey + '_alternate']) this.dataDoc[this.fieldKey + '_2'] = DocUtils.copyDragFactory(Doc.UserDoc().emptyNote as Doc);
+ }
+ }),
+ false,
+ undefined,
+ action(() => {
+ if (this._childActive) return;
+ this._animating = 'all 200ms';
+ // on click, animate slider movement to the targetWidth
+ this.layoutDoc[this.clipWidthKey] = (targetWidth * 100) / this._props.PanelWidth();
+
+ setTimeout(
+ action(() => {
+ this._animating = '';
+ }),
+ 200
+ );
+ })
+ );
+ }
+ };
+
/**
- * Tests for whether a comparison box slot (ie, before or after) has renderable text content.
- * If it does, render a FormattedTextBox for that slot that references the comparisonBox's slot field
- * @param whichSlot field key for start or end slot
- * @returns a JSX layout string if a text field is found, othwerise undefined
+ * Set up speech to text tool.
*/
- testForTextFields = (whichSlot: string) => {
- const slotData = Doc.Get(this.dataDoc, whichSlot, true);
- const slotHasText = slotData instanceof RichTextField || typeof slotData === 'string';
- const subjectText = RTFCast(this.Document[this.fieldKey])?.Text.trim();
- const altText = RTFCast(this.Document[this.fieldKey + '_alternate'])?.Text.trim();
- const layoutTemplateString =
- slotHasText ? FormattedTextBox.LayoutString(whichSlot):
- whichSlot.endsWith('1') ? (subjectText !== undefined ? FormattedTextBox.LayoutString(this.fieldKey) : undefined) :
- altText !== undefined ? FormattedTextBox.LayoutString(this.fieldKey + '_alternate'): undefined; // prettier-ignore
+ setListening = () => {
+ if (this.SpeechRecognition) {
+ this.recognition.continuous = true;
+ this.recognition.interimResults = true;
+ this.recognition.lang = 'en-US';
+ this.recognition.onresult = this.handleResult.bind(this);
+ }
+ ContextMenu.Instance.setLangIndex(0);
+ };
- // A bit hacky to try out the concept of using GPT to fill in flashcards
- // If the second slot doesn't have anything in it, but the fieldKey slot has text (e.g., this.text is a string)
- // and the fieldKey + "_alternate" has text that includes a GPT query (indicated by (( && )) ) that is parameterized (optionally) by the fieldKey text (this) or other metadata (this.<field>).
- // eg., this.text_alternate is
- // "((Provide a one sentence definition for (this) that doesn't use any word in (this.excludeWords) ))"
- // where (this) is replaced by the text in the fieldKey slot abd this.excludeWords is repalced by the conetnts of the excludeWords field
- // The GPT call will put the "answer" in the second slot of the comparison (eg., text_2)
- if (whichSlot.endsWith('2') && !layoutTemplateString?.includes(whichSlot)) {
- const queryText = altText?.replace('(this)', subjectText); // TODO: this should be done in Doc.setField but it doesn't know about the fieldKey ...
- if (queryText?.match(/\(\(.*\)\)/)) {
- Doc.SetField(this.Document, whichSlot, ':=' + queryText, false); // make the second slot be a computed field on the data doc that calls ChatGpt
- }
+ startListening = () => {
+ this.recognition.start();
+ this._listening = true;
+ };
+
+ stopListening = () => {
+ this.recognition.stop();
+ this._listening = false;
+ };
+
+ setLanguage = (language: string, ind: number) => {
+ this.recognition.lang = language;
+ ContextMenu.Instance.setLangIndex(ind);
+ };
+
+ /**
+ * Determine which language the speech to text tool is in.
+ * @returns
+ */
+ convertAbr = () => {
+ switch (this.recognition.lang) {
+ case 'en-US': return 'English'; //prettier-ignore
+ case 'es-ES': return 'Spanish'; //prettier-ignore
+ case 'fr-FR': return 'French'; //prettier-ignore
+ case 'it-IT': return 'Italian'; //prettier-ignore
+ case 'zh-CH': return 'Mandarin Chinese'; //prettier-ignore
+ case 'ja': return 'Japanese'; //prettier-ignore
+ default: return 'Korean'; //prettier-ignore
}
- return layoutTemplateString;
+ };
+
+ openContextMenu = (x: number, y: number, evalu: boolean) => {
+ ContextMenu.Instance.clearItems();
+ ContextMenu.Instance.addItem({ description: 'English', event: () => this.setLanguage('en-US', 0), icon: 'question' }); //prettier-ignore
+ ContextMenu.Instance.addItem({ description: 'Spanish', event: () => this.setLanguage('es-ES', 1 ), icon: 'question'}); //prettier-ignore
+ ContextMenu.Instance.addItem({ description: 'French', event: () => this.setLanguage('fr-FR', 2), icon: 'question' }); //prettier-ignore
+ ContextMenu.Instance.addItem({ description: 'Italian', event: () => this.setLanguage('it-IT', 3), icon: 'question' }); //prettier-ignore
+ if (!evalu) ContextMenu.Instance.addItem({ description: 'Mandarin Chinese', event: () => this.setLanguage('zh-CH', 4), icon: 'question' }); //prettier-ignore
+ ContextMenu.Instance.addItem({ description: 'Japanese', event: () => this.setLanguage('ja', 5), icon: 'question' }); //prettier-ignore
+ ContextMenu.Instance.addItem({ description: 'Korean', event: () => this.setLanguage('ko', 6), icon: 'question' }); //prettier-ignore
+ ContextMenu.Instance.displayMenu(x, y);
};
/**
- * Flips a flashcard to the alternate side for the user to view.
+ * Creates an AudioBox to record a user's audio.
*/
- flipFlashcard = () => {
- this.useAlternate = !this.useAlternate;
+ evaluatePronunciation = () => {
+ const newAudio = Docs.Create.AudioDocument(nullAudio, { _width: 200, _height: 100 });
+ this.Document.audio = newAudio[DocData];
+ this._props.DocumentView?.()._props.addDocument?.(newAudio);
};
/**
- * Changes the view option to hover for a flashcard.
+ * Gets the transcription of an audio recording by sending the
+ * recording to backend.
*/
- hoverFlip = (alternate: boolean) => {
- if (this.revealOp === 'hover') this.useAlternate = alternate;
+ pushInfo = async () => {
+ const audio = {
+ file: DocCast(this.Document.audio)[DocData].url,
+ };
+ const response = await axios.post('http://localhost:105/recognize/', audio, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ this.Document.phoneticTranscription = response.data.transcription;
};
/**
- * Creates the button used to flip the flashcards.
+ * Extracts the id of the youtube video url.
+ * @param url
+ * @returns
*/
- @computed get overlayAlternateIcon() {
- return (
- <Tooltip title={<div className="dash-tooltip">flip</div>}>
- <div
- className="formattedTextBox-alternateButton"
- onPointerDown={e =>
- setupMoveUpEvents(e.target, e, returnFalse, emptyFunction, () => {
- if (!this.revealOp || this.revealOp === 'flip') {
- this.flipFlashcard();
- console.log('Print Front of cards: ' + (RTFCast(DocCast(this.dataDoc[this.fieldKey + '_0']).text)?.Text ?? ''));
- console.log('Print Back of cards: ' + (RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text ?? ''));
- }
- })
- }
- style={{
- background: this.useAlternate ? 'white' : 'black',
- color: this.useAlternate ? 'black' : 'white',
- }}>
- <FontAwesomeIcon icon="turn-up" size="sm" />
- </div>
- </Tooltip>
- );
- }
+ getYouTubeVideoId = (url: string) => {
+ const regExp = /^.*(youtu.be\/|v\/|u\/\w\/|embed\/|watch\?v=|&v=|\?v=)([^#&?]*).*/;
+ const match = url.match(regExp);
+ return match && match[2].length === 11 ? match[2] : null;
+ };
- @action handleRenderGPTClick = () => {
- // Call the GPT model and get the output
- this.useAlternate = true;
- this._outputValue = '';
- if (this._inputValue) this.askGPT();
+ /**
+ * Gets the transcript of a youtube video by sending the video url to the backend.
+ * @returns transcription of youtube recording
+ */
+ youtubeUpload = async () => {
+ const audio = {
+ file: this.getYouTubeVideoId(StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text)),
+ };
+ const response = await axios.post('http://localhost:105/youtube/', audio, {
+ headers: {
+ 'Content-Type': 'application/json',
+ },
+ });
+ return response.data.transcription;
};
- @action handleRenderClick = () => {
- // Call the GPT model and get the output
- this.useAlternate = false;
+ createFlashcardPile(collectionArr: Doc[], gpt: boolean) {
+ const newCol = Docs.Create.CarouselDocument(collectionArr, {
+ _width: NumCast(this.layoutDoc['_' + this._props.fieldKey + '_width'], 250) + 50,
+ _height: NumCast(this.layoutDoc['_' + this._props.fieldKey + '_width'], 200) + 50,
+ _layout_fitWidth: false,
+ _layout_autoHeight: true,
+ _xMargin: 5,
+ _yMargin: 5,
+ });
+ newCol.x = this.layoutDoc.x;
+ newCol.y = NumCast(this.layoutDoc.y) + 50;
+ newCol.type_collection = CollectionViewType.Carousel as string;
+ for (let i = 0; i < collectionArr.length; i++) {
+ DocCast(collectionArr[i])[DocData].embedContainer = newCol;
+ }
+
+ if (gpt) {
+ this._props.DocumentView?.()._props.addDocument?.(newCol);
+ this._props.removeDocument?.(this.Document);
+ } else {
+ this._props.addDocument?.(newCol);
+ this._props.removeDocument?.(this.Document);
+ this.Document.embedContainer = newCol;
+ }
+ }
+
+ /**
+ * Transfers the content of flashcards into a flashcard pile.
+ */
+ gptFlashcardPile = async () => {
+ const text = await this.askGPT(GPTCallType.STACK);
+ const senArr = text?.split('Question: ') ?? [];
+ const collectionArr: Doc[] = [];
+ for (let i = 1; i < senArr.length; i++) {
+ const newDoc = Docs.Create.ComparisonDocument(senArr[i], { _layout_isFlashcard: true, _width: 300, _height: 300 });
+
+ if (senArr[i].includes('Keyword: ')) {
+ const question = StrCast(senArr![i]).split('Keyword: ');
+ const questionTxt = question[0].includes('Answer: ') ? question[0].split('Answer: ')[0] : question[0];
+ const answerTxt = question[0].includes('Answer: ') ? question[0].split('Answer: ')[1] : question[1];
+ this.fetchImages(question[1]).then(img => {
+ const rtfiel = new RichTextField(
+ JSON.stringify({
+ // this is a RichText json that has the question text placed above a related image
+ doc: {
+ type: 'doc',
+ content: [
+ {
+ type: 'paragraph',
+ attrs: { align: null, color: null, id: null, indent: null, inset: null, lineSpacing: null, paddingBottom: null, paddingTop: null },
+ content: [{ type: 'text', text: questionTxt }, img ? { type: 'dashDoc', attrs: { width: '200px', height: '200px', title: 'dashDoc', float: 'unset', hidden: false, docId: img[Id] } } : {}],
+ },
+ ],
+ },
+ selection: { type: 'text', anchor: 2, head: 2 },
+ }),
+ questionTxt
+ );
+ newDoc[DocData][this.fieldKey + '_1'] = Docs.Create.TextDocument(questionTxt, { text: rtfiel });
+ newDoc[DocData][this.fieldKey + '_0'] = Docs.Create.TextDocument(answerTxt);
+ });
+ }
+
+ collectionArr.push(newDoc);
+ }
+ this.createFlashcardPile(collectionArr, true);
};
/**
- * Calls the GPT model to create QuizCards. Evaluates how similar the user's response is to the alternate
- * side of the flashcard.
+ * Calls GPT for each flashcard type.
*/
- askGPT = async (): Promise<string | undefined> => {
+ askGPT = async (callType: GPTCallType): Promise<string | undefined> => {
const questionText = 'Question: ' + StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text);
- const rubricText = ' Rubric: ' + StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_0']).text)?.Text);
- const queryText = questionText + ' UserAnswer: ' + this._inputValue + '. ' + rubricText;
+ // const rubricText = ' Rubric: ' + StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_0']).text)?.Text);
+ // const queryText = questionText + ' UserAnswer: ' + this._inputValue + '. ' + rubricText;
+ this._loading = true;
- try {
- const res = await gptAPICall(queryText, GPTCallType.QUIZ);
- if (!res) {
- console.error('GPT call failed');
+ if (callType == GPTCallType.CHATCARD) {
+ if (StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text) === '') {
+ this._loading = false;
return;
}
- this._outputValue = res;
- } catch {
- console.error('GPT call failed');
}
+ try {
+ const res = await gptAPICall(questionText, GPTCallType.FLASHCARD);
+ runInAction(() => {
+ if (!res) {
+ console.error('GPT call failed');
+ return;
+ }
+ if (callType == GPTCallType.CHATCARD) {
+ DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text = res;
+ } else if (callType == GPTCallType.QUIZ) {
+ this._frontSide = true;
+ this._outputValue = res.replace(/UserAnswer/g, "user's answer").replace(/Rubric/g, 'rubric');
+ } else if (callType === GPTCallType.FLASHCARD) {
+ this._loading = false;
+ return res;
+ }
+ this._loading = false;
+ });
+ return res;
+ } catch (err) {
+ console.error('GPT call failed', err);
+ }
+ this._loading = false;
};
layoutWidth = () => NumCast(this.layoutDoc.width, 200);
layoutHeight = () => NumCast(this.layoutDoc.height, 200);
+ findImageTags = async () => {
+ const c = this.DocumentView?.().ContentDiv?.getElementsByTagName('img');
+ if (c?.length === 0) this.askGPT(GPTCallType.CHATCARD);
+ if (c) {
+ this._loading = true;
+ for (const i of c) {
+ if (i.className !== 'ProseMirror-separator') this.getImageDesc(i.src);
+ }
+ this._loading = false;
+ }
+ };
+
+ /**
+ * Ask GPT for advice on how to improve speech by comparing the phonetic transcription of
+ * a users audio recording with the phonetic transcription of their intended sentence.
+ * @param phonemes
+ */
+ askGPTPhonemes = async (phonemes: string) => {
+ const sentence = StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text);
+ const phon6 = 'huː ɑɹ juː tədeɪ';
+ const phon4 = 'kamo estas hɔi';
+ const promptEng =
+ 'Consider all possible phonetic transcriptions of the intended sentence "' +
+ sentence +
+ '" that is standard in American speech without showing the user. Compare each word in the following phonemes with those phonetic transcriptions without displaying anything to the user: "' +
+ phon6 +
+ '". Steps to do this: Align the words with each word in the intended sentence by combining the phonemes to get a pronunciation that resembles the word in order. Do not describe phonetic corrections with the phonetic alphabet - describe it by providing other examples of how it should sound. Note if a word or sound missing, including missing vowels and consonants. If there is an additional word that does not match with the provided sentence, say so. For each word, if any letters mismatch and would sound weird in American speech and they are not allophones of the same phoneme and they are far away from each on the ipa vowel chat and that pronunciation is not normal for the meaning of the word, note this difference and explain how it is supposed to sound. Only note the difference if they are not allophones of the same phoneme and if they are far away on the vowel chart. The goal is to be understood, not sound like a native speaker. Just so you know, "i" sounds like "ee" as in "bee", not "ih" as an "lick". Interpret "ɹ" as the same as "r". Interpret "ʌ" as the same as "ə". If "ɚ", "ɔː", and "ɔ" are options for pronunciation, do not choose "ɚ". Ignore differences with colons. Ignore redundant letters and words and sounds and the splitting of words; do not mention this since there could be repeated words in the sentence. Provide a response like this: "Lets work on improving the pronunciation of "coffee." You said "ceeffee," which is close, but we need to adjust the vowel sound. In American English, "coffee" is pronounced /ˈkɔːfi/, with a long "aw" sound. Try saying "kah-fee." Your intonation is good, but try putting a bit more stress on "like" in the sentence "I would like a coffee with milk." This will make your speech sound more natural. Keep practicing, and lets try saying the whole sentence again!"';
+ const promptSpa =
+ 'Consider all possible phonetic transcriptions of the intended sentence "' +
+ 'como estás hoy' +
+ '" that is standard in Spanish speech without showing the user. Compare each word in the following phonemes with those phonetic transcriptions without displaying anything to the user: "' +
+ phon4 +
+ '". Steps to do this: Align the words with each word in the intended sentence by combining the phonemes to get a pronunciation that resembles the word in order. Do not describe phonetic corrections with the phonetic alphabet - describe it by providing other examples of how it should sound. Note if a word or sound missing, including missing vowels and consonants. If there is an additional word that does not match with the provided sentence, say so. For each word, if any letters mismatch and would sound weird in Spanish speech and they are not allophones of the same phoneme and they are far away from each on the ipa vowel chat and that pronunciation is not normal for the meaning of the word, note this difference and explain how it is supposed to sound. Only note the difference if they are not allophones of the same phoneme and if they are far away on the vowel chart; say good job if it would be understood by a native Spanish speaker. Just so you know, "i" sounds like "ee" as in "bee", not "ih" as an "lick". Interpret "ɹ" as the same as "r". Interpret "ʌ" as the same as "ə". Do not make "θ" and "f" interchangable. Do not make "n" and "ɲ" interchangable. Do not make "e" and "i" interchangable. If "ɚ", "ɔː", and "ɔ" are options for pronunciation, do not choose "ɚ". Ignore differences with colons. Ignore redundant letters and words and sounds and the splitting of words; do not mention this since there could be repeated words in the sentence. Identify "ɔi" sounds like "oy". Ignore accents and do not say anything to the user about this.';
+ const promptAll =
+ 'Consider all possible phonetic transcriptions of the intended sentence "' +
+ sentence +
+ '" that is standard in ' +
+ this.convertAbr() +
+ ' speech without showing the user. Compare each word in the following phonemes with those phonetic transcriptions without displaying anything to the user: "' +
+ phonemes +
+ '". Steps to do this: Align the words with each word in the intended sentence by combining the phonemes to get a pronunciation that resembles the word in order. Do not describe phonetic corrections with the phonetic alphabet - describe it by providing other examples of how it should sound. Note if a word or sound missing, including missing vowels and consonants. If there is an additional word that does not match with the provided sentence, say so. For each word, if any letters mismatch and would sound weird in ' +
+ this.convertAbr() +
+ ' speech and they are not allophones of the same phoneme and they are far away from each on the ipa vowel chat and that pronunciation is not normal for the meaning of the word, note this difference and explain how it is supposed to sound. Just so you know, "i" sounds like "ee" as in "bee", not "ih" as an "lick". Interpret "ɹ" as the same as "r". Interpret "ʌ" as the same as "ə". Do not make "θ" and "f" interchangable. Do not make "n" and "ɲ" interchangable. Do not make "e" and "i" interchangable. If "ɚ", "ɔː", and "ɔ" are options for pronunciation, do not choose "ɚ". Ignore differences with colons. Ignore redundant letters and words and sounds and the splitting of words; do not mention this since there could be repeated words in the sentence. Provide a response like this: "Lets work on improving the pronunciation of "coffee." You said "cawffee," which is close, but we need to adjust the vowel sound. In American English, "coffee" is pronounced /ˈkɔːfi/, with a long "aw" sound. Try saying "kah-fee." Your intonation is good, but try putting a bit more stress on "like" in the sentence "I would like a coffee with milk." This will make your speech sound more natural. Keep practicing, and lets try saying the whole sentence again!"';
+
+ switch (this.recognition.lang) {
+ case 'en-US':
+ this._outputValue = await gptAPICall(promptEng, GPTCallType.PRONUNCIATION);
+ break;
+ case 'es-ES':
+ this._outputValue = await gptAPICall(promptSpa, GPTCallType.PRONUNCIATION);
+ break;
+ default:
+ this._outputValue = await gptAPICall(promptAll, GPTCallType.PRONUNCIATION);
+ break;
+ }
+ };
+
+ /**
+ * Display a user's speech to text result.
+ * @param e
+ */
+ handleResult = (e: SpeechRecognitionEvent) => {
+ let finalTranscript = '';
+ for (let i = e.resultIndex; i < e.results.length; i++) {
+ const transcript = e.results[i][0].transcript;
+ if (e.results[i].isFinal) {
+ finalTranscript += transcript;
+ }
+ }
+ this._inputValue += finalTranscript;
+ };
+
+ /**
+ * Get images from unsplash api and place that will be placed inside generated flashcard.
+ * @param selection
+ * @returns Image Document
+ */
+ fetchImages = async (selection: string) => {
+ try {
+ const { data } = await axios.get(`${API_URL}?query=${selection}&page=1&per_page=${1}&client_id=Q4zruu6k6lum2kExiGhLNBJIgXDxD6NNj0SRHH_XXU0`);
+ const imageSnapshot = Docs.Create.ImageDocument(data.results[0].urls.small, {
+ _nativeWidth: Doc.NativeWidth(this.layoutDoc),
+ _nativeHeight: Doc.NativeHeight(this.layoutDoc),
+ x: NumCast(this.layoutDoc.x),
+ y: NumCast(this.layoutDoc.y),
+ onClick: FollowLinkScript(),
+ _width: 150,
+ _height: 150,
+ title: '--snapshot' + NumCast(this.layoutDoc._layout_currentTimecode) + ' image-',
+ });
+ imageSnapshot.x = this.layoutDoc.x;
+ imageSnapshot.y = this.layoutDoc.y;
+ return imageSnapshot;
+ } catch (error) {
+ console.log(error);
+ }
+ };
+
+ getImageDesc = async (u: string) => {
+ try {
+ const hrefBase64 = await imageUrlToBase64(u);
+ const response = await gptImageLabel(hrefBase64, 'Answer the following question as a short flashcard response. Do not include a label.' + (this.dataDoc.text as RichTextField)?.Text);
+
+ DocCast(this.dataDoc[this.props.fieldKey + '_0'])[DocData].text = response;
+ } catch (error) {
+ console.log('Error', error);
+ }
+ };
+
+ @action
+ flipFlashcard = () => {
+ this._frontSide = !this._frontSide;
+ };
+
+ hoverFlip = (side: boolean) => {
+ if (this.revealOp === 'hover') this._frontSide = side;
+ };
+
+ testForTextFields = (whichSlot: string) => {
+ const slotData = Doc.Get(this.dataDoc, whichSlot, true);
+ const slotHasText = slotData instanceof RichTextField || typeof slotData === 'string';
+ const subjectText = RTFCast(this.Document[this.fieldKey])?.Text.trim();
+ const altText = RTFCast(this.Document[this.fieldKey + '_alternate'])?.Text.trim();
+ const layoutTemplateString =
+ slotHasText ? FormattedTextBox.LayoutString(whichSlot):
+ whichSlot.endsWith('1') ? (subjectText !== undefined ? FormattedTextBox.LayoutString(this.fieldKey) : undefined) :
+ altText !== undefined ? FormattedTextBox.LayoutString(this.fieldKey + '_alternate'): undefined; // prettier-ignore
+
+ // A bit hacky to try out the concept of using GPT to fill in flashcards
+ // If the second slot doesn't have anything in it, but the fieldKey slot has text (e.g., this.text is a string)
+ // and the fieldKey + "_alternate" has text that includes a GPT query (indicated by (( && )) ) that is parameterized (optionally) by the fieldKey text (this) or other metadata (this.<field>).
+ // eg., this.text_alternate is
+ // "((Provide a one sentence definition for (this) that doesn't use any word in (this.excludeWords) ))"
+ // where (this) is replaced by the text in the fieldKey slot abd this.excludeWords is repalced by the conetnts of the excludeWords field
+ // The GPT call will put the "answer" in the second slot of the comparison (eg., text_2)
+ if (whichSlot.endsWith('2') && !layoutTemplateString?.includes(whichSlot)) {
+ const queryText = altText?.replace('(this)', subjectText); // TODO: this should be done in Doc.setField but it doesn't know about the fieldKey ...
+ if (queryText?.match(/\(\(.*\)\)/)) {
+ Doc.SetField(this.Document, whichSlot, ':=' + queryText, false); // make the second slot be a computed field on the data doc that calls ChatGpt
+ }
+ }
+ return layoutTemplateString;
+ };
+
+ childActiveFunc = () => this._childActive;
+
+ contentScreenToLocalXf = () => this._props.ScreenToLocalTransform().scale(this._props.NativeDimScaling?.() || 1);
render() {
const clearButton = (which: string) => (
<Tooltip title={<div className="dash-tooltip">remove</div>}>
@@ -289,7 +664,7 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
className={`clear-button ${which}`}
onPointerDown={e => this.closeDown(e, which)} // prevent triggering slider movement in registerSliding
>
- <FontAwesomeIcon className={`clear-button ${which}`} icon="times" size="sm" />
+ <FontAwesomeIcon className={`clear-button ${which}`} icon="times" size="xs" />
</div>
</Tooltip>
);
@@ -302,25 +677,28 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
<>
<DocumentView
{...this._props}
- fitWidth={undefined}
- NativeHeight={returnZero}
- NativeWidth={returnZero}
+ showTags={undefined}
+ fitWidth={undefined} // set to returnTrue to make images fill the comparisonBox-- should be a user option
ignoreUsePath={layoutString ? true : undefined}
renderDepth={this.props.renderDepth + 1}
LayoutTemplateString={layoutString}
Document={layoutString ? this.Document : targetDoc}
- containerViewPath={this.DocumentView?.().docViewPath}
+ containerViewPath={this._props.docViewPath}
moveDocument={whichSlot.endsWith('1') ? this.moveDoc1 : this.moveDoc2}
removeDocument={whichSlot.endsWith('1') ? this.remDoc1 : this.remDoc2}
- isContentActive={emptyFunction}
+ NativeWidth={returnZero}
+ NativeHeight={returnZero}
+ ScreenToLocalTransform={this.contentScreenToLocalXf}
+ isContentActive={this.childActiveFunc}
isDocumentActive={returnFalse}
+ dontSelect={returnTrue}
whenChildContentsActiveChanged={this.whenChildContentsActiveChanged}
- styleProvider={this._isAnyChildContentActive ? this._props.styleProvider : this.docStyleProvider}
+ styleProvider={this._childActive ? this._props.styleProvider : this.docStyleProvider}
hideLinkButton
- pointerEvents={this._isAnyChildContentActive ? undefined : returnNone}
+ pointerEvents={this._childActive ? undefined : returnNone}
/>
- {layoutString ? null : clearButton(whichSlot)}
- </> // placeholder image if doc is missing
+ {!this.Document._layout_isFlashcard ? clearButton(whichSlot) : null}
+ </>
) : (
<div className="placeholder">
<FontAwesomeIcon className="upload-icon" icon="cloud-upload-alt" size="lg" />
@@ -328,54 +706,85 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
);
};
const displayBox = (which: string, index: number, cover: number) => (
- <div className={`${index === 0 ? 'before' : 'after'}Box-cont`} key={which} style={{ width: this._props.PanelWidth() }} onPointerDown={e => this.registerSliding(e, cover)} ref={ele => this.createDropTarget(ele, which, index)}>
- {displayDoc(which)}
+ <div
+ className={`${index === 0 ? 'before' : 'after'}Box-cont`}
+ key={which}
+ style={{ width: this._props.PanelWidth() }}
+ onPointerDown={e => {
+ this.registerSliding(e, cover);
+ this.Document._layout_isFlashcard && this.activateContent();
+ }}
+ ref={ele => this.createDropTarget(ele, which, index)}>
+ {!this._isEmpty ? displayDoc(which) : null}
</div>
);
if (this.Document._layout_isFlashcard) {
- const side = this.useAlternate ? 1 : 0;
+ const side = this._frontSide ? 1 : 0;
+ const dataSplit = StrCast(this.dataDoc.data).includes('Keyword: ') ? StrCast(this.dataDoc.data).split('Keyword: ') : StrCast(this.dataDoc.data).split('Answer: ');
+ const textCreator = (which: number, title: string, text: string) => {
+ const newDoc = Docs.Create.TextDocument(text, {
+ title, //
+ _layout_autoHeight: true,
+ _layout_centered: true,
+ text_align: 'center',
+ _layout_fitWidth: true,
+ });
+ this.addDoc(newDoc, this.fieldKey + '_' + which);
+ return newDoc;
+ };
// add text box to each side when comparison box is first created
- if (!(this.dataDoc[this.fieldKey + '_0'] || this.dataDoc[this.fieldKey + '_0'] === 'empty')) {
- const dataSplit = StrCast(this.dataDoc.data).split('Answer');
- const newDoc = Docs.Create.TextDocument(dataSplit[1]);
- // if there is text from the pdf ai cards, put the question on the front side.
- newDoc[DocData].text = dataSplit[1];
- this.addDoc(newDoc, this.fieldKey + '_0');
+ if (!this.dataDoc[this.fieldKey + '_0'] && !this._isEmpty) {
+ textCreator(0, 'answer', dataSplit[1]);
}
- if (!(this.dataDoc[this.fieldKey + '_1'] || this.dataDoc[this.fieldKey + '_1'] === 'empty')) {
- const dataSplit = StrCast(this.dataDoc.data).split('Answer');
- const newDoc = Docs.Create.TextDocument(dataSplit[0]);
- // if there is text from the pdf ai cards, put the answer on the alternate side.
- newDoc[DocData].text = dataSplit[0];
- this.addDoc(newDoc, this.fieldKey + '_1');
+
+ if (!this.dataDoc[this.fieldKey + '_1'] && !this._isEmpty) {
+ const question = textCreator(1, 'question', dataSplit[0] || 'hint: Enter a topic, select this document and click the stack button to have GPT create a deck of cards');
+ Doc.SelectOnLoad = dataSplit[0] ? undefined : question;
}
- // render the QuizCards
- if (DocCast(this.Document.embedContainer) && DocCast(this.Document.embedContainer).filterOp === 'quiz') {
+ if (DocCast(this.Document.embedContainer).practiceMode === practiceMode.QUIZ) {
+ const text = StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text);
return (
- <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}`} style={{ display: 'flex', flexDirection: 'column' }}>
- <p style={{ color: 'white', padding: 10 }}>{StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text)}</p>
- {/* {StrCast(RTFCast(DocCast(this.dataDoc[this.fieldKey + '_1']).text)?.Text)} */}
+ <div className={`comparisonBox${this._props.isContentActive() ? '-interactive' : ''}`}>
+ <p style={{ color: 'white', padding: 10 }}>{text}</p>
+ <p style={{ display: text === '' ? 'flex' : 'none', color: 'white', marginLeft: '10px' }}>Return to all flashcards and add text to both sides. </p>
<div className="input-box">
<textarea
- value={this.useAlternate ? this._outputValue : this._inputValue}
- onChange={action(e => {
- this._inputValue = e.target.value;
- })}
- readOnly={this.useAlternate}
- />
- </div>
- <div className="submit-button" style={{ display: this.useAlternate ? 'none' : 'flex' }}>
- <button type="button" onClick={this.handleRenderGPTClick}>
- Submit
- </button>
+ value={this._frontSide ? this._outputValue : this._inputValue}
+ onChange={this.handleInputChange}
+ onScroll={e => {
+ e.stopPropagation();
+ e.preventDefault();
+ }}
+ placeholder={!this.layoutDoc[`_${this._props.fieldKey}_usePath`] ? 'Enter a response for GPT to evaluate.' : ''}
+ readOnly={this._frontSide}></textarea>
+
+ {this._loading ? (
+ <div className="loading-spinner">
+ <ReactLoading type="spin" height={30} width={30} color={'blue'} />
+ </div>
+ ) : null}
</div>
- <div className="submit-button" style={{ display: this.useAlternate ? 'flex' : 'none' }}>
- <button type="button" onClick={this.handleRenderClick}>
- Edit Your Response
- </button>
+ <div>
+ <div className="submit-button">
+ <div className="submit-buttonschema-header-button" onPointerDown={e => this.openContextMenu(e.clientX, e.clientY, false)}>
+ <FontAwesomeIcon color="white" icon="caret-down" />
+ </div>
+ <button className="submit-buttonrecord" onClick={this._listening ? this.stopListening : this.startListening} style={{ background: this._listening ? 'lightgray' : '' }}>
+ {<FontAwesomeIcon icon="microphone" size="lg" />}
+ </button>
+ <div className="submit-buttonschema-header-button" onPointerDown={e => this.openContextMenu(e.clientX, e.clientY, true)} style={{ left: '50px', zIndex: '100' }}>
+ <FontAwesomeIcon color="white" icon="caret-down" />
+ </div>
+ <button className="submit-buttonpronunciation" onClick={this.evaluatePronunciation}>
+ Evaluate Pronunciation
+ </button>
+ <button className="submit-buttonsubmit" type="button" onClick={this._frontSide ? this.handleRenderClick : this.handleRenderGPTClick}>
+ {this._frontSide ? 'Redo the Question' : 'Submit'}
+ </button>
+ </div>
</div>
</div>
);
@@ -389,7 +798,12 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
onMouseEnter={() => this.hoverFlip(true)}
onMouseLeave={() => this.hoverFlip(false)}>
{displayBox(`${this.fieldKey}_${side === 0 ? 1 : 0}`, side, this._props.PanelWidth() - 3)}
- {this.overlayAlternateIcon}
+ {this._loading ? (
+ <div className="loading-spinner">
+ <ReactLoading type="spin" height={30} width={30} color={'blue'} />
+ </div>
+ ) : null}
+ {this.flashcardMenu}
</div>
);
}
@@ -405,10 +819,9 @@ export class ComparisonBox extends ViewBoxAnnotatableComponent<FieldViewProps>()
className="slide-bar"
style={{
left: `calc(${this.clipWidth + '%'} - 0.5px)`,
- transition: this._animating,
cursor: this.clipWidth < 5 ? 'e-resize' : this.clipWidth / 100 > (this._props.PanelWidth() - 5) / this._props.PanelWidth() ? 'w-resize' : undefined,
}}
- onPointerDown={e => this.registerSliding(e, this._props.PanelWidth() / 2)} /* if clicked, return slide-bar to center */
+ onPointerDown={e => !this._isAnyChildContentActive && this.registerSliding(e, this._props.PanelWidth() / 2)} /* if clicked, return slide-bar to center */
>
<div className="slide-handle" />
</div>
diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx
index afc160297..9aa000ba7 100644
--- a/src/client/views/nodes/DocumentContentsView.tsx
+++ b/src/client/views/nodes/DocumentContentsView.tsx
@@ -129,6 +129,7 @@ export class DocumentContentsView extends ObservableReactComponent<DocumentConte
'childContentPointerEvents',
'LayoutTemplateString',
'LayoutTemplate',
+ 'showTags',
'layoutFieldKey',
'dontCenter',
'DataTransition',
diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx
index 428fe5acb..f7aba7542 100644
--- a/src/client/views/nodes/DocumentView.tsx
+++ b/src/client/views/nodes/DocumentView.tsx
@@ -68,6 +68,7 @@ export interface DocumentViewProps extends FieldViewSharedProps {
contentPointerEvents?: Property.PointerEvents | undefined; // pointer events allowed for content of a document view. eg. set to "none" in menuSidebar for sharedDocs so that you can select a document, but not interact with its contents
dontCenter?: 'x' | 'y' | 'xy';
showTags?: boolean;
+ hideFilterStatus?: boolean;
childHideDecorationTitle?: boolean;
childHideResizeHandles?: boolean;
childDragAction?: dropActionType; // allows child documents to be dragged out of collection without holding the embedKey or dragging the doc decorations title bar.
@@ -105,6 +106,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
private _downTime: number = 0;
private _lastTap: number = 0;
private _doubleTap = false;
+ private _loading = false;
private _mainCont = React.createRef<HTMLDivElement>();
private _titleRef = React.createRef<EditableView>();
private _dropDisposer?: DragManager.DragDropDisposer;
@@ -506,6 +508,7 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
};
onContextMenu = (e?: React.MouseEvent, pageX?: number, pageY?: number) => {
+ if (this._props.dontSelect?.()) return;
if (e && this.layoutDoc.layout_hideContextMenu && Doc.noviceMode) {
e.preventDefault();
e.stopPropagation();
@@ -542,6 +545,9 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
return;
}
+ const items = this._props.styleProvider?.(this.Document, this._props, StyleProp.ContextMenuItems) as ContextMenuProps[];
+ items?.forEach(item => ContextMenu.Instance.addItem(item));
+
const customScripts = Cast(this.Document.contextMenuScripts, listSpec(ScriptField), []);
StrListCast(this.Document.contextMenuLabels).forEach((label, i) =>
cm.addItem({ description: label, event: () => customScripts[i]?.script.run({ documentView: this, this: this.Document, scriptContext: this._props.scriptContext }), icon: 'sticky-note' })
@@ -571,13 +577,13 @@ export class DocumentViewInternal extends DocComponent<FieldViewProps & Document
!appearance && appearanceItems.length && cm.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'compass' });
// creates menu for the user to select how to reveal the flashcards
- if (this.Document._layout_isFlashcard) {
- const revealOptions = cm.findByDescription('Reveal Options');
- const revealItems = revealOptions?.subitems ?? [];
- revealItems.push({ description: 'Hover', event: () => { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'hover'; }, icon: 'hand-point-up' }); // prettier-ignore
- revealItems.push({ description: 'Flip', event: () => { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'flip'; }, icon: 'rotate' }); // prettier-ignore
- !revealOptions && cm.addItem({ description: 'Reveal Options', addDivider: false, noexpand: true, subitems: revealItems, icon: 'layer-group' });
- }
+ // if (this.Document._layout_isFlashcard) {
+ // const revealOptions = cm.findByDescription('Reveal Options');
+ // const revealItems: ContextMenuProps[] = revealOptions && 'subitems' in revealOptions ? revealOptions.subitems : [];
+ // revealItems.push({ description: 'Hover', event: () => { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'hover'; }, icon: 'hand-point-up' }); // prettier-ignore
+ // revealItems.push({ description: 'Flip', event: () => { this.layoutDoc[`_${this._props.fieldKey}_revealOp`] = 'flip'; }, icon: 'rotate' }); // prettier-ignore
+ // !revealOptions && cm.addItem({ description: 'Reveal Options', addDivider: false, noexpand: true, subitems: revealItems, icon: 'layer-group' });
+ // }
if (this._props.bringToFront) {
const zorders = cm.findByDescription('ZOrder...');
@@ -1412,7 +1418,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() {
screenToLocalScale = () => this._props.ScreenToLocalTransform().Scale;
isSelected = () => this.IsSelected;
select = (extendSelection: boolean, focusSelection?: boolean) => {
- DocumentView.SelectView(this, extendSelection);
+ if (!this._props.dontSelect?.()) DocumentView.SelectView(this, extendSelection);
if (focusSelection) {
DocumentView.showDocument(this.Document, {
willZoomCentered: true,
diff --git a/src/client/views/nodes/FieldView.tsx b/src/client/views/nodes/FieldView.tsx
index 741d63909..02d4d9adb 100644
--- a/src/client/views/nodes/FieldView.tsx
+++ b/src/client/views/nodes/FieldView.tsx
@@ -14,6 +14,7 @@ import { DocumentView } from './DocumentView';
import { FocusViewOptions } from './FocusViewOptions';
import { OpenWhere } from './OpenWhere';
import { WebField } from '../../../fields/URLField';
+import { ContextMenuProps } from '../ContextMenuItem';
export type FocusFuncType = (doc: Doc, options: FocusViewOptions) => Opt<number>;
export type StyleProviderFuncType = (
@@ -23,6 +24,7 @@ export type StyleProviderFuncType = (
property: string
) =>
| Opt<FieldType>
+ | ContextMenuProps[]
| { clipPath: string; jsx: JSX.Element }
| JSX.Element
| JSX.IntrinsicElements
@@ -49,6 +51,7 @@ export interface FieldViewSharedProps {
LayoutTemplate?: () => Opt<Doc>;
renderDepth: number;
scriptContext?: unknown; // can be assigned anything and will be passed as 'scriptContext' to any OnClick script that executes on this document
+ screenXPadding?: () => number; // padding in screen space coordinates (used by text box to reflow around UI buttons in carouselView)
xPadding?: number;
yPadding?: number;
dontRegisterView?: boolean;
@@ -69,6 +72,7 @@ export interface FieldViewSharedProps {
PanelHeight: () => number;
isDocumentActive?: () => boolean | undefined; // whether a document should handle pointer events
isContentActive: () => boolean | undefined; // whether document contents should handle pointer events
+ dontSelect?: () => boolean | undefined;
childFilters: () => string[];
childFiltersByRanges: () => string[];
styleProvider: Opt<StyleProviderFuncType>;
diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx
index c156c80e4..0dfc0ec28 100644
--- a/src/client/views/nodes/ImageBox.tsx
+++ b/src/client/views/nodes/ImageBox.tsx
@@ -1,10 +1,12 @@
import { FontAwesomeIcon } from '@fortawesome/react-fontawesome';
import { Tooltip } from '@mui/material';
+import axios from 'axios';
import { Colors } from 'browndash-components';
import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction } from 'mobx';
import { observer } from 'mobx-react';
import { extname } from 'path';
import * as React from 'react';
+import ReactLoading from 'react-loading';
import { ClientUtils, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents } from '../../../ClientUtils';
import { Doc, DocListCast, Opt } from '../../../fields/Doc';
import { DocData } from '../../../fields/DocSymbols';
@@ -17,11 +19,11 @@ import { TraceMobx } from '../../../fields/util';
import { emptyFunction } from '../../../Utils';
import { Docs } from '../../documents/Documents';
import { DocumentType } from '../../documents/DocumentTypes';
-import { DocUtils } from '../../documents/DocUtils';
+import { DocUtils, FollowLinkScript } from '../../documents/DocUtils';
import { Networking } from '../../Network';
import { DragManager } from '../../util/DragManager';
import { SnappingManager } from '../../util/SnappingManager';
-import { undoBatch } from '../../util/UndoManager';
+import { undoable, undoBatch } from '../../util/UndoManager';
import { CollectionFreeFormView } from '../collections/collectionFreeForm/CollectionFreeFormView';
import { ContextMenu } from '../ContextMenu';
import { ContextMenuProps } from '../ContextMenuItem';
@@ -60,25 +62,31 @@ export class ImageEditorData {
public static get AddDoc() { return ImageEditorData.imageData.addDoc; } // prettier-ignore
public static set AddDoc(addDoc: Opt<(doc: Doc | Doc[], annotationKey?: string) => boolean>) { ImageEditorData.set(this.imageData.open, this.imageData.rootDoc, this.imageData.source, addDoc); } // prettier-ignore
}
+
+const API_URL = 'https://api.unsplash.com/search/photos';
@observer
export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
public static LayoutString(fieldKey: string) {
return FieldView.LayoutString(ImageBox, fieldKey);
}
+ _ffref = React.createRef<CollectionFreeFormView>();
private _ignoreScroll = false;
private _forcedScroll = false;
private _dropDisposer?: DragManager.DragDropDisposer;
private _disposers: { [name: string]: IReactionDisposer } = {};
private _getAnchor: (savedAnnotations: Opt<ObservableMap<number, HTMLDivElement[]>>, addAsAnnotation: boolean) => Opt<Doc> = () => undefined;
private _overlayIconRef = React.createRef<HTMLDivElement>();
- private _marqueeref = React.createRef<MarqueeAnnotator>();
private _mainCont: React.RefObject<HTMLDivElement> = React.createRef();
private _annotationLayer: React.RefObject<HTMLDivElement> = React.createRef();
- @observable _savedAnnotations = new ObservableMap<number, (HTMLDivElement & { marqueeing?: boolean })[]>();
- @observable _curSuffix = '';
- @observable _error = '';
- @observable _isHovering = false; // flag to switch between primary and alternate images on hover
- _ffref = React.createRef<CollectionFreeFormView>();
+ imageRef: HTMLImageElement | null = null; // <video> ref
+ marqueeref = React.createRef<MarqueeAnnotator>();
+ @observable Loading = false; // bcz: this should be migrated into StylProviderQuiz since it's not fundamental to the imageBox
+
+ @observable private _searchInput = '';
+ @observable private _savedAnnotations = new ObservableMap<number, (HTMLDivElement & { marqueeing?: boolean })[]>();
+ @observable private _curSuffix = '';
+ @observable private _error = '';
+ @observable private _isHovering = false; // flag to switch between primary and alternate images on hover
constructor(props: FieldViewProps) {
super(props);
@@ -90,7 +98,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
this._dropDisposer?.();
ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.Document));
};
-
getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => {
const visibleAnchor = this._getAnchor?.(this._savedAnnotations, true); // use marquee anchor, otherwise, save zoom/pan as anchor
const anchor =
@@ -149,8 +156,33 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
Object.values(this._disposers).forEach(disposer => disposer?.());
}
- @undoBatch
- drop = (e: Event, de: DragManager.DropEvent) => {
+ /**
+ * Find images from the unsplash api to add to flashcards.
+ */
+ fetchImages = async () => {
+ try {
+ const { data } = await axios.get(`${API_URL}?query=${this._searchInput}&page=1&per_page=${1}&client_id=${process.env.VITE_API_KEY}`);
+ const imageSnapshot = Docs.Create.ImageDocument(data.results[0].urls.small, {
+ _nativeWidth: Doc.NativeWidth(this.layoutDoc),
+ _nativeHeight: Doc.NativeHeight(this.layoutDoc),
+ x: NumCast(this.layoutDoc.x),
+ y: NumCast(this.layoutDoc.y),
+ onClick: FollowLinkScript(),
+ _width: 150,
+ _height: 150,
+ title: '--snapshot' + NumCast(this.layoutDoc._layout_currentTimecode) + ' image-',
+ });
+ this._props.addDocument?.(imageSnapshot);
+ } catch (error) {
+ console.log(error);
+ }
+ };
+
+ handleSelection = async (selection: string) => {
+ this._searchInput = selection;
+ };
+
+ drop = undoable((e: Event, de: DragManager.DropEvent) => {
if (de.complete.docDragData) {
let added: boolean | undefined;
const targetIsBullseye = (ele: HTMLElement): boolean => {
@@ -179,7 +211,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
return added;
}
return false;
- };
+ }, 'image drop');
@undoBatch
resolution = () => {
@@ -404,6 +436,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
<div className="imageBox-fader" style={{ opacity: backAlpha }}>
<img
alt=""
+ ref={action((r: HTMLImageElement | null) => (this.imageRef = r))}
key="paths"
src={srcpath}
style={{ transform, transformOrigin, objectFit: 'fill', height: '100%' }}
@@ -444,7 +477,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
e,
action(moveEv => {
MarqueeAnnotator.clearAnnotations(this._savedAnnotations);
- this._marqueeref.current?.onInitiateSelection([moveEv.clientX, moveEv.clientY]);
+ this.marqueeref.current?.onInitiateSelection([moveEv.clientX, moveEv.clientY]);
return true;
}),
returnFalse,
@@ -456,7 +489,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
@action
finishMarquee = () => {
this._getAnchor = AnchorMenu.Instance?.GetAnchor;
- this._marqueeref.current?.onTerminateSelection();
+ this._props.styleProvider?.(this.Document, this._props, StyleProp.AnchorMenuItems);
+ AnchorMenu.Instance.addToCollection = this._props.DocumentView?.()._props.addDocument;
+ AnchorMenu.Instance.marqueeWidth = this.marqueeref.current?.Width ?? 0;
+ AnchorMenu.Instance.marqueeHeight = this.marqueeref.current?.Height ?? 0;
+ this.marqueeref.current?.onTerminateSelection();
this._props.select(false);
};
focus = (anchor: Doc, options: FocusViewOptions) => (anchor.type === DocumentType.CONFIG ? undefined : this._ffref.current?.focus(anchor, options));
@@ -490,7 +527,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
height: this._props.PanelHeight() ? undefined : `100%`,
pointerEvents: this.layoutDoc._lockedPosition ? 'none' : undefined,
borderRadius,
- overflow: this.layoutDoc.layout_fitWidth || this._props.fitWidth?.(this.Document) ? 'auto' : undefined,
+ overflow: this.layoutDoc.layout_fitWidth || this._props.fitWidth?.(this.Document) ? 'auto' : 'hidden',
}}>
<CollectionFreeFormView
ref={this._ffref}
@@ -517,11 +554,16 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
addDocument={this.addDocument}>
{this.content}
</CollectionFreeFormView>
+ {this.Loading ? (
+ <div className="loading-spinner" style={{ position: 'absolute' }}>
+ <ReactLoading type="spin" height={50} width={50} color={'blue'} />
+ </div>
+ ) : null}
{this.annotationLayer}
{!this._mainCont.current || !this.DocumentView || !this._annotationLayer.current ? null : (
<MarqueeAnnotator
Document={this.Document}
- ref={this._marqueeref}
+ ref={this.marqueeref}
scrollTop={0}
annotationLayerScrollTop={0}
scaling={returnOne}
@@ -535,6 +577,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
marqueeContainer={this._mainCont.current}
highlightDragSrcColor=""
anchorMenuCrop={this.crop}
+ // anchorMenuFlashcard={() => this.getImageDesc()}
/>
)}
</div>
diff --git a/src/client/views/nodes/LabelBox.scss b/src/client/views/nodes/LabelBox.scss
index 0b195713d..ca4b3d467 100644
--- a/src/client/views/nodes/LabelBox.scss
+++ b/src/client/views/nodes/LabelBox.scss
@@ -23,6 +23,41 @@
}
}
+.answer-icon {
+ position: absolute;
+ right: 8;
+ bottom: 5;
+ color: black;
+ display: inline-block;
+ font-size: 10px;
+ cursor: pointer;
+ border-radius: 50%;
+ overflow: hidden;
+}
+
+.q-icon {
+ position: absolute;
+ right: 6;
+ bottom: 5;
+ color: white;
+ display: inline-block;
+ font-size: 10px;
+ cursor: pointer;
+ border-radius: 50%;
+ overflow: hidden;
+}
+
+.edit-icon {
+ position: absolute;
+ right: 20;
+ bottom: 5;
+ display: inline-block;
+ font-size: 10px;
+ cursor: pointer;
+ border-radius: 50%;
+ overflow: hidden;
+}
+
.labelBox-params {
display: flex;
flex-direction: row;
diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx
index 8974cccaf..94a9541f2 100644
--- a/src/client/views/nodes/LabelBox.tsx
+++ b/src/client/views/nodes/LabelBox.tsx
@@ -51,8 +51,6 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
this._timeout && clearTimeout(this._timeout);
}
- specificContextMenu = (): void => {};
-
drop = (/* e: Event, de: DragManager.DropEvent */) => {
return false;
};
@@ -105,7 +103,7 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
const boxParams = this.fitTextToBox(undefined); // this causes mobx to trigger re-render when data changes
const label = this.Title.startsWith('#') ? null : this.Title;
return (
- <div key={label?.length} className="labelBox-outerDiv" ref={this.createDropTarget} onContextMenu={this.specificContextMenu} style={{ boxShadow: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BoxShadow) as string }}>
+ <div key={label?.length} className="labelBox-outerDiv" ref={this.createDropTarget} style={{ boxShadow: this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BoxShadow) as string }}>
<div
className="labelBox-mainButton"
style={{
@@ -134,15 +132,13 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() {
})}
onKeyUp={action(e => {
e.stopPropagation();
- if (e.key === 'Enter') {
- this.dataDoc[this.fieldKey] = this._divRef?.innerText ?? '';
- setTimeout(() => this._props.select(false));
- }
+ this.dataDoc[this.fieldKey] = this._divRef?.innerText ?? '';
+ setTimeout(() => this._props.select(false));
})}
onBlur={() => {
this.dataDoc[this.fieldKey] = this._divRef?.innerText ?? '';
}}
- contentEditable={this._props.onClickScript?.() ? false : true}
+ contentEditable={this._props.onClickScript?.() ? undefined : true}
ref={r => {
this._divRef = r;
this.fitTextToBox(r);
diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx
index 7ef431885..816d4a3b0 100644
--- a/src/client/views/nodes/PDFBox.tsx
+++ b/src/client/views/nodes/PDFBox.tsx
@@ -120,11 +120,9 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
this.replaceCanvases(docViewContent, newDiv);
const htmlString = this._pdfViewer?._mainCont.current && new XMLSerializer().serializeToString(newDiv);
- // const anchx = NumCast(cropping.x);
- // const anchy = NumCast(cropping.y);
const anchw = NumCast(cropping._width) * (this._props.NativeDimScaling?.() || 1);
const anchh = NumCast(cropping._height) * (this._props.NativeDimScaling?.() || 1);
- // const viewScale = 1;
+
cropping.title = 'crop: ' + this.Document.title;
cropping.x = NumCast(this.Document.x) + NumCast(this.layoutDoc._width);
cropping.y = NumCast(this.Document.y);
@@ -471,7 +469,6 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
!Doc.noviceMode && optionItems.push({ description: 'Toggle Sidebar Type', event: this.toggleSidebarType, icon: 'expand-arrows-alt' });
!Doc.noviceMode && optionItems.push({ description: 'update icon', event: () => this.pdfUrl && this.updateIcon(), icon: 'expand-arrows-alt' });
- // optionItems.push({ description: "Toggle Sidebar ", event: () => this.toggleSidebar(), icon: "expand-arrows-alt" });
!options && ContextMenu.Instance.addItem({ description: 'Options...', subitems: optionItems, icon: 'asterisk' });
const help = cm.findByDescription('Help...');
const helpItems = help?.subitems ?? [];
diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx
index d653b27d7..de51f6447 100644
--- a/src/client/views/nodes/VideoBox.tsx
+++ b/src/client/views/nodes/VideoBox.tsx
@@ -879,7 +879,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
ref={action((r: CollectionStackedTimeline) => {
this._stackedTimeline = r;
})}
- // eslint-disable-next-line react/jsx-props-no-spreading
{...this._props}
dataFieldKey={this.fieldKey}
fieldKey={this.annotationKey}
@@ -990,7 +989,6 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() {
left: (this._props.PanelWidth() - this.panelWidth()) / 2,
}}>
<CollectionFreeFormView
- // eslint-disable-next-line react/jsx-props-no-spreading
{...this._props}
ref={this._ffref}
setContentViewBox={emptyFunction}
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss
index 72d550c7e..84859b94d 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.scss
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss
@@ -108,6 +108,15 @@ audiotag:hover {
position: absolute;
}
}
+
+.answer-tooltip {
+ font-size: 15px;
+ padding: 2px;
+ max-width: 150;
+ line-height: 150%;
+ position: relative;
+}
+
.formattedTextBox-alternateButton {
align-items: center;
flex-direction: column;
@@ -116,8 +125,8 @@ audiotag:hover {
background: black;
right: 0;
bottom: 0;
- width: 11;
- height: 11;
+ width: 15;
+ height: 22;
cursor: default;
}
@@ -199,6 +208,8 @@ audiotag:hover {
border-style: inset;
border-width: 1px;
}
+ // margin-left: 5px;
+ // margin-right: 5px;
}
.gpt-typing-wrapper {
diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
index 0d7914a82..18b8c9d34 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBox.tsx
+++ b/src/client/views/nodes/formattedText/FormattedTextBox.tsx
@@ -13,7 +13,7 @@ import { EditorState, NodeSelection, Plugin, Selection, TextSelection, Transacti
import { EditorView, NodeViewConstructor } from 'prosemirror-view';
import * as React from 'react';
import { BsMarkdownFill } from 'react-icons/bs';
-import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, ClientUtils, DivWidth, returnFalse, returnZero, setupMoveUpEvents, simMouseEvent, smoothScroll, StopEvent } from '../../../../ClientUtils';
+import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, ClientUtils, DivWidth, imageUrlToBase64, returnFalse, returnZero, setupMoveUpEvents, simMouseEvent, smoothScroll, StopEvent } from '../../../../ClientUtils';
import { DateField } from '../../../../fields/DateField';
import { CreateLinkToActiveAudio, Doc, DocListCast, Field, FieldType, Opt, StrListCast } from '../../../../fields/Doc';
import { AclAdmin, AclAugment, AclEdit, AclSelfEdit, DocCss, DocData, ForceServerWrite, UpdatingFromServer } from '../../../../fields/DocSymbols';
@@ -26,7 +26,7 @@ import { ComputedField } from '../../../../fields/ScriptField';
import { BoolCast, Cast, DateCast, DocCast, FieldValue, NumCast, RTFCast, ScriptCast, StrCast } from '../../../../fields/Types';
import { GetEffectiveAcl, TraceMobx } from '../../../../fields/util';
import { emptyFunction, numberRange, unimplementedFunction, Utils } from '../../../../Utils';
-import { gptAPICall, GPTCallType } from '../../../apis/gpt/GPT';
+import { gptAPICall, GPTCallType, gptImageLabel } from '../../../apis/gpt/GPT';
import { DocServer } from '../../../DocServer';
import { Docs } from '../../../documents/Documents';
import { CollectionViewType, DocumentType } from '../../../documents/DocumentTypes';
@@ -77,35 +77,37 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
return FieldView.LayoutString(FormattedTextBox, fieldStr);
}
public static MakeConfig(rules?: RichTextRules, props?: FormattedTextBoxProps) {
- const keymapping = buildKeymap(schema, props ?? {});
return {
schema,
plugins: [
inputRules(rules?.inpRules ?? { rules: [] }),
...(props ? [FormattedTextBox.richTextMenuPlugin(props)] : []),
history(),
- keymap(keymapping),
+ keymap(buildKeymap(schema, props ?? {})),
keymap(baseKeymap),
new Plugin({ props: { attributes: { class: 'ProseMirror-example-setup-style' } } }),
new Plugin({ view: () => new FormattedTextBoxComment() }),
],
};
}
- private static nodeViews: (self: FormattedTextBox) => { [key: string]: NodeViewConstructor };
/**
* Initialize the class with all the plugin node view components
* @param nodeViews prosemirror plugins that render a custom UI for specific node types
*/
- public static Init(nodeViews: (self: FormattedTextBox) => { [key: string]: NodeViewConstructor }) {
- FormattedTextBox.nodeViews = nodeViews;
- }
+ public static Init(nodeViews: (self: FormattedTextBox) => { [key: string]: NodeViewConstructor }) { FormattedTextBox._nodeViews = nodeViews; } // prettier-ignore
+
+ public static PasteOnLoad: ClipboardEvent | undefined;
+ public static DontSelectInitialText = false; // whether initial text should be selected or not
+ public static SelectOnLoadChar = '';
public static LiveTextUndo: UndoManager.Batch | undefined; // undo batch when typing a new text note into a collection
- static _globalHighlightsCache: string = '';
- static _globalHighlights = new ObservableSet<string>(['Audio Tags', 'Text from Others', 'Todo Items', 'Important Items', 'Disagree Items', 'Ignore Items']);
- static _highlightStyleSheet = addStyleSheet();
- static _bulletStyleSheet = addStyleSheet();
- static _userStyleSheet = addStyleSheet();
- static _hadSelection: boolean = false;
+
+ private static _nodeViews: (self: FormattedTextBox) => { [key: string]: NodeViewConstructor };
+ private static _globalHighlightsCache: string = '';
+ private static _globalHighlights = new ObservableSet<string>(['Audio Tags', 'Text from Others', 'Todo Items', 'Important Items', 'Disagree Items', 'Ignore Items']);
+ private static _highlightStyleSheet = addStyleSheet();
+ private static _bulletStyleSheet = addStyleSheet();
+ private static _userStyleSheet = addStyleSheet();
+
private _oldWheel: HTMLDivElement | null = null;
private _selectionHTML: string | undefined;
private _sidebarRef = React.createRef<SidebarAnnos>();
@@ -113,7 +115,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
private _ref: React.RefObject<HTMLDivElement> = React.createRef();
private _scrollRef: HTMLDivElement | null = null;
private _editorView: Opt<EditorView & { TextView?: FormattedTextBox | undefined }>;
- public _applyingChange: string = '';
private _inDrop = false;
private _finishingLink = false;
private _searchIndex = 0;
@@ -127,38 +128,36 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
private _rules: RichTextRules | undefined;
private _forceUncollapse = true; // if the cursor doesn't move between clicks, then the selection will disappear for some reason. This flags the 2nd click as happening on a selection which allows bullet points to toggle
private _break = true;
+
+ public _applyingChange: string = '';
public ProseRef?: HTMLDivElement;
+
+ @observable _showSidebar = false;
+
+ @computed get fontColor() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontColor) as string; } // prettier-ignore
+ @computed get fontSize() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontSize) as string; } // prettier-ignore
+ @computed get fontFamily() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontFamily) as string; } // prettier-ignore
+ @computed get fontWeight() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontWeight) as string; } // prettier-ignore
+
set _recordingDictation(value) {
!this.dataDoc[`${this.fieldKey}_recordingSource`] && (this.dataDoc.mediaState = value ? mediaState.Recording : undefined);
}
+
+ // eslint-disable-next-line no-return-assign
+ @computed get config() { return FormattedTextBox.MakeConfig(this._rules = new RichTextRules(this.Document, this), this._props); } // prettier-ignore
@computed get _recordingDictation() { return this.dataDoc?.mediaState === mediaState.Recording; } // prettier-ignore
- @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.SidebarKey]); } // prettier-ignore
+ @computed get SidebarShown() { return !!(this._showSidebar || this.layoutDoc._layout_showSidebar); } // prettier-ignore
+ @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.sidebarKey]); } // prettier-ignore
@computed get noSidebar() { return this.DocumentView?.()._props.hideDecorationTitle || this._props.noSidebar || this.Document._layout_noSidebar; } // prettier-ignore
@computed get layout_sidebarWidthPercent() { return this._showSidebar ? '20%' : StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%'); } // prettier-ignore
@computed get sidebarColor() { return StrCast(this.layoutDoc.sidebar_color, StrCast(this.layoutDoc[this.fieldKey + '_backgroundColor'], '#e4e4e4')); } // prettier-ignore
@computed get layout_autoHeight() { return (this._props.forceAutoHeight || this.layoutDoc._layout_autoHeight) && !this._props.ignoreAutoHeight; } // prettier-ignore
@computed get textHeight() { return NumCast(this.dataDoc[this.fieldKey + '_height']); } // prettier-ignore
@computed get scrollHeight() { return NumCast(this.dataDoc[this.fieldKey + '_scrollHeight']); } // prettier-ignore
- @computed get sidebarHeight() { return !this.sidebarWidth() ? 0 : NumCast(this.dataDoc[this.SidebarKey + '_height']); } // prettier-ignore
+ @computed get sidebarHeight() { return !this.sidebarWidth() ? 0 : NumCast(this.dataDoc[this.sidebarKey + '_height']); } // prettier-ignore
@computed get titleHeight() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.HeaderMargin) as number || 0; } // prettier-ignore
@computed get layout_autoHeightMargins() { return this.titleHeight + NumCast(this.layoutDoc._layout_autoHeightMargins); } // prettier-ignore
- @computed get config() {
- this._rules = new RichTextRules(this.Document, this);
- return FormattedTextBox.MakeConfig(this._rules, this._props);
- }
-
- public get EditorView() {
- return this._editorView;
- }
- public get SidebarKey() {
- return this.fieldKey + '_sidebar';
- }
- public makeAIFlashcards: () => void = unimplementedFunction;
- public addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined;
-
- public static PasteOnLoad: ClipboardEvent | undefined;
- public static DontSelectInitialText = false; // whether initial text should be selected or not
- public static SelectOnLoadChar = '';
+ @computed get sidebarKey() { return this.fieldKey + '_sidebar'; } // prettier-ignore
constructor(props: FormattedTextBoxProps) {
super(props);
@@ -166,6 +165,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
this._recordingStart = Date.now();
}
+ public get EditorView() { return this._editorView; } // prettier-ignore
+
+ // public makeAIFlashcards: () => void = unimplementedFunction;
+ public addToCollection: ((doc: Doc | Doc[], annotationKey?: string | undefined) => boolean) | undefined;
+
// removes all hyperlink anchors for the removed linkDoc
// TODO: bcz: Argh... if a section of text has multiple anchors, this should just remove the intended one.
// but since removing one anchor from the list of attr anchors isn't implemented, this will end up removing nothing.
@@ -212,9 +216,23 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
return anchor;
};
+ gptPDFFlashcards = async () => {
+ const queryText = window.getSelection()?.toString() ?? '';
+ try {
+ if (queryText) {
+ const res = await gptAPICall(queryText, GPTCallType.FLASHCARD);
+ AnchorMenu.Instance.transferToFlashcard(res || 'Something went wrong', NumCast(this.layoutDoc.x), NumCast(this.layoutDoc.y));
+ }
+ } catch (err) {
+ console.error(err);
+ }
+ };
+
@action
setupAnchorMenu = () => {
AnchorMenu.Instance.Status = 'marquee';
+ AnchorMenu.Instance.gptFlashcards = this.gptPDFFlashcards;
+ AnchorMenu.Instance.addToCollection = this._props.DocumentView?.()._props.addDocument;
AnchorMenu.Instance.OnClick = () => {
!this.layoutDoc.layout_showSidebar && this.toggleSidebar();
setTimeout(() => this._sidebarRef.current?.anchorMenuClick(this.makeLinkAnchor(undefined, OpenWhere.addRight, undefined, 'Anchored Selection', true))); // give time for sidebarRef to be created
@@ -338,10 +356,10 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
const rtField = (layoutData !== prevData ? layoutData : undefined) ?? protoData;
if (this._applyingChange !== this.fieldKey && (force || textChange || removeSelection(newJson) !== removeSelection(prevData?.Data))) {
this._applyingChange = this.fieldKey;
- textChange && (dataDoc[this.fieldKey + '_modificationDate'] = new DateField(new Date(Date.now())));
if ((!prevData && !protoData && !layoutData) || newText || (!newText && !protoData && !layoutData)) {
// 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 (force || ((this._finishingLink || this._props.isContentActive() || this._inDrop) && (textChange || removeSelection(newJson) !== removeSelection(prevData?.Data)))) {
+ textChange && (dataDoc[this.fieldKey + '_modificationDate'] = new DateField(new Date(Date.now())));
const numstring = NumCast(dataDoc[this.fieldKey], null);
dataDoc[this.fieldKey] =
numstring !== undefined ? Number(newText) : newText || (DocCast(dataDoc.proto)?.[this.fieldKey] === undefined && this.layoutDoc[this.fieldKey] === undefined) ? new RichTextField(newJson, newText) : undefined;
@@ -350,6 +368,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
unchanged = false;
}
} else if (rtField) {
+ textChange && (dataDoc[this.fieldKey + '_modificationDate'] = new DateField(new Date(Date.now())));
// if we've deleted all the text in a note driven by a template, then restore the template data
dataDoc[this.fieldKey] = undefined;
this._editorView.updateState(EditorState.fromJSON(this.config, JSON.parse(rtField.Data)));
@@ -717,18 +736,13 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
this.layoutDoc[DocCss] = this.layoutDoc[DocCss] + 1; // css changes happen outside of react/mobx. so we need to set a flag that will notify anyone interested in layout changes triggered by css changes (eg., CollectionLinkView)
};
- @observable _showSidebar = false;
- @computed get SidebarShown() {
- return !!(this._showSidebar || this.layoutDoc._layout_showSidebar);
- }
-
@action
toggleSidebar = (preview: boolean = false) => {
const defaultSidebar = 250;
const prevWidth = 1 - this.sidebarWidth() / DivWidth(this._ref.current!);
if (preview) this._showSidebar = true;
else {
- this.layoutDoc[this.SidebarKey + '_freeform_scale_max'] = 1;
+ this.layoutDoc[this.sidebarKey + '_freeform_scale_max'] = 1;
this.layoutDoc._layout_showSidebar =
(this.layoutDoc._layout_sidebarWidthPercent = StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%') === '0%' ? `${(defaultSidebar / (NumCast(this.layoutDoc._width) + defaultSidebar)) * 100}%` : '0%') !== '0%';
}
@@ -750,10 +764,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
);
};
sidebarMove = (e: PointerEvent, down: number[], delta: number[]) => {
- const localDelta = this._props
- .ScreenToLocalTransform()
- .scale(this._props.NativeDimScaling?.() || 1)
- .transformDirection(delta[0], delta[1]);
+ const localDelta = this.DocumentView?.().screenToViewTransform().transformDirection(delta[0], delta[1]) ?? delta;
const sidebarWidth = (NumCast(this.layoutDoc._width) * Number(this.layout_sidebarWidthPercent.replace('%', ''))) / 100;
const width = NumCast(this.layoutDoc._width) + localDelta[0];
this.layoutDoc._layout_sidebarWidthPercent = Math.max(0, (sidebarWidth + localDelta[0]) / width) * 100 + '%';
@@ -793,6 +804,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
isTargetToggler = (anchor: Doc) => BoolCast(anchor.followLinkToggle);
specificContextMenu = (e: React.MouseEvent): void => {
+ if (this._props.dontSelect?.()) return;
const cm = ContextMenu.Instance;
let target: Element | HTMLElement | null = e.target as HTMLElement; // hrefs are stored on the database of the <a> node that wraps the hyerlink <span>
@@ -886,6 +898,11 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
);
const appearance = cm.findByDescription('Appearance...');
const appearanceItems = appearance?.subitems ?? [];
+ // appearanceItems.push({
+ // description: 'Find image tags',
+ // event: this.findImageTags,
+ // icon: !this.Document._layout_noSidebar ? 'eye-slash' : 'eye',
+ // });
appearanceItems.push({
description: !this.Document._layout_noSidebar ? 'Hide Sidebar Handle' : 'Show Sidebar Handle',
@@ -949,7 +966,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
icon: 'star',
});
optionItems.push({ description: `Generate Dall-E Image`, event: () => this.generateImage(), icon: 'star' });
- optionItems.push({ description: `Make AI Flashcards`, event: () => this.makeAIFlashcards(), icon: 'lightbulb' });
+ // optionItems.push({ description: `Make AI Flashcards`, event: () => this.makeAIFlashcards(), icon: 'lightbulb' });
optionItems.push({ description: `Ask GPT-3`, event: this.askGPT, icon: 'lightbulb' });
this._props.renderDepth &&
optionItems.push({
@@ -974,6 +991,34 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
!help && cm.addItem({ description: 'Help...', subitems: helpItems, icon: 'eye' });
};
+ findImageTags = async () => {
+ const c = this.ProseRef?.getElementsByTagName('img');
+ if (c) {
+ for (const i of c) {
+ console.log(i);
+
+ // console.log(canvas.toDataURL());
+ // canvas.style.zIndex = '2000000';
+ // document.body.appendChild(canvas);
+ if (i.className !== 'ProseMirror-separator') this.getImageDesc(i.src);
+ }
+ }
+ // console.log('HI' + this.ProseRef?.getElementsByTagName('img'));
+ };
+
+ getImageDesc = async (u: string) => {
+ try {
+ const hrefBase64 = await imageUrlToBase64(u);
+ const response = await gptImageLabel(
+ hrefBase64,
+ 'Make flashcards out of this text and image with each question and answer labeled as question and answer. Do not label each flashcard and do not include asterisks: ' + (this.dataDoc.text as RichTextField)?.Text
+ );
+ AnchorMenu.Instance.transferToFlashcard(response || 'Something went wrong', NumCast(this.dataDoc['x']), NumCast(this.dataDoc['y']));
+ } catch (error) {
+ console.log('Error', error);
+ }
+ };
+
animateRes = (resIndex: number, newText: string) => {
if (resIndex < newText.length) {
const marks = this._editorView?.state.storedMarks ?? [];
@@ -984,7 +1029,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
askGPT = action(async () => {
try {
- GPTPopup.Instance.setSidebarId(this.SidebarKey);
+ GPTPopup.Instance.setSidebarId(this.sidebarKey);
GPTPopup.Instance.addDoc = this.sidebarAddDocument;
const res = await gptAPICall((this.dataDoc.text as RichTextField)?.Text, GPTCallType.COMPLETION);
if (!res) {
@@ -1111,7 +1156,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
}
getView = async (doc: Doc, options: FocusViewOptions) => {
- if (DocListCast(this.dataDoc[this.SidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) {
+ if (DocListCast(this.dataDoc[this.sidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) {
if (!this.SidebarShown) {
this.toggleSidebar(false);
options.didMove = true;
@@ -1207,7 +1252,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
this._cachedLinks = Doc.Links(this.Document);
this._disposers.breakupDictation = reaction(() => Doc.RecordingEvent, this.breakupDictation);
this._disposers.layout_autoHeight = reaction(
- () => ({ autoHeight: this.layout_autoHeight, fontSize: this.fontSize, css: this.Document[DocCss] }),
+ () => ({ autoHeight: this.layout_autoHeight, fontSize: this.fontSize, css: this.Document[DocCss], xMargin: this.Document.xMargin, yMargin: this.Document.yMargin }),
autoHeight => setTimeout(() => autoHeight && this.tryUpdateScrollHeight())
);
this._disposers.highlights = reaction(
@@ -1323,7 +1368,33 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
{ fireImmediately: true }
);
this.tryUpdateScrollHeight();
+
+ if (this.Document.image) {
+ // const node = schema.nodes.dashDoc.create({
+ // width: 200,
+ // height: 200,
+ // title: 'dashDoc',
+ // docId: DocCast(this.Document.image)[Id],
+ // float: 'unset',
+ // });
+
+ // DocCast(this.Document.image)._freeform_fitContentsToBox = true;
+ // Doc.SetContainer(DocCast(this.Document.image), this.Document);
+ // const view = this._editorView!;
+ // try {
+ // this._inDrop = true;
+ // const pos = view.posAtCoords({ left: 0, top: 0 })?.pos;
+ // pos && view.dispatch(view.state.tr.insert(pos, node));
+ // } catch (err) {
+ // console.log('Drop failed', err);
+ // }
+ // console.log('LKSDFLJ');
+ this.addDocument?.(DocCast(this.Document.image));
+ }
+
+ //if (this.Document.image) this.addDocument?.(DocCast(this.Document.image));
setTimeout(this.tryUpdateScrollHeight, 250);
+ AnchorMenu.Instance.addToCollection = this._props.DocumentView?.()._props.addDocument;
}
clipboardTextSerializer = (slice: Slice): string => {
@@ -1431,7 +1502,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
return true;
},
dispatchTransaction: this.dispatchTransaction,
- nodeViews: FormattedTextBox.nodeViews(this),
+ nodeViews: FormattedTextBox._nodeViews(this),
clipboardTextSerializer: this.clipboardTextSerializer,
handlePaste: this.handlePaste,
});
@@ -1447,6 +1518,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
selectAll(this._editorView.state, tr => {
this._editorView!.dispatch(tr.replaceSelectionWith(state.schema.nodes.paragraph.create({ align: textAlign })));
});
+ this.tryUpdateDoc(true);
}
}
this._editorView.TextView = this;
@@ -1566,7 +1638,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
}
};
onSelectEnd = () => {
- GPTPopup.Instance.setSidebarId(this.SidebarKey);
+ GPTPopup.Instance.setSidebarId(this.sidebarKey);
GPTPopup.Instance.addDoc = this.sidebarAddDocument;
document.removeEventListener('pointerup', this.onSelectEnd);
};
@@ -1706,7 +1778,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
if (RichTextMenu.Instance?.view === this._editorView && !this._props.rootSelected?.()) {
RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined);
}
- FormattedTextBox._hadSelection = window.getSelection()?.toString() !== '';
// this is the markdown for @<published name> document publishing to Doc.myPublishedDocs
const match = RTFCast(this.Document[this.fieldKey])?.Text.match(/^(@[a-zA-Z][a-zA-Z_0-9 -]*[a-zA-Z_0-9-]+)/);
@@ -1818,14 +1889,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
};
fitContentsToBox = () => BoolCast(this.Document._freeform_fitContentsToBox);
sidebarContentScaling = () => (this._props.NativeDimScaling?.() || 1) * NumCast(this.layoutDoc._freeform_scale, 1);
- sidebarAddDocument = (doc: Doc | Doc[], sidebarKey: string = this.SidebarKey) => {
+ sidebarAddDocument = (doc: Doc | Doc[], sidebarKey: string = this.sidebarKey) => {
if (!this.layoutDoc._layout_showSidebar) this.toggleSidebar();
return this.addDocument(doc, sidebarKey);
};
- sidebarMoveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean) => this.moveDocument(doc, targetCollection, addDocument, this.SidebarKey);
- sidebarRemDocument = (doc: Doc | Doc[]) => this.removeDocument(doc, this.SidebarKey);
+ sidebarMoveDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (doc: Doc | Doc[]) => boolean) => this.moveDocument(doc, targetCollection, addDocument, this.sidebarKey);
+ sidebarRemDocument = (doc: Doc | Doc[]) => this.removeDocument(doc, this.sidebarKey);
setSidebarHeight = (height: number) => {
- this.dataDoc[this.SidebarKey + '_height'] = height;
+ this.dataDoc[this.sidebarKey + '_height'] = height;
};
sidebarWidth = () => (Number(this.layout_sidebarWidthPercent.substring(0, this.layout_sidebarWidthPercent.length - 1)) / 100) * this._props.PanelWidth();
sidebarScreenToLocal = () =>
@@ -1855,7 +1926,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
}
@computed get sidebarHandle() {
TraceMobx();
- const annotated = DocListCast(this.dataDoc[this.SidebarKey]).filter(d => d?.author).length;
+ const annotated = DocListCast(this.dataDoc[this.sidebarKey]).filter(d => d?.author).length;
const color = !annotated ? Colors.WHITE : Colors.BLACK;
const backgroundColor = !annotated ? (this.sidebarWidth() ? Colors.MEDIUM_BLUE : Colors.BLACK) : (this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.WidgetColor + (annotated ? ':annotated' : '')) as string);
@@ -1907,7 +1978,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
PanelWidth={this.sidebarWidth}
xPadding={0}
yPadding={0}
- viewField={this.SidebarKey}
+ viewField={this.sidebarKey}
isAnnotationOverlay={false}
select={emptyFunction}
isAnyChildContentActive={returnFalse}
@@ -1922,14 +1993,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
fitContentsToBox={this.fitContentsToBox}
noSidebar
treeViewHideTitle
- fieldKey={this.layoutDoc[this.SidebarKey + '_type_collection'] === 'translation' ? `${this.fieldKey}_translation` : `${this.fieldKey}_sidebar`}
+ fieldKey={this.layoutDoc[this.sidebarKey + '_type_collection'] === 'translation' ? `${this.fieldKey}_translation` : `${this.fieldKey}_sidebar`}
/>
</div>
);
};
return (
<div className={'formattedTextBox-sidebar' + (Doc.ActiveTool !== InkTool.None ? '-inking' : '')} style={{ width: `${this.layout_sidebarWidthPercent}`, backgroundColor: `${this.sidebarColor}` }}>
- {renderComponent(StrCast(this.layoutDoc[this.SidebarKey + '_type_collection']))}
+ {renderComponent(StrCast(this.layoutDoc[this.sidebarKey + '_type_collection']))}
</div>
);
}
@@ -1971,6 +2042,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
</Tooltip>
);
}
+
get fieldKey() {
return this._fieldKey;
}
@@ -1999,17 +2071,18 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
e.stopPropagation();
}
};
- @computed get fontColor() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontColor) as string; } // prettier-ignore
- @computed get fontSize() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontSize) as string; } // prettier-ignore
- @computed get fontFamily() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontFamily) as string; } // prettier-ignore
- @computed get fontWeight() { return this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.FontWeight) as string; } // prettier-ignore
+
render() {
TraceMobx();
const scale = this._props.NativeDimScaling?.() || 1;
const rounded = StrCast(this.layoutDoc.layout_borderRounding) === '100%' ? '-rounded' : '';
setTimeout(() => !this._props.isContentActive() && FormattedTextBoxComment.textBox === this && FormattedTextBoxComment.Hide);
- const paddingX = Math.max(this._props.xPadding ?? 0, NumCast(this.layoutDoc._xMargin));
- const paddingY = Math.max(this._props.yPadding ?? 0, NumCast(this.layoutDoc._yMargin));
+
+ const scrSize = (which: number, view = this._props.docViewPath().slice(-which)[0]) =>
+ [view._props.PanelWidth() / view.screenToLocalScale(), view._props.PanelHeight() / view.screenToLocalScale()]; // prettier-ignore
+ const scrMargin = [Math.max(0, (scrSize(2)[0] - scrSize(1)[0]) / 2), Math.max(0, (scrSize(2)[1] - scrSize(1)[1]) / 2)];
+ const paddingX = Math.max(NumCast(this.layoutDoc._xMargin), this._props.xPadding ?? 0, 0, ((this._props.screenXPadding?.() ?? 0) - scrMargin[0]) * this.ScreenToLocalBoxXf().Scale);
+ const paddingY = Math.max(NumCast(this.layoutDoc._yMargin), 0, ((this._props.yPadding ?? 0) - scrMargin[1]) * this.ScreenToLocalBoxXf().Scale);
const styleFromLayout = styleFromLayoutString(this.Document, this._props, scale); // this converts any expressions in the format string to style props. e.g., <FormattedTextBox height='{this._header_height}px' >
return styleFromLayout?.height === '0px' ? null : (
<div
@@ -2080,7 +2153,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB
/>
</div>
{this.noSidebar || !this.SidebarShown || this.layout_sidebarWidthPercent === '0%' ? null : this.sidebarCollection}
- {this.noSidebar || this.Document._layout_noSidebar || this.Document._createDocOnCR || this.layoutDoc._chromeHidden ? null : this.sidebarHandle}
+ {this.noSidebar || this.Document._layout_noSidebar || this.Document._createDocOnCR || this.layoutDoc._chromeHidden || this.Document.quiz ? null : this.sidebarHandle}
{this.audioHandle}
{this.layoutDoc._layout_enableAltContentUI && !this.layoutDoc._chromeHidden ? this.overlayAlternateIcon : null}
</div>
@@ -2099,7 +2172,6 @@ Docs.Prototypes.TemplateMap.set(DocumentType.RTF, {
_layout_nativeDimEditable: true,
_layout_reflowVertical: true,
_layout_reflowHorizontal: true,
- _layout_noSidebar: true,
defaultDoubleClick: 'ignore',
systemIcon: 'BsFileEarmarkTextFill',
},
diff --git a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss
index 55b8446e9..bc0810f22 100644
--- a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss
+++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss
@@ -13,7 +13,7 @@
box-shadow: 3px 3px 1.5px grey;
max-width: 400;
max-height: 235;
- height:max-content;
+ height: max-content;
.formattedTextBox-tooltipText {
height: max-content;
text-overflow: ellipsis;
@@ -21,7 +21,7 @@
}
.formattedTextBox-tooltip:before {
- content: "";
+ content: '';
height: 0;
width: 0;
position: absolute;
@@ -34,7 +34,7 @@
}
.formattedTextBox-tooltip:after {
- content: "";
+ content: '';
height: 0;
width: 0;
position: absolute;
@@ -44,4 +44,4 @@
border: 5px solid transparent;
border-bottom-width: 0;
border-top-color: white;
-} \ No newline at end of file
+}
diff --git a/src/client/views/pdf/AnchorMenu.tsx b/src/client/views/pdf/AnchorMenu.tsx
index 1a79bbbfe..7243473e0 100644
--- a/src/client/views/pdf/AnchorMenu.tsx
+++ b/src/client/views/pdf/AnchorMenu.tsx
@@ -28,6 +28,7 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
private _disposer: IReactionDisposer | undefined;
private _commentRef = React.createRef<HTMLDivElement>();
private _cropRef = React.createRef<HTMLDivElement>();
+ @observable private _loading = false;
constructor(props: AntimodeMenuProps) {
super(props);
@@ -42,12 +43,22 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
// GPT additions
@observable private _selectedText: string = '';
+ @observable private _x: number = 0;
+ @observable private _y: number = 0;
@observable private _isLoading: boolean = false;
@action
public setSelectedText = (txt: string) => {
this._selectedText = txt.trim();
};
+ @action
+ public setLocation = (x: number, y: number) => {
+ this._x = x;
+ this._y = y;
+ };
+ @computed public get selectedText() {
+ return this._selectedText;
+ }
public onMakeAnchor: () => Opt<Doc> = () => undefined; // Method to get anchor from text search
public OnCrop: (e: PointerEvent) => void = unimplementedFunction;
@@ -62,6 +73,10 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
public MakeTargetToggle: () => void = unimplementedFunction;
public ShowTargetTrail: () => void = unimplementedFunction;
public IsTargetToggler: () => boolean = returnFalse;
+ public gptFlashcards: () => void = unimplementedFunction;
+ public makeLabels: () => void = unimplementedFunction;
+ public marqueeWidth = 0;
+ public marqueeHeight = 0;
public get Active() {
return this._left > 0;
}
@@ -96,39 +111,19 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
}
GPTPopup.Instance.setLoading(false);
};
- // gptSummarize = async () => {
- // GPTPopup.Instance?.setSelectedText(this._selectedText);
- // GPTPopup.Instance.generateSummary();
- // };
-
- /**
- * Invokes the API with the selected text and stores it in the selected text.
- * @param e pointer down event
- */
- gptFlashcards = async () => {
- const queryText = this._selectedText;
- try {
- const res = await gptAPICall(queryText, GPTCallType.FLASHCARD);
- console.log(res);
- GPTPopup.Instance.setText(res || 'Something went wrong.');
- this.transferToFlashcard(res || 'Something went wrong');
- } catch (err) {
- console.error(err);
- }
- GPTPopup.Instance.setLoading(false);
- };
/*
* Transfers the flashcard text generated by GPT on flashcards and creates a collection out them.
*/
- transferToFlashcard = (text: string) => {
+
+ transferToFlashcard = (text: string, x: number, y: number) => {
// put each question generated by GPT on the front of the flashcard
- const senArr = text.split('Question');
+ const senArr = text.trim().split('Question:');
const collectionArr: Doc[] = [];
for (let i = 1; i < senArr.length; i++) {
- console.log('Arr ' + i + ': ' + senArr[i]);
const newDoc = Docs.Create.ComparisonDocument(senArr[i], { _layout_isFlashcard: true, _width: 300, _height: 300 });
newDoc.text = senArr[i];
+
collectionArr.push(newDoc);
}
// create a new carousel collection of these flashcards
@@ -139,7 +134,12 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
_layout_autoHeight: true,
});
+ newCol.x = x;
+ newCol.y = y;
+ newCol.zIndex = 1000;
+
this.addToCollection?.(newCol);
+ this._loading = false;
};
/**
@@ -254,12 +254,8 @@ export class AnchorMenu extends AntimodeMenu<AntimodeMenuProps> {
/>
)}
{/* Adds a create flashcards option to the anchor menu, which calls the gptFlashcard method. */}
- <IconButton
- tooltip="Create flashcards" //
- onPointerDown={this.gptFlashcards}
- icon={<FontAwesomeIcon icon="id-card" size="lg" />}
- color={SettingsManager.userColor}
- />
+ <IconButton tooltip="Create flashcards" onPointerDown={this.gptFlashcards} icon={<FontAwesomeIcon icon="layer-group" size="lg" />} color={SettingsManager.userColor} />
+ <IconButton tooltip="Create labels" onPointerDown={this.makeLabels} icon={<FontAwesomeIcon icon="tag" size="lg" />} color={SettingsManager.userColor} />
{this._selectedText && (
<IconButton
tooltip="Create drawing"
diff --git a/src/client/views/pdf/Annotation.scss b/src/client/views/pdf/Annotation.scss
index 1de60ffed..da7efe3da 100644
--- a/src/client/views/pdf/Annotation.scss
+++ b/src/client/views/pdf/Annotation.scss
@@ -7,4 +7,4 @@
&:hover {
cursor: pointer;
}
-} \ No newline at end of file
+}
diff --git a/src/client/views/pdf/PDFViewer.scss b/src/client/views/pdf/PDFViewer.scss
index d3dd9f727..a225c4b59 100644
--- a/src/client/views/pdf/PDFViewer.scss
+++ b/src/client/views/pdf/PDFViewer.scss
@@ -19,10 +19,6 @@
overflow-x: hidden;
transform-origin: top left;
- // .canvasWrapper {
- // transform: scale(0.75);
- // transform-origin: top left;
- // }
.textLayer {
opacity: unset;
mix-blend-mode: multiply; // bcz: makes text fuzzy!
@@ -107,3 +103,22 @@
.pdfViewerDash-interactive {
pointer-events: all;
}
+
+.loading-spinner {
+ position: absolute;
+ display: flex;
+ justify-content: center;
+ align-items: center;
+ height: 100%;
+ width: 100%;
+ z-index: 200;
+ font-size: 20px;
+ font-weight: bold;
+ color: #17175e;
+}
+
+@keyframes spin {
+ to {
+ transform: rotate(360deg);
+ }
+}
diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx
index 7a86ee802..358557ad7 100644
--- a/src/client/views/pdf/PDFViewer.tsx
+++ b/src/client/views/pdf/PDFViewer.tsx
@@ -28,8 +28,8 @@ import { AnchorMenu } from './AnchorMenu';
import { Annotation } from './Annotation';
import { GPTPopup } from './GPTPopup/GPTPopup';
import './PDFViewer.scss';
-// The workerSrc property shall be specified.
-// Pdfjs.GlobalWorkerOptions.workerSrc = 'https://unpkg.com/pdfjs-dist@4.4.168/build/pdf.worker.mjs';
+import { GPTCallType, gptAPICall } from '../../apis/gpt/GPT';
+import ReactLoading from 'react-loading';
interface IViewerProps extends FieldViewProps {
pdfBox: PDFBox;
@@ -64,6 +64,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> {
@observable _textSelecting = true;
@observable _showWaiting = true;
@observable Index: number = -1;
+ @observable private _loading = false;
private _pdfViewer!: PDFJSViewer.PDFViewer;
private _styleRule: number | undefined; // stylesheet rule for making hyperlinks clickable
@@ -394,6 +395,23 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> {
}
};
+ /**
+ * Create a flashcard pile based on the selected text of a pdf.
+ */
+ gptPDFFlashcards = async () => {
+ const queryText = this._selectionText;
+ this._loading = true;
+ try {
+ const res = await gptAPICall(queryText, GPTCallType.FLASHCARD);
+
+ AnchorMenu.Instance.transferToFlashcard(res || 'Something went wrong', NumCast(this._props.layoutDoc['x']), NumCast(this._props.layoutDoc['y']));
+ this._selectionText = '';
+ } catch (err) {
+ console.error(err);
+ }
+ this._loading = false;
+ };
+
@action
finishMarquee = (/* x?: number, y?: number */) => {
this._getAnchor = AnchorMenu.Instance?.GetAnchor;
@@ -411,8 +429,10 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> {
document.removeEventListener('pointerup', this.onSelectEnd);
const sel = window.getSelection();
+
if (sel) {
AnchorMenu.Instance.setSelectedText(sel.toString());
+ AnchorMenu.Instance.setLocation(NumCast(this._props.layoutDoc['x']), NumCast(this._props.layoutDoc['y']));
}
if (sel?.type === 'Range') {
@@ -424,6 +444,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> {
GPTPopup.Instance.addDoc = this._props.sidebarAddDoc;
// allows for creating collection
AnchorMenu.Instance.addToCollection = this._props.DocumentView?.()._props.addDocument;
+ AnchorMenu.Instance.gptFlashcards = this.gptPDFFlashcards;
AnchorMenu.Instance.AddDrawingAnnotation = this.addDrawingAnnotation;
};
@@ -459,6 +480,7 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> {
this._mainCont.current!.style.transform = '';
}
this._selectionContent = selRange.cloneContents();
+
this._selectionText = this._selectionContent?.textContent || '';
// clear selection
@@ -617,6 +639,11 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> {
/>
)}
</div>
+ {this._loading ? (
+ <div className="loading-spinner" style={{ position: 'absolute' }}>
+ <ReactLoading type="spin" height={80} width={80} color={'blue'} />
+ </div>
+ ) : null}
</div>
);
}
diff --git a/src/client/views/smartdraw/SmartDrawHandler.tsx b/src/client/views/smartdraw/SmartDrawHandler.tsx
index 75ef55060..b4635673c 100644
--- a/src/client/views/smartdraw/SmartDrawHandler.tsx
+++ b/src/client/views/smartdraw/SmartDrawHandler.tsx
@@ -7,6 +7,7 @@ import React from 'react';
import { AiOutlineSend } from 'react-icons/ai';
import ReactLoading from 'react-loading';
import { INode, parse } from 'svgson';
+import { imageUrlToBase64 } from '../../../ClientUtils';
import { unimplementedFunction } from '../../../Utils';
import { Doc, DocListCast } from '../../../fields/Doc';
import { DocData } from '../../../fields/DocSymbols';
@@ -19,7 +20,6 @@ import { undoable } from '../../util/UndoManager';
import { SVGToBezier, SVGType } from '../../util/bezierFit';
import { InkingStroke } from '../InkingStroke';
import { ObservableReactComponent } from '../ObservableReactComponent';
-import { CollectionCardView } from '../collections/CollectionCardDeckView';
import { MarqueeView } from '../collections/collectionFreeForm';
import { ActiveArrowEnd, ActiveArrowStart, ActiveDash, ActiveFillColor, ActiveInkBezierApprox, ActiveInkColor, ActiveInkWidth, ActiveIsInkMask, DocumentView } from '../nodes/DocumentView';
import './SmartDrawHandler.scss';
@@ -310,7 +310,7 @@ export class SmartDrawHandler extends ObservableReactComponent<object> {
const hrefParts = href.split('.');
const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`;
try {
- const hrefBase64 = await CollectionCardView.imageUrlToBase64(hrefComplete);
+ const hrefBase64 = await imageUrlToBase64(hrefComplete);
const strokes = DocListCast(drawing[DocData].data);
const coords: string[] = [];
strokes.forEach((stroke, i) => {
diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts
index 6f05ddf96..81241f9fe 100644
--- a/src/fields/Doc.ts
+++ b/src/fields/Doc.ts
@@ -1363,6 +1363,13 @@ export namespace Doc {
export const FilterAny = '--any--';
export const FilterNone = '--undefined--';
+ export function hasDocFilter(container: Opt<Doc>, key: string, value: string | undefined, fieldPrefix?: string) {
+ if (!container) return;
+ const filterField = '_' + (fieldPrefix ? fieldPrefix + '_' : '') + 'childFilters';
+ const childFilters = StrListCast(container[filterField]);
+ return childFilters.some(filter => filter.split(FilterSep)[0] === key && (value === undefined || value === Doc.FilterAny || filter.split(FilterSep)[1] === value));
+ }
+
// filters document in a container collection:
// all documents with the specified value for the specified key are included/excluded
// based on the modifiers :"check", "x", undefined
@@ -1373,8 +1380,8 @@ export namespace Doc {
runInAction(() => {
for (let i = 0; i < childFilters.length; i++) {
const fields = childFilters[i].split(FilterSep); // split key:value:modifier
- if (fields[0] === key && (fields[1] === value?.toString() || modifiers === 'match' || (fields[2] === 'match' && modifiers === 'remove'))) {
- if (fields[2] === modifiers && modifiers && fields[1] === value?.toString()) {
+ if (fields[0] === key && (fields[1] === value?.toString() || value === Doc.FilterAny || modifiers === 'match' || (fields[2] === 'match' && modifiers === 'remove'))) {
+ if (fields[2] === modifiers && modifiers && (fields[1] === value?.toString() || value === Doc.FilterAny)) {
// eslint-disable-next-line no-param-reassign
if (toggle) modifiers = 'remove';
else return;
diff --git a/src/fields/RichTextField.ts b/src/fields/RichTextField.ts
index 3f13f7e6d..613bb0fd1 100644
--- a/src/fields/RichTextField.ts
+++ b/src/fields/RichTextField.ts
@@ -13,10 +13,15 @@ export class RichTextField extends ObjectField {
@serializable(true)
readonly Text: string;
- constructor(data: string, text: string = '') {
+ /**
+ * NOTE: if 'text' doesn't match the plain text of 'data', this can cause infinite loop problems or other artifacts when rendered.
+ * @param data this is the formatted text representation of the RTF
+ * @param text this is the plain text of whatever text is in the 'data'
+ */
+ constructor(data: string, text: string) {
super();
this.Data = data;
- this.Text = text;
+ this.Text = text; // ideally, we'd compute 'text' from 'data' by doing what Prosemirror does at run-time ... just need to figure out how to write that function accurately
}
Empty() {