diff options
author | bobzel <zzzman@gmail.com> | 2025-06-26 10:53:54 -0400 |
---|---|---|
committer | bobzel <zzzman@gmail.com> | 2025-06-26 10:53:54 -0400 |
commit | baae27b205356898c5866a0f095e4ec056e02459 (patch) | |
tree | 1b62de5579b8de8be81b6d342a9767f0f379bb91 /src | |
parent | ccfdf905400cd4b81d8cde0f16bb0e15cd65621b (diff) | |
parent | 0093370a04348ef38b91252d02ab850f25d753b2 (diff) |
Merge branch 'master' into agent-paper-main
Diffstat (limited to 'src')
250 files changed, 6726 insertions, 4440 deletions
diff --git a/src/ClientUtils.ts b/src/ClientUtils.ts index cc8b715b4..8eb1a39ec 100644 --- a/src/ClientUtils.ts +++ b/src/ClientUtils.ts @@ -670,7 +670,8 @@ export function setupMoveUpEvents( } export function DivHeight(ele: HTMLElement | null): number { - return ele ? Number(getComputedStyle(ele).height.replace('px', '')) : 0; + const hgt = ele ? Number(getComputedStyle(ele).height.replace('px', '')) : 0; + return Number.isNaN(hgt) ? 0 : hgt; } export function DivWidth(ele: HTMLElement | null): number { return ele ? Number(getComputedStyle(ele).width.replace('px', '')) : 0; diff --git a/src/Utils.ts b/src/Utils.ts index 724725c23..e96b8e231 100644 --- a/src/Utils.ts +++ b/src/Utils.ts @@ -202,6 +202,9 @@ export function aggregateBounds(boundsList: { x: number; y: number; width?: numb export function intersectRect(r1: { left: number; top: number; width: number; height: number }, r2: { left: number; top: number; width: number; height: number }) { return !(r2.left > r1.left + r1.width || r2.left + r2.width < r1.left || r2.top > r1.top + r1.height || r2.top + r2.height < r1.top); } +export function rectContains(r1: { left: number; top: number; width: number; height: number }, r2: { left: number; top: number; width: number; height: number }) { + if (r1.left < r2.left && r1.left + r1.width > r2.left + r2.width && r1.top < r2.top && r1.top + r1.height > r2.top + r2.height) return true; +} export function stringHash(s?: string) { return !s ? undefined : Math.abs(s.split('').reduce((a, b) => (n => n & n)((a << 5) - a + b.charCodeAt(0)), 0)); diff --git a/src/client/apis/GoogleAuthenticationManager.tsx b/src/client/apis/GoogleAuthenticationManager.tsx index 94ce42d8d..67a6e01e9 100644 --- a/src/client/apis/GoogleAuthenticationManager.tsx +++ b/src/client/apis/GoogleAuthenticationManager.tsx @@ -1,4 +1,4 @@ -import { action, IReactionDisposer, observable, reaction, runInAction } from 'mobx'; +import { action, IReactionDisposer, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { Opt } from '../../fields/Doc'; @@ -6,12 +6,12 @@ import { Networking } from '../Network'; import { ScriptingGlobals } from '../util/ScriptingGlobals'; import { MainViewModal } from '../views/MainViewModal'; import './GoogleAuthenticationManager.scss'; +import { ObservableReactComponent } from '../views/ObservableReactComponent'; -const AuthenticationUrl = 'https://accounts.google.com/o/oauth2/v2/auth'; const prompt = 'Paste authorization code here...'; @observer -export class GoogleAuthenticationManager extends React.Component<object> { +export class GoogleAuthenticationManager extends ObservableReactComponent<object> { // eslint-disable-next-line no-use-before-define public static Instance: GoogleAuthenticationManager; private authenticationLink: Opt<string> = undefined; @@ -23,6 +23,12 @@ export class GoogleAuthenticationManager extends React.Component<object> { @observable private credentials: { user_info: { name: string; picture: string }; access_token: string } | undefined = undefined; private disposer: Opt<IReactionDisposer>; + constructor(props: object) { + super(props); + makeObservable(this); + GoogleAuthenticationManager.Instance = this; + } + private set isOpen(value: boolean) { runInAction(() => (this.openState = value)); } @@ -35,44 +41,51 @@ export class GoogleAuthenticationManager extends React.Component<object> { this.openState && this.resetState(0, 0); } - public fetchOrGenerateAccessToken = async (displayIfFound = false) => { + public fetchOrGenerateAccessToken = async (): Promise<string | undefined> => { const response = await Networking.FetchFromServer('/readGoogleAccessToken'); - // if this is an authentication url, activate the UI to register the new access token - if (new RegExp(AuthenticationUrl).test(response)) { - this.isOpen = true; - this.authenticationLink = response; - return new Promise<string>(resolve => { - this.disposer?.(); - this.disposer = reaction( - () => this.authenticationCode, - async authenticationCode => { - if (authenticationCode && /\d{1}\/[\w-]{55}/.test(authenticationCode)) { - this.disposer?.(); - const response2 = await Networking.PostToServer('/writeGoogleAccessToken', { authenticationCode }); - runInAction(() => { - this.success = true; - this.credentials = response2 as { user_info: { name: string; picture: string }; access_token: string }; - }); - this.resetState(); - resolve((response2 as { access_token: string }).access_token); - } - } - ); + + // This will return a JSON object with { access_token, user_info } if already linked + try { + const parsed = JSON.parse(response) as { access_token: string; user_info: { name: string; picture: string } }; + + runInAction(() => { + this.success = true; + this.credentials = parsed; }); + + return parsed.access_token; + } catch { + console.warn('Not linked yet or invalid JSON. open auth...'); + // This is an auth URL — redirect the user to /refreshGoogle + if (typeof response === 'string' && response.startsWith('http')) { + if (window.confirm('Authorize Dash to access your Google account?')) { + window.open(response)?.focus(); + return undefined; + } + } + + throw new Error('Unable to fetch Google access token.'); } + }; - // otherwise, we already have a valid, stored access token and user info - const response2 = JSON.parse(response) as { user_info: { name: string; picture: string }; access_token: string }; - if (displayIfFound) { + public fetchAccessTokenSilently = async (): Promise<string | undefined> => { + const response = await Networking.FetchFromServer('/readGoogleAccessToken'); + + try { + const parsed = JSON.parse(response) as { access_token: string; user_info: { name: string; picture: string } }; + runInAction(() => { this.success = true; - this.credentials = response2; + this.credentials = parsed; }); - this.resetState(-1, -1); - this.isOpen = true; + + return parsed.access_token; + } catch { + // Do nothing — just return undefined silently + return undefined; } - return (response2 as { access_token: string }).access_token; }; + resetState = action((visibleForMS: number = 3000, fadesOutInMS: number = 500) => { if (!visibleForMS && !fadesOutInMS) { @@ -107,11 +120,6 @@ export class GoogleAuthenticationManager extends React.Component<object> { } }); - constructor(props: object) { - super(props); - GoogleAuthenticationManager.Instance = this; - } - private get renderPrompt() { return ( <div className={'authorize-container'}> @@ -127,7 +135,7 @@ export class GoogleAuthenticationManager extends React.Component<object> { </button> ) : null} {this.showPasteTargetState ? <input className={'paste-target'} onChange={action(e => (this.authenticationCode = e.currentTarget.value))} placeholder={prompt} /> : null} - {this.credentials ? ( + {this.credentials?.user_info?.picture ? ( <> <img className={'avatar'} src={this.credentials.user_info.picture} /> <span className={'welcome'}>Welcome to Dash, {this.credentials.user_info.name}</span> diff --git a/src/client/apis/gpt/GPT.ts b/src/client/apis/gpt/GPT.ts index 9cb47995c..6258a82dc 100644 --- a/src/client/apis/gpt/GPT.ts +++ b/src/client/apis/gpt/GPT.ts @@ -11,6 +11,12 @@ export enum GPTDocCommand { export const DescriptionSeperator = '======'; export const DocSeperator = '------'; +export const DataSeperator = '>>>>>>'; +export enum TextClassifications { + Title = 'word', //a few words + Caption = 'sentence', //few sentences + LengthyDescription = 'paragraphs', +} enum GPTCallType { SUMMARY = 'summary', @@ -34,8 +40,12 @@ enum GPTCallType { MAKERUBRIC = 'make_rubric', // create a definition rubric for a document to be used when quizzing the user COMMANDTYPE = 'command_type', // Determine the type of command being made (GPTQueryType - eg., AssignTags, Sort, Filter, DocInfo, GenInfo) and possibly some parameters (eg, Tag type for Tags) SUBSETDOCS = 'subset_docs', // select a subset of documents based on their descriptions + TAGDOCS = 'tag_docs', // choose a tags for each Doc DOCINFO = 'doc_info', // provide information about a document SORTDOCS = 'sort_docs', + CLASSIFYTEXTMINIMAL = 'classify_text_minimal', // classify text into one of the three categories: title, caption, lengthy description + CLASSIFYTEXTFULL = 'classify_text_full', //tags pdf content + GENERATESCRAPBOOK = 'generate_scrapbook', } type GPTCallOpts = { @@ -48,6 +58,20 @@ type GPTCallOpts = { const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = { // newest model: gpt-4 summary: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Summarize the text given in simpler terms.' }, + + sort_docs: { + model: 'gpt-4o', + maxTokens: 2048, + temp: 0.25, + prompt: `The user is going to give you a list of descriptions. + Each one is separated by '${DescriptionSeperator}' on either side. + Descriptions will vary in length, so make sure to only separate when you see '${DescriptionSeperator}'. + Sort them by the user's specifications. + Make sure each description is only in the list once. Each item should be separated by '${DescriptionSeperator}'. + Immediately afterward, surrounded by '${DocSeperator}' on BOTH SIDES, provide some insight into your reasoning for the way you sorted (and mention nothing about the formatting details given in this description). + It is VERY important that you format it exactly as described, ensuring the proper number of '${DescriptionSeperator[0]}' and '${DocSeperator[0]}' (${DescriptionSeperator.length} of each) and NO commas`, + }, + edit: { model: 'gpt-4-turbo', maxTokens: 256, temp: 0.5, prompt: 'Reword the text.' }, stack: { model: 'gpt-4o', @@ -69,17 +93,23 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = { temp: 0.5, prompt: "You are a helpful resarch assistant. Analyze the user's data to find meaningful patterns and/or correlation. Please only return a JSON with a correlation column 1 propert, a correlation column 2 property, and an analysis property. ", }, - sort_docs: { + //new + classify_text_minimal: { model: 'gpt-4o', maxTokens: 2048, temp: 0.25, - prompt: `The user is going to give you a list of descriptions. - Each one is separated by '${DescriptionSeperator}' on either side. - Descriptions will vary in length, so make sure to only separate when you see '${DescriptionSeperator}'. - Sort them by the user's specifications. - Make sure each description is only in the list once. Each item should be separated by '${DescriptionSeperator}'. - Immediately afterward, surrounded by '${DocSeperator}' on BOTH SIDES, provide some insight into your reasoning for the way you sorted (and mention nothing about the formatting details given in this description). - It is VERY important that you format it exactly as described, ensuring the proper number of '${DescriptionSeperator[0]}' and '${DocSeperator[0]}' (${DescriptionSeperator.length} of each) and NO commas`, + prompt: `Based on the content of the the text, classify it into the + most appropriate category: '${TextClassifications.Title}' if it is a few words, '${TextClassifications.Caption}' if it is a couple sentences or less, or '${TextClassifications.LengthyDescription}' if it is a lengthy description. Output exclusively the classification in your response. + `, + }, + classify_text_full: { + model: 'gpt-4o', + maxTokens: 2048, + temp: 0.25, + prompt: `Based on the content of the the text, classify it into the + most appropriate category: '${TextClassifications.Title}', '${TextClassifications.Caption}', or '${TextClassifications.LengthyDescription}'. + Then provide five more descriptive tags (single words) separated by spaces. + Finally, include a more detailed summary phrase tag using underscores, for a total of seven tags.`, }, describe: { model: 'gpt-4-vision-preview', maxTokens: 2048, temp: 0, prompt: 'Describe these images in 3-5 words' }, flashcard: { @@ -102,19 +132,20 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = { 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', + model: 'gpt-4.1', maxTokens: 512, temp: 0.5, prompt: 'You will be given a list of field descriptions for one or more templates in the format {field #0: “description”}{field #1: “description”}{...}, and a list of column descriptions in the format {“title”: “description”}{...}. Your job is to match columns with fields based on their descriptions. Your output should be in the following JSON format: {“template_title”:{“#”: “title”, “#”: “title”, “#”: “title” …}, “template_title”:{“#”: “title”, “#”: “title”, “#”: “title” …}} where “template_title” is the templates title as specified in the description provided, # represents the field # and “title” the title of the column assigned to it. A filled out example might look like {“fivefield2”:{“0”:”Name”, “1”:”Image”, “2”:”Caption”, “3”:”Position”, “4”:”Stats”}, “fivefield3”:{0:”Image”, 1:”Name”, 2:”Caption”, 3:”Stats”, 4:”Position”}. Include one object for each template. IT IS VERY IMPORTANT THAT YOU ONLY INCLUDE TEXT IN THE FORMAT ABOVE, WITH NO ADDITIONS WHATSOEVER. Do not include extraneous ‘#’ characters, ‘column’ for columns, or ‘template’ for templates: ONLY THE TITLES AND NUMBERS. There should never be one column assigned to more than one field (ie. if the “name” column is assigned to field 1, it can’t be assigned to any other fields) . Do this for each template whose fields are described. The descriptions are as follows:', }, + vizsum: { - model: 'gpt-4-turbo', + model: 'gpt-4.1', maxTokens: 512, temp: 0.5, prompt: 'Your job is to provide brief descriptions for columns in a dataset based on example rows. Your descriptions should be geared towards how each column’s data might fit together into a visual template. Would they make good titles, main focuses, captions, descriptions, etc. Pay special attention to connections between columns, i.e. is there one column that specifically seems to describe/be related to another more than the rest? You should provide your analysis in JSON format like so: {“col1”:”description”, “col2”:”description”, …}. DO NOT INCLUDE ANY OTHER TEXT, ONLY THE JSON.', }, vizsum2: { - model: 'gpt-4-turbo', + model: 'gpt-4.1', maxTokens: 512, temp: 0.5, prompt: 'Your job is to provide structured information on columns in a dataset based on example rows. You will categorize each column in two ways: by type and size. The size categories are as follows: tiny (one or two words), small (a sentence/multiple words), medium (a few sentences), large (a longer paragraph), and huge (a very long or multiple paragraphs). The type categories are as follows: visual (links/file paths to images, pdfs, maps, or any other visual media type), and text (plain text that isn’t a link/file path). Visual media should be assumed to have size “medium” “large” or “huge”. You will give your responses in JSON format, like so: {“title (of column)”:{“type”:”text”, “size”:”small”}, “title (of column)”:{“type”:”visual”, “size”:”medium”}, …}. DO NOT INCLUDE ANY OTHER TEXT, ONLY THE JSON.', @@ -138,18 +169,54 @@ const callTypeMap: { [type in GPTCallType]: GPTCallOpts } = { temp: 0.5, prompt: 'You will be coloring drawings. You will be given what the drawing is, then a list of descriptions for parts of the drawing. Based on each description, respond with the stroke and fill color that it should be. Follow the rules: 1. Avoid using black for stroke color 2. Make the stroke color 1-3 shades darker than the fill color 3. Use the same colors when possible. Format as {#abcdef #abcdef}, making sure theres a color for each description, and do not include any additional text.', }, + + generate_scrapbook: { + model: 'gpt-4o', + maxTokens: 2048, + temp: 0.5, + prompt: `Generate an aesthetically pleasing scrapbook layout preset based on these items. + Return your response as JSON in the format: + [{ + "type": rich text or image or pdf or video or collection + "tag": a singular tag summarizing the document + "acceptTags": [a list of all relevant tags that this document accepts, like ['PERSON', 'LANDSCAPE']] + "x": number, + "y": number, + "width": number, **note: if it is in an image, please respect existing aspect ratio if it is provided + "height": number + }, ...] + If there are mutliple documents and you wish to nest documents into a collection for aesthetic purposes, you may include + "children": [ + { type: + tag: + x: , y: , width: , height: + } + ] `, + }, + command_type: { model: 'gpt-4-turbo', maxTokens: 1024, temp: 0, - prompt: `I'm going to provide you with a question. - Based on the question, is the user asking you to - ${GPTDocCommand.AssignTags}. Assigns docs with tags(like star / heart etc)/labels. + prompt: `Is the provided command/question asking you to: + ${GPTDocCommand.AssignTags}. Choose a descriptive tag/label. ${GPTDocCommand.GetInfo}. Provide information about a specific doc. ${GPTDocCommand.Filter}. Filter docs based on a question/information. ${GPTDocCommand.Sort}. Put docs in a specific order. - Answer with only the number for ${GPTDocCommand.GetInfo}-${GPTDocCommand.Sort}. - For number one, provide the number (${GPTDocCommand.AssignTags}) and the appropriate tag`, + Answer with only the number, do not add any other punctuation or formatting of any kind`, + }, + tag_docs: { + model: 'gpt-4-turbo', + maxTokens: 1024, + temp: 0, + prompt: `I'm going to give you a list of descriptions. + Each one is separated by '${DescriptionSeperator}' on either side. + Descriptions will vary in length, so make sure to only separate when you see '${DescriptionSeperator}'. + Based on the question/command the user asks, provide a tag label of the given descriptions that best matches the user's specifications. + Format your response precisely as a single string that prints each description without including any '${DescriptionSeperator}', followed by '${DataSeperator}', followed by the tag label, followed by '${DescriptionSeperator}'. + Do not use any additional formatting marks or punctuation'. + Immediately afterward, surrounded by '${DocSeperator}' on BOTH SIDES, provide some insight into your reasoning for the way you sorted (and mention nothing about the formatting details given in this description). + `, }, subset_docs: { model: 'gpt-4-turbo', @@ -222,11 +289,12 @@ const gptAPICall = async (inputTextIn: string, callType: GPTCallType, prompt?: s const gptImageCall = async (prompt: string, n?: number) => { try { const response = await openai.images.generate({ + model: 'dall-e-3', prompt: prompt, n: n ?? 1, size: '1024x1024', }); - return response.data.map((data: Image) => data.url); + return (response.data ?? []).map((data: Image) => data.url); // return response.data.data[0].url; } catch (err) { console.error(err); diff --git a/src/client/documents/DocUtils.ts b/src/client/documents/DocUtils.ts index 36e03daed..9704867d5 100644 --- a/src/client/documents/DocUtils.ts +++ b/src/client/documents/DocUtils.ts @@ -587,6 +587,15 @@ export namespace DocUtils { doc.onClick = FollowLinkScript(); } + /** + * iterate through all items and their children and return a flat list of leaf placeholder content Docs + * @param items + * @returns list of placeholder content Docs + */ + export function unwrapPlaceholders(items: Doc[]): Doc[] { + return items.flatMap(d => (d.$type === DocumentType.COL ? unwrapPlaceholders(DocListCast(d[Doc.LayoutDataKey(d)])) : [d])); + } + export function LeavePushpin(doc: Doc, annotationField: string) { if (doc.followLinkToggle) return undefined; const context = Cast(doc.embedContainer, Doc, null) ?? Cast(doc.annotationOn, Doc, null); @@ -642,27 +651,27 @@ export namespace DocUtils { return dd; } - export function assignUploadInfo(result: Upload.FileInformation, protoIn: Doc) { + export function assignUploadInfo(result: Upload.FileInformation, protoIn: Doc, fieldKey: string = 'data') { const proto = protoIn; if (Upload.isTextInformation(result)) { proto.text = result.rawText; } if (Upload.isVideoInformation(result)) { - proto.data_duration = result.duration; + proto[`${fieldKey}_duration`] = result.duration; } if (Upload.isImageInformation(result)) { 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; - proto.data_nativeHeight = result.nativeWidth < result.nativeHeight ? maxNativeDim : maxNativeDim / (result.nativeWidth / result.nativeHeight); - if (NumCast(proto.data_nativeOrientation) >= 5) { - proto.data_nativeHeight = result.nativeWidth < result.nativeHeight ? (maxNativeDim * result.nativeWidth) / result.nativeHeight : maxNativeDim; - proto.data_nativeWidth = result.nativeWidth < result.nativeHeight ? maxNativeDim : maxNativeDim / (result.nativeWidth / result.nativeHeight); + proto[`${fieldKey}_nativeOrientation`] = result.exifData?.data?.image?.Orientation ?? (exifRotation.includes('rotate 90') || exifRotation.includes('rotate 270') ? 5 : undefined); + proto[`${fieldKey}_nativeWidth`] = result.nativeWidth < result.nativeHeight ? (maxNativeDim * result.nativeWidth) / result.nativeHeight : maxNativeDim; + proto[`${fieldKey}_nativeHeight`] = result.nativeWidth < result.nativeHeight ? maxNativeDim : maxNativeDim / (result.nativeWidth / result.nativeHeight); + if (NumCast(proto[`${fieldKey}_nativeOrientation`]) >= 5) { + proto[`${fieldKey}_nativeHeight`] = result.nativeWidth < result.nativeHeight ? (maxNativeDim * result.nativeWidth) / result.nativeHeight : maxNativeDim; + proto[`${fieldKey}_nativeWidth`] = result.nativeWidth < result.nativeHeight ? maxNativeDim : maxNativeDim / (result.nativeWidth / result.nativeHeight); } - proto.data_exif = JSON.stringify(result.exifData?.data); - proto.data_contentSize = result.contentSize; + proto[`${fieldKey}_exif`] = JSON.stringify(result.exifData?.data); + proto[`${fieldKey}_contentSize`] = result.contentSize; // exif gps data coordinates are stored in DMS (Degrees Minutes Seconds), the following operation converts that to decimal coordinates const latitude = result.exifData?.data?.GPSLatitude; const latitudeDirection = result.exifData?.data?.GPSLatitudeRef; @@ -724,7 +733,7 @@ export namespace DocUtils { _width: width || BoolCast(Doc.UserDoc().fitBox) ? Number(StrCast(Doc.UserDoc().fontSize).replace('px', '')) * 1.5 * 6 : 200, _height: BoolCast(Doc.UserDoc().fitBox) ? Number(StrCast(Doc.UserDoc().fontSize).replace('px', '')) * 1.5 : 35, _layout_autoHeight: true, - backgroundColor: StrCast(Doc.UserDoc().textBackgroundColor), + backgroundColor: backgroundColor ?? StrCast(Doc.UserDoc().textBackgroundColor), borderColor: Doc.UserDoc().borderColor as string, borderWidth: Doc.UserDoc().borderWidth as number, text_centered: BoolCast(Doc.UserDoc().textCentered), @@ -842,7 +851,7 @@ export namespace DocUtils { const { source: { newFilename, mimetype }, result, - } = upfiles.lastElement() ?? { source: { newFilename: '', mimetype: '' }, result: new Error('upload failed') }; + } = upfiles.lastElement() ?? { source: { newFilename: '', mimetype: '' }, result: upfiles[0]?.result ?? new Error('unknown error') }; if (result instanceof Error) { if (overwriteDoc) { overwriteDoc.loadingError = result.message; diff --git a/src/client/documents/DocumentTypes.ts b/src/client/documents/DocumentTypes.ts index cef44e999..a73d8ba59 100644 --- a/src/client/documents/DocumentTypes.ts +++ b/src/client/documents/DocumentTypes.ts @@ -44,8 +44,9 @@ export enum DocumentType { SCRIPTDB = 'scriptdb', // database of scripts GROUPDB = 'groupdb', // database of groups + JOURNAL = 'journal', // AARAV ADD JOURNAL + TASK = 'task', // AARAV ADD TASK SCRAPBOOK = 'scrapbook', - JOURNAL = 'journal', // AARAV ADD } export enum CollectionViewType { // general collections diff --git a/src/client/documents/Documents.ts b/src/client/documents/Documents.ts index 6371c4e9d..e0c7bce82 100644 --- a/src/client/documents/Documents.ts +++ b/src/client/documents/Documents.ts @@ -182,8 +182,6 @@ export class DocumentOptions { identifier?: STRt = new StrInfo('documentIcon displayed for each doc as "d[x]"', false); _rotation?: NUMt = new NumInfo('Amount of rotation on a document in degrees', false); - date_range?: STRt = new StrInfo('date range for calendar', false); - chat?: STRt = new StrInfo('fields related to chatBox', false); chat_history?: STRt = new StrInfo('chat history for chatbox', false); chat_thread_id?: STRt = new StrInfo('thread id for chatbox', false); @@ -290,6 +288,7 @@ export class DocumentOptions { _layout_showSidebar?: BOOLt = new BoolInfo('whether an annotationsidebar should be displayed for text docuemnts'); _layout_showCaption?: string; // which field to display in the caption area. leave empty to have no caption _layout_showTags?: BOOLt = new BoolInfo('whether to show the list of document tags at the bottom of a DocView'); + _layout_hideScroll?: BOOLt = new BoolInfo('whether to hide scroll bars on the document'); _chromeHidden?: BOOLt = new BoolInfo('whether the editing chrome for a document is hidden'); hideClickBehaviors?: BOOLt = new BoolInfo('whether to hide click behaviors in context menu'); @@ -333,6 +332,9 @@ export class DocumentOptions { toolType?: string; // type of pen tool expertMode?: BOOLt = new BoolInfo('something available only in expert (not novice) mode'); + placeholder_docType?: STRt = new StrInfo('type of document that this document accepts as a child', false, false, Array.from(Object.keys(DocumentType))); + placeholder_acceptTags?: List<string>; + contextMenuFilters?: List<ScriptField>; contextMenuScripts?: List<ScriptField>; contextMenuLabels?: List<string>; @@ -362,7 +364,6 @@ export class DocumentOptions { isBaseProto?: BOOLt = new BoolInfo('is doc a base level prototype for data documents as opposed to data documents which are prototypes for layout documents. base protos are not cloned during a deep'); isTemplateForField?: string; // the field key for which the containing document is a rendering template isTemplateDoc?: BOOLt = new BoolInfo('is the document a template for creating other documents'); - isGroup?: BOOLt = new BoolInfo('should collection use a grouping UI behavior'); isFolder?: BOOLt = new BoolInfo('is document a tree view folder'); _isTimelineLabel?: BOOLt = new BoolInfo('is document a timeline label'); isLightbox?: BOOLt = new BoolInfo('whether a collection acts as a lightbox by opening lightbox links by hiding all other documents in collection besides link target'); @@ -402,6 +403,7 @@ export class DocumentOptions { // freeform properties freeform?: STRt = new StrInfo(''); + freeform_isGroup?: BOOLt = new BoolInfo('should collection use a grouping UI behavior'); _freeform_backgroundGrid?: BOOLt = new BoolInfo('whether background grid is shown on freeform collections'); _freeform_scale_min?: NUMt = new NumInfo('how far out a view can zoom (used by image/videoBoxes that are clipped'); _freeform_scale_max?: NUMt = new NumInfo('how far in a view can zoom (used by sidebar freeform views'); @@ -524,6 +526,19 @@ export class DocumentOptions { ai_prompt?: STRt = new StrInfo('input prompt to GAI engine'); ai_generatedDocs?: List<Doc>; // list of documents generated by GAI engine + // TASK MANAGER + $task_dateRange?: STRt = new StrInfo('date range for calendar', false); + $task_startTime?: DateInfo | DateField = new DateInfo('start date and time', /*filterable*/ false); + $task_endTime?: DateInfo | DateField = new DateInfo('end date and time', /*filterable*/ false); + $task_allDay?: BoolInfo | boolean = new BoolInfo('whether task is all-day or not', /*filterable*/ false); + $task_completed?: BoolInfo | boolean = new BoolInfo('whether the task is completed', /*filterable*/ false); + $googleTaskId?: STRt = new StrInfo('Google Task ID for syncing'); + $task_lastSyncedAt?: STRt = new StrInfo('last update time for task node'); + $task_deleted?: BoolInfo | boolean = new BoolInfo('whether task is deleted or not', /*filterable*/ false); + + _calendar_date?: DateInfo | DateField = new DateInfo('current selected date of a calendar', /*filterable*/ false); + _calendar_dateRange?: STRt = new StrInfo('date range shown on a calendar', false); + /** * JSON‐stringified slot configuration for ScrapbookBox */ @@ -936,31 +951,6 @@ export namespace Docs { // AARAV ADD // export function DailyJournalDocument(text: string | RichTextField, options: DocumentOptions = {}, fieldKey: string = 'text') { - // const getFormattedDate = () => { - // const date = new Date().toLocaleDateString(undefined, { - // weekday: 'long', - // year: 'numeric', - // month: 'long', - // day: 'numeric', - // }); - // return date; - // }; - - // const getDailyText = () => { - // const placeholderText = 'Start writing here...'; - // const dateText = `${getFormattedDate()}`; - - // return RichTextField.textToRtfFormat( - // [ - // { text: 'Journal Entry:', styles: { bold: true, color: 'black', fontSize: 20 } }, - // { text: dateText, styles: { italic: true, color: 'gray', fontSize: 15 } }, - // { text: placeholderText, styles: { fontSize: 14, color: 'gray' } }, - // ], - // undefined, - // placeholderText.length - // ); - // }; - return InstanceFromProto( Prototypes.get(DocumentType.JOURNAL), '', @@ -973,7 +963,19 @@ export namespace Docs { ); } - // AARAV ADD // + // eslint-disable-next-line @typescript-eslint/no-unused-vars + export function TaskDocument(text = '', options: DocumentOptions = {}, fieldKey = 'text') { + return InstanceFromProto( + Prototypes.get(DocumentType.TASK), + '', + { + title: '', + ...options, + }, + undefined, + fieldKey + ); + } export function LinkDocument(source: Doc, target: Doc, options: DocumentOptions = {}, id?: string) { const linkDoc = InstanceFromProto( @@ -1054,6 +1056,7 @@ export namespace Docs { _layout_nativeDimEditable: true, _layout_reflowHorizontal: true, _layout_reflowVertical: true, + calendar: '', ...options, _type_collection: CollectionViewType.Calendar, }); diff --git a/src/client/util/BranchingTrailManager.tsx b/src/client/util/BranchingTrailManager.tsx index 65336812d..a0871514c 100644 --- a/src/client/util/BranchingTrailManager.tsx +++ b/src/client/util/BranchingTrailManager.tsx @@ -1,5 +1,3 @@ -/* eslint-disable react/no-unused-class-component-methods */ -/* eslint-disable react/no-array-index-key */ import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; @@ -52,7 +50,6 @@ export class BranchingTrailManager extends React.Component { this.slideHistoryStack = newArr; }); - // eslint-disable-next-line react/sort-comp observeDocumentChange = (targetDoc: Doc, pres: PresBox) => { const presId = pres.Document[Id]; if (this.prevPresId === presId) { diff --git a/src/client/util/CalendarManager.tsx b/src/client/util/CalendarManager.tsx index b50e39c02..a8460ed4b 100644 --- a/src/client/util/CalendarManager.tsx +++ b/src/client/util/CalendarManager.tsx @@ -156,7 +156,7 @@ export class CalendarManager extends ObservableReactComponent<object> { const subDocEmbedding = Doc.MakeEmbedding(targetDoc); // embedding console.log('subdoc embedding', subDocEmbedding); subDocEmbedding.embedContainer = calendar; // set embed container - subDocEmbedding.date_range = `${startDateStr}|${endDateStr}`; // set subDoc date range + subDocEmbedding.task_dateRange = `${startDateStr}|${endDateStr}`; // set subDoc date range Doc.AddDocToList(calendar, 'data', subDocEmbedding); // add embedded subDoc to calendar diff --git a/src/client/util/CaptureManager.scss b/src/client/util/CaptureManager.scss index e7cbb4287..9e81f2669 100644 --- a/src/client/util/CaptureManager.scss +++ b/src/client/util/CaptureManager.scss @@ -67,7 +67,7 @@ font-weight: bold; text-align: left; color: black; - width: 80; + width: 80px; margin-right: 50px; margin-bottom: 5px; } @@ -114,8 +114,8 @@ .buttons { display: flex; position: absolute; - bottom: 0; - right: 15; + bottom: 0px; + right: 15px; justify-content: flex-end; align-items: center; height: 60px; diff --git a/src/client/util/CurrentUserUtils.ts b/src/client/util/CurrentUserUtils.ts index 28b19d55e..9fbc82bef 100644 --- a/src/client/util/CurrentUserUtils.ts +++ b/src/client/util/CurrentUserUtils.ts @@ -407,7 +407,8 @@ pie title Minerals in my tap water {key: "DataViz", creator: opts => Docs.Create.DataVizDocument("", opts), opts: { _width: 300, _height: 300, }}, // AARAV ADD // {key: "DailyJournal",creator:opts => Docs.Create.DailyJournalDocument("", opts),opts: { _width: 300, _height: 300, }}, - {key: "Scrapbook",creator:opts => Docs.Create.ScrapbookDocument([], opts),opts:{ _width: 300, _height: 300}}, + {key: "Task", creator: opts => Docs.Create.TaskDocument("", opts), opts: { _width: 300, _height: 300, _layout_autoHeight: true, title: "Task", }}, + {key: "Scrapbook", creator: opts => Docs.Create.ScrapbookDocument([], opts), opts: { _width: 300, _height: 300}}, //{key: "Scrapbook",creator:opts => Docs.Create.ScrapbookDocument([], opts),opts:{ _width: 300, _height: 300}}, {key: "Chat", creator: Docs.Create.ChatDocument, opts: { _width: 500, _height: 500, _layout_fitWidth: true, }}, {key: "MetaNote", creator: metaNoteTemplate, opts: { _width: 300, _height: 120, _header_pointerEvents: "all", _header_height: 50, _header_fontSize: 9,_layout_autoHeightMargins: 50, _layout_autoHeight: true, treeView_HideUnrendered: true}}, @@ -453,6 +454,7 @@ pie title Minerals in my tap water { toolTip: "Tap or drag to create a data viz node", title: "DataViz", icon: "chart-bar", dragFactory: doc.emptyDataViz as Doc, clickFactory: DocCast(doc.emptyDataViz)}, { toolTip: "Tap or drag to create a scrapbook template", title: "Scrapbook", icon: "palette", dragFactory: doc.emptyScrapbook as Doc,clickFactory:DocCast(doc.emptyScrapbook), }, { toolTip: "Tap or drag to create a journal entry", title: "Journal", icon: "book", dragFactory:doc.emptyDailyJournal as Doc,clickFactory: DocCast(doc.emptyDataJournal), }, + { toolTip: "Tap or drag to create a task", title: "Task", icon: "check-square", dragFactory: doc.emptyTask as Doc, clickFactory: DocCast(doc.emptyTask), }, { toolTip: "Tap or drag to create a bullet slide", title: "PPT Slide", icon:"person-chalkboard",dragFactory: doc.emptySlide as Doc, clickFactory: DocCast(doc.emptySlide), openFactoryLocation: OpenWhere.overlay, funcs: { hidden: "IsNoviceMode()"}}, { toolTip: "Tap or drag to create a view slide", title: "View Slide", icon: "address-card", dragFactory: doc.emptyViewSlide as Doc, clickFactory: DocCast(doc.emptyViewSlide), openFactoryLocation: OpenWhere.overlay, funcs: { hidden: "IsNoviceMode()"}}, { toolTip: "Tap or drag to create a data note", title: "MetaNote", icon: "window-maximize", dragFactory: doc.emptyMetaNote as Doc, clickFactory: DocCast(doc.emptyMetaNote), openFactoryAsDelegate: true, funcs: { hidden: "IsNoviceMode()"} }, @@ -560,7 +562,7 @@ pie title Minerals in my tap water const reqdToolOps:DocumentOptions = { title: "My Tools", isSystem: true, ignoreClick: true, layout_boxShadow: "0 0", layout_explainer: "This is a palette of documents that can be created.", _layout_dontCenter: "y", - _layout_showTitle: "title", _width: 500, _yMargin: 20, _lockedPosition: true, _forceActive: true, _dragOnlyWithinContainer: true, layout_hideContextMenu: true, _chromeHidden: true, + _layout_showTitle: "title", _width: 500, _lockedPosition: true, _forceActive: true, _dragOnlyWithinContainer: true, layout_hideContextMenu: true, _chromeHidden: true, }; return DocUtils.AssignDocField(doc, field, (opts, items) => Docs.Create.StackingDocument(items??[], opts), reqdToolOps, [creatorBtns, userBtns]); } @@ -794,6 +796,7 @@ pie title Minerals in my tap water { title: "Pen", toolTip: "Pen (Ctrl+P)", btnType: ButtonType.ToggleButton, icon: "pen-nib", toolType: InkInkTool.Pen, ignoreClick: true, scripts: {onClick:'{ return setActiveTool(this.toolType, true, _readOnly_);}' }}, { title: "Highlight",toolTip: "Highlight (Ctrl+H)", btnType: ButtonType.ToggleButton, icon: "highlighter", toolType: InkInkTool.Highlight, ignoreClick: true, scripts: {onClick:'{ return setActiveTool(this.toolType, true, _readOnly_);}' }}, { title: "Write", toolTip: "Write (Ctrl+Shift+P)", btnType: ButtonType.ToggleButton, icon: "pen", toolType: InkInkTool.Write, ignoreClick: true, scripts: {onClick:'{ return setActiveTool(this.toolType, true, _readOnly_);}' }, funcs: {hidden:"IsNoviceMode()" }}, + { title: "Math", toolTip: "Math", btnType: ButtonType.ToggleButton, icon: "calculator", toolType: InkInkTool.Math, ignoreClick: true, scripts: {onClick:'{ return setActiveTool(this.toolType, true, _readOnly_);}' }, funcs: {hidden:"IsNoviceMode()" }}, ]}, { title: "Width", toolTip: "Stroke width", btnType: ButtonType.NumberSliderButton, toolType: InkProperty.StrokeWidth,ignoreClick: true, scripts: {script: '{ return setInkProperty(this.toolType, value, _readOnly_);}'}, funcs: {hidden:"!activeInkTool()"}, numBtnMin: 1, linearView_btnWidth:40}, { title: "Color", toolTip: "Stroke color", btnType: ButtonType.ColorButton, icon: "pen", toolType: InkProperty.StrokeColor,ignoreClick: true, scripts: {script: '{ return setInkProperty(this.toolType, value, _readOnly_);}'}, funcs: {hidden:"!activeInkTool()"}}, @@ -992,7 +995,7 @@ pie title Minerals in my tap water const reqdOpts:DocumentOptions = { title: "My Imports", _forceActive: true, _layout_showTitle: "title", childLayoutString: ImportElementBox.LayoutString('data'), _dragOnlyWithinContainer: true, layout_hideContextMenu: true, childLimitHeight: 0, onClickScriptDisable:"never", - childDragAction: dropActionType.copy, _layout_autoHeight: true, _yMargin: 50, _gridGap: 15, layout_boxShadow: "0 0", _lockedPosition: true, isSystem: true, _chromeHidden: true, + childDragAction: dropActionType.copy, _layout_autoHeight: true, _gridGap: 15, layout_boxShadow: "0 0", _lockedPosition: true, isSystem: true, dontRegisterView: true, layout_explainer: "This is where documents that are Imported into Dash will go." }; const myImports = DocUtils.AssignDocField(doc, field, (opts) => Docs.Create.MasonryDocument([], opts), reqdOpts, undefined, {onClick: "deselectAll()"}); @@ -1030,6 +1033,9 @@ pie title Minerals in my tap water doc[`active${InkInkTool.Write}Color`] ?? (doc[`active${InkInkTool.Write}Color`] = "rgb(255, 0, 0)"); doc[`active${InkInkTool.Write}Width`] ?? (doc[`active${InkInkTool.Write}Width`] = 1); doc[`active${InkInkTool.Write}Bezier`] ?? (doc[`active${InkInkTool.Write}Bezier`] = "0"); + doc[`active${InkInkTool.Math}Color`] ?? (doc[`active${InkInkTool.Math}Color`] = "rgb(0, 0, 255)"); + doc[`active${InkInkTool.Math}Width`] ?? (doc[`active${InkInkTool.Math}Width`] = 1); + doc[`active${InkInkTool.Math}Bezier`] ?? (doc[`active${InkInkTool.Math}Bezier`] = "0"); doc[`active${InkInkTool.Highlight}Color`] ?? (doc[`active${InkInkTool.Highlight}Color`] = 'transparent'); doc[`active${InkInkTool.Highlight}Width`] ?? (doc[`active${InkInkTool.Highlight}Width`] = 20); doc[`active${InkInkTool.Highlight}Bezier`] ?? (doc[`active${InkInkTool.Highlight}Bezier`] = "0"); diff --git a/src/client/util/DocumentManager.ts b/src/client/util/DocumentManager.ts index fc8f22cf3..1c2ffab1b 100644 --- a/src/client/util/DocumentManager.ts +++ b/src/client/util/DocumentManager.ts @@ -250,7 +250,7 @@ export class DocumentManager { const options = optionsIn; Doc.MyRecentlyClosed && Doc.RemoveDocFromList(Doc.MyRecentlyClosed, undefined, targetDoc); const docContextPath = DocumentManager.GetContextPath(targetDoc, true); - if (docContextPath.some(doc => doc.hidden)) options.toggleTarget = false; + if (docContextPath.some(doc => doc !== targetDoc && doc.hidden)) options.toggleTarget = false; let activatedTab = false; if (DocumentView.activateTabView(docContextPath[0])) { options.toggleTarget = false; @@ -274,6 +274,7 @@ export class DocumentManager { // even if we found the document view, if the target is a lightbox, we try to open it in the lightbox to preserve lightbox semantics (eg, there's only one active doc in the lightbox) const target = DocCast(targetDoc.annotationOn, targetDoc)!; const compView = this.getDocumentView(DocCast(target.embedContainer))?.ComponentView; + options.didMove = target.hidden || options.didMove ? true : false; if ((compView?.addDocTab ?? compView?._props.addDocTab)?.(target, options.openLocation)) { await new Promise<void>(waitres => { setTimeout(() => waitres()); diff --git a/src/client/util/GroupManager.scss b/src/client/util/GroupManager.scss index 673af16ee..66f11c529 100644 --- a/src/client/util/GroupManager.scss +++ b/src/client/util/GroupManager.scss @@ -21,7 +21,6 @@ } .group-input { - input { padding: 8px; width: 100%; @@ -33,13 +32,13 @@ border-color: inherit; background: white; color: black; - height: 30; + height: 30px; } } .select-users { - margin-right: 3; - max-height: 30; + margin-right: 3px; + max-height: 30px; width: 100%; display: inline-flex; flex-direction: row; @@ -51,7 +50,6 @@ } } - button { align-self: center; outline: none; @@ -108,7 +106,7 @@ .sort-groups { text-align: left; - margin-left: 5; + margin-left: 5px; width: 50px; cursor: pointer; display: flex; @@ -122,7 +120,7 @@ .group-body { justify-content: space-between; - height: 225; + height: 225px; padding-right: 1em; justify-content: space-around; @@ -149,8 +147,8 @@ button { position: absolute; width: 30%; - right: 2; - margin-top: 0; + right: 2px; + margin-top: 0px; } } @@ -161,7 +159,6 @@ min-width: 100%; margin: 2px 0; } - } } -}
\ No newline at end of file +} diff --git a/src/client/util/GroupMemberView.scss b/src/client/util/GroupMemberView.scss index d50569b26..cb1a4e098 100644 --- a/src/client/util/GroupMemberView.scss +++ b/src/client/util/GroupMemberView.scss @@ -6,7 +6,7 @@ overflow: visible; hr { - margin-top: 20; + margin-top: 20px; } button { @@ -31,7 +31,7 @@ } .editing-header { - margin-bottom: 5; + margin-bottom: 5px; .group-title { font-weight: bold; @@ -39,7 +39,7 @@ text-align: center; border: none; outline: none; - height: 20; + height: 20px; text-overflow: ellipsis; background: none; @@ -57,12 +57,12 @@ .group-buttons { display: flex; - margin-top: 5; - margin-bottom: 25; + margin-top: 5px; + margin-bottom: 25px; .add-member-dropdown { - margin-right: 3; - max-height: 30; + margin-right: 3px; + max-height: 30px; width: 250px; display: inline-flex; flex-direction: row; @@ -108,11 +108,9 @@ .remove-button { position: absolute; - right: 10; + right: 10px; cursor: pointer; } } } - - -}
\ No newline at end of file +} diff --git a/src/client/util/LinkFollower.ts b/src/client/util/LinkFollower.ts index 6081c3fae..a4f54f1cf 100644 --- a/src/client/util/LinkFollower.ts +++ b/src/client/util/LinkFollower.ts @@ -134,6 +134,6 @@ export class LinkFollower { // eslint-disable-next-line prefer-arrow-callback ScriptingGlobals.add(function followLink(doc: Doc, altKey: boolean) { - DocumentView.DeselectAll(); + // DocumentView.DeselectAll(); return LinkFollower.FollowLink(undefined, doc, altKey) ? undefined : { select: true }; }); diff --git a/src/client/util/PingManager.ts b/src/client/util/PingManager.ts index 0e4f8cab0..593978b4a 100644 --- a/src/client/util/PingManager.ts +++ b/src/client/util/PingManager.ts @@ -27,7 +27,7 @@ export class PingManager { this.IsBeating = status; setTimeout(this.showAlert, 100); }); - Networking.PostToServer('/ping', { date: new Date() }) + Networking.PostToServer('/DashPing', { date: new Date() }) .then(res => { SnappingManager.SetServerVersion((res as { message: string }).message); !this.IsBeating && setIsBeating(true); diff --git a/src/client/util/SearchUtil.ts b/src/client/util/SearchUtil.ts index fc3bb99ab..2f23d07dc 100644 --- a/src/client/util/SearchUtil.ts +++ b/src/client/util/SearchUtil.ts @@ -8,6 +8,13 @@ import { DocOptions, FInfo } from '../documents/Documents'; export namespace SearchUtil { export type HighlightingResult = { [id: string]: { [key: string]: string[] } }; + /** + * Recursively search all Docs within the collection for the query string. + * @param {Doc} collectionDoc - The collection document to search within. + * @param {string} queryIn - The query string to search for. + * @param {boolean} matchKeyNames - Whether to match metadata field names in addition to field values + * @param {string[]} onlyKeys - Optional: restrict search to only look in the specified array of field names. + */ export function SearchCollection(collectionDoc: Opt<Doc>, queryIn: string, matchKeyNames: boolean, onlyKeys?: string[]) { const blockedTypes = [DocumentType.PRESSLIDE, DocumentType.CONFIG, DocumentType.KVP, DocumentType.FONTICON, DocumentType.BUTTON, DocumentType.SCRIPTING]; const blockedKeys = matchKeyNames @@ -27,11 +34,13 @@ export namespace SearchUtil { const dtype = StrCast(doc.type) as DocumentType; if (dtype && !blockedTypes.includes(dtype) && !docIDs.includes(doc[Id]) && depth >= 0) { const hlights = new Set<string>(); - (onlyKeys ?? SearchUtil.documentKeys(doc)).forEach( - key => - (val => (exact ? val === query.toLowerCase() : val.includes(query.toLowerCase())))( - matchKeyNames ? key : Field.toString(doc[key] as FieldType)) - && hlights.add(key) + const fieldsToSearch = onlyKeys ?? SearchUtil.documentKeys(doc); + fieldsToSearch.forEach( + key => { + const val = (matchKeyNames ? key : Field.toString(doc[key] as FieldType)).toLowerCase(); + const accept = (exact ? val === query.toLowerCase() : val.includes(query.toLowerCase())); + accept && hlights.add(key); + } ); // prettier-ignore blockedKeys.forEach(key => hlights.delete(key)); @@ -45,18 +54,17 @@ export namespace SearchUtil { return results; } /** - * @param {Doc} doc - doc for which keys are returned + * @param {Doc} doc - Doc to search for used field names * - * This method returns a list of a document doc's keys. + * An array of all field names used by the Doc or its prototypes. */ export function documentKeys(doc: Doc) { - const keys: { [key: string]: boolean } = {}; - Doc.GetAllPrototypes(doc).map(proto => - Object.keys(proto).forEach(key => { - keys[key] = false; - }) - ); - return Array.from(Object.keys(keys)); + return Object.keys(Doc.GetAllPrototypes(doc).filter(proto => proto).reduce( + (keys, proto) => { + Object.keys(proto).forEach(keys.add.bind(keys)); + return keys; + }, + new Set<string>())); // prettier-ignore } /** diff --git a/src/client/util/SettingsManager.scss b/src/client/util/SettingsManager.scss index f81f17589..51b719200 100644 --- a/src/client/util/SettingsManager.scss +++ b/src/client/util/SettingsManager.scss @@ -26,7 +26,7 @@ font-size: 16; font-weight: bold; text-align: left; - width: 80; + width: 80px; margin-right: 50px; } @@ -81,7 +81,7 @@ .acl-text { color: black; - margin-top: 2; + margin-top: 2px; text-align: left; } } @@ -119,7 +119,7 @@ .preferences-color-text { margin-top: 3px; - margin-right: 4; + margin-right: 4px; flex: 1 1 auto; text-align: left; } @@ -134,8 +134,8 @@ margin-top: 2px; .preferences-font-text { - margin-top: 4; - margin-right: 4; + margin-top: 4px; + margin-right: 4px; margin-bottom: 2px; text-align: left; } @@ -149,7 +149,7 @@ .font-select { height: 35px; font-size: 9; - margin-right: 6; + margin-right: 6px; border-radius: 5px; width: 65%; @@ -171,9 +171,9 @@ } .preferences-check { - margin-right: 4; + margin-right: 4px; margin-bottom: -3; - margin-left: 5; + margin-left: 5px; margin-top: -1px; display: inline-block; padding-left: 5px; @@ -205,14 +205,14 @@ .error-text { color: #c40233; font-size: 10; - margin-bottom: 4; + margin-bottom: 4px; } .success-text { - width: 300; + width: 300px; margin-left: -20; font-size: 10; - margin-bottom: 4; + margin-bottom: 4px; margin-top: -3; color: #009f6b; } @@ -225,7 +225,7 @@ text-transform: uppercase; letter-spacing: 2px; font-size: 19; - margin-top: 0; + margin-top: 0px; font-weight: bold; } @@ -246,7 +246,7 @@ font-style: normal; font-size: 15; font-weight: normal; - padding: 0; + padding: 0px; .padding { padding: 0 0 0 20px; @@ -273,8 +273,8 @@ align-items: center; flex-direction: column; bottom: 10px; - left: 0; - right: 0; + left: 0px; + right: 0px; .settings-username { padding-right: 0px; diff --git a/src/client/util/SettingsManager.tsx b/src/client/util/SettingsManager.tsx index 9e79fd870..f54dea90c 100644 --- a/src/client/util/SettingsManager.tsx +++ b/src/client/util/SettingsManager.tsx @@ -40,7 +40,7 @@ export class SettingsManager extends React.Component<object> { @observable private _activeTab = 'Accounts'; @observable private _isOpen = false; - private googleAuthorize = action(() => GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(true)); + private googleAuthorize = action(() => GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken()); public closeMgr = action(() => { this._isOpen = false; diff --git a/src/client/util/SharingManager.scss b/src/client/util/SharingManager.scss index c9f86f0e1..553f3a184 100644 --- a/src/client/util/SharingManager.scss +++ b/src/client/util/SharingManager.scss @@ -4,7 +4,7 @@ .select { text-align: justify; - text-align-last: end + text-align-last: end; } .sharing-contents { @@ -36,15 +36,14 @@ } .share-container { - .share-setup { display: flex; margin-bottom: 20px; align-items: center; - max-height: 28; + max-height: 28px; .user-search { - max-height: 30; + max-height: 30px; width: 90%; display: inline-flex; flex-direction: row; @@ -68,7 +67,7 @@ text-align-last: end; font-size: 13px; min-width: 90px; - height: 28; + height: 28px; margin-left: 2px; } @@ -99,8 +98,8 @@ display: flex; float: right; align-items: baseline; - margin-top: -12; - margin-bottom: 10; + margin-top: -12px; + margin-bottom: 10px; .layoutDoc-acls, .myDocs-acls { @@ -109,7 +108,7 @@ label { font-weight: normal; font-style: italic; - padding-right: 12; + padding-right: 12px; } input { @@ -127,13 +126,13 @@ .group-container { width: 50%; display: flex; - top:0; + top: 0px; flex-direction: column; border-radius: 4px; .user-sort { text-align: left; - margin-left: 10; + margin-left: 10px; width: 100%; cursor: pointer; } @@ -180,7 +179,7 @@ padding: 0 10; margin: 0 5; transition: transform 0.2s; - height: 25; + height: 25px; } } @@ -205,7 +204,7 @@ .people-with { font-size: 14px; - margin: 0; + margin: 0px; padding-top: 3px; font-style: normal; } @@ -216,30 +215,30 @@ } } - .title-individual{ + .title-individual { height: 25px; - padding-left: 2; + padding-left: 2px; width: 97%; border-radius: 4px; margin-top: 10px; margin-left: -8px; font-size: 14; - margin-bottom: -4; + margin-bottom: -4px; border: 2px solid; border-bottom: none; align-items: center; display: flex; } - .title-group{ + .title-group { height: 25px; - padding-left: 2; + padding-left: 2px; width: 97%; border-radius: 4px; margin-top: 10px; margin-left: -8px; font-size: 14; - margin-bottom: -4; + margin-bottom: -4px; border: 2px solid; border-bottom: none; align-items: center; @@ -289,18 +288,17 @@ text-align: right; margin-left: auto; margin-right: -12px; - } .edit-actions { display: flex; position: absolute; align-items: flex-end; - right: -10; + right: -10px; } } - .permissions-dropdown-None{ + .permissions-dropdown-None { height: 100%; min-width: 85px; text-align: right; @@ -325,7 +323,7 @@ border-radius: 6px; border: 1px solid rgb(75, 75, 5); } - .permissions-dropdown-Augment{ + .permissions-dropdown-Augment { height: 100%; min-width: 85px; text-align: right; @@ -333,12 +331,11 @@ padding: 0px; padding-left: 3px; background: rgb(208, 255, 208); - color:rgb(19, 80, 19); + color: rgb(19, 80, 19); border-radius: 6px; border: 1px solid rgb(19, 80, 19); - } - .permissions-dropdown-View{ + .permissions-dropdown-View { height: 100%; min-width: 85px; text-align: right; @@ -350,7 +347,7 @@ border-radius: 6px; border: 1px solid rgb(25, 25, 101); } - .permissions-dropdown-Not-Shared{ + .permissions-dropdown-Not-Shared { height: 100%; min-width: 85px; text-align: right; @@ -398,4 +395,4 @@ // padding-top: 12px; // } // } -}
\ No newline at end of file +} diff --git a/src/client/util/TrackMovements.ts b/src/client/util/TrackMovements.ts index 7da0281c0..74a3be83d 100644 --- a/src/client/util/TrackMovements.ts +++ b/src/client/util/TrackMovements.ts @@ -98,7 +98,6 @@ export class TrackMovements { // new tab was added - need to add it if (tabbedFFViews.size > this.recordingFFViews.size) { - // eslint-disable-next-line no-restricted-syntax for (const DashDoc of tabbedDocs) { if (!this.recordingFFViews.has(DashDoc)) { if (isFFView(DashDoc)) { @@ -112,7 +111,6 @@ export class TrackMovements { } // tab was removed - need to remove it from recordingFFViews else if (tabbedFFViews.size < this.recordingFFViews.size) { - // eslint-disable-next-line no-restricted-syntax for (const [doc] of this.recordingFFViews) { if (!tabbedFFViews.has(doc)) { this.removeRecordingFFView(doc); diff --git a/src/client/util/clamp.js b/src/client/util/clamp.js index 9c7fd78a4..8d74b5636 100644 --- a/src/client/util/clamp.js +++ b/src/client/util/clamp.js @@ -1,15 +1,15 @@ -"use strict"; +'use strict'; -Object.defineProperty(exports, "__esModule", { - value: true +Object.defineProperty(exports, '__esModule', { + value: true, }); -exports.default = clamp; function clamp(min, val, max) { - if (val < min) { - return min; - } - if (val > max) { - return max; - } - return val; -}
\ No newline at end of file + if (val < min) { + return min; + } + if (val > max) { + return max; + } + return val; +} +exports.default = clamp; diff --git a/src/client/util/convertToCSSPTValue.js b/src/client/util/convertToCSSPTValue.js index 66f8db5a1..032385d81 100644 --- a/src/client/util/convertToCSSPTValue.js +++ b/src/client/util/convertToCSSPTValue.js @@ -3,11 +3,8 @@ Object.defineProperty(exports, '__esModule', { value: true, }); -exports.PT_TO_PX_RATIO = exports.PX_TO_PT_RATIO = undefined; -exports.default = convertToCSSPTValue; -exports.toClosestFontPtSize = toClosestFontPtSize; -var SIZE_PATTERN = /([\d\.]+)(px|pt)/i; +var SIZE_PATTERN = /([\d.]+)(px|pt)/i; var PX_TO_PT_RATIO = (exports.PX_TO_PT_RATIO = 0.7518796992481203); // 1 / 1.33. var PT_TO_PX_RATIO = (exports.PT_TO_PX_RATIO = 1.33); @@ -39,3 +36,7 @@ function toClosestFontPtSize(styleValue) { return Math.abs(curr - originalPTValue) < Math.abs(prev - originalPTValue) ? curr : prev; }, Number.NEGATIVE_INFINITY); } + +exports.PT_TO_PX_RATIO = exports.PX_TO_PT_RATIO = undefined; +exports.default = convertToCSSPTValue; +exports.toClosestFontPtSize = toClosestFontPtSize; diff --git a/src/client/util/reportManager/ReportManager.scss b/src/client/util/reportManager/ReportManager.scss index 806741c22..65894fbd6 100644 --- a/src/client/util/reportManager/ReportManager.scss +++ b/src/client/util/reportManager/ReportManager.scss @@ -14,8 +14,8 @@ } h2 { - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; font-size: 24px; } } @@ -27,8 +27,8 @@ gap: 4px; h2 { - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; padding-bottom: 8px; font-size: 24px; } @@ -92,7 +92,7 @@ outline: none; border-bottom: 1px solid; padding: 8px; - padding-left: 0; + padding-left: 0px; transition: all 0.2s ease; background: transparent; @@ -178,15 +178,15 @@ cursor: pointer; font-size: 14px; font-weight: 400; - padding: 0; - margin: 0; + padding: 0px; + margin: 0px; } .issue-title { font-size: 16px; font-weight: 500; - padding: 0; - margin: 0; + padding: 0px; + margin: 0px; } } @@ -211,8 +211,8 @@ .file-list { box-sizing: border-box; - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; font-size: 14px; width: 100%; overflow-x: auto; @@ -251,8 +251,8 @@ .issue-title { font-size: 24px; - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; } .issue-date { @@ -314,8 +314,8 @@ .report-media-content::after { content: ''; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); /* Adjust the opacity as desired */ diff --git a/src/client/util/reportManager/ReportManager.tsx b/src/client/util/reportManager/ReportManager.tsx index a6b5911f7..87e8a8bf1 100644 --- a/src/client/util/reportManager/ReportManager.tsx +++ b/src/client/util/reportManager/ReportManager.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/no-unused-class-component-methods */ import { Octokit } from '@octokit/core'; import { Button, Dropdown, DropdownType, IconButton, Type } from '@dash/components'; import { action, makeObservable, observable } from 'mobx'; @@ -31,7 +30,6 @@ export class ReportManager extends React.Component<object> { @observable private isOpen = false; @observable private query = ''; - // eslint-disable-next-line react/sort-comp @action private setQuery = (q: string) => { this.query = q; }; diff --git a/src/client/views/ContextMenu.scss b/src/client/views/ContextMenu.scss index d22c4d096..337c976cb 100644 --- a/src/client/views/ContextMenu.scss +++ b/src/client/views/ContextMenu.scss @@ -68,8 +68,8 @@ width: 100%; height: 100%; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; z-index: 0; filter: opacity(0); } @@ -187,10 +187,10 @@ display: flex; .close-menu { - margin-top: 0; - margin-bottom: 0; - margin-right: 0; - padding: 0; + margin-top: 0px; + margin-bottom: 0px; + margin-right: 0px; + padding: 0px; margin-left: auto; z-index: 999999999; width: 20px; diff --git a/src/client/views/DashboardView.scss b/src/client/views/DashboardView.scss index daa711bc4..d74441b9c 100644 --- a/src/client/views/DashboardView.scss +++ b/src/client/views/DashboardView.scss @@ -79,8 +79,8 @@ $dashboard-container-width: 250px; position: absolute; width: 100%; height: 100%; - left: 0; - top: 0; + left: 0px; + top: 0px; z-index: -1; } } @@ -137,8 +137,8 @@ $dashboard-container-width: 250px; position: absolute; width: 100%; height: 100%; - left: 0; - top: 0; + left: 0px; + top: 0px; z-index: -1; } } diff --git a/src/client/views/DocumentButtonBar.tsx b/src/client/views/DocumentButtonBar.tsx index bc669fc4e..a845e4936 100644 --- a/src/client/views/DocumentButtonBar.tsx +++ b/src/client/views/DocumentButtonBar.tsx @@ -294,7 +294,7 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => ( {metaBtn('keywords', 'id-card')} </div> */} - <Tooltip title={<div className="dash-keyword-button">Open keyword menu</div>}> + <Tooltip title={<div className="dash-keyword-button">Open tags menu</div>}> <div className="documentButtonBar-icon" style={{ color: 'white' }} @@ -511,7 +511,7 @@ export class DocumentButtonBar extends ObservableReactComponent<{ views: () => ( {!DocumentView.Selected().some(v => v.allLinks.length) ? null : <div className="documentButtonBar-button">{this.followLinkButton}</div>} <div className="documentButtonBar-button">{this.pinButton}</div> <div className="documentButtonBar-button">{this.recordButton}</div> - <div className="documentButtonBar-button">{this.calendarButton}</div> + {Doc.noviceMode ? null : <div className="documentButtonBar-button">{this.calendarButton}</div>} {this.view0?.HasAIEditor ? <div className="documentButtonBar-button">{this.aiEditorButton}</div> : null} <div className="documentButtonBar-button">{this.keywordButton}</div> {!Doc.UserDoc().documentLinksButton_fullMenu ? null : <div className="documentButtonBar-button">{this.shareButton}</div>} diff --git a/src/client/views/DocumentDecorations.scss b/src/client/views/DocumentDecorations.scss index 09a13634f..d79826b3c 100644 --- a/src/client/views/DocumentDecorations.scss +++ b/src/client/views/DocumentDecorations.scss @@ -11,10 +11,10 @@ $resizeHandler: 8px; // Rotation handler .documentDecorations-rotation { border-radius: 100%; - height: 30; - width: 30; - right: -40; - bottom: -20; + height: 30px; + width: 30px; + right: -40px; + bottom: -20px; //top: calc(50% - 15px); position: absolute; pointer-events: all; @@ -32,8 +32,10 @@ $resizeHandler: 8px; } .documentDecorations-rotationCenter { position: absolute; - width: 6px; - height: 6px; + width: 9px; + height: 9px; + left: -4.5px; + top: -4.5px; pointer-events: all; background: green; border-radius: 50%; @@ -43,15 +45,15 @@ $resizeHandler: 8px; width: 100%; pointer-events: all; border-radius: 50%; - top: 30; // offset by height of documentButtonBar so that items can be clicked without overlap interference + top: 30px; // offset by height of documentButtonBar so that items can be clicked without overlap interference color: black; } } .documentDecorations-container { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; transform-origin: 50% calc(50% + 10px); display: grid; grid-template-rows: $headerHeight $resizeHandler 1fr $resizeHandler; @@ -119,7 +121,7 @@ $resizeHandler: 8px; } > svg { - margin: 0; + margin: 0px; } } @@ -148,7 +150,7 @@ $resizeHandler: 8px; } > svg { - margin: 0; + margin: 0px; } } &:hover { @@ -183,7 +185,7 @@ $resizeHandler: 8px; } > svg { - margin: 0; + margin: 0px; } } @@ -244,7 +246,7 @@ $resizeHandler: 8px; display: inline; position: relative; top: -2.5; - left: 35; + left: 35px; zoom: 0.7; } @@ -358,8 +360,8 @@ $resizeHandler: 8px; left: 7px; top: 7px; background: global.$medium-gray; - height: 10; - width: 10; + height: 10px; + width: 10px; opacity: 0.5; pointer-events: all; cursor: nwse-resize; @@ -369,8 +371,8 @@ $resizeHandler: 8px; position: relative; background: black; color: rgb(145, 144, 144); - height: 20; - width: 20; + height: 20px; + width: 20px; pointer-events: all; margin: auto; display: flex; @@ -378,8 +380,8 @@ $resizeHandler: 8px; border-radius: 100%; cursor: default; svg { - width: 10; - height: 10; + width: 10px; + height: 10px; margin: auto; } } @@ -387,7 +389,7 @@ $resizeHandler: 8px; .documentDecorations-rotationPath { position: absolute; width: 100%; - height: 0; + height: 0px; transform: translate(0px, -25%); padding-bottom: 100%; border-radius: 100%; diff --git a/src/client/views/DocumentDecorations.tsx b/src/client/views/DocumentDecorations.tsx index ab665e984..2b7050cf0 100644 --- a/src/client/views/DocumentDecorations.tsx +++ b/src/client/views/DocumentDecorations.tsx @@ -1,7 +1,7 @@ +import { IconButton } from '@dash/components'; import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { Tooltip } from '@mui/material'; -import { IconButton } from '@dash/components'; import { action, computed, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; @@ -27,15 +27,13 @@ import './DocumentDecorations.scss'; import { InkStrokeProperties } from './InkStrokeProperties'; import { InkingStroke } from './InkingStroke'; import { ObservableReactComponent } from './ObservableReactComponent'; +import { TagsView } from './TagsView'; import { CollectionDockingView } from './collections/CollectionDockingView'; import { CollectionFreeFormView } from './collections/collectionFreeForm'; import { Colors } from './global/globalEnums'; import { CollectionFreeFormDocumentView } from './nodes/CollectionFreeFormDocumentView'; import { DocumentView } from './nodes/DocumentView'; -import { ImageBox } from './nodes/ImageBox'; import { OpenWhere, OpenWhereMod } from './nodes/OpenWhere'; -import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox'; -import { TagsView } from './TagsView'; interface DocumentDecorationsProps { PanelWidth: number; @@ -58,7 +56,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora private _inkDragDocs: { doc: Doc; x: number; y: number; width: number; height: number }[] = []; private _interactionLock?: boolean; - @observable _showNothing = true; + @observable private _showNothing = true; @observable private _forceRender = 0; @observable private _accumulatedTitle = ''; @observable private _titleControlString: string = '$title'; @@ -349,13 +347,14 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora e.stopPropagation(); }; - setRotateCenter = (seldocview: DocumentView, rotCenter: number[]) => { + setDocRotateCenter = (seldocview: DocumentView, rotCenter: number[]) => { const selDoc = seldocview.Document; const newloccentern = seldocview.screenToViewTransform().transformPoint(rotCenter[0], rotCenter[1]); const newlocenter = [newloccentern[0] - NumCast(seldocview.layoutDoc._width) / 2, newloccentern[1] - NumCast(seldocview.layoutDoc._height) / 2]; const final = Utils.rotPt(newlocenter[0], newlocenter[1], -(NumCast(seldocview.Document._rotation) / 180) * Math.PI); selDoc._rotation_centerX = final.x / NumCast(seldocview.layoutDoc._width); selDoc._rotation_centerY = final.y / NumCast(seldocview.layoutDoc._height); + return false; }; @action @@ -365,10 +364,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora setupMoveUpEvents( this, e, - (moveEv: PointerEvent, down: number[], delta: number[]) => { - this.setRotateCenter(seldocview, [this.rotCenter[0] + delta[0], this.rotCenter[1] + delta[1]]); - return false; - }, + (moveEv) => this.setDocRotateCenter(seldocview, [moveEv.clientX, moveEv.clientY]), action(() => { this._isRotating = false; }), // upEvent action(() => { seldocview.Document._rotation_centerX = seldocview.Document._rotation_centerY = 0; }), true @@ -377,7 +373,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora }; @action - onRotateDown = (e: React.PointerEvent): void => { + onRotateHdlDown = (e: React.PointerEvent): void => { this._isRotating = true; const rcScreen = { X: this.rotCenter[0], Y: this.rotCenter[1] }; const rotateUndo = UndoManager.StartBatch('drag rotation'); @@ -393,17 +389,20 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora const unrotatedDocPos = { x: NumCast(dv.Document.x) + localRotCtrOffset[0] - startRotCtr.x, y: NumCast(dv.Document.y) + localRotCtrOffset[1] - startRotCtr.y }; infos.set(dv.Document, { unrotatedDocPos, startRotCtr, accumRot }); }); - const infoRot = (angle: number, isAbs = false) => { - DocumentView.Selected().forEach( - action(dv => { - const { unrotatedDocPos, startRotCtr, accumRot } = infos.get(dv.Document)!; - const endRotCtr = Utils.rotPt(startRotCtr.x, startRotCtr.y, isAbs ? angle : accumRot + angle); - infos.set(dv.Document, { unrotatedDocPos, startRotCtr, accumRot: isAbs ? angle : accumRot + angle }); - dv.Document.x = infos.get(dv.Document)!.unrotatedDocPos.x - (endRotCtr.x - startRotCtr.x); - dv.Document.y = infos.get(dv.Document)!.unrotatedDocPos.y - (endRotCtr.y - startRotCtr.y); - dv.Document._rotation = ((isAbs ? 0 : NumCast(dv.Document._rotation)) + (angle * 180) / Math.PI) % 360; // Rotation between -360 and 360 - }) - ); + const rotateDocs = (angle: number, isAbs = false) => { + if (selectedInk.length) { + InkStrokeProperties.Instance.rotateInk(selectedInk, angle, rcScreen); // rotate ink + return this.setDocRotateCenter(seldocview, centerPoint); + } + DocumentView.Selected().forEach(action(dv => { + const { unrotatedDocPos, startRotCtr, accumRot } = infos.get(dv.Document)!; + const endRotCtr = Utils.rotPt(startRotCtr.x, startRotCtr.y, isAbs ? angle : accumRot + angle); + infos.set(dv.Document, { unrotatedDocPos, startRotCtr, accumRot: isAbs ? angle : accumRot + angle }); + dv.Document.x = infos.get(dv.Document)!.unrotatedDocPos.x - (endRotCtr.x - startRotCtr.x); + dv.Document.y = infos.get(dv.Document)!.unrotatedDocPos.y - (endRotCtr.y - startRotCtr.y); + dv.Document._rotation = ((isAbs ? 0 : NumCast(dv.Document._rotation)) + (angle * 180) / Math.PI) % 360; // Rotation between -360 and 360 + })); // prettier-ignore + return false; }; setupMoveUpEvents( this, @@ -411,34 +410,17 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora (moveEv: PointerEvent, down: number[], delta: number[]) => { const previousPoint = { X: moveEv.clientX, Y: moveEv.clientY }; const movedPoint = { X: moveEv.clientX - delta[0], Y: moveEv.clientY - delta[1] }; - const deltaAng = InkStrokeProperties.angleChange(movedPoint, previousPoint, rcScreen); - if (selectedInk.length) { - deltaAng && InkStrokeProperties.Instance.rotateInk(selectedInk, deltaAng, rcScreen); - this.setRotateCenter(seldocview, centerPoint); - } else { - infoRot(deltaAng); - } - return false; + return rotateDocs(InkStrokeProperties.angleChange(movedPoint, previousPoint, rcScreen)); }, // moveEvent action(() => { const oldRotation = NumCast(seldocview.Document._rotation); - const diff = oldRotation - Math.round(oldRotation / 45) * 45; - if (Math.abs(diff) < 5) { - if (selectedInk.length) { - InkStrokeProperties.Instance.rotateInk(selectedInk, ((Math.round(oldRotation / 45) * 45 - oldRotation) / 180) * Math.PI, rcScreen); - } else { - infoRot(((Math.round(oldRotation / 45) * 45) / 180) * Math.PI, true); - } - } - if (selectedInk.length) { - this.setRotateCenter(seldocview, centerPoint); - } + if (Math.abs(oldRotation - Math.round(oldRotation / 45) * 45) < 5) { // rptation witihin 5deg of a 45deg angle multiple + rotateDocs(((Math.round(oldRotation / 45) * 45) / 180) * Math.PI, true); + } // prettier-ignore this._isRotating = false; rotateUndo?.end(); }), // upEvent - action(() => { - this._showRotCenter = !this._showRotCenter; - }) // clickEvent + action(() => (this._showRotCenter = !this._showRotCenter)) // clickEvent ); }; @@ -446,7 +428,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora onPointerDown = (e: React.PointerEvent): void => { SnappingManager.SetIsResizing(DocumentView.Selected().lastElement()?.Document[Id]); // turns off pointer events on things like youtube videos and web pages so that dragging doesn't get "stuck" when cursor moves over them DocumentView.Selected() - .filter(dv => e.shiftKey && dv.ComponentView instanceof ImageBox) + .filter(dv => e.shiftKey && dv.ComponentView?.isOutpaintable?.()) .forEach(dv => { dv.Document[dv.ComponentView!.fieldKey + '_outpaintOriginalWidth'] = NumCast(dv.Document._width); dv.Document[dv.ComponentView!.fieldKey + '_outpaintOriginalHeight'] = NumCast(dv.Document._height); @@ -502,7 +484,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora this._interactionLock = true; this._snapPt = thisPt; - const outpainted = e.shiftKey ? DocumentView.Selected().filter(dv => dv.ComponentView instanceof ImageBox) : []; + const outpainted = e.shiftKey ? DocumentView.Selected().filter(dv => dv.ComponentView?.isOutpaintable?.()) : []; const notOutpainted = e.shiftKey ? DocumentView.Selected().filter(dv => !outpainted.includes(dv)) : DocumentView.Selected(); // Special handling for shift-drag resize (outpainting of Images by resizing without scaling content - fill in with firefly GAI) @@ -524,7 +506,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora resizeViewForOutpainting = (docView: DocumentView, refPt: number[], scale: { x: number; y: number }, opts: { dragHdl: string; shiftKey: boolean }) => { const doc = docView.Document; - if (doc.isGroup) { + if (Doc.IsFreeformGroup(doc)) { DocListCast(doc.data) .map(member => DocumentView.getDocumentView(member, docView)!) .forEach(member => this.resizeViewForOutpainting(member, refPt, scale, opts)); @@ -598,14 +580,14 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora // // determines if anything being dragged directly or via a group has a fixed aspect ratio (in which case we resize uniformly) // - hasFixedAspect = (doc: Doc): boolean => (doc.isGroup ? DocListCast(doc.data).some(this.hasFixedAspect) : !BoolCast(doc._layout_nativeDimEditable)); + hasFixedAspect = (doc: Doc): boolean => (Doc.IsFreeformGroup(doc) ? DocListCast(doc.data).some(this.hasFixedAspect) : !BoolCast(doc._layout_nativeDimEditable)); // // resize a single DocumentView about the specified reference point, possibly setting/updating the native dimensions of the Doc // resizeView = (docView: DocumentView, refPt: number[], scale: { x: number; y: number }, opts: { dragHdl: string; freezeNativeDims: boolean }) => { const doc = docView.Document; - if (doc.isGroup) { + if (Doc.IsFreeformGroup(doc)) { DocListCast(doc.data) .map(member => DocumentView.getDocumentView(member, docView)!) .forEach(member => this.resizeView(member, refPt, scale, opts)); @@ -623,8 +605,10 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora if (nwidth && nheight && !cornerReflow && !horizontalReflow && !verticalReflow) { scale.x === 1 ? (scale.x = scale.y) : (scale.y = scale.x); } + if (NumCast(doc._height) * scale.y < NumCast(doc._height_min, 10)) scale.y = NumCast(doc._height_min, 10) / NumCast(doc._height); + if (NumCast(doc._width) * scale.x < NumCast(doc._width_min, 25)) scale.x = NumCast(doc._width_min, 25) / NumCast(doc._width); - if ((horizontalReflow || cornerReflow) && Doc.NativeWidth(doc)) { + if ((horizontalReflow || cornerReflow) && Doc.NativeWidth(doc) && scale.x > 0) { const setData = Doc.NativeWidth(doc[DocData]) === doc.nativeWidth; doc._nativeWidth = scale.x * Doc.NativeWidth(doc); if (setData) Doc.SetNativeWidth(doc[DocData], NumCast(doc.nativeWidth)); @@ -632,25 +616,23 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora doc._nativeHeight = (initHeight / initWidth) * nwidth; // initializes the nativeHeight for a PDF } } - if ((verticalReflow || cornerReflow) && Doc.NativeHeight(doc)) { + if ((verticalReflow || cornerReflow) && Doc.NativeHeight(doc) && scale.y > 0) { const setData = Doc.NativeHeight(doc[DocData]) === doc.nativeHeight && !doc.layout_reflowVertical; doc._nativeHeight = scale.y * Doc.NativeHeight(doc); if (setData) Doc.SetNativeHeight(doc[DocData], NumCast(doc._nativeHeight)); } - doc._width = Math.max(NumCast(doc._width_min, 25), NumCast(doc._width) * scale.x); - doc._height = Math.max(NumCast(doc._height_min, 10), NumCast(doc._height) * scale.y); + doc._width = NumCast(doc._width) * scale.x; + doc._height = NumCast(doc._height) * scale.y; const { deltaX, deltaY } = this.realignRefPt(doc, refCent, initWidth || 1, initHeight || 1); doc.x = NumCast(doc.x) + deltaX; doc.y = NumCast(doc.y) + deltaY; doc._layout_modificationDate = new DateField(); - if (scale.y !== 1) { - const docLayout = docView.layoutDoc; - docLayout._layout_autoHeight = undefined; - if (docView.layoutDoc._layout_autoHeight) { - // if autoHeight is still on because of a prototype - docLayout._layout_autoHeight = false; // then don't inherit, but explicitly set it to false + if (scale.y !== 1 && !opts.freezeNativeDims) { + doc._layout_autoHeight = undefined; + if (doc._layout_autoHeight) { + doc._layout_autoHeight = false; // set explicitly to false if inherited from parent of layout } } } @@ -695,7 +677,8 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora if (lastView) { const invXf = lastView.screenToViewTransform().inverse(); const seldoc = lastView.layoutDoc; - const loccenter = Utils.rotPt(NumCast(seldoc._rotation_centerX) * NumCast(seldoc._width), NumCast(seldoc._rotation_centerY) * NumCast(seldoc._height), invXf.Rotate); + const rcent = this._showRotCenter ? [NumCast(seldoc._rotation_centerX), NumCast(seldoc._rotation_centerY)] : [0, 0]; + const loccenter = Utils.rotPt(rcent[0] * NumCast(seldoc._width), rcent[1] * NumCast(seldoc._height), invXf.Rotate); return invXf.transformPoint(loccenter.x + NumCast(seldoc._width) / 2, loccenter.y + NumCast(seldoc._height) / 2); } return this._rotCenter; @@ -737,7 +720,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora hideDecorations || seldocview._props.hideOpenButton || seldocview.Document.layout_hideOpenButton || - DocumentView.Selected().some(docView => docView.Document._dragOnlyWithinContainer || docView.Document.isGroup || docView.Document.layout_hideOpenButton) || + DocumentView.Selected().some(docView => docView.Document._dragOnlyWithinContainer || docView.Document.layout_hideOpenButton) || this._isRounding || this._isRotating; const hideDeleteButton = @@ -765,7 +748,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora const rotation = DocumentView.Selected().length === 1 ? seldocview.screenToContentsTransform().inverse().RotateDeg : 0; // Radius constants - const useRounding = seldocview.ComponentView instanceof ImageBox || seldocview.ComponentView instanceof FormattedTextBox || seldocview.ComponentView instanceof CollectionFreeFormView; + const useRounding = seldocview.ComponentView?.showBorderRounding?.(); const borderRadius = numberValue(Cast(seldocview.Document.layout_borderRounding, 'string', null)); const docMax = Math.min(NumCast(seldocview.Document._width) / 2, NumCast(seldocview.Document._height) / 2); const maxDist = Math.min((this.Bounds.r - this.Bounds.x) / 2, (this.Bounds.b - this.Bounds.y) / 2); @@ -923,16 +906,14 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora <div style={{ position: 'absolute', - transform: `rotate(${rotation}deg)`, - width: this.Bounds.r - this.Bounds.x + 'px', - height: this.Bounds.b - this.Bounds.y + 'px', - left: this.Bounds.x, - top: this.Bounds.y, + transform: `translate(${bounds.x - this._resizeBorderWidth}px, ${bounds.y - this._resizeBorderWidth}px) rotate(${rotation}deg)`, + width: bounds.r - bounds.x + 2 * this._resizeBorderWidth + 'px', + height: bounds.b - bounds.y + 2 * this._resizeBorderWidth + 'px', pointerEvents: 'none', }}> {this._isRotating ? null : ( <Tooltip enterDelay={750} title={<div className="dash-tooltip">tap to set rotate center, drag to rotate</div>}> - <div className="documentDecorations-rotation" onPointerDown={this.onRotateDown} onContextMenu={e => e.preventDefault()}> + <div className="documentDecorations-rotation" onPointerDown={this.onRotateHdlDown} onContextMenu={e => e.preventDefault()}> <IconButton icon={<FaUndo />} color={SettingsManager.userColor} /> </div> </Tooltip> @@ -941,7 +922,7 @@ export class DocumentDecorations extends ObservableReactComponent<DocumentDecora {!this._showRotCenter ? null : ( <div className="documentDecorations-rotationCenter" - style={{ transform: `translate(${this.rotCenter[0] - 3}px, ${this.rotCenter[1] - 3}px)` }} + style={{ transform: `translate(${this.rotCenter[0]}px, ${this.rotCenter[1]}px)` }} onPointerDown={this.onRotateCenterDown} onContextMenu={e => e.preventDefault()} /> diff --git a/src/client/views/EditableView.scss b/src/client/views/EditableView.scss index fa4542ac4..333939d03 100644 --- a/src/client/views/EditableView.scss +++ b/src/client/views/EditableView.scss @@ -6,14 +6,14 @@ overflow-y: auto; height: 100%; width: 100%; - min-width: 20; + min-width: 20px; text-overflow: ellipsis; - -ms-overflow-style: none; - scrollbar-width: none; + -ms-overflow-style: none; + scrollbar-width: none; } .editableView-container-editing::-webkit-scrollbar { - display: none; + display: none; } .editableView-container-editing-oneLine { @@ -44,4 +44,3 @@ border: none; outline: none; } - diff --git a/src/client/views/EditableView.tsx b/src/client/views/EditableView.tsx index d9447b7ec..deeabaa28 100644 --- a/src/client/views/EditableView.tsx +++ b/src/client/views/EditableView.tsx @@ -236,6 +236,7 @@ export class EditableView extends ObservableReactComponent<EditableProps> { return this._editing; }; + setInputRef = (r: HTMLInputElement | HTMLTextAreaElement | null) => (this._inputref = r); renderEditor() { return this._props.autosuggestProps ? ( <Autosuggest @@ -255,7 +256,7 @@ export class EditableView extends ObservableReactComponent<EditableProps> { ) : this._props.oneLine !== false && this._props.GetValue()?.toString().indexOf('\n') === -1 ? ( <input className="editableView-input" - ref={r => { this._inputref = r; }} // prettier-ignore + ref={this.setInputRef} style={{ display: this._props.display, overflow: 'auto', fontSize: this._props.fontSize, minWidth: 20, background: this._props.background }} placeholder={this._props.placeholder} onBlur={e => this.finalizeEdit(e.currentTarget.value, false, true, false)} @@ -270,7 +271,7 @@ export class EditableView extends ObservableReactComponent<EditableProps> { ) : ( <textarea className="editableView-input" - ref={r => { this._inputref = r; }} // prettier-ignore + ref={this.setInputRef} style={{ display: this._props.display, overflow: 'auto', fontSize: this._props.fontSize, minHeight: `min(100%, ${(this._props.GetValue()?.split('\n').length || 1) * 15})`, minWidth: 20, background: this._props.background }} placeholder={this._props.placeholder} onBlur={e => this.finalizeEdit(e.currentTarget.value, false, true, false)} diff --git a/src/client/views/FieldsDropdown.tsx b/src/client/views/FieldsDropdown.tsx index e7ab6a180..0bdf92bbc 100644 --- a/src/client/views/FieldsDropdown.tsx +++ b/src/client/views/FieldsDropdown.tsx @@ -6,7 +6,7 @@ * this list is then pruned down to only include fields that are not marked in Documents.ts to be non-filterable */ -import { computed, makeObservable, observable, runInAction } from 'mobx'; +import { action, computed, makeObservable, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import Select from 'react-select'; @@ -24,6 +24,7 @@ interface fieldsDropdownProps { placeholder?: string | (() => string); showPlaceholder?: true; // if true, then input field always shows the placeholder value; otherwise, it shows the current selection addedFields?: string[]; + isInactive?: boolean; } @observer @@ -57,8 +58,8 @@ export class FieldsDropdown extends ObservableReactComponent<fieldsDropdownProps const filteredOptions = ['author', ...(this._newField ? [this._newField] : []), ...(this._props.addedFields ?? []), ...this.fieldsOfDocuments.filter(facet => facet[0] === facet.charAt(0).toUpperCase())]; Object.entries(DocOptions) - .filter(opts => opts[1].filterable) - .forEach((pair: [string, FInfo]) => filteredOptions.push(pair[0])); + .filter(opts => opts[1] instanceof FInfo && opts[1].filterable) + .forEach((pair: [string, unknown]) => filteredOptions.push(pair[0])); const options = filteredOptions.sort().map(facet => ({ value: facet, label: facet })); return ( @@ -77,11 +78,13 @@ export class FieldsDropdown extends ObservableReactComponent<fieldsDropdownProps ...baseStyles, color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor, + display: this._props.isInactive ? 'none' : undefined, }), placeholder: (baseStyles /* , state */) => ({ ...baseStyles, color: SnappingManager.userColor, background: SnappingManager.userBackgroundColor, + display: this._props.isInactive ? 'none' : undefined, }), input: (baseStyles /* , state */) => ({ ...baseStyles, @@ -104,14 +107,12 @@ export class FieldsDropdown extends ObservableReactComponent<fieldsDropdownProps options={options} isMulti={false} onChange={val => this._props.selectFunc((val as { value: string; label: string }).value)} - onKeyDown={e => { + onKeyDown={action(e => { if (e.key === 'Enter') { - runInAction(() => { - this._props.selectFunc((this._newField = (e.nativeEvent.target as HTMLSelectElement)?.value)); - }); + this._props.selectFunc((this._newField = (e.nativeEvent.target as HTMLSelectElement)?.value)); } e.stopPropagation(); - }} + })} onMenuClose={this._props.menuClose} closeMenuOnSelect value={this._props.showPlaceholder ? null : undefined} diff --git a/src/client/views/FilterPanel.scss b/src/client/views/FilterPanel.scss index 508b1ee1f..e32db000f 100644 --- a/src/client/views/FilterPanel.scss +++ b/src/client/views/FilterPanel.scss @@ -1,4 +1,3 @@ - .filterBox-flyout { display: block; text-align: left; @@ -29,7 +28,7 @@ // .filterBox-bottom { // // position: fixed; -// // bottom: 0; +// // bottom: 0px; // // width: 100%; // } @@ -88,7 +87,7 @@ // padding-bottom: 20px; // border-bottom: 2px solid black; // position: fixed; -// top: 0; +// top: 0px; // width: 100%; // } @@ -154,8 +153,8 @@ flex-direction: column; width: 100%; position: relative; - right: 0; - top: 0; + right: 0px; + top: 0px; z-index: 1; // background-color: #9f9f9f; @@ -240,60 +239,52 @@ transition: all 0.3s ease-out; display: flex; flex-direction: row; - padding: 5px; - + padding: 5px; - &:hover{ + &:hover { border-color: #e9e9e9; - background-color: #6d6c6c + background-color: #6d6c6c; } - .hotKey-icon, .hotKey-close{ + .hotKey-icon, + .hotKey-close { background-color: transparent; border-radius: 10%; padding: 5px; - - &:hover{ + &:hover { background-color: #616060; } } - .hotKey-close{ + .hotKey-close { right: 30px; - position: fixed; + position: fixed; padding-top: 10px; - -} + } - .hotkey-title{ + .hotkey-title { top: 6px; position: relative; cursor: text; - } - .hotkey-title-input{ + .hotkey-title-input { background-color: transparent; border: none; border-color: transparent; outline: none; cursor: text; - } } .hotKeyButtons { position: relative; width: 100%; - } .hotKey-icon-button { - - background-color: transparent; - - + background-color: transparent; } .icon-panel { @@ -305,24 +296,19 @@ border-radius: 10%; background-color: #323232; - .icon-panel-button{ + .icon-panel-button { background-color: #323232; border-radius: 10%; - - &:hover{ - background-color:#7a7878 + &:hover { + background-color: #7a7878; } } - - - } - // .sliderBox-outerDiv { // width: 30%;// width: calc(100% - 14px); // 14px accounts for handles that are at the max value of the slider that would extend outside the box -// height: 40; // height: 100%; +// height: 40px; // height: 100%; // border-radius: inherit; // display: flex; // flex-direction: column; diff --git a/src/client/views/GestureOverlay.scss b/src/client/views/GestureOverlay.scss index bfe2d5c64..0fa3fd973 100644 --- a/src/client/views/GestureOverlay.scss +++ b/src/client/views/GestureOverlay.scss @@ -3,7 +3,7 @@ height: 100%; position: absolute; touch-action: none; - top: 0; + top: 0px; .pointerBubbles { width: 100%; diff --git a/src/client/views/GestureOverlay.tsx b/src/client/views/GestureOverlay.tsx index 777a34ebc..8488c5293 100644 --- a/src/client/views/GestureOverlay.tsx +++ b/src/client/views/GestureOverlay.tsx @@ -3,7 +3,7 @@ import { action, computed, makeObservable, observable, runInAction } from 'mobx' import { observer } from 'mobx-react'; import * as React from 'react'; import { setupMoveUpEvents } from '../../ClientUtils'; -import { emptyFunction, intersectRect } from '../../Utils'; +import { emptyFunction, intersectRect, rectContains } from '../../Utils'; import { Doc } from '../../fields/Doc'; import { InkData, InkField, InkTool } from '../../fields/InkField'; import { NumCast } from '../../fields/Types'; @@ -166,7 +166,7 @@ export class GestureOverlay extends ObservableReactComponent<React.PropsWithChil */ docsInBoundingBox = (boundingBox: { topLeft: number[]; bottomRight: number[] }, childDocs: Doc[]): Doc[] => { const rect = { left: boundingBox.topLeft[0], top: boundingBox.topLeft[1], width: boundingBox.bottomRight[0] - boundingBox.topLeft[0], height: boundingBox.bottomRight[1] - boundingBox.topLeft[1] }; - return childDocs.filter(doc => intersectRect(rect, { left: NumCast(doc.x), top: NumCast(doc.y), width: NumCast(doc._width), height: NumCast(doc._height) })); + return childDocs.filter(doc => rectContains(rect, { left: NumCast(doc.x), top: NumCast(doc.y), width: NumCast(doc._width), height: NumCast(doc._height) })); }; /** * Determines if what the array of cusp/intersection data corresponds to a scribble. diff --git a/src/client/views/InkStroke.scss b/src/client/views/InkStroke.scss index c672824bf..0595283fc 100644 --- a/src/client/views/InkStroke.scss +++ b/src/client/views/InkStroke.scss @@ -8,8 +8,8 @@ svg:not(:root) { overflow: visible !important; position: absolute; - left: 0; - top: 0; + left: 0px; + top: 0px; } } diff --git a/src/client/views/InkStrokeProperties.ts b/src/client/views/InkStrokeProperties.ts index 41f38c008..9f2c1fc4e 100644 --- a/src/client/views/InkStrokeProperties.ts +++ b/src/client/views/InkStrokeProperties.ts @@ -209,17 +209,18 @@ export class InkStrokeProperties { * @param scrpt The center point of the rotation in screen coordinates */ rotateInk = undoable((inkStrokes: DocumentView[], angle: number, scrpt: PointData) => { - this.applyFunction(inkStrokes, (view: DocumentView, ink: InkData, xScale: number, yScale: number /* , inkStrokeWidth: number */) => { - const inkCenterPt = view.ComponentView?.ptFromScreen?.(scrpt); - return !inkCenterPt - ? ink - : ink.map(i => { - const pt = { X: i.X - inkCenterPt.X, Y: i.Y - inkCenterPt.Y }; - const newX = Math.cos(angle) * pt.X - (Math.sin(angle) * pt.Y * yScale) / xScale; - const newY = (Math.sin(angle) * pt.X * xScale) / yScale + Math.cos(angle) * pt.Y; - return { X: newX + inkCenterPt.X, Y: newY + inkCenterPt.Y }; - }); - }); + angle && + this.applyFunction(inkStrokes, (view: DocumentView, ink: InkData, xScale: number, yScale: number /* , inkStrokeWidth: number */) => { + const inkCenterPt = view.ComponentView?.ptFromScreen?.(scrpt); + return !inkCenterPt + ? ink + : ink.map(i => { + const pt = { X: i.X - inkCenterPt.X, Y: i.Y - inkCenterPt.Y }; + const newX = Math.cos(angle) * pt.X - (Math.sin(angle) * pt.Y * yScale) / xScale; + const newY = (Math.sin(angle) * pt.X * xScale) / yScale + Math.cos(angle) * pt.Y; + return { X: newX + inkCenterPt.X, Y: newY + inkCenterPt.Y }; + }); + }); }, 'rotate ink'); /** diff --git a/src/client/views/InkTangentHandles.tsx b/src/client/views/InkTangentHandles.tsx index 577acc4d1..0af46df5d 100644 --- a/src/client/views/InkTangentHandles.tsx +++ b/src/client/views/InkTangentHandles.tsx @@ -105,7 +105,6 @@ export class InkTangentHandles extends React.Component<InkHandlesProps> { return ( <> {tangentHandles.map((pts, i) => ( - // eslint-disable-next-line react/no-array-index-key <svg height="10" width="10" key={`hdl${i}`}> <circle cx={pts.X} @@ -135,7 +134,6 @@ export class InkTangentHandles extends React.Component<InkHandlesProps> { /> ); return ( - // eslint-disable-next-line react/no-array-index-key <svg height="100" width="100" key={`line${i}`}> {tangentLine(pts.X1, pts.Y1, pts.X2, pts.Y2)} {tangentLine(pts.X2, pts.Y2, pts.X3, pts.Y3)} diff --git a/src/client/views/InkTranscription.tsx b/src/client/views/InkTranscription.tsx index 2e6b477e9..32ca5f56b 100644 --- a/src/client/views/InkTranscription.tsx +++ b/src/client/views/InkTranscription.tsx @@ -4,8 +4,8 @@ import * as React from 'react'; import { imageUrlToBase64 } from '../../ClientUtils'; import { aggregateBounds } from '../../Utils'; import { Doc, DocListCast } from '../../fields/Doc'; -import { InkData, InkField, InkInkTool, InkTool } from '../../fields/InkField'; -import { Cast, DateCast, ImageCast, NumCast } from '../../fields/Types'; +import { InkData, InkInkTool, InkTool } from '../../fields/InkField'; +import { Cast, DateCast, ImageCast, InkCast, NumCast } from '../../fields/Types'; import { ImageField, URLField } from '../../fields/URLField'; import { gptHandwriting } from '../apis/gpt/GPT'; import { DocumentType } from '../documents/DocumentTypes'; @@ -30,9 +30,9 @@ export class InkTranscription extends React.Component { // eslint-disable-next-line @typescript-eslint/no-explicit-any @observable _textRef: any = undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any - @observable iinkEditor: any = undefined; + @observable _iinkEditor: any = undefined; // eslint-disable-next-line @typescript-eslint/no-explicit-any - private lastJiix: any; + @observable _iinkMathEditor: any = undefined; private currGroup?: Doc; private collectionFreeForm?: CollectionFreeFormView; @@ -44,7 +44,7 @@ export class InkTranscription extends React.Component { @action // eslint-disable-next-line @typescript-eslint/no-explicit-any setMathRef = async (r: any) => { - if (!this._textRegister && r) { + if (!this._mathRegister && r) { const options = { configuration: { server: { @@ -55,19 +55,17 @@ export class InkTranscription extends React.Component { protocol: 'WEBSOCKET', }, recognition: { - type: 'TEXT', - lang: 'en_US', - text: { - mimeTypes: ['application/vnd.myscript.jiix'] as 'application/vnd.myscript.jiix'[], + type: 'MATH', + math: { + mimeTypes: ['application/x-latex'] as unknown as 'application/vnd.myscript.jiix'[], }, }, }, }; - await iink.Editor.load(r, 'INKV2', options); - - this._textRegister = r; + this._iinkMathEditor = await iink.Editor.load(r, 'INKV2', options); + this._mathRegister = r; // eslint-disable-next-line @typescript-eslint/no-explicit-any - r?.addEventListener('exported', (e: any) => this.exportInk(e, this._textRef)); + r?.addEventListener('exported', (e: any) => this.exportInk(e)); return (this._textRef = r); } @@ -94,15 +92,17 @@ export class InkTranscription extends React.Component { }, }, }; - this.iinkEditor = await iink.Editor.load(r, 'INKV2', options); + this._iinkEditor = await iink.Editor.load(r, 'INKV2', options); this._textRegister = r; // eslint-disable-next-line @typescript-eslint/no-explicit-any - r?.addEventListener('exported', (e: any) => this.exportInk(e, this._textRef)); + r?.addEventListener('exported', (e: any) => this.exportInk(e)); return (this._textRef = r); } }; + _ffview: CollectionFreeFormView | undefined; + /** * Handles processing Dash Doc data for ink transcription. * @@ -110,7 +110,7 @@ export class InkTranscription extends React.Component { * @param inkDocs the ink docs contained within the selected group * @param math boolean whether to do math transcription or not */ - transcribeInk = (groupDoc: Doc | undefined, inkDocs: Doc[], math: boolean) => { + transcribeInk = (ffview: CollectionFreeFormView, groupDoc: Doc | undefined, inkDocs: Doc[], math: boolean) => { if (!groupDoc) return; const validInks = inkDocs.filter(s => s.type === DocumentType.INK); @@ -118,20 +118,18 @@ export class InkTranscription extends React.Component { const times: number[] = []; validInks - .filter(i => Cast(i[Doc.LayoutDataKey(i)], InkField)) + .filter(i => InkCast(i[Doc.LayoutDataKey(i)])) .forEach(i => { - const d = Cast(i[Doc.LayoutDataKey(i)], InkField, null); + const d = InkCast(i[Doc.LayoutDataKey(i)])!; + const authorTime = DateCast(i.author_date)?.getDate().getTime() ?? 0; const inkStroke = DocumentView.getDocumentView(i)?.ComponentView as InkingStroke; strokes.push(d.inkData.map(pd => inkStroke.ptToScreen({ X: pd.X, Y: pd.Y }))); - times.push(DateCast(i.author_date).getDate().getTime()); + times.push(authorTime); }); + this._ffview = ffview; this.currGroup = groupDoc; const pointerData = strokes.map((stroke, i) => this.inkJSON(stroke, times[i])); - if (math) { - this.iinkEditor.importPointEvents(pointerData); - } else { - this.iinkEditor.importPointEvents(pointerData); - } + (math ? this._iinkMathEditor : this._iinkEditor).importPointEvents(pointerData); }; convertPointsToString(points: InkData[]): string { return points[0].map(point => `new Point(${point.X}, ${point.Y})`).join(','); @@ -203,83 +201,92 @@ export class InkTranscription extends React.Component { * @param ref the ref to the editor */ // eslint-disable-next-line @typescript-eslint/no-explicit-any - exportInk = async (e: any, ref: any) => { - const exports = e.detail['application/vnd.myscript.jiix']; - if (exports) { - if (exports['type'] == 'Math') { - const latex = exports['application/x-latex']; - if (this.currGroup) { - this.currGroup.text = latex; - this.currGroup.title = latex; - } + exportInk = (e: { detail: { [key: string]: string } }) => { + const exports = e.detail; + if (exports['application/x-latex']) { + const latex = exports['application/x-latex']; + if (this.currGroup) { + this.currGroup.text = latex; + this.currGroup.title = latex; + } - ref.editor.clear(); - } else if (exports['type'] == 'Text') { - if (exports['application/vnd.myscript.jiix']) { - this.lastJiix = JSON.parse(exports['application/vnd.myscript.jiix']); - // map timestamp to strokes - const timestampWord = new Map<number, string>(); - // eslint-disable-next-line @typescript-eslint/no-explicit-any - this.lastJiix.words.map((word: any) => { - if (word.items) { - word.items.forEach((i: { id: string; timestamp: string; X: Array<number>; Y: Array<number>; F: Array<number> }) => { - const ms = Date.parse(i.timestamp); - timestampWord.set(ms, word.label); - }); - } + this._ffview?.addDocument( + Docs.Create.EquationDocument(latex, { + title: '', + x: this.currGroup?.x as number, + y: (this.currGroup?.y as number) + (this.currGroup?.height as number), + nativeHeight: 40, + _height: 50, + nativeWidth: 40, + _width: 50, + }) + ); + // this.showRecogBox(latex as string); + this._iinkMathEditor.clear(); + } else if (exports['application/vnd.myscript.jiix']) { + const lastJiix = exports['application/vnd.myscript.jiix'] as unknown as { words: string[]; label: string }; + // map timestamp to strokes + const timestampWord = new Map<number, string>(); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + lastJiix.words.map((word: any) => { + if (word.items) { + word.items.forEach((i: { id: string; timestamp: string; X: Array<number>; Y: Array<number>; F: Array<number> }) => { + const ms = Date.parse(i.timestamp); + timestampWord.set(ms, word.label); }); - - const wordInkDocMap = new Map<string, Doc[]>(); - if (this.currGroup) { - const docList = DocListCast(this.currGroup.data); - docList.forEach((inkDoc: Doc) => { - // just having the times match up and be a unique value (actual timestamp doesn't matter) - const ms = DateCast(inkDoc.author_date).getDate().getTime() + 14400000; - const word = timestampWord.get(ms); - if (!word) { - return; - } - const entry = wordInkDocMap.get(word); - if (entry) { - entry.push(inkDoc); - wordInkDocMap.set(word, entry); - } else { - const newEntry = [inkDoc]; - wordInkDocMap.set(word, newEntry); - } - }); - if (this.lastJiix.words.length > 1) this.subgroupsTranscriptions(wordInkDocMap); - } } - const text = exports['label']; + }); - if (this.currGroup && text) { - DocumentView.getDocumentView(this.currGroup)?.ComponentView?.updateIcon?.(); - const image = await this.getIcon(); - const { href } = (image as URLField).url; - const hrefParts = href.split('.'); - const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`; - let response; - try { - const hrefBase64 = await imageUrlToBase64(hrefComplete); - response = await gptHandwriting(hrefBase64); - } catch { - console.error('Error getting image'); - } - const textBoxText = 'iink: ' + text + '\n' + '\n' + 'ChatGPT: ' + response; - this.currGroup.transcription = response; - this.currGroup.title = response; - if (!this.currGroup.hasTextBox) { - const newDoc = Docs.Create.TextDocument(textBoxText, { title: '', x: this.currGroup.x as number, y: (this.currGroup.y as number) + (this.currGroup.height as number) }); - newDoc.height = 200; - this.collectionFreeForm?.addDocument(newDoc); - this.currGroup.hasTextBox = true; + const wordInkDocMap = new Map<string, Doc[]>(); + if (this.currGroup) { + DocListCast(this.currGroup.data).forEach((inkDoc: Doc) => { + // just having the times match up and be a unique value (actual timestamp doesn't matter) + const ms = (DateCast(inkDoc.author_date)?.getDate().getTime() ?? 0) + 14400000; + const word = timestampWord.get(ms); + if (word) { + const entry = wordInkDocMap.get(word); + if (entry) { + entry.push(inkDoc); + wordInkDocMap.set(word, entry); + } else { + const newEntry = [inkDoc]; + wordInkDocMap.set(word, newEntry); + } } - ref.editor.clear(); - } + }); + if (lastJiix.words.length > 1) this.subgroupsTranscriptions(wordInkDocMap); } + this.showRecogBox(lastJiix.label); + this._iinkEditor.clear(); } }; + private showRecogBox(text: string) { + if (this.currGroup) { + let response; + // DocumentView.getDocumentView(this.currGroup)?.ComponentView?.updateIcon?.(); + // const image = await this.getIcon(); + // const { href } = (image as URLField).url; + // const hrefParts = href.split('.'); + // const hrefComplete = `${hrefParts[0]}_o.${hrefParts[1]}`; + // try { + // const hrefBase64 = await imageUrlToBase64(hrefComplete); + // response = await gptHandwriting(hrefBase64); + // } catch { + // console.error('Error getting image'); + // } + const textBoxText = 'iink: ' + text + '\n' + '\n' + 'ChatGPT: ' + response; + + this.currGroup.transcription = response; + this.currGroup.title = response; + if (!this.currGroup.hasTextBox) { + const newDoc = Docs.Create.TextDocument(textBoxText, { title: '', x: this.currGroup.x as number, y: (this.currGroup.y as number) + (this.currGroup.height as number) }); + newDoc.height = 200; + this.collectionFreeForm?.addDocument(newDoc); + this.currGroup.hasTextBox = true; + } + } + } + /** * gets the icon of the collection that was just made * @returns the image of the collection @@ -298,7 +305,7 @@ export class InkTranscription extends React.Component { */ createInkGroup() { // TODO nda - if document being added to is a inkGrouping then we can just add to that group - if (Doc.ActiveTool === InkTool.Ink && Doc.ActiveInk === InkInkTool.Write) { + if (Doc.ActiveTool === InkTool.Ink && [InkInkTool.Write, InkInkTool.Math].includes(Doc.ActiveInk)) { CollectionFreeFormView.collectionsWithUnprocessedInk.forEach(ffView => { // TODO: nda - will probably want to go through ffView unprocessed docs and then see if any of the inksToGroup docs are in it and only use those const selected = ffView.unprocessedDocs; @@ -308,7 +315,7 @@ export class InkTranscription extends React.Component { ); ffView.unprocessedDocs = []; - InkTranscription.Instance.transcribeInk(newCollection, selected, false); + InkTranscription.Instance.transcribeInk(ffView, newCollection, selected, Doc.ActiveInk === InkInkTool.Math); }); } CollectionFreeFormView.collectionsWithUnprocessedInk.clear(); diff --git a/src/client/views/InkingStroke.tsx b/src/client/views/InkingStroke.tsx index 253db08de..4b651af7d 100644 --- a/src/client/views/InkingStroke.tsx +++ b/src/client/views/InkingStroke.tsx @@ -124,7 +124,7 @@ export class InkingStroke extends ViewBoxAnnotatableComponent<FieldViewProps>() ); ffView.unprocessedDocs = []; - InkTranscription.Instance.transcribeInk(newCollection, selected, false); + InkTranscription.Instance.transcribeInk(ffView, newCollection, selected, false); } }; diff --git a/src/client/views/LightboxView.scss b/src/client/views/LightboxView.scss index 3e65843df..7a481d887 100644 --- a/src/client/views/LightboxView.scss +++ b/src/client/views/LightboxView.scss @@ -1,12 +1,12 @@ .lightboxView-navBtn { margin: auto; position: absolute; - right: 19; - top: 10; + right: 19px; + top: 10px; background: transparent; - border-radius: 8; + border-radius: 8px; opacity: 0.7; - width: 25; + width: 25px; flex-direction: column; display: flex; &:hover { @@ -16,12 +16,12 @@ .lightboxView-tabBtn { margin: auto; position: absolute; - right: 54; - top: 10; + right: 54px; + top: 10px; background: transparent; - border-radius: 8; + border-radius: 8px; opacity: 0.7; - width: 25; + width: 25px; flex-direction: column; display: flex; &:hover { @@ -31,12 +31,12 @@ .lightboxView-paletteBtn { margin: auto; position: absolute; - right: 89; - top: 10; + right: 89px; + top: 10px; background: transparent; - border-radius: 8; + border-radius: 8px; opacity: 0.7; - width: 25; + width: 25px; flex-direction: column; display: flex; &:hover { @@ -47,12 +47,12 @@ .lightboxView-penBtn { margin: auto; position: absolute; - right: 124; - top: 10; + right: 124px; + top: 10px; background: transparent; - border-radius: 8; + border-radius: 8px; opacity: 0.7; - width: 25; + width: 25px; flex-direction: column; display: flex; &:hover { @@ -62,12 +62,12 @@ .lightboxView-exploreBtn { margin: auto; position: absolute; - right: 159; - top: 10; + right: 159px; + top: 10px; background: transparent; - border-radius: 8; + border-radius: 8px; opacity: 0.7; - width: 25; + width: 25px; flex-direction: column; display: flex; &:hover { @@ -76,8 +76,8 @@ } .lightboxView-frame { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; z-index: 1000; @@ -91,9 +91,9 @@ margin: auto; position: relative; background: transparent; - border-radius: 8; + border-radius: 8px; opacity: 0.7; - width: 35; + width: 35px; &:hover { opacity: 1; } diff --git a/src/client/views/LightboxView.tsx b/src/client/views/LightboxView.tsx index 0eb21b943..62b1180ec 100644 --- a/src/client/views/LightboxView.tsx +++ b/src/client/views/LightboxView.tsx @@ -244,6 +244,7 @@ export class LightboxView extends ObservableReactComponent<LightboxViewProps> { </div> </div> ); + setStickerRef = (r: StickerPalette | null) => (this._annoPaletteView = r); render() { let downx = 0; let downy = 0; @@ -317,7 +318,7 @@ export class LightboxView extends ObservableReactComponent<LightboxViewProps> { </GestureOverlay> </div> - {this._showPalette && <StickerPalette ref={r => (this._annoPaletteView = r)} Doc={DocCast(Doc.UserDoc().myLightboxDrawings)} />} + {this._showPalette && DocCast(Doc.UserDoc().myLightboxDrawings) && <StickerPalette ref={this.setStickerRef} Doc={DocCast(Doc.UserDoc().myLightboxDrawings)!} />} {this.renderNavBtn(0, undefined, this._props.PanelHeight / 2 - 12.5, 'chevron-left', this._doc && this._history.length ? true : false, this.previous)} {this.renderNavBtn( this._props.PanelWidth - Math.min(this._props.PanelWidth / 4, this._props.maxBorder[0]), diff --git a/src/client/views/Main.scss b/src/client/views/Main.scss index bea1de435..df4160fc1 100644 --- a/src/client/views/Main.scss +++ b/src/client/views/Main.scss @@ -12,10 +12,10 @@ body { overflow: hidden; font-family: global.$sans-serif; font-size: global.$body-text; - margin: 0; + margin: 0px; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; } // div { diff --git a/src/client/views/Main.tsx b/src/client/views/Main.tsx index b884eb8c8..33e930abf 100644 --- a/src/client/views/Main.tsx +++ b/src/client/views/Main.tsx @@ -31,6 +31,8 @@ import './global/globalScripts'; import { AudioBox } from './nodes/AudioBox'; import { ComparisonBox } from './nodes/ComparisonBox'; import { DataVizBox } from './nodes/DataVizBox/DataVizBox'; +import { TemplateField } from './nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateField'; +import { TemplateFieldUtils } from './nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateFieldUtils'; import { DiagramBox } from './nodes/DiagramBox'; import { DocumentContentsView, HTMLtag } from './nodes/DocumentContentsView'; import { EquationBox } from './nodes/EquationBox'; @@ -48,6 +50,7 @@ import { PDFBox } from './nodes/PDFBox'; import { RecordingBox } from './nodes/RecordingBox'; import { ScreenshotBox } from './nodes/ScreenshotBox'; import { ScriptingBox } from './nodes/ScriptingBox'; +import { TaskBox } from './nodes/TaskBox'; import { VideoBox } from './nodes/VideoBox'; import { WebBox } from './nodes/WebBox'; import { CalendarBox } from './nodes/calendarBox/CalendarBox'; @@ -61,11 +64,11 @@ import { FootnoteView } from './nodes/formattedText/FootnoteView'; import { FormattedTextBox } from './nodes/formattedText/FormattedTextBox'; import { SummaryView } from './nodes/formattedText/SummaryView'; import { ImportElementBox } from './nodes/importBox/ImportElementBox'; +import { ScrapbookBox } from './nodes/scrapbook/ScrapbookBox'; import { PresBox, PresSlideBox } from './nodes/trails'; import { FaceRecognitionHandler } from './search/FaceRecognitionHandler'; import { SearchBox } from './search/SearchBox'; import { StickerPalette } from './smartdraw/StickerPalette'; -import { ScrapbookBox } from './nodes/scrapbook/ScrapbookBox'; dotenv.config(); @@ -101,6 +104,7 @@ FieldLoader.ServerLoadStatus = { requested: 0, retrieved: 0, message: 'cache' }; new PingManager(); new KeyManager(); new FaceRecognitionHandler(); + TemplateField.CreateField = TemplateFieldUtils.CreateField; // set the init function for fields // initialize plugins and classes that require plugins CollectionDockingView.Init(TabDocView); @@ -120,6 +124,7 @@ FieldLoader.ServerLoadStatus = { requested: 0, retrieved: 0, message: 'cache' }; StickerPalette: StickerPalette, FormattedTextBox, DailyJournal, // AARAV + TaskBox, // AARAV ImageBox, FontIconBox, LabelBox, diff --git a/src/client/views/MainView.scss b/src/client/views/MainView.scss index db949285b..d5e6b8998 100644 --- a/src/client/views/MainView.scss +++ b/src/client/views/MainView.scss @@ -10,7 +10,7 @@ body { .dash-tooltip { font-size: 11px; padding: 2px; - max-width: 150; + max-width: 150px; line-height: 150%; } @@ -49,8 +49,8 @@ body { .mainView-snapLines { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; pointer-events: none; @@ -61,8 +61,8 @@ body { height: 100%; position: absolute; pointer-events: all; - top: 0; - left: 0; + top: 0px; + left: 0px; z-index: 1; touch-action: none; } @@ -86,8 +86,8 @@ body { .properties-container { height: 100%; position: absolute; - right: 0; - top: 0; + right: 0px; + top: 0px; } .mainView-propertiesDragger-minified, @@ -98,7 +98,7 @@ body { width: 17px; position: absolute; top: 50%; - border-radius: 0; + border-radius: 0px; border-top-left-radius: 10px; border-bottom-left-radius: 10px; border-right: unset; @@ -141,7 +141,7 @@ body { } .propertiesView { - left: 0; + left: 0px; position: absolute; z-index: 2; // background-color: linen; //$light-gray; @@ -165,7 +165,7 @@ body { } ::-webkit-scrollbar { - width: 0; + width: 0px; } } @@ -173,19 +173,19 @@ body { width: 100%; height: 100%; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; overflow: hidden; } .buttonContainer { position: absolute; - bottom: 0; + bottom: 0px; .mainView-settings { // position: absolute; - // left: 0; - // bottom: 0; + // left: 0px; + // bottom: 0px; border-radius: 25%; margin-left: -5px; background: darkblue; @@ -198,8 +198,8 @@ body { .mainView-logout { position: absolute; - right: 0; - bottom: 0; + right: 0px; + bottom: 0px; font-size: 8px; } @@ -223,12 +223,12 @@ body { } .mainView-libraryFlyout-close { - right: 6; - top: 5; + right: 6px; + top: 5px; position: absolute; margin-right: 6px; z-index: 10; - margin-bottom: 10; + margin-bottom: 10px; } } diff --git a/src/client/views/MainView.tsx b/src/client/views/MainView.tsx index c49b7e6de..867a5a304 100644 --- a/src/client/views/MainView.tsx +++ b/src/client/views/MainView.tsx @@ -730,7 +730,8 @@ export class MainView extends ObservableReactComponent<object> { style={{ width: `calc(100% - ${this._leftMenuFlyoutWidth + this.leftMenuWidth() + this.propertiesWidth()}px)`, minWidth: `calc(100% - ${this._leftMenuFlyoutWidth + this.leftMenuWidth() + this.propertiesWidth()}px)`, - transform: DocumentView.LightboxDoc() ? 'scale(0.0001)' : undefined, + opacity: DocumentView.LightboxDoc() ? 0 : undefined, + pointerEvents: DocumentView.LightboxDoc() ? 'none' : undefined, }}> {!this.mainContainer ? null : this.mainDocView} </div> @@ -920,19 +921,19 @@ export class MainView extends ObservableReactComponent<object> { ); } + setMainDashRef = (r: HTMLDivElement | null) => + r && + new ResizeObserver( + action(() => { + this._dashUIWidth = r.getBoundingClientRect().width; + this._dashUIHeight = r.getBoundingClientRect().height; + }) + ).observe(r); @computed get mainDashboardArea() { return !this.userDoc ? null : ( <div className="mainView-dashboardArea" - ref={r => { - r && - new ResizeObserver( - action(() => { - this._dashUIWidth = r.getBoundingClientRect().width; - this._dashUIHeight = r.getBoundingClientRect().height; - }) - ).observe(r); - }} + ref={this.setMainDashRef} style={{ color: 'black', height: `calc(100% - ${this.topOfDashUI + this.topMenuHeight()}px)`, @@ -1038,7 +1039,7 @@ export class MainView extends ObservableReactComponent<object> { @computed get inkResources() { return ( - <svg width={0} height={0}> + <svg width={0} height={0} style={{ display: 'block' }}> <defs> <filter id="inkSelectionHalo"> <feColorMatrix @@ -1071,6 +1072,14 @@ export class MainView extends ObservableReactComponent<object> { }; lightboxMaxBorder = [200, 50]; + setMainViewRef = (r: HTMLDivElement | null) => + r && + new ResizeObserver( + action(() => { + this._windowWidth = r.getBoundingClientRect().width; + this._windowHeight = r.getBoundingClientRect().height; + }) + ).observe(r); render() { return ( <div @@ -1084,15 +1093,7 @@ export class MainView extends ObservableReactComponent<object> { ele.scrollTop = ele.scrollLeft = 0; })(document.getElementById('root')!) } - ref={r => { - r && - new ResizeObserver( - action(() => { - this._windowWidth = r.getBoundingClientRect().width; - this._windowHeight = r.getBoundingClientRect().height; - }) - ).observe(r); - }}> + ref={this.setMainViewRef}> {this.inkResources} <DictationOverlay /> <SharingManager /> diff --git a/src/client/views/MainViewModal.tsx b/src/client/views/MainViewModal.tsx index b05292c47..d7640dc72 100644 --- a/src/client/views/MainViewModal.tsx +++ b/src/client/views/MainViewModal.tsx @@ -41,7 +41,7 @@ export class MainViewModal extends React.Component<MainViewOverlayProps> { className="overlay" onClick={this.props?.closeOnExternalClick} style={{ - backgroundColor: isDark(SnappingManager.userColor) ? '#DFDFDF30' : '#32323230', + backgroundColor: isDark(SnappingManager.userColor ?? '') ? '#DFDFDF30' : '#32323230', ...(p.overlayStyle || {}), }} /> diff --git a/src/client/views/MarqueeAnnotator.tsx b/src/client/views/MarqueeAnnotator.tsx index b2e42652d..d354dd2c0 100644 --- a/src/client/views/MarqueeAnnotator.tsx +++ b/src/client/views/MarqueeAnnotator.tsx @@ -194,14 +194,13 @@ export class MarqueeAnnotator extends ObservableReactComponent<MarqueeAnnotatorP AnchorMenu.Instance.StartDrag = action((e: PointerEvent, ele: HTMLElement) => { e.preventDefault(); e.stopPropagation(); - const sourceAnchorCreator = () => this.highlight(this.props.highlightDragSrcColor ?? 'rgba(173, 216, 230, 0.75)', true, undefined, true); // hyperlink color - const targetCreator = (annotationOn: Doc | undefined) => { const target = DocUtils.GetNewTextDoc('Note linked to ' + this.props.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); target.layout_fitWidth = true; DocumentView.SetSelectOnLoad(target); return target; }; + const sourceAnchorCreator = () => this.highlight(this.props.highlightDragSrcColor ?? 'rgba(173, 216, 230, 0.75)', true, undefined, true); // hyperlink color DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(this.props.docView(), sourceAnchorCreator, targetCreator), e.pageX, e.pageY, { dragComplete: dragEv => { if (!dragEv.aborted && dragEv.annoDragData && dragEv.annoDragData.linkSourceDoc && dragEv.annoDragData.dropDocument && dragEv.linkDocument) { diff --git a/src/client/views/MetadataEntryMenu.scss b/src/client/views/MetadataEntryMenu.scss index 28de0b7a5..924476a30 100644 --- a/src/client/views/MetadataEntryMenu.scss +++ b/src/client/views/MetadataEntryMenu.scss @@ -3,7 +3,7 @@ width: 310px; flex-direction: column; - input[type=checkbox] { + input[type='checkbox'] { margin-left: 5px; } } @@ -11,7 +11,7 @@ .metadataEntry-autoSuggester { width: 80%; height: 100%; - margin: 0; + margin: 0px; display: inline-block; } @@ -20,13 +20,13 @@ } .metadataEntry-keys { - max-height: 80; - overflow-y: auto; + max-height: 80px; + overflow-y: auto; display: flex; flex-direction: column; } .metadataEntry-inputArea { - display:inline-block; + display: inline-block; flex-direction: row; } @@ -53,8 +53,8 @@ } .react-autosuggest__input--open { - border-bottom-left-radius: 0; - border-bottom-right-radius: 0; + border-bottom-left-radius: 0px; + border-bottom-right-radius: 0px; } .react-autosuggest__suggestions-container { @@ -78,8 +78,8 @@ } .react-autosuggest__suggestions-list { - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; list-style-type: none; } @@ -90,4 +90,4 @@ .react-autosuggest__suggestion--highlighted { background-color: #ddd; -}
\ No newline at end of file +} diff --git a/src/client/views/OverlayView.scss b/src/client/views/OverlayView.scss index 2e8621b5b..44203e38f 100644 --- a/src/client/views/OverlayView.scss +++ b/src/client/views/OverlayView.scss @@ -1,7 +1,7 @@ .overlayView { position: absolute; pointer-events: none; - top: 0; + top: 0px; width: 100vw; height: 100vh; z-index: 2002; // shouold be greater than LightboxView's z-index so that link lines and the presentation mini player appear @@ -14,8 +14,8 @@ overflow: hidden; display: flex; flex-direction: column; - top: 0; - left: 0; + top: 0px; + left: 0px; pointer-events: all; box-shadow: black 5px 5px 5px; } @@ -46,7 +46,7 @@ float: right; height: 20px; width: 20px; - padding: 0; + padding: 0px; background-color: inherit; } @@ -62,6 +62,6 @@ .overlayView-doc { z-index: 9002; //so that it appears above chroma position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; } diff --git a/src/client/views/PreviewCursor.scss b/src/client/views/PreviewCursor.scss index 82488c750..eef120c58 100644 --- a/src/client/views/PreviewCursor.scss +++ b/src/client/views/PreviewCursor.scss @@ -2,8 +2,8 @@ color: black; position: absolute; transform-origin: left top; - top: 0; - left: 0; + top: 0px; + left: 0px; pointer-events: none; opacity: 1; z-index: 1001; diff --git a/src/client/views/PropertiesButtons.tsx b/src/client/views/PropertiesButtons.tsx index ded342df0..2c84d7fe7 100644 --- a/src/client/views/PropertiesButtons.tsx +++ b/src/client/views/PropertiesButtons.tsx @@ -238,8 +238,8 @@ export class PropertiesButtons extends React.Component { // on => `Display collection as a Group`, // on => 'object-group', // (dv, doc) => { - // doc.isGroup = !doc.isGroup; - // doc.forceActive = doc.isGroup; + // docfreeform_isGroup = !docfreeform_isGroup; + // doc.forceActive = docfreeform_isGroup; // } // ); // } diff --git a/src/client/views/PropertiesSection.tsx b/src/client/views/PropertiesSection.tsx index 12a46c7a4..9ea9c3a3d 100644 --- a/src/client/views/PropertiesSection.tsx +++ b/src/client/views/PropertiesSection.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { action, computed } from 'mobx'; import { observer } from 'mobx-react'; diff --git a/src/client/views/PropertiesView.scss b/src/client/views/PropertiesView.scss index 280de4893..de3012948 100644 --- a/src/client/views/PropertiesView.scss +++ b/src/client/views/PropertiesView.scss @@ -5,7 +5,7 @@ } .propertiesView-presentationTrails-title-icon { position: absolute; - right: 4; + right: 4px; } .propertiesView-palette { cursor: pointer; @@ -24,7 +24,7 @@ } .propertiesView { height: 100%; - width: 250; + width: 250px; font-family: 'Roboto'; font-size: 12px; cursor: auto; @@ -61,7 +61,7 @@ } .propertiesView-info { - margin-top: -5; + margin-top: -5px; float: right; font-size: 20; path { @@ -78,10 +78,10 @@ display: flex; button { - width: 15; - height: 15; - padding: 0; - margin-top: -5; + width: 15px; + height: 15px; + padding: 0px; + margin-top: -5px; } } @@ -89,8 +89,8 @@ display: flex; button { - width: 5; - height: 5; + width: 5px; + height: 5px; } input { @@ -171,16 +171,16 @@ display: flex; button { - width: 15; - height: 15; - padding: 0; - margin-top: -5; + width: 15px; + height: 15px; + padding: 0px; + margin-top: -5px; } } button { - width: 5; - height: 5; + width: 5px; + height: 5px; } input { @@ -301,8 +301,8 @@ padding: 7px; border-radius: 7px; margin-right: 32px; - width: 32; - height: 32; + width: 32px; + height: 32px; padding-top: 9px; margin-left: 18px; @@ -318,8 +318,8 @@ padding: 7px; border-radius: 7px; margin-right: 32px; - width: 32; - height: 32; + width: 32px; + height: 32px; padding-top: 9px; padding-left: 10px; @@ -334,8 +334,8 @@ background-color: #333333; padding: 7px; border-radius: 7px; - width: 32; - height: 32; + width: 32px; + height: 32px; padding-top: 9px; padding-left: 10px; @@ -410,7 +410,7 @@ .color-palette { width: 160px; - height: 360; + height: 360px; } .strokeAndFill { @@ -465,7 +465,7 @@ .propertiesView-selectedList { min-width: max-content; width: 100%; - max-height: 180; + max-height: 180px; overflow: hidden; overflow-y: scroll; border-left: solid 1px darkgrey; @@ -475,7 +475,7 @@ .selectedList-items { font-size: 12; font-weight: 300; - margin-top: 1; + margin-top: 1px; } } } @@ -500,7 +500,7 @@ .width-range { margin-right: 1px; - margin-bottom: 6; + margin-bottom: 6px; } } diff --git a/src/client/views/PropertiesView.tsx b/src/client/views/PropertiesView.tsx index acf6f928a..06463b2a2 100644 --- a/src/client/views/PropertiesView.tsx +++ b/src/client/views/PropertiesView.tsx @@ -81,7 +81,7 @@ export class PropertiesView extends ObservableReactComponent<PropertiesViewProps } @computed get selectedDoc() { - return DocumentView.SelectedSchemaDoc() || this.selectedDocumentView?.Document || Doc.ActiveDashboard; + return DocumentView.SelectedSchemaDoc() ?? this.selectedDocumentView?.Document ?? Doc.ActiveDashboard; } @computed get selectedLink() { @@ -89,7 +89,7 @@ export class PropertiesView extends ObservableReactComponent<PropertiesViewProps } @computed get selectedLayoutDoc() { - return DocumentView.SelectedSchemaDoc() || this.selectedDocumentView?.layoutDoc || Doc.ActiveDashboard; + return DocumentView.SelectedSchemaDoc() ?? this.selectedDocumentView?.layoutDoc ?? Doc.ActiveDashboard; } @computed get selectedDocumentView() { return DocumentView.Selected().lastElement(); @@ -149,7 +149,7 @@ export class PropertiesView extends ObservableReactComponent<PropertiesViewProps return this.selectedDoc?.type === DocumentType.INK; } @computed get isGroup() { - return this.selectedDoc?.isGroup; + return Doc.IsFreeformGroup(this.selectedDoc); } @computed get isStack() { return [ diff --git a/src/client/views/SidebarAnnos.scss b/src/client/views/SidebarAnnos.scss index abfd04f11..c2b9dcce5 100644 --- a/src/client/views/SidebarAnnos.scss +++ b/src/client/views/SidebarAnnos.scss @@ -2,7 +2,7 @@ position: absolute; width: 100%; height: 100%; - right: 0; + right: 0px; .sidebarAnnos-stacking { width: 100%; position: relative; @@ -20,12 +20,12 @@ .sidebarAnnos-filterUser-active { font-weight: bold; font-size: 10px; - padding-left: 5; - padding-right: 5; + padding-left: 5px; + padding-right: 5px; box-shadow: black 1px 1px 3px; - border-radius: 5; - margin: 2; - height: 15; + border-radius: 5px; + margin: 2px; + height: 15px; background-color: lightgrey; } .sidebarAnnos-filterUser, diff --git a/src/client/views/SidebarAnnos.tsx b/src/client/views/SidebarAnnos.tsx index 573c28ccf..71b479a22 100644 --- a/src/client/views/SidebarAnnos.tsx +++ b/src/client/views/SidebarAnnos.tsx @@ -3,7 +3,7 @@ import { observer } from 'mobx-react'; import * as React from 'react'; import { ClientUtils, returnFalse, returnOne, returnZero } from '../../ClientUtils'; import { emptyFunction } from '../../Utils'; -import { Doc, DocListCast, Field, FieldResult, FieldType, StrListCast } from '../../fields/Doc'; +import { Doc, DocListCast, Field, FieldResult, FieldType, Opt, StrListCast } from '../../fields/Doc'; import { Id } from '../../fields/FieldSymbols'; import { List } from '../../fields/List'; import { RichTextField } from '../../fields/RichTextField'; @@ -19,6 +19,7 @@ import { StyleProp } from './StyleProp'; import { CollectionStackingView } from './collections/CollectionStackingView'; import { DocumentView } from './nodes/DocumentView'; import { FieldViewProps } from './nodes/FieldView'; +import { FocusViewOptions } from './nodes/FocusViewOptions'; interface ExtraProps { fieldKey: string; @@ -149,15 +150,33 @@ export class SidebarAnnos extends ObservableReactComponent<FieldViewProps & Extr }; makeDocUnfiltered = (doc: Doc) => { if (DocListCast(this._props.Doc[this.sidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) { - if (this.childFilters()) { + if (this.childFilters().length) { // if any child filters exist, get rid of them this._props.layoutDoc._childFilters = new List<string>(); + return true; } - return true; } return false; }; + public static getView(sidebar: SidebarAnnos | null, sidebarShown: boolean, toggleSidebar: () => void, doc: Doc, options: FocusViewOptions) { + if (!sidebarShown) { + options.didMove = true; + toggleSidebar(); + } + options.didMove = sidebar?.makeDocUnfiltered(doc) || options.didMove; + + if (!doc.hidden) { + if (!options.didMove && options.toggleTarget) { + options.toggleTarget = false; + options.didMove = doc.hidden = true; + } + } else { + options.didMove = !(doc.hidden = false); + } + return new Promise<Opt<DocumentView>>(res => DocumentView.addViewRenderedCb(doc, res)); + } + get sidebarKey() { return this._props.fieldKey + '_sidebar'; } @@ -181,8 +200,8 @@ export class SidebarAnnos extends ObservableReactComponent<FieldViewProps & Extr layout_showTitle = () => 'title'; setHeightCallback = (height: number) => this._props.setHeight?.(height + this.filtersHeight()); sortByLinkAnchorY = (a: Doc, b: Doc) => { - const ay = Doc.Links(a).length && DocCast(Doc.Links(a)[0].link_anchor_1).y; - const by = Doc.Links(b).length && DocCast(Doc.Links(b)[0].link_anchor_1).y; + const ay = Doc.Links(a).length && DocCast(Doc.Links(a)[0].link_anchor_1)?.y; + const by = Doc.Links(b).length && DocCast(Doc.Links(b)[0].link_anchor_1)?.y; return NumCast(ay) - NumCast(by); }; render() { diff --git a/src/client/views/StyleProvider.scss b/src/client/views/StyleProvider.scss index 99796f1fb..cbb1fd5d5 100644 --- a/src/client/views/StyleProvider.scss +++ b/src/client/views/StyleProvider.scss @@ -4,11 +4,11 @@ .styleProvider-lock { z-index: 2; // has to be above title which is z-index 1 font-size: 10; - width: 20; - height: 20; + width: 20px; + height: 20px; position: absolute; - right: -20; - top: 0; + right: -20px; + top: 0px; background: black; pointer-events: all; opacity: 0.3; @@ -20,10 +20,10 @@ cursor: default; } .styleProvider-filter { - right: 20; + right: 20px; .styleProvider-filterShift { - left: 0; - top: 0; + left: 0px; + top: 0px; position: absolute; } .dropdown-container { @@ -33,10 +33,10 @@ } .styleProvider-paint-selected, .styleProvider-paint { - top: 15; + top: 15px; } .styleProvider-paint-selected { - right: -40; + right: -40px; } .styleProvider-lock:hover, .styleProvider-filter:hover { @@ -45,8 +45,8 @@ .styleProvider-treeView-icon, .styleProvider-treeView-icon-active { - margin-left: 0; - margin-right: 0; + margin-left: 0px; + margin-right: 0px; } .styleProvider-treeView-icon { diff --git a/src/client/views/StyleProvider.tsx b/src/client/views/StyleProvider.tsx index 9110e198f..b52f17102 100644 --- a/src/client/views/StyleProvider.tsx +++ b/src/client/views/StyleProvider.tsx @@ -154,7 +154,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & const highlightStyle = ['solid', 'dashed', 'solid', 'solid', 'solid'][highlightIndex]; if (highlightIndex) { return { - highlightStyle: doc.isGroup ? "dotted": highlightStyle, + highlightStyle: Doc.IsFreeformGroup(doc) ? "dotted": highlightStyle, highlightColor, highlightIndex, highlightStroke: BoolCast(layoutDoc?.layout_isSvg), @@ -284,7 +284,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & ? SnappingManager.userBackgroundColor : doc?.annotationOn ? '#00000010' // faint interior for collections on PDFs, images, etc - : doc?.isGroup + : doc && Doc.IsFreeformGroup(doc) ? undefined : doc?._type_collection === CollectionViewType.Stacking ? (Colors.DARK_GRAY) @@ -308,7 +308,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & doc?.layout_boxShadow, doc?._type_collection === CollectionViewType.Pile ? '4px 4px 10px 2px' - : lockedPosition() || doc?.isGroup || LayoutTemplateString + : lockedPosition() || (doc && Doc.IsFreeformGroup(doc)) || LayoutTemplateString ? undefined // groups have no drop shadow -- they're supposed to be "invisible". LayoutString's imply collection is being rendered as something else (e.g., title of a Slide) : `${Colors.DARK_GRAY} ${StrCast(doc.layout_boxShadow, '0.2vw 0.2vw 0.8vw')}` ); @@ -338,7 +338,7 @@ export function DefaultStyleProvider(doc: Opt<Doc>, props: Opt<FieldViewProps & if (SnappingManager.ExploreMode || doc?.layout_unrendered) return isInk() ? 'visiblePainted' : 'all'; if (pointerEvents?.() === 'none') return 'none'; if (opacity() === 0) return 'none'; - if (isGroupActive?.() ) return isInk() ? 'visiblePainted': (doc?.isGroup ) ? undefined: 'all'; + if (isGroupActive?.()) return isInk() ? 'visiblePainted': doc && Doc.IsFreeformGroup(doc) ? undefined: 'all'; if (isDocumentActive?.()) return isInk() ? 'visiblePainted' : 'all'; return undefined; // fixes problem with tree view elements getting pointer events when the tree view is not active case StyleProp.Decorations: { diff --git a/src/client/views/StyleProviderQuiz.scss b/src/client/views/StyleProviderQuiz.scss index 84b3f1fef..53ca34c1b 100644 --- a/src/client/views/StyleProviderQuiz.scss +++ b/src/client/views/StyleProviderQuiz.scss @@ -13,8 +13,8 @@ .check-icon { position: absolute; - right: 40; - bottom: 10; + right: 40px; + bottom: 10px; color: green; display: inline-block; font-size: 20px; @@ -23,8 +23,8 @@ .redo-icon { position: absolute; - right: 10; - bottom: 10; + right: 10px; + bottom: 10px; color: black; display: inline-block; font-size: 20px; diff --git a/src/client/views/UndoStack.tsx b/src/client/views/UndoStack.tsx index 067020a62..3755b1ec1 100644 --- a/src/client/views/UndoStack.tsx +++ b/src/client/views/UndoStack.tsx @@ -9,6 +9,7 @@ import './UndoStack.scss'; @observer export class UndoStack extends React.Component<object> { + setRef = (r: HTMLDivElement | null) => r?.scroll({ behavior: 'auto', top: (r?.scrollHeight ?? 0) + 20 }); render() { const background = UndoManager.batchCounter.get() ? 'yellow' : SettingsManager.userVariantColor; const color = UndoManager.batchCounter.get() ? 'black' : SettingsManager.userColor; @@ -25,7 +26,7 @@ export class UndoStack extends React.Component<object> { popup={ <div className="undoStack-commandsContainer" - ref={r => r?.scroll({ behavior: 'auto', top: (r?.scrollHeight ?? 0) + 20 })} + ref={this.setRef} style={{ background, color, diff --git a/src/client/views/ViewBoxInterface.ts b/src/client/views/ViewBoxInterface.ts index d8dab8e89..514dc4ae8 100644 --- a/src/client/views/ViewBoxInterface.ts +++ b/src/client/views/ViewBoxInterface.ts @@ -24,6 +24,9 @@ export abstract class ViewBoxInterface<P> extends ObservableReactComponent<React promoteCollection?: () => void; // moves contents of collection to parent hasChildDocs?: () => Doc[]; docEditorView?: () => void; + autoTag?: () => void; // auto tag the document + isOutpaintable?: () => boolean; // can document be resized and outpainted + showBorderRounding?: () => boolean; // can document borders be rounded showSmartDraw?: (x: number, y: number, regenerate?: boolean) => void; updateIcon?: (usePanelDimensions?: boolean) => Promise<void>; // updates the icon representation of the document getAnchor?: (addAsAnnotation: boolean, pinData?: PinProps) => Doc; // returns an Anchor Doc that represents the current state of the doc's componentview (e.g., the current playhead location of a an audio/video box) diff --git a/src/client/views/animationtimeline/Timeline.scss b/src/client/views/animationtimeline/Timeline.scss index e1d3b190c..3a50183d2 100644 --- a/src/client/views/animationtimeline/Timeline.scss +++ b/src/client/views/animationtimeline/Timeline.scss @@ -112,8 +112,8 @@ $timelineDark: #77a1aa; input { position: absolute; opacity: 0; - height: 0; - width: 0; + height: 0px; + width: 0px; } .round-toggle-slider { diff --git a/src/client/views/animationtimeline/Timeline.tsx b/src/client/views/animationtimeline/Timeline.tsx index 814e9a7a0..c2bd01334 100644 --- a/src/client/views/animationtimeline/Timeline.tsx +++ b/src/client/views/animationtimeline/Timeline.tsx @@ -533,6 +533,7 @@ export class Timeline extends ObservableReactComponent<FieldViewProps & { Doc: D this._totalLength = RegionHelpers.convertPixelTime(this._time, 'mili', 'pixel', this._tickSpacing, this._tickIncrement); }; + setTrackRef = (r: Track) => this.mapOfTracks.push(r); /** * if you have any question here, just shoot me an email or text. * basically the only thing you need to edit besides render methods in track (individual track lines) and keyframe (green region) @@ -554,7 +555,7 @@ export class Timeline extends ObservableReactComponent<FieldViewProps & { Doc: D {[...this.children, this._props.Doc].map(doc => ( <Track key={doc[Id]} - ref={ref => this.mapOfTracks.push(ref)} + ref={this.setTrackRef} timeline={this} animatedDoc={doc} currentBarX={this._currentBarX} diff --git a/src/client/views/animationtimeline/TimelineMenu.tsx b/src/client/views/animationtimeline/TimelineMenu.tsx index 0d7873931..79283479c 100644 --- a/src/client/views/animationtimeline/TimelineMenu.tsx +++ b/src/client/views/animationtimeline/TimelineMenu.tsx @@ -1,5 +1,3 @@ -/* eslint-disable jsx-a11y/no-noninteractive-element-interactions */ -/* eslint-disable jsx-a11y/click-events-have-key-events */ import { IconLookup } from '@fortawesome/fontawesome-svg-core'; import { faChartLine, faClipboard } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; @@ -19,7 +17,7 @@ export class TimelineMenu extends React.Component { @observable private _y = 0; @observable private _currentMenu: JSX.Element[] = []; - constructor(props: any) { + constructor(props: object) { super(props); makeObservable(this); TimelineMenu.Instance = this; diff --git a/src/client/views/animationtimeline/TimelineOverview.tsx b/src/client/views/animationtimeline/TimelineOverview.tsx index 7bf685c9e..fff756980 100644 --- a/src/client/views/animationtimeline/TimelineOverview.tsx +++ b/src/client/views/animationtimeline/TimelineOverview.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/no-unused-prop-types */ import { action, IReactionDisposer, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; @@ -14,7 +13,7 @@ interface TimelineOverviewProps { isAuthoring: boolean; parent: Timeline; changeCurrentBarX: (pixel: number) => void; - movePanX: (pixel: number) => any; + movePanX: (pixel: number) => void; time: number; tickSpacing: number; tickIncrement: number; @@ -139,7 +138,7 @@ export class TimelineOverview extends React.Component<TimelineOverviewProps> { const percentVisible = this.visibleTime / this.props.time; const visibleBarWidth = percentVisible * this.activeOverviewWidth; - const percentScrubberStart = this.currentX / this.props.time; + // const percentScrubberStart = this.currentX / this.props.time; let scrubberStart = (this.props.currentBarX / this.props.totalLength) * this.activeOverviewWidth; if (scrubberStart > this.activeOverviewWidth) scrubberStart = this.activeOverviewWidth; diff --git a/src/client/views/collections/CollectionCardDeckView.scss b/src/client/views/collections/CollectionCardDeckView.scss index e6cc398af..981e528cc 100644 --- a/src/client/views/collections/CollectionCardDeckView.scss +++ b/src/client/views/collections/CollectionCardDeckView.scss @@ -18,7 +18,7 @@ } .collectionCardView-flashcardUI { - top: 0; + top: 0px; position: absolute; width: 100%; height: 100%; @@ -33,7 +33,7 @@ } .collectionCardView-cardSizeDragger { position: absolute; - top: 0; + top: 0px; width: 28px; height: 28px; > svg { diff --git a/src/client/views/collections/CollectionCarousel3DView.scss b/src/client/views/collections/CollectionCarousel3DView.scss index 13e6b54c2..361d88cb6 100644 --- a/src/client/views/collections/CollectionCarousel3DView.scss +++ b/src/client/views/collections/CollectionCarousel3DView.scss @@ -12,7 +12,7 @@ position: absolute; top: global.$CAROUSEL3D_TOP * 1%; height: (global.$CAROUSEL3D_SIDE_SCALE * 100) * 1%; - align-items: center; + //align-items: center; transition: transform 0.3s cubic-bezier(0.455, 0.03, 0.515, 0.955); .collectionCarousel3DView-item, @@ -67,8 +67,8 @@ .carousel3DView-fwd-scroll-hidden { position: absolute; display: flex; - width: 30; - height: 30; + width: 30px; + height: 30px; align-items: center; border-radius: 5px; justify-content: center; @@ -78,7 +78,7 @@ .carousel3DView-fwd, .carousel3DView-back { - top: 0; + top: 0px; background: transparent; width: calc((1 - #{global.$CAROUSEL3D_CENTER_SCALE} * 0.33) / 2 * 100%); height: 100%; @@ -94,13 +94,13 @@ .carousel3DView-fwd, .carousel3DView-fwd-scroll, .carousel3DView-fwd-scroll-hidden { - right: 0; + right: 0px; } .carousel3DView-back, .carousel3DView-back-scroll, .carousel3DView-back-scroll-hidden { - left: 0; + left: 0px; } .carousel3DView-fwd-scroll-hidden, diff --git a/src/client/views/collections/CollectionCarouselView.scss b/src/client/views/collections/CollectionCarouselView.scss index 962b590c8..4c999b6dd 100644 --- a/src/client/views/collections/CollectionCarouselView.scss +++ b/src/client/views/collections/CollectionCarouselView.scss @@ -6,10 +6,10 @@ transform-origin: top left; .collectionCarouselView-caption { - height: 50; + height: 50px; display: inline-block; width: 100%; - bottom: 0; + bottom: 0px; position: absolute; } .collectionCarouselView-image { @@ -18,8 +18,8 @@ width: 100%; user-select: none; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; } } .collectionCarouselView-recentlyMissed { @@ -34,8 +34,8 @@ .carouselView-fwd { position: absolute; display: flex; - width: 30; - height: 30; + width: 30px; + height: 30px; align-items: center; border-radius: 5px; justify-content: center; @@ -47,12 +47,12 @@ } .carouselView-fwd { top: calc(50% - 15px); - right: 0; + right: 0px; transform-origin: right top; } .carouselView-back { top: calc(50% - 15px); - left: 0; + left: 0px; transform-origin: top left; } .carouselView-back:hover, diff --git a/src/client/views/collections/CollectionDockingView.scss b/src/client/views/collections/CollectionDockingView.scss index 7c19d39da..de214e2ef 100644 --- a/src/client/views/collections/CollectionDockingView.scss +++ b/src/client/views/collections/CollectionDockingView.scss @@ -17,8 +17,8 @@ } .lm_maximised { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; z-index: 40; } .lm_maximise_placeholder { @@ -62,8 +62,8 @@ text-align: center; } .lm_header ul { - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; list-style-type: none; } .lm_header .lm_tab { @@ -81,11 +81,11 @@ position: absolute; } .lm_header .lm_tab i.lm_left { - top: 0; + top: 0px; left: -2px; } .lm_header .lm_tab i.lm_right { - top: 0; + top: 0px; right: -2px; } .lm_header .lm_tab .lm_title { @@ -97,8 +97,8 @@ width: 14px; height: 14px; position: absolute; - top: 0; - right: 0; + top: 0px; + right: 0px; text-align: center; } .lm_stack.lm_left .lm_header, @@ -118,14 +118,14 @@ .lm_stack.lm_left .lm_header .lm_tabs, .lm_stack.lm_right .lm_header .lm_tabs { transform-origin: left top; - top: 0; + top: 0px; width: 1000px; } .lm_dragProxy.lm_left .lm_header .lm_controls, .lm_dragProxy.lm_right .lm_header .lm_controls, .lm_stack.lm_left .lm_header .lm_controls, .lm_stack.lm_right .lm_header .lm_controls { - bottom: 0; + bottom: 0px; } .lm_dragProxy.lm_left .lm_items, .lm_dragProxy.lm_right .lm_items, @@ -136,7 +136,7 @@ .lm_dragProxy.lm_left .lm_header .lm_tabs, .lm_stack.lm_left .lm_header .lm_tabs { transform: rotate(-90deg) scaleX(-1); - left: 0; + left: 0px; } .lm_dragProxy.lm_left .lm_header .lm_tabs .lm_tab, .lm_stack.lm_left .lm_header .lm_tabs .lm_tab { @@ -156,7 +156,7 @@ .lm_stack.lm_right .lm_header .lm_tabs { transform: rotate(90deg) scaleX(1); left: 100%; - margin-left: 0; + margin-left: 0px; } .lm_dragProxy.lm_right .lm_header .lm_controls, .lm_stack.lm_right .lm_header .lm_controls { @@ -169,7 +169,7 @@ } .lm_dragProxy.lm_bottom .lm_header .lm_tab, .lm_stack.lm_bottom .lm_header .lm_tab { - margin-top: 0; + margin-top: 0px; border-top: none; } .lm_dragProxy.lm_bottom .lm_header .lm_controls, @@ -189,8 +189,8 @@ } .lm_header .lm_controls .lm_tabdropdown:before { content: ''; - width: 0; - height: 0; + width: 0px; + height: 0px; vertical-align: middle; display: inline-block; border-top: 5px dashed; @@ -201,14 +201,14 @@ .lm_header .lm_tabdropdown_list { position: absolute; top: 20px; - right: 0; + right: 0px; z-index: 5; overflow: hidden; } .lm_header .lm_tabdropdown_list .lm_tab { clear: both; padding-right: 10px; - margin: 0; + margin: 0px; } .lm_header .lm_tabdropdown_list .lm_tab .lm_title { width: 100px; @@ -218,8 +218,8 @@ } .lm_dragProxy { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; z-index: 30; } .lm_dragProxy .lm_header { @@ -238,32 +238,32 @@ width: 100%; height: 100%; position: relative; - top: 0; - left: 0; + top: 0px; + left: 0px; } .lm_transition_indicator { display: none; width: 20px; height: 20px; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; z-index: 20; } .lm_popin { width: 20px; height: 20px; position: absolute; - bottom: 0; - right: 0; + bottom: 0px; + right: 0px; z-index: 9999; } .lm_popin > * { width: 100%; height: 100%; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; } .lm_popin > .lm_bg { z-index: 10; @@ -307,7 +307,7 @@ width: max-content; height: 100%; display: flex; - max-width: 100; + max-width: 100px; text-overflow: ellipsis; } @@ -328,7 +328,7 @@ ul.lm_tabs::before { content: ' '; position: absolute; - bottom: 0; + bottom: 0px; width: 100%; z-index: 1; pointer-events: none; @@ -349,9 +349,9 @@ ul.lm_tabs::before { } } .lm_header .lm_tab.lm_active { - padding: 0; + padding: 0px; opacity: 1; - margin: 0; + margin: 0px; box-shadow: none; height: 27px; margin-right: 2px; @@ -405,7 +405,7 @@ ul.lm_tabs::before { } .lm_drag_tab { - padding: 0; + padding: 0px; width: 15px !important; height: 15px !important; position: relative !important; @@ -418,7 +418,7 @@ ul.lm_tabs::before { .lm_close_tab { display: inline-flex !important; - padding: 0; + padding: 0px; opacity: 1 !important; align-self: center; margin-right: 5px; @@ -455,7 +455,7 @@ ul.lm_tabs::before { content: 'x'; margin: auto; position: relative; - top: -2; + top: -2px; font-size: medium; font-family: sans-serif; } @@ -478,8 +478,8 @@ ul.lm_tabs::before { width: 100%; height: 100%; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; .collectionDockingView-drag { touch-action: none; @@ -514,7 +514,7 @@ ul.lm_tabs::before { border-bottom-left-radius: 10px; background: #93939347; z-index: 100; - //left: -3; + //left: -3px; &:hover { background: gray; color: white !important; @@ -524,7 +524,7 @@ ul.lm_tabs::before { content: '+'; margin: auto; font-size: x-large; - top: -4; + top: -4px; position: relative; } .lm_maximise { @@ -548,10 +548,10 @@ ul.lm_tabs::before { } .flexlayout__layout { - left: 0; - top: 0; - right: 0; - bottom: 0; + left: 0px; + top: 0px; + right: 0px; + bottom: 0px; position: absolute; overflow: hidden; } @@ -691,8 +691,8 @@ ul.lm_tabs::before { .flexlayout__tabset_header { position: absolute; - left: 0; - right: 0; + left: 0px; + right: 0px; color: #eee; background-color: #212121; padding: 3px 3px 3px 5px; @@ -702,17 +702,17 @@ ul.lm_tabs::before { .flexlayout__tab_header_inner { position: absolute; - left: 0; - top: 0; - bottom: 0; + left: 0px; + top: 0px; + bottom: 0px; width: 10000px; } .flexlayout__tab_header_outer { background-color: global.$dark-gray; position: absolute; - left: 0; - right: 0; + left: 0px; + right: 0px; /*top: 0px;*/ /*height: 100px;*/ overflow: hidden; @@ -731,23 +731,23 @@ ul.lm_tabs::before { display: flex; flex-direction: row-reverse; align-items: center; - top: 0; - bottom: 0; - right: 0; + top: 0px; + bottom: 0px; + right: 0px; } .flexlayout__tab_toolbar_button-min { width: 20px; height: 20px; border: none; - outline-width: 0; + outline-width: 0px; } .flexlayout__tab_toolbar_button-max { width: 20px; height: 20px; border: none; - outline-width: 0; + outline-width: 0px; } .flexlayout__popup_menu_item { @@ -870,9 +870,9 @@ ul.lm_tabs::before { display: flex; flex-direction: column-reverse; align-items: center; - bottom: 0; - left: 0; - right: 0; + bottom: 0px; + left: 0px; + right: 0px; } .flexlayout__border_toolbar_right { @@ -880,9 +880,9 @@ ul.lm_tabs::before { display: flex; flex-direction: column-reverse; align-items: center; - bottom: 0; - left: 0; - right: 0; + bottom: 0px; + left: 0px; + right: 0px; } .flexlayout__border_toolbar_top { @@ -890,9 +890,9 @@ ul.lm_tabs::before { display: flex; flex-direction: row-reverse; align-items: center; - top: 0; - bottom: 0; - right: 0; + top: 0px; + bottom: 0px; + right: 0px; } .flexlayout__border_toolbar_bottom { @@ -900,8 +900,8 @@ ul.lm_tabs::before { display: flex; flex-direction: row-reverse; align-items: center; - top: 0; - bottom: 0; - right: 0; + top: 0px; + bottom: 0px; + right: 0px; } } diff --git a/src/client/views/collections/CollectionMasonryViewFieldRow.tsx b/src/client/views/collections/CollectionMasonryViewFieldRow.tsx index 89ccf5a0f..164c6e831 100644 --- a/src/client/views/collections/CollectionMasonryViewFieldRow.tsx +++ b/src/client/views/collections/CollectionMasonryViewFieldRow.tsx @@ -7,6 +7,7 @@ import { emptyFunction, numberRange } from '../../../Utils'; import { Doc } from '../../../fields/Doc'; import { PastelSchemaPalette, SchemaHeaderField } from '../../../fields/SchemaHeaderField'; import { ScriptField } from '../../../fields/ScriptField'; +import { StrCast } from '../../../fields/Types'; import { Docs } from '../../documents/Documents'; import { DragManager } from '../../util/DragManager'; import { CompileScript } from '../../util/Scripting'; @@ -16,6 +17,7 @@ import { undoBatch, undoable } from '../../util/UndoManager'; import { EditableView } from '../EditableView'; import { ObservableReactComponent } from '../ObservableReactComponent'; import { DocumentView } from '../nodes/DocumentView'; +import { ImportElementBox } from '../nodes/importBox/ImportElementBox'; import { CollectionStackingView } from './CollectionStackingView'; import './CollectionStackingView.scss'; @@ -28,12 +30,14 @@ interface CMVFieldRowProps { headingObject: SchemaHeaderField | undefined; docList: Doc[]; parent: CollectionStackingView; + panelWidth: () => number; + columnWidth: () => number; pivotField: string; type: 'string' | 'number' | 'bigint' | 'boolean' | 'symbol' | 'undefined' | 'object' | 'function' | undefined; createDropTarget: (ele: HTMLDivElement) => void; screenToLocalTransform: () => Transform; setDocHeight: (key: string, thisHeight: number) => void; - refList: Element[]; + sectionRefs: Element[]; showHandle: boolean; } @@ -74,7 +78,7 @@ export class CollectionMasonryViewFieldRow extends ObservableReactComponent<CMVF createRowDropRef = (ele: HTMLDivElement | null) => { this._dropDisposer?.(); if (ele) this._dropDisposer = DragManager.MakeDropTarget(ele, this.rowDrop.bind(this), this._props.Doc); - else if (this._ele) this.props.refList.splice(this.props.refList.indexOf(this._ele), 1); + else if (this._ele) this.props.sectionRefs.splice(this.props.sectionRefs.indexOf(this._ele), 1); this._ele = ele; }; @action @@ -82,10 +86,10 @@ export class CollectionMasonryViewFieldRow extends ObservableReactComponent<CMVF this.heading = this._props.headingObject?.heading || ''; this.color = this._props.headingObject?.color || '#f1efeb'; this.collapsed = this._props.headingObject?.collapsed || false; - this._ele && this.props.refList.push(this._ele); + this._ele && this.props.sectionRefs.push(this._ele); } componentWillUnmount() { - this._ele && this.props.refList.splice(this.props.refList.indexOf(this._ele), 1); + this._ele && this.props.sectionRefs.splice(this.props.sectionRefs.indexOf(this._ele), 1); this._ele = null; } @@ -128,10 +132,8 @@ export class CollectionMasonryViewFieldRow extends ObservableReactComponent<CMVF const key = this._props.pivotField; const castedValue = this.getValue(value); if (castedValue) { - if (this._props.parent.colHeaderData) { - if (this._props.parent.colHeaderData.map(i => i.heading).indexOf(castedValue.toString()) > -1) { - return false; - } + if (this._props.parent.colHeaderData?.map(i => i.heading).indexOf(castedValue.toString()) || 0 > -1) { + return false; } key && this._props.docList.forEach(d => Doc.SetInPlace(d, key, castedValue, true)); this._heading = castedValue.toString(); @@ -251,20 +253,11 @@ export class CollectionMasonryViewFieldRow extends ObservableReactComponent<CMVF textCallback = (/* char: string */) => this.addDocument('', false); @computed get contentLayout() { - const rows = Math.max(1, Math.min(this._props.docList.length, Math.floor((this._props.parent._props.PanelWidth() - 2 * this._props.parent.xMargin) / (this._props.parent.columnWidth + this._props.parent.gridGap)))); - const showChrome = !this._props.chromeHidden; - const stackPad = showChrome ? `0px ${this._props.parent.xMargin}px` : `${this._props.parent.yMargin}px ${this._props.parent.xMargin}px 0px ${this._props.parent.xMargin}px `; + const rows = Math.max(1, Math.min(this._props.docList.length, Math.floor(this._props.panelWidth() / this._props.columnWidth()))); return this.collapsed ? null : ( <div style={{ position: 'relative' }}> - {showChrome ? ( - <div - className="collectionStackingView-addDocumentButton" - style={ - { - // width: style.columnWidth / style.numGroupColumns, - // padding: `${NumCast(this._props.parent.layoutDoc._yPadding, this._props.parent.yMargin)}px 0px 0px 0px`, - } - }> + {!this._props.chromeHidden && !StrCast(this._props.Doc.childLayoutString).includes(ImportElementBox.name) ? ( + <div className="collectionStackingView-addDocumentButton"> <EditableView GetValue={returnEmptyString} SetValue={this.addDocument} textCallback={this.textCallback} contents="+ NEW" /> </div> ) : null} @@ -272,11 +265,9 @@ export class CollectionMasonryViewFieldRow extends ObservableReactComponent<CMVF className="collectionStackingView-masonryGrid" ref={this._contRef} style={{ - padding: stackPad, minHeight: this._props.showHandle && this._props.parent._props.isContentActive() ? '10px' : undefined, - width: this._props.parent.NodeWidth, gridGap: this._props.parent.gridGap, - gridTemplateColumns: numberRange(rows).reduce(list => list + ` ${this._props.parent.columnWidth}px`, ''), + gridTemplateColumns: numberRange(rows).reduce(list => list + ` ${this._props.columnWidth()}px`, ''), }}> {this._props.parent.children(this._props.docList)} </div> @@ -339,7 +330,12 @@ export class CollectionMasonryViewFieldRow extends ObservableReactComponent<CMVF render() { const background = this._background; return ( - <div className="collectionStackingView-masonrySection" style={{ width: this._props.parent.NodeWidth, background }} ref={this.createRowDropRef} onPointerEnter={this.pointerEnteredRow} onPointerLeave={this.pointerLeaveRow}> + <div + className="collectionStackingView-masonrySection" + style={{ width: this._props.pivotField ? this._props.panelWidth() : '100%', background }} + ref={this.createRowDropRef} + onPointerEnter={this.pointerEnteredRow} + onPointerLeave={this.pointerLeaveRow}> {this.headingView} {this.contentLayout} </div> diff --git a/src/client/views/collections/CollectionNoteTakingView.scss b/src/client/views/collections/CollectionNoteTakingView.scss index 0d24a56b5..231085338 100644 --- a/src/client/views/collections/CollectionNoteTakingView.scss +++ b/src/client/views/collections/CollectionNoteTakingView.scss @@ -82,7 +82,7 @@ height: 100%; width: 100%; position: absolute; - top: 0; + top: 0px; overflow-y: auto; overflow-x: hidden; transition: top 0.5s; @@ -130,8 +130,8 @@ display: flex; flex-direction: column; align-items: center; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; position: absolute; margin: auto; @@ -152,20 +152,20 @@ } .collectionNoteTakingView-columnDragger { - width: 15; - height: 15; + width: 15px; + height: 15px; position: absolute; - margin-left: -5; + margin-left: -5px; } .collectionNoteTakingView-sectionDelete { display: none; position: absolute; - right: 0; + right: 0px; width: max-content; height: max-content; - top: 10; - padding: 2; + top: 10px; + padding: 2px; } // Documents in NoteTaking view @@ -210,8 +210,8 @@ height: 5px; &.active { - margin-left: 0; - margin-right: 0; + margin-left: 0px; + margin-right: 0px; background: red; } } @@ -303,8 +303,8 @@ .collectionNoteTakingView-sectionColor { position: absolute; - left: 0; - top: 0; + left: 0px; + top: 0px; height: 100%; display: none; @@ -345,8 +345,8 @@ .collectionNoteTakingView-sectionOptions { position: absolute; - right: 0; - top: 0; + right: 0px; + top: 0px; height: 100%; display: none; diff --git a/src/client/views/collections/CollectionNoteTakingView.tsx b/src/client/views/collections/CollectionNoteTakingView.tsx index 7f639a11e..7a4408931 100644 --- a/src/client/views/collections/CollectionNoteTakingView.tsx +++ b/src/client/views/collections/CollectionNoteTakingView.tsx @@ -256,9 +256,10 @@ export class CollectionNoteTakingView extends CollectionSubView() { const height = () => this.getDocHeight(doc); let dref: Opt<DocumentView>; const noteTakingDocTransform = () => this.getDocTransform(doc, dref); + const setRef = (r: DocumentView | null) => (dref = r || undefined); return ( <DocumentView - ref={r => (dref = r || undefined)} + ref={setRef} Document={doc} TemplateDataDocument={doc.isTemplateDoc || doc.isTemplateForField ? this._props.TemplateDataDocument : undefined} pointerEvents={this.blockPointerEventsWhenDragging} diff --git a/src/client/views/collections/CollectionPivotView.tsx b/src/client/views/collections/CollectionPivotView.tsx index 4736070c3..5487315f0 100644 --- a/src/client/views/collections/CollectionPivotView.tsx +++ b/src/client/views/collections/CollectionPivotView.tsx @@ -102,13 +102,7 @@ export class CollectionPivotView extends CollectionSubView() { <div className="collectionTimeView-pivot" style={{ width: this._props.PanelWidth(), height: '100%' }}> {this.contents} <div style={{ right: 0, top: 0, position: 'absolute' }}> - <FieldsDropdown - Doc={this.Document} - selectFunc={fieldKey => { - this.layoutDoc._pivotField = fieldKey; - }} - placeholder={StrCast(this.layoutDoc._pivotField)} - /> + <FieldsDropdown Doc={this.Document} isInactive={!this._props.isContentActive()} selectFunc={fieldKey => (this.layoutDoc._pivotField = fieldKey)} placeholder={StrCast(this.layoutDoc._pivotField)} /> </div> </div> ); diff --git a/src/client/views/collections/CollectionStackedTimeline.scss b/src/client/views/collections/CollectionStackedTimeline.scss index d05c0ffde..e71df2164 100644 --- a/src/client/views/collections/CollectionStackedTimeline.scss +++ b/src/client/views/collections/CollectionStackedTimeline.scss @@ -38,7 +38,7 @@ height: 100%; background-color: global.$dark-gray; opacity: 0.3; - top: 0; + top: 0px; } .collectionStackedTimeline-trim-controls { @@ -49,8 +49,8 @@ display: flex; justify-content: space-between; max-width: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; .collectionStackedTimeline-trim-handle { background-color: global.$medium-blue; @@ -106,18 +106,18 @@ .collectionStackedTimeline-resizer { background: global.$dark-gray; position: absolute; - top: 0; + top: 0px; height: 100%; width: 10px; pointer-events: all; z-index: 100; } .collectionStackedTimeline-resizer { - right: 0; + right: 0px; cursor: e-resize; } .collectionStackedTimeline-left-resizer { - left: 0; + left: 0px; cursor: w-resize; } } @@ -126,8 +126,8 @@ position: absolute; width: 100%; height: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; pointer-events: none; } } diff --git a/src/client/views/collections/CollectionStackingView.scss b/src/client/views/collections/CollectionStackingView.scss index 05ac52ff9..2cf361847 100644 --- a/src/client/views/collections/CollectionStackingView.scss +++ b/src/client/views/collections/CollectionStackingView.scss @@ -2,10 +2,12 @@ .collectionMasonryView { display: inline; + flex-wrap: wrap; } .collectionStackingView { display: flex; + justify-content: space-between; } .collectionStackingMasonry-cont { @@ -14,10 +16,10 @@ width: 100%; .collectionStackingView-columnDragger { - width: 28; - height: 28; - position: absolute; - margin-left: -5; + width: 28px; + height: 28px; + position: relative; + margin-left: -5px; z-index: 10; > svg { width: 100%; @@ -53,10 +55,9 @@ height: 100%; width: 100%; position: absolute; - top: 0; + top: 0px; overflow-y: auto; overflow-x: hidden; - flex-wrap: wrap; transition: top 0.5s; > div { @@ -92,8 +93,8 @@ .collectionStackingView-masonryGrid { width: 100%; display: grid; - top: 0; - left: 0; + top: 0px; + left: 0px; } .collectionStackingView-masonrySingle { @@ -114,8 +115,8 @@ position: absolute; display: flex; flex-direction: column; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; position: absolute; } @@ -158,12 +159,12 @@ width: 100%; display: none; position: absolute; - top: 0; + top: 0px; cursor: default; &.active { - margin-left: 0; - margin-right: 0; + margin-left: 0px; + margin-right: 0px; background: red; } } @@ -210,7 +211,6 @@ .collectionStackingView-sectionHeader { text-align: center; margin: auto; - margin-bottom: 10px; background: global.$medium-gray; // overflow: hidden; overflow is visible so the color menu isn't hidden -ftong @@ -262,8 +262,8 @@ .collectionStackingView-sectionColor { position: absolute; - left: 0; - top: 0; + left: 0px; + top: 0px; height: 100%; display: none; @@ -304,8 +304,8 @@ .collectionStackingView-sectionOptions { position: absolute; - right: 0; - top: 0; + right: 0px; + top: 0px; height: 100%; display: none; @@ -339,7 +339,7 @@ .collectionStackingView-sectionDelete { position: absolute; right: 0px; - top: 0; + top: 0px; height: 100%; display: none; } @@ -367,7 +367,6 @@ .collectionStackingView-addGroupButton { display: flex; overflow: hidden; - margin: auto; width: 90%; overflow: ellipses; diff --git a/src/client/views/collections/CollectionStackingView.tsx b/src/client/views/collections/CollectionStackingView.tsx index 25a222cbb..fbdd23315 100644 --- a/src/client/views/collections/CollectionStackingView.tsx +++ b/src/client/views/collections/CollectionStackingView.tsx @@ -1,10 +1,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import * as CSS from 'csstype'; -import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction, runInAction } from 'mobx'; +import { action, computed, IReactionDisposer, makeObservable, observable, ObservableMap, reaction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { ClientUtils, DivHeight, returnNone, returnZero, setupMoveUpEvents, smoothScroll } from '../../../ClientUtils'; -import { Doc, Opt } from '../../../fields/Doc'; +import { Doc, Field, Opt } from '../../../fields/Doc'; import { Id } from '../../../fields/FieldSymbols'; import { List } from '../../../fields/List'; import { listSpec } from '../../../fields/Schema'; @@ -35,6 +35,7 @@ import { CollectionStackingViewFieldColumn } from './CollectionStackingViewField import { CollectionSubView, SubCollectionViewProps } from './CollectionSubView'; import { computedFn } from 'mobx-utils'; import { FormattedTextBox } from '../nodes/formattedText/FormattedTextBox'; +import { FieldsDropdown } from '../FieldsDropdown'; export type collectionStackingViewProps = { sortFunc?: (a: Doc, b: Doc) => number; @@ -48,28 +49,43 @@ export type collectionStackingViewProps = { @observer export class CollectionStackingView extends CollectionSubView<Partial<collectionStackingViewProps>>() { _disposers: { [key: string]: IReactionDisposer } = {}; + _addGroupRef = React.createRef<HTMLDivElement>(); _masonryGridRef: HTMLDivElement | null = null; // used in a column dragger, likely due for the masonry grid view. We want to use this _draggerRef = React.createRef<HTMLDivElement>(); // keeping track of documents. Updated on internal and external drops. What's the difference? _docXfs: { height: () => number; width: () => number; stackedDocTransform: () => Transform }[] = []; - // Doesn't look like this field is being used anywhere. Obsolete? - _columnStart: number = 0; - @observable _refList: HTMLElement[] = []; + @observable _colStackRefs: HTMLElement[] = []; + @observable _colHdrRefs: HTMLElement[] = []; // map of node headers to their heights. Used in Masonry @observable _heightMap = new Map<string, number>(); // Assuming that this is the current css cursor style @observable _cursor: CSS.Property.Cursor = 'ew-resize'; // gets reset whenever we scroll. Not sure what it is @observable _scroll = 0; // used to force the document decoration to update when scrolling - // does this mean whether the browser is hidden? Or is chrome something else entirely? + // whether ui/editing controls are shown @computed get chromeHidden() { return this._props.chromeHidden || BoolCast(this.layoutDoc.chromeHidden); } - // it looks like this gets the column headers that Mehek was showing just now @computed get colHeaderData() { - return Cast(this.dataDoc['_' + this.fieldKey + '_columnHeaders'], listSpec(SchemaHeaderField), null); + return Cast(this.dataDoc[this.fieldKey + '_columnHeaders'], listSpec(SchemaHeaderField), null); + } + + @computed get Sections() { + return this.filteredChildren.reduce( + (map, d) => { + const docHeader = d[this.pivotField] ? d[this.pivotField] : `NO ${this.pivotField.toUpperCase()} VALUE`; + const docHeaderString = docHeader !== undefined ? Field.toString(docHeader) : `NO ${this.pivotField.toUpperCase()} VALUE`; + + // find existing header or create + const existingHeader = Array.from(map.keys()).find(sh => sh.heading === docHeaderString); + if (!existingHeader) map.set(new SchemaHeaderField(docHeaderString), [d]); + else map.get(existingHeader)!.push(d); + return map; + }, + new ObservableMap<SchemaHeaderField, Doc[]>(this.colHeaderData?.map(hdata => [hdata, []] as [SchemaHeaderField, Doc[]]) ?? []) + ); } // Still not sure what a pivot is, but it appears that we can actually filter docs somehow? @computed get pivotField() { @@ -107,9 +123,12 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection @computed get showAddAGroup() { return this.pivotField && !this.chromeHidden; } + @computed get availableWidth() { + return this._props.PanelWidth() - 2 * this.xMargin - (this.isStackingView ? this.gridGap * ((this.numGroupColumns || 1) - 1) : 0); + } // columnWidth handles the margin on the left and right side of the documents @computed get columnWidth() { - const availableWidth = this._props.PanelWidth() - 2 * this.xMargin; + const availableWidth = this.availableWidth; const cwid = availableWidth / (NumCast(this.Document._layout_columnCount) || this._props.PanelWidth() / NumCast(this.Document._layout_columnWidth, this._props.PanelWidth() / 4)); return Math.min(availableWidth, this.isStackingView ? availableWidth / (this.numGroupColumns || 1) : cwid - this.gridGap); } @@ -121,28 +140,17 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection constructor(props: SubCollectionViewProps) { super(props); makeObservable(this); - if (this.colHeaderData === undefined) { - // TODO: what is a layout doc? Is it literally how this document is supposed to be layed out? - // here we're making an empty list of column headers (again, what Mehek showed us) - this.dataDoc['_' + this.fieldKey + '_columnHeaders'] = new List<SchemaHeaderField>(); - } } + availableWidthFn = () => this.availableWidth; columnWidthFn = () => this.columnWidth; columnDocHeightFn = (doc: Doc) => () => (this.isStackingView ? this.getDocHeight(doc)() : Math.min(this.getDocHeight(doc)(), this._props.PanelHeight())); - // TODO: plj - these are the children children = (docs: Doc[]) => { - // TODO: can somebody explain me to what exactly TraceMobX is? TraceMobx(); - // appears that we are going to reset the _docXfs. TODO: what is Xfs? this._docXfs.length = 0; - this._renderCount < docs.length && - setTimeout( - action(() => { - this._renderCount = Math.min(docs.length, this._renderCount + 5); - }) - ); + this._renderCount < docs.length && + setTimeout(action(() => (this._renderCount = Math.min(docs.length, this._renderCount + 5)))); // prettier-ignore return docs.map((d, i) => { // assuming we need to get rowSpan because we might be dealing with many columns. Grid gap makes sense if multiple columns const rowSpan = Math.ceil((this.getDocHeight(d)() + this.gridGap) / this.gridGap); @@ -153,76 +161,28 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection margin: undefined, transition: this.getDocTransition(d)(), width: this.columnWidth, - marginTop: i ? this.gridGap : 0, height: this.getDocHeight(d)(), zIndex: DocumentView.getFirstDocumentView(d)?.IsSelected ? 1000 : 0, } : { gridRowEnd: `span ${rowSpan}`, zIndex: DocumentView.getFirstDocumentView(d)?.IsSelected ? 1000 : 0 }; // So we're choosing whether we're going to render a column or a masonry doc return ( - <div className={`collectionStackingView-${this.isStackingView ? 'columnDoc' : 'masonryDoc'}`} key={d[Id]} style={style}> + <div className={`collectionStackingView${this.isStackingView ? '-columnDoc' : '-masonryDoc'}`} key={d[Id]} style={style}> {this.getDisplayDoc(d, this.getDocTransition(d), i)} </div> ); }); }; @action - setDocHeight = (key: string, sectionHeight: number) => { - this._heightMap.set(key, sectionHeight); + setDocHeight = (key: string, sectionHeight: number) => this._heightMap.set(key, sectionHeight); + + setAutoHeight = () => { + const maxHeader = this.isStackingView ? this._colHdrRefs.reduce((p, r) => Math.max(p, DivHeight(r)), 0) + (this._colHdrRefs.length ? this.gridGap : 0) : 0; + const maxCol = this.isStackingView + ? this._colStackRefs.reduce((p, r) => Math.max(p, DivHeight(r)), 0) + this.gridGap + : this._colStackRefs.reduce((p, r) => p + DivHeight(r), this._addGroupRef.current ? DivHeight(this._addGroupRef.current) : 0); + this._props.setHeight?.(this.headerMargin + 2 * this.yMargin + maxCol + maxHeader); }; - - // is sections that all collections inherit? I think this is how we show the masonry/columns - // TODO: this seems important - get Sections() { - // appears that pivot field IS actually for sorting - if (!this.pivotField || this.colHeaderData instanceof Promise) return new Map<SchemaHeaderField, Doc[]>(); - - if (this.colHeaderData === undefined) { - setTimeout(() => { - this.dataDoc['_' + this.fieldKey + '_columnHeaders'] = new List<SchemaHeaderField>(); - }); - return new Map<SchemaHeaderField, Doc[]>(); - } - const colHeaderData = Array.from(this.colHeaderData); - const fields = new Map<SchemaHeaderField, Doc[]>(colHeaderData.map(sh => [sh, []] as [SchemaHeaderField, []])); - let changed = false; - this.filteredChildren.forEach(d => { - const sectionValue = (d[this.pivotField] ? d[this.pivotField] : `NO ${this.pivotField.toUpperCase()} VALUE`) as object; - // the next five lines ensures that floating point rounding errors don't create more than one section -syip - const parsed = parseInt(sectionValue.toString()); - const castedSectionValue = !isNaN(parsed) ? parsed : sectionValue; - - // look for if header exists already - const existingHeader = colHeaderData.find(sh => sh.heading === (castedSectionValue ? castedSectionValue.toString() : `NO ${this.pivotField.toUpperCase()} VALUE`)); - if (existingHeader) { - fields.get(existingHeader)!.push(d); - } else { - const newSchemaHeader = new SchemaHeaderField(castedSectionValue ? castedSectionValue.toString() : `NO ${this.pivotField.toUpperCase()} VALUE`); - fields.set(newSchemaHeader, [d]); - colHeaderData.push(newSchemaHeader); - changed = true; - } - }); - // remove all empty columns if hideHeadings is set - // we will want to have something like this, so that we can hide columns and add them back in - if (this.layoutDoc._columnsHideIfEmpty) { - Array.from(fields.keys()) - .filter(key => !fields.get(key)!.length) - .forEach(header => { - fields.delete(header); - colHeaderData.splice(colHeaderData.indexOf(header), 1); - changed = true; - }); - } - changed && - setTimeout( - action(() => this.colHeaderData?.splice(0, this.colHeaderData.length, ...colHeaderData)), - 0 - ); - return fields; - } - - setAutoHeight = () => this._props.setHeight?.(this.headerMargin + (this.isStackingView ? Math.max(...this._refList.map(DivHeight)) : 2 * this.yMargin + this._refList.reduce((p, r) => p + DivHeight(r), 0))); observer = new ResizeObserver(this.setAutoHeight); componentDidMount() { @@ -232,9 +192,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection // reset section headers when a new filter is inputted this._disposers.pivotField = reaction( () => this.pivotField, - () => { - this.dataDoc['_' + this.fieldKey + '_columnHeaders'] = new List(); - } + () => (this.dataDoc[this.fieldKey + '_columnHeaders'] = new List()) ); // reset section headers when a new filter is inputted this._disposers.width = reaction( @@ -252,7 +210,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection ); this._disposers.refList = reaction( - () => ({ refList: this._refList.slice(), autoHeight: this.layoutDoc._layout_autoHeight && !DocumentView.LightboxContains(this.DocumentView?.()) }), + () => ({ refList: this._colStackRefs.slice(), autoHeight: this.layoutDoc._layout_autoHeight && !DocumentView.LightboxContains(this.DocumentView?.()) }), ({ refList, autoHeight }) => { this.observer.disconnect(); if (autoHeight) refList.forEach(r => this.observer.observe(r)); @@ -409,11 +367,10 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection getDocWidth = computedFn((d?: Doc) => () => { if (!d) return 0; const childLayoutDoc = Doc.LayoutDoc(d, this._props.childLayoutTemplate?.()); - const maxWidth = this.columnWidth / this.numGroupColumns; if (!this.layoutDoc._columnsFill && !this.childFitWidth(childLayoutDoc)) { - return Math.min(NumCast(d._width), maxWidth); + return Math.min(NumCast(d._width), this.columnWidth); } - return maxWidth; + return this.columnWidth; }); getDocTransition = computedFn((d?: Doc) => () => StrCast(d?.dataTransition)); getDocHeight = computedFn((d?: Doc) => () => { @@ -424,7 +381,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection const nw = Doc.NativeWidth(childLayoutDoc, childDataDoc) || (!this.childFitWidth(childLayoutDoc) ? NumCast(d._width) : 0); const nh = Doc.NativeHeight(childLayoutDoc, childDataDoc) || (!this.childFitWidth(childLayoutDoc) ? NumCast(d._height) : 0); if (nw && nh) { - const colWid = this.columnWidth / (this.isStackingView ? this.numGroupColumns : 1); + const colWid = this.columnWidth; const docWid = this.layoutDoc._columnsFill ? colWid : Math.min(this.getDocWidth(d)(), colWid); return Math.min(maxHeight, (docWid * nh) / nw); } @@ -434,10 +391,8 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection }); // This following three functions must be from the view Mehek showed - columnDividerDown = (e: React.PointerEvent) => { - runInAction(() => { - this._cursor = 'grabbing'; - }); + columnDividerDown = action((e: React.PointerEvent) => { + this._cursor = 'grabbing'; const batch = UndoManager.StartBatch('stacking width'); setupMoveUpEvents( this, @@ -449,7 +404,7 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection }), emptyFunction ); - }; + }); @action onDividerMove = (e: PointerEvent) => { this.Document._layout_columnWidth = Math.max(10, (this._props.DocumentView?.().screenToViewTransform().transformPoint(e.clientX, 0)[0] ?? 0) - this.xMargin); @@ -571,7 +526,8 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection } return ( <CollectionStackingViewFieldColumn - refList={this._refList} + colStackRefs={this._colStackRefs} + colHeaderRefs={this._colHdrRefs} addDocument={this.addDocument} chromeHidden={this.chromeHidden} colHeaderData={this.colHeaderData} @@ -608,13 +564,15 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection return ( <div key={(heading?.heading ?? '') + 'head'}> {this._props.isContentActive() && !this.isStackingView && !this.chromeHidden ? this.columnDragger : null} - <div style={{ top: this.yMargin }}> + <div style={{ position: 'relative' }}> <CollectionMasonryViewFieldRow showHandle={first} Doc={this.Document} chromeHidden={this.chromeHidden} + panelWidth={this.availableWidthFn} + columnWidth={this.columnWidthFn} pivotField={this.pivotField} - refList={this._refList} + sectionRefs={this._colStackRefs} key={heading ? heading.heading : ''} rows={rows} headings={this.headings} @@ -635,9 +593,11 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection /// add a new group category (column) to the active set of note categories. (e.g., if the pivot field is 'transportation', groups might be 'car', 'plane', 'bike', etc) @action addGroup = (value: string) => { + if (!this.colHeaderData) { + this.dataDoc[this.fieldKey + '_columnHeaders'] = new List(); + } if (value && this.colHeaderData) { - const schemaHdrField = new SchemaHeaderField(value); - this.colHeaderData.push(schemaHdrField); + this.colHeaderData.push(new SchemaHeaderField(value)); return true; } return false; @@ -745,22 +705,25 @@ export class CollectionStackingView extends CollectionSubView<Partial<collection this.fixWheelEvents(ele, this._props.isContentActive); }} style={{ - overflowY: this.isContentActive() ? 'auto' : 'hidden', + paddingBottom: this.yMargin, + paddingTop: this.yMargin, + paddingLeft: this.xMargin, + paddingRight: this.xMargin, + overflowY: this.isContentActive() && !this.layoutDoc._layout_autoHeight ? 'auto' : 'hidden', background: this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor) as string, pointerEvents: this._props.pointerEvents?.() ?? this.backgroundEvents, }} - onScroll={action(e => { - this._scroll = e.currentTarget.scrollTop; - })} + onScroll={action(e => (this._scroll = e.currentTarget.scrollTop))} onDrop={this.onExternalDrop.bind(this)} onContextMenu={this.onContextMenu} onWheel={e => this.isContentActive() && e.stopPropagation()}> {this.renderedSections} - {!this.showAddAGroup ? null : ( - <div key={`${this.Document[Id]}-addGroup`} className="collectionStackingView-addGroupButton" style={{ width: !this.isStackingView ? '100%' : this.columnWidth / this.numGroupColumns - 10, marginTop: 10 }}> - <EditableView {...editableViewProps} /> - </div> - )} + <div className="collectionStackingView-addGroupButton" ref={this._addGroupRef} style={{ width: !this.isStackingView ? '100%' : this.columnWidth, display: this.showAddAGroup ? undefined : 'none' }}> + <EditableView {...editableViewProps} /> + </div> + <div style={{ right: 0, top: 0, position: 'absolute', display: !this.layoutDoc._pivotField ? 'none' : undefined }}> + <FieldsDropdown Doc={this.Document} isInactive={!this._props.isContentActive()} selectFunc={fieldKey => (this.layoutDoc._pivotField = fieldKey)} placeholder={StrCast(this.layoutDoc._pivotField)} /> + </div> </div> </div> </> diff --git a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx index 345f60e75..b5efa7a72 100644 --- a/src/client/views/collections/CollectionStackingViewFieldColumn.tsx +++ b/src/client/views/collections/CollectionStackingViewFieldColumn.tsx @@ -1,9 +1,10 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; +import { action, computed, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { DivHeight, DivWidth, returnEmptyString, returnTrue, setupMoveUpEvents } from '../../../ClientUtils'; import { Doc, DocListCast, Opt } from '../../../fields/Doc'; +import { DocData } from '../../../fields/DocSymbols'; import { RichTextField } from '../../../fields/RichTextField'; import { PastelSchemaPalette, SchemaHeaderField } from '../../../fields/SchemaHeaderField'; import { ScriptField } from '../../../fields/ScriptField'; @@ -26,7 +27,6 @@ import { EditableView } from '../EditableView'; import { DocumentView } from '../nodes/DocumentView'; import { ObservableReactComponent } from '../ObservableReactComponent'; import './CollectionStackingView.scss'; -import { DocData } from '../../../fields/DocSymbols'; // So this is how we are storing a column interface CSVFieldColumnProps { @@ -50,14 +50,14 @@ interface CSVFieldColumnProps { addDocument: (doc: Doc | Doc[]) => boolean; createDropTarget: (ele: HTMLDivElement) => void; screenToLocalTransform: () => Transform; - refList: HTMLElement[]; + colStackRefs: HTMLElement[]; + colHeaderRefs: HTMLElement[]; } @observer export class CollectionStackingViewFieldColumn extends ObservableReactComponent<CSVFieldColumnProps> { private dropDisposer?: DragManager.DragDropDisposer; private _disposers: { [name: string]: IReactionDisposer } = {}; - private _headerRef: React.RefObject<HTMLDivElement> = React.createRef(); @observable _background = 'inherit'; @observable _paletteOn = false; @observable _heading = ''; @@ -72,6 +72,7 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent< _ele: HTMLElement | null = null; _eleMasonrySingle = React.createRef<HTMLDivElement>(); + _headerRef: HTMLDivElement | null = null; protected onInternalPreDrop = (e: Event, de: DragManager.DropEvent, targetDropAction: dropActionType) => { const dragData = de.complete.docDragData; @@ -93,13 +94,13 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent< createColumnDropRef = (ele: HTMLDivElement | null) => { this.dropDisposer?.(); if (ele) this.dropDisposer = DragManager.MakeDropTarget(ele, this.columnDrop.bind(this), this._props.Doc, this.onInternalPreDrop.bind(this)); - else if (this._eleMasonrySingle.current) this.props.refList.splice(this.props.refList.indexOf(this._eleMasonrySingle.current), 1); + else if (this._eleMasonrySingle.current) runInAction(() => this.props.colStackRefs.splice(this.props.colStackRefs.indexOf(this._eleMasonrySingle.current!), 1)); this._ele = ele; }; @action componentDidMount() { - this._eleMasonrySingle.current && this.props.refList.push(this._eleMasonrySingle.current); + this._eleMasonrySingle.current && this.props.colStackRefs.push(this._eleMasonrySingle.current); this._disposers.collapser = reaction( () => this._props.headingObject?.collapsed, collapsed => { this.collapsed = collapsed !== undefined ? BoolCast(collapsed) : false; }, // prettier-ignore @@ -108,7 +109,7 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent< } componentWillUnmount() { this._disposers.collapser?.(); - this._ele && this.props.refList.splice(this.props.refList.indexOf(this._ele), 1); + this._ele && runInAction(() => this.props.colStackRefs.splice(this.props.colStackRefs.indexOf(this._ele!), 1)); this._ele = null; } @@ -192,7 +193,7 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent< value = typeof value === 'string' ? `"${value}"` : value; embedding.viewSpecScript = ScriptField.MakeFunction(`doc.${this._props.pivotField} === ${value}`, { doc: Doc.name }); if (embedding.viewSpecScript) { - DragManager.StartDocumentDrag([this._headerRef.current!], new DragManager.DocumentDragData([embedding]), e.clientX, e.clientY); + DragManager.StartDocumentDrag([this._headerRef!], new DragManager.DocumentDragData([embedding]), e.clientX, e.clientY); return true; } return false; @@ -303,6 +304,12 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent< ContextMenu.Instance.displayMenu(pt[0], pt[1], undefined, true); }; + setRef = (r: HTMLDivElement | null) => { + if (this._headerRef && this._props.colHeaderRefs.includes(this._headerRef)) this._props.colHeaderRefs.splice(this._props.colStackRefs.indexOf(this._headerRef), 1); + r && this._props.colHeaderRefs.push(r); + this._headerRef = r; + }; + @computed get innards() { TraceMobx(); const key = this._props.pivotField; @@ -314,9 +321,10 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent< <div key={heading} className="collectionStackingView-sectionHeader" - ref={this._headerRef} + ref={this.setRef} style={{ - marginTop: this._props.yMargin, + marginTop: 0, + marginBottom: this._props.gridGap, width: this._props.columnWidth, }}> {/* the default bucket (no key value) has a tooltip that describes what it is. @@ -367,7 +375,7 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent< ref={this._eleMasonrySingle} className="collectionStackingView-masonrySingle" style={{ - padding: `${columnYMargin}px ${0}px ${this._props.yMargin}px ${0}px`, + padding: `${columnYMargin}px ${0}px ${0}px ${0}px`, margin: this._props.dontCenter.includes('x') ? undefined : 'auto', height: 'max-content', position: 'relative', @@ -400,15 +408,13 @@ export class CollectionStackingViewFieldColumn extends ObservableReactComponent< render() { TraceMobx(); - const headings = this._props.headings(); const heading = this._heading; - const uniqueHeadings = headings.map((i, idx) => headings.indexOf(i) === idx); return ( <div className="collectionStackingViewFieldColumn" key={heading} style={{ - width: `${100 / (uniqueHeadings.length + (this._props.chromeHidden ? 0 : 1) || 1)}%`, + width: this._props.columnWidth, height: undefined, background: this._background, }} diff --git a/src/client/views/collections/CollectionTimeView.scss b/src/client/views/collections/CollectionTimeView.scss index d995cbcd2..d56999974 100644 --- a/src/client/views/collections/CollectionTimeView.scss +++ b/src/client/views/collections/CollectionTimeView.scss @@ -27,7 +27,7 @@ transform: rotate(45deg); display: inline-block; background: gray; - bottom: 0; + bottom: 0px; margin-bottom: -17px; border-radius: 9px; opacity: 0.25; @@ -67,9 +67,9 @@ pointer-events: all; padding: 5px; border: 1px solid black; - display:none; + display: none; span { - margin-left : 10px; + margin-left: 10px; } } @@ -86,8 +86,9 @@ } } -.collectionTimeView:hover, .collectionTimeView-pivot:hover { +.collectionTimeView:hover, +.collectionTimeView-pivot:hover { .pivotKeyEntry { - display:unset; + display: unset; } -}
\ No newline at end of file +} diff --git a/src/client/views/collections/CollectionTreeView.scss b/src/client/views/collections/CollectionTreeView.scss index 2a03ea708..95faaa3f0 100644 --- a/src/client/views/collections/CollectionTreeView.scss +++ b/src/client/views/collections/CollectionTreeView.scss @@ -11,7 +11,7 @@ height: 100%; width: 100%; position: relative; - top: 0; + top: 0px; // background: global.$light-gray; font-size: 13px; overflow: auto; @@ -33,12 +33,12 @@ } .no-indent { - padding-left: 0; + padding-left: 0px; //width: max-content; } .no-indent-outline { - padding-left: 0; + padding-left: 0px; width: 100%; } diff --git a/src/client/views/collections/CollectionTreeView.tsx b/src/client/views/collections/CollectionTreeView.tsx index bee5d016d..4625965b4 100644 --- a/src/client/views/collections/CollectionTreeView.tsx +++ b/src/client/views/collections/CollectionTreeView.tsx @@ -407,6 +407,7 @@ export class CollectionTreeView extends CollectionSubView<Partial<collectionTree remAnnotationDocument = (doc: Doc | Doc[]) => this.removeDocument(doc, `${this._props.fieldKey}_annotations`) || false; moveAnnotationDocument = (doc: Doc | Doc[], targetCollection: Doc | undefined, addDocument: (document: Doc | Doc[], annotationKey?: string) => boolean) => this.moveDocument(doc, targetCollection, addDocument) || false; + setRef = (r: HTMLDivElement | null) => !this.Document.treeView_HasOverlay && r && this.createTreeDropTarget(r); @observable _headerHeight = 0; @computed get content() { const background = () => this._props.styleProvider?.(this.Document, this._props, StyleProp.BackgroundColor) as string; @@ -428,7 +429,7 @@ export class CollectionTreeView extends CollectionSubView<Partial<collectionTree <div className="collectionTreeView-contents" key="tree" - ref={r => !this.Document.treeView_HasOverlay && r && this.createTreeDropTarget(r)} + ref={this.setRef} style={{ ...(!titleBar ? { marginLeft: this.marginX(), paddingTop: this.marginTop() } : {}), color: color(), diff --git a/src/client/views/collections/CollectionView.scss b/src/client/views/collections/CollectionView.scss index 06c324bd0..837219e1d 100644 --- a/src/client/views/collections/CollectionView.scss +++ b/src/client/views/collections/CollectionView.scss @@ -1,7 +1,7 @@ @use '../global/globalCssVariables.module.scss' as global; .collectionView { - border-width: 0; + border-width: 0px; border-color: global.$light-gray; border-style: solid; border-radius: 0 0 global.$border-radius global.$border-radius; @@ -18,7 +18,7 @@ position: absolute; top: 55%; border: 1px black solid; - border-radius: 0; + border-radius: 0px; border-top-left-radius: 20px; border-bottom-left-radius: 20px; border-right: unset; @@ -31,8 +31,8 @@ width: 200px; height: 100%; position: absolute; - right: 0; - top: 0; + right: 0px; + top: 0px; border-left: solid 1px; z-index: 1; diff --git a/src/client/views/collections/CollectionView.tsx b/src/client/views/collections/CollectionView.tsx index eb9caf29d..72c8c3f8c 100644 --- a/src/client/views/collections/CollectionView.tsx +++ b/src/client/views/collections/CollectionView.tsx @@ -113,7 +113,7 @@ export class CollectionView extends ViewBoxAnnotatableComponent<CollectionViewPr }; setupViewTypes(category: string, func: (type_collection: CollectionViewType) => Doc) { - if (!Doc.IsSystem(this.Document) && this.Document._type_collection !== CollectionViewType.Docking && !this.dataDoc.isGroup && !this.Document.annotationOn) { + if (!Doc.IsSystem(this.Document) && this.Document._type_collection !== CollectionViewType.Docking && !this.Document.annotationOn) { // prettier-ignore const subItems: ContextMenuProps[] = [ { description: 'Freeform', event: () => func(CollectionViewType.Freeform), icon: 'signature' }, diff --git a/src/client/views/collections/FlashcardPracticeUI.scss b/src/client/views/collections/FlashcardPracticeUI.scss index 210c6798f..0cc4711b3 100644 --- a/src/client/views/collections/FlashcardPracticeUI.scss +++ b/src/client/views/collections/FlashcardPracticeUI.scss @@ -8,8 +8,8 @@ .FlashcardPracticeUI-check { position: absolute; display: flex; - width: 30; - height: 30; + width: 30px; + height: 30px; align-items: center; border-radius: 5px; justify-content: center; @@ -39,7 +39,7 @@ display: flex; top: 0px; left: 0px; - width: 30; + width: 30px; transform-origin: top left; border-radius: 5px; color: rgba(255, 255, 255, 0.5); @@ -49,7 +49,7 @@ width: 100%; display: flex; flex-direction: column; - top: 0; + top: 0px; position: relative; .FlashcardPracticeUI-quiz, .FlashcardPracticeUI-practice { diff --git a/src/client/views/collections/FlashcardPracticeUI.tsx b/src/client/views/collections/FlashcardPracticeUI.tsx index 2f46c00bd..17b65334c 100644 --- a/src/client/views/collections/FlashcardPracticeUI.tsx +++ b/src/client/views/collections/FlashcardPracticeUI.tsx @@ -61,7 +61,8 @@ export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProp 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_flashcardType) ? StrCast(this._props.layoutDoc.practiceMode) : ''; } // prettier-ignore + @computed get hasFlashcards() { return this._props.allChildDocs().some(doc => doc._layout_flashcardType); } // prettier-ignore + @computed get practiceMode() { return this.hasFlashcards ? StrCast(this._props.layoutDoc.practiceMode) : ''; } // prettier-ignore btnHeight = () => NumCast(this.filterDoc?.height); btnWidth = () => (!this.filterDoc ? 1 : NumCast(this.filterDoc._width)); @@ -130,7 +131,7 @@ export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProp 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_flashcardType) ? null : ( + return !this.hasFlashcards ? null : ( <div className="FlashcardPracticeUI-practiceModes" style={{ @@ -141,8 +142,8 @@ export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProp type={Type.PRIM} color={SnappingManager.userColor} background={SnappingManager.userVariantColor} + showUntilToggle={false} multiSelect={false} - toggleStatus={!!this.practiceMode} label="Practice" items={[ [practiceMode.QUIZ, 'file-pen', 'Practice flashcards using GPT'], @@ -160,8 +161,8 @@ export class FlashcardPracticeUI extends ObservableReactComponent<PracticeUIProp type={Type.PRIM} color={SnappingManager.userColor} background={SnappingManager.userVariantColor} + showUntilToggle={false} multiSelect={false} - toggleStatus={!!this.practiceMode} label={StrCast(this._props.layoutDoc.revealOp, flashcardRevealOp.FLIP)} items={[ ['reveal', StrCast(this._props.layoutDoc.revealOp) === flashcardRevealOp.SLIDE ? 'expand' : 'question', StrCast(this._props.layoutDoc.revealOp, flashcardRevealOp.FLIP)], diff --git a/src/client/views/collections/KeyRestrictionRow.tsx b/src/client/views/collections/KeyRestrictionRow.tsx deleted file mode 100644 index 7dc08389b..000000000 --- a/src/client/views/collections/KeyRestrictionRow.tsx +++ /dev/null @@ -1,72 +0,0 @@ -/* eslint-disable react/button-has-type */ -import { observable, runInAction } from 'mobx'; -import { observer } from 'mobx-react'; -import * as React from 'react'; - -interface IKeyRestrictionProps { - contains: boolean; - script: (value: string) => void; - field: string; - value: string; -} - -@observer -export default class KeyRestrictionRow extends React.Component<IKeyRestrictionProps> { - @observable private _key = this.props.field; - @observable private _value = this.props.value; - @observable private _contains = this.props.contains; - - render() { - if (this._key && this._value) { - let parsedValue: string | number = `"${this._value}"`; - const parsed = parseInt(this._value); - let type = 'string'; - if (!isNaN(parsed)) { - parsedValue = parsed; - type = 'number'; - } - const scriptText = `${this._contains ? '' : '!'}(((doc.${this._key} && (doc.${this._key} as ${type})${type === 'string' ? '.includes' : '<='}(${parsedValue}))) || - ((doc.data_ext && doc.data_ext.${this._key}) && (doc.data_ext.${this._key} as ${type})${type === 'string' ? '.includes' : '<='}(${parsedValue}))))`; - // let doc = new Doc(); - // ((doc.data_ext && doc.data_ext!.text) && (doc.data_ext!.text as string).includes("hello")); - this.props.script(scriptText); - } else { - this.props.script(''); - } - - return ( - <div className="collectionViewBaseChrome-viewSpecsMenu-row"> - <input - className="collectionViewBaseChrome-viewSpecsMenu-rowLeft" - value={this._key} - onChange={e => - runInAction(() => { - this._key = e.target.value; - }) - } - placeholder="KEY" - /> - <button - className="collectionViewBaseChrome-viewSpecsMenu-rowMiddle" - style={{ background: this._contains ? '#77dd77' : '#ff6961' }} - onClick={() => - runInAction(() => { - this._contains = !this._contains; - }) - }> - {this._contains ? 'CONTAINS' : 'DOES NOT CONTAIN'} - </button> - <input - className="collectionViewBaseChrome-viewSpecsMenu-rowRight" - value={this._value} - onChange={e => - runInAction(() => { - this._value = e.target.value; - }) - } - placeholder="VALUE" - /> - </div> - ); - } -} diff --git a/src/client/views/collections/TabDocView.scss b/src/client/views/collections/TabDocView.scss index 931cdac2b..b705d17f3 100644 --- a/src/client/views/collections/TabDocView.scss +++ b/src/client/views/collections/TabDocView.scss @@ -92,6 +92,6 @@ input.lm_title { .miniMap-hidden { cursor: pointer; position: absolute; - bottom: 5; - right: 5; + bottom: 5px; + right: 5px; } diff --git a/src/client/views/collections/TabDocView.tsx b/src/client/views/collections/TabDocView.tsx index c4373aaa7..897a86e7f 100644 --- a/src/client/views/collections/TabDocView.tsx +++ b/src/client/views/collections/TabDocView.tsx @@ -584,9 +584,7 @@ export class TabDocView extends ObservableReactComponent<TabDocViewProps> { miniMapColor = () => Colors.MEDIUM_GRAY; tabView = () => this._view; disableMinimap = () => !this._document; - whenChildContentActiveChanges = (isActive: boolean) => { - this._isAnyChildContentActive = isActive; - }; + whenChildContentActiveChanges = (isActive: boolean) => (this._isAnyChildContentActive = isActive); isContentActiveFunc = () => this.isContentActive; waitForDoubleClick = () => (SnappingManager.ExploreMode ? 'never' : undefined); renderDocView = (doc: Doc) => ( diff --git a/src/client/views/collections/TreeView.scss b/src/client/views/collections/TreeView.scss index 78794d112..542b0cc87 100644 --- a/src/client/views/collections/TreeView.scss +++ b/src/client/views/collections/TreeView.scss @@ -136,8 +136,8 @@ width: 100%; height: 100%; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; z-index: 0; filter: opacity(0); } diff --git a/src/client/views/collections/TreeView.tsx b/src/client/views/collections/TreeView.tsx index 5b2f1ff81..f84c7d3c0 100644 --- a/src/client/views/collections/TreeView.tsx +++ b/src/client/views/collections/TreeView.tsx @@ -1294,6 +1294,7 @@ export class TreeView extends ObservableReactComponent<TreeViewProps> { return null; } + const setRef = (r: TreeView | null) => treeViewRefs.set(child, r || undefined); const dentDoc = (editTitle: boolean, newParent: Doc, addAfter: Doc | undefined, parent: TreeView | CollectionTreeView | undefined) => { if (parent instanceof TreeView && parent._props.treeView.fileSysMode && !newParent.isFolder) return; const fieldKey = Doc.LayoutDataKey(newParent); @@ -1317,7 +1318,7 @@ export class TreeView extends ObservableReactComponent<TreeViewProps> { return ( <TreeView key={child[Id]} - ref={r => treeViewRefs.set(child, r || undefined)} + ref={setRef} Document={pair.layout} dataDoc={pair.data} treeViewParent={treeViewParent} diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.scss b/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.scss index 7951aff65..32cf3586f 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.scss +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormRemoteCursors.scss @@ -18,5 +18,5 @@ color: black; // fontStyle: "italic", margin-left: -12; - margin-top: 4; + margin-top: 4px; } diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss index 6c47a71b0..ac1ef7d65 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.scss @@ -2,8 +2,8 @@ .collectionfreeformview-none { position: inherit; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; transform-origin: left top; @@ -12,10 +12,10 @@ border-radius: inherit; } .collectionFreeForm-groupDropper { - width: 10000; - height: 10000; - left: -5000; - top: -5000; + width: 10000px; + height: 10000px; + left: -5000px; + top: -5000px; position: absolute; background: transparent; pointer-events: all; @@ -24,8 +24,8 @@ .collectionfreeformview-grid { transform-origin: top left; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; pointer-events: none; } @@ -219,8 +219,8 @@ border-radius: inherit; box-sizing: border-box; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; align-items: center; @@ -264,7 +264,7 @@ .collectionFreeform-infoUI { position: absolute; display: block; - top: 0; + top: 0px; color: white; background-color: #5075ef; @@ -275,19 +275,19 @@ padding: 10px; .collectionFreeform-infoUI-close { position: absolute; - top: -10; - left: -10; + top: -10px; + left: -10px; } .collectionFreeform-infoUI-msg { position: relative; - max-width: 500; - margin: 10; + max-width: 500px; + margin: 10px; } .collectionFreeform-infoUI-button { border-radius: 50px; font-size: 12px; - padding: 6; + padding: 6px; position: relative; } diff --git a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx index bb3c59eae..6e9e503f4 100644 --- a/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx +++ b/src/client/views/collections/collectionFreeForm/CollectionFreeFormView.tsx @@ -8,7 +8,7 @@ import { computedFn } from 'mobx-utils'; import * as React from 'react'; import { AiOutlineSend } from 'react-icons/ai'; import ReactLoading from 'react-loading'; -import { ClientUtils, DashColor, lightOrDark, OmitKeys, returnFalse, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../../ClientUtils'; +import { ClientUtils, DashColor, lightOrDark, OmitKeys, returnFalse, returnTrue, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../../ClientUtils'; import { DateField } from '../../../../fields/DateField'; import { Doc, DocListCast, Field, FieldType, Opt, StrListCast } from '../../../../fields/Doc'; import { DocData, DocLayout, Height, Width } from '../../../../fields/DocSymbols'; @@ -176,7 +176,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection return renderableEles; } @computed get fitContentsToBox() { - return (this._props.fitContentsToBox?.() || this.Document._freeform_fitContentsToBox) && !this.isAnnotationOverlay; + return (this._props.fitContentsToBox?.() || this.Document._freeform_fitContentsToBox || this.Document.freeform_isGroup) && !this.isAnnotationOverlay; } @computed get nativeWidth() { return this._props.NativeWidth?.() || Doc.NativeWidth(this.Document); @@ -342,7 +342,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection */ focusOnPoint = (options: FocusViewOptions) => { const { pointFocus, zoomTime, didMove } = options; - if (!this.Document.isGroup && pointFocus && !didMove) { + if (!this.Document.freeform_isGroup && pointFocus && !didMove) { const dfltScale = this.isAnnotationOverlay ? 1 : 0.25; if (this.layoutDoc[this.scaleFieldKey] !== dfltScale) { this.zoomSmoothlyAboutPt(this.screenToFreeformContentsXf.transformPoint(pointFocus.X, pointFocus.Y), dfltScale, zoomTime); @@ -380,7 +380,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection * @returns */ focus = (anchor: Doc, options: FocusViewOptions) => { - if (anchor.isGroup && !options.docTransform && options.contextPath?.length) { + if (Doc.IsFreeformGroup(anchor) && !options.docTransform && options.contextPath?.length) { // don't focus on group if there's a context path because we're about to focus on a group item // which will override any group focus. (If we allowed the group to focus, it would mark didMove even if there were no net movement) return undefined; @@ -395,7 +395,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection } const xfToCollection = options?.docTransform ?? Transform.Identity(); const savedState = { panX: NumCast(this.Document[this.panXFieldKey]), panY: NumCast(this.Document[this.panYFieldKey]), scale: options?.willZoomCentered ? this.Document[this.scaleFieldKey] : undefined }; - const cantTransform = this.fitContentsToBox || ((this.Document.isGroup || this.layoutDoc._lockedTransform) && !DocumentView.LightboxDoc()); + const cantTransform = this.fitContentsToBox || ((this.Document.freeform_isGroup || this.layoutDoc._lockedTransform) && !DocumentView.LightboxDoc()); const { panX, panY, scale } = cantTransform || (!options.willPan && !options.willZoomCentered) ? savedState : this.calculatePanIntoView(anchor, xfToCollection, options?.willZoomCentered ? (options?.zoomScale ?? 0.75) : undefined); // focus on the document in the collection @@ -514,7 +514,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection this._downTime = Date.now(); const scrollMode = e.altKey ? (Doc.UserDoc().freeformScrollMode === freeformScrollMode.Pan ? freeformScrollMode.Zoom : freeformScrollMode.Pan) : Doc.UserDoc().freeformScrollMode; if (e.button === 0 && (!(e.ctrlKey && !e.metaKey) || scrollMode !== freeformScrollMode.Pan) && this._props.isContentActive()) { - if (!this.Document.isGroup) { + if (!this.Document.freeform_isGroup) { // group freeforms don't pan when dragged -- instead let the event go through to allow the group itself to drag // prettier-ignore const hit = this._clusters.handlePointerDown(this.screenToFreeformContentsXf.transformPoint(e.clientX, e.clientY)); @@ -562,7 +562,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const B = this.screenToFreeformContentsXf.transformBounds(ge.bounds.left, ge.bounds.top, ge.bounds.width, ge.bounds.height); const inkDoc = this.createInkDoc(points, B); if (Doc.ActiveInk === InkInkTool.Highlight) inkDoc.$backgroundColor = 'transparent'; - if (Doc.ActiveInk === InkInkTool.Write) { + if ([InkInkTool.Write, InkInkTool.Math].includes(Doc.ActiveInk)) { this.unprocessedDocs.push(inkDoc); CollectionFreeFormView.collectionsWithUnprocessedInk.add(this); } @@ -1271,7 +1271,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @action zoom = (pointX: number, pointY: number, deltaY: number): void => { - if (this.Document.isGroup || this.Document[(this._props.viewField ?? '_') + 'freeform_noZoom']) return; + if (this.Document.freeform_isGroup || this.Document[(this._props.viewField ?? '_') + 'freeform_noZoom']) return; let deltaScale = deltaY > 0 ? 1 / 1.05 : 1.05; if (deltaScale < 0) deltaScale = -deltaScale; const [x, y] = this.screenToFreeformContentsXf.transformPoint(pointX, pointY); @@ -1298,7 +1298,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @action onPointerWheel = (e: React.WheelEvent): void => { - if (this.Document.isGroup || !this.isContentActive()) return; // group style collections neither pan nor zoom + if (this.Document.freeform_isGroup || !this.isContentActive()) return; // group style collections neither pan nor zoom SnappingManager.TriggerUserPanned(); if (this.layoutDoc._Transform || this.Document.treeView_OutlineMode === TreeViewType.outline) return; e.stopPropagation(); @@ -1422,7 +1422,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection @action zoomSmoothlyAboutPt(docpt: number[], scale: number, transitionTime = 500) { - if (this.Document.isGroup) return; + if (this.Document.freeform_isGroup) return; this.setPanZoomTransition(transitionTime); const screenXY = this.screenToFreeformContentsXf.inverse().transformPoint(docpt[0], docpt[1]); this.layoutDoc[this.scaleFieldKey] = scale; @@ -1505,7 +1505,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection removeDocument = (docs: Doc | Doc[], annotationKey?: string | undefined) => { const ret = !!this._props.removeDocument?.(docs, annotationKey); // if this is a group and we have fewer than 2 Docs, then just promote what's left to our parent and get rid of the group. - if (ret && DocListCast(this.dataDoc[annotationKey ?? this.fieldKey]).length < 2 && this.Document.isGroup) { + if (ret && DocListCast(this.dataDoc[annotationKey ?? this.fieldKey]).length < 2 && this.Document.freeform_isGroup) { this.promoteCollection(); } return ret; @@ -1548,7 +1548,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection searchFilterDocs={this.searchFilterDocs} isDocumentActive={childLayout.pointerEvents === 'none' ? returnFalse : this._props.childDocumentsActive?.() ? this._props.isDocumentActive : this.isContentActive} isContentActive={this.childContentsActive} - focus={this.Document.isGroup ? this.groupFocus : this.isAnnotationOverlay ? this._props.focus : this.focus} + focus={this.Document.freeform_isGroup ? this.groupFocus : this.isAnnotationOverlay ? this._props.focus : this.focus} addDocTab={this.addDocTab} addDocument={this._props.addDocument} removeDocument={this.removeDocument} @@ -1733,15 +1733,16 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }); PinDocView( anchor, - { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ? { ...pinProps.pinData, poslayoutview: pinProps.pinData.dataview } : {}), pannable: !this.Document.isGroup, collectionType: true, filters: true } }, + { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ? { ...pinProps.pinData, poslayoutview: pinProps.pinData.dataview } : {}), pannable: !this.Document.freeform_isGroup, collectionType: true, filters: true } }, this.Document ); if (addAsAnnotation) { - if (Cast(this.dataDoc[this._props.fieldKey + '_annotations'], listSpec(Doc), null) !== undefined) { - Cast(this.dataDoc[this._props.fieldKey + '_annotations'], listSpec(Doc), [])?.push(anchor); + const fieldKey = this._props.isAnnotationOverlay ? this._props.fieldKey : this._props.fieldKey + '_annotations'; + if (Cast(this.dataDoc[fieldKey], listSpec(Doc), null) !== undefined) { + Cast(this.dataDoc[fieldKey], listSpec(Doc), [])?.push(anchor); } else { - this.dataDoc[this._props.fieldKey + '_annotations'] = new List<Doc>([anchor]); + this.dataDoc[fieldKey] = new List<Doc>([anchor]); } } return anchor; @@ -1766,7 +1767,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection this._firstRender = false; this._disposers.groupBounds = reaction( () => { - if (this.Document.isGroup && this.childDocs.length === this.childDocList?.length) { + if (this.Document.freeform_isGroup && this.childDocs.length === this.childDocList?.length) { const clist = this.childDocs.map(cd => ({ x: NumCast(cd.x), y: NumCast(cd.y), width: NumCast(cd._width), height: NumCast(cd._height) })); return aggregateBounds(clist, NumCast(this.layoutDoc._xMargin), NumCast(this.layoutDoc._yMargin)); } @@ -1928,8 +1929,8 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const appearance = ContextMenu.Instance.findByDescription('Appearance...'); const appearanceItems = appearance?.subitems ?? []; - !this.Document.isGroup && appearanceItems.push({ description: 'Reset View', event: this.resetView, icon: 'compress-arrows-alt' }); - !this.Document.isGroup && appearanceItems.push({ description: 'Toggle Auto Reset View', event: this.toggleResetView, icon: 'compress-arrows-alt' }); + !this.Document.freeform_isGroup && appearanceItems.push({ description: 'Reset View', event: this.resetView, icon: 'compress-arrows-alt' }); + !this.Document.freeform_isGroup && appearanceItems.push({ description: 'Toggle Auto Reset View', event: this.toggleResetView, icon: 'compress-arrows-alt' }); if (this._props.setContentViewBox === emptyFunction) { !appearance && ContextMenu.Instance.addItem({ description: 'Appearance...', subitems: appearanceItems, icon: 'eye' }); return; @@ -1947,7 +1948,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection !Doc.noviceMode && appearanceItems.push({ description: `update icon`, event: () => this.updateIcon(), icon: 'compress-arrows-alt' }); this._props.renderDepth && appearanceItems.push({ description: 'Ungroup collection', event: this.promoteCollection, icon: 'table' }); - this.Document.isGroup && this.Document.transcription && appearanceItems.push({ description: 'Ink to text', event: this.transcribeStrokes, icon: 'font' }); + this.Document.freeform_isGroup && this.Document.transcription && appearanceItems.push({ description: 'Ink to text', event: this.transcribeStrokes, icon: 'font' }); !Doc.noviceMode ? appearanceItems.push({ description: 'Arrange contents in grid', event: this.layoutDocsInGrid, icon: 'table' }) : null; !Doc.noviceMode ? appearanceItems.push({ description: (this.Document._freeform_useClusters ? 'Hide' : 'Show') + ' Clusters', event: () => this._clusters.updateClusters(!this.Document._freeform_useClusters), icon: 'braille' }) : null; @@ -2006,7 +2007,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection }; transcribeStrokes = undoable(() => { - if (this.Document.isGroup && this.Document.transcription) { + if (this.Document.freeform_isGroup && this.Document.transcription) { const text = StrCast(this.Document.transcription); const lines = text.split('\n'); const height = 30 + 15 * lines.length; @@ -2024,7 +2025,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection dragStarting = (snapToDraggedDoc: boolean = false, showGroupDragTarget: boolean = true, visited = new Set<Doc>()) => { if (visited.has(this.Document)) return; visited.add(this.Document); - showGroupDragTarget && (this.GroupChildDrag = BoolCast(this.Document.isGroup)); + showGroupDragTarget && (this.GroupChildDrag = BoolCast(this.Document.freeform_isGroup)); const activeDocs = this.getActiveDocuments(); const size = this.screenToFreeformContentsXf.transformDirection(this._props.PanelWidth(), this._props.PanelHeight()); const selRect = { left: this.panX() - size[0] / 2, top: this.panY() - size[1] / 2, width: size[0], height: size[1] }; @@ -2032,13 +2033,15 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection const isDocInView = (doc: Doc, rect: { left: number; top: number; width: number; height: number }) => intersectRect(docDims(doc), rect); const snappableDocs = activeDocs.filter(doc => doc.z === undefined && isDocInView(doc, selRect)); // first see if there are any foreground docs to snap to - activeDocs.filter(doc => doc.isGroup && SnappingManager.IsResizing !== doc[Id] && !DragManager.docsBeingDragged.includes(doc)).forEach(doc => DocumentView.getDocumentView(doc)?.ComponentView?.dragStarting?.(snapToDraggedDoc, false, visited)); + activeDocs + .filter(doc => Doc.IsFreeformGroup(doc) && SnappingManager.IsResizing !== doc[Id] && !DragManager.docsBeingDragged.includes(doc)) + .forEach(doc => DocumentView.getDocumentView(doc)?.ComponentView?.dragStarting?.(snapToDraggedDoc, false, visited)); const horizLines: number[] = []; const vertLines: number[] = []; const invXf = this.screenToFreeformContentsXf.inverse(); snappableDocs - .filter(doc => !doc.isGroup && (snapToDraggedDoc || (SnappingManager.IsResizing !== doc[Id] && !DragManager.docsBeingDragged.includes(doc)))) + .filter(doc => !Doc.IsFreeformGroup(doc) && (snapToDraggedDoc || (SnappingManager.IsResizing !== doc[Id] && !DragManager.docsBeingDragged.includes(doc)))) .forEach(doc => { const { left, top, width, height } = docDims(doc); const topLeftInScreen = invXf.transformPoint(left, top); @@ -2062,6 +2065,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection } this.childDocs.some(doc => !this._renderCutoffData.get(doc[Id])) && setTimeout(this.incrementalRender, 1); }); + showBorderRounding = returnTrue; showPresPaths = () => SnappingManager.ShowPresPaths; brushedView = () => this._brushedView; gridColor = () => DashColor(lightOrDark(this.backgroundColor)).fade(0.5).toString(); // prettier-ignore @@ -2130,7 +2134,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection {...this._props} ref={this._marqueeViewRef} Doc={this.Document} - ungroup={this.Document.isGroup ? this.promoteCollection : undefined} + ungroup={this.Document.freeform_isGroup ? this.promoteCollection : undefined} nudge={this.isAnnotationOverlay || this._props.renderDepth > 0 ? undefined : this.nudge} addDocTab={this.addDocTab} slowLoadDocuments={this.slowLoadDocuments} @@ -2216,18 +2220,41 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection ); }; + @computed get inkEraser() { + return ( + Doc.ActiveTool === InkTool.Eraser && + Doc.ActiveEraser === InkEraserTool.Radius && + this._showEraserCircle && ( + <div + onPointerMove={this.onCursorMove} + style={{ + position: 'fixed', + left: this._eraserX, + top: this._eraserY, + width: (ActiveEraserWidth() + 5) * 2, + height: (ActiveEraserWidth() + 5) * 2, + borderRadius: '50%', + border: '1px solid gray', + transform: 'translate(-50%, -50%)', + }} + /> + ) + ); + } + + setRef = (r: HTMLDivElement | null) => { + this.createDashEventsTarget(r); + this.fixWheelEvents(r, this._props.isContentActive, this.onPassiveWheel); + r?.addEventListener('mouseleave', this.onMouseLeave); + r?.addEventListener('mouseenter', this.onMouseEnter); + }; render() { TraceMobx(); return ( <div className="collectionfreeformview-container" id={this._paintedId} - ref={r => { - this.createDashEventsTarget(r); - this.fixWheelEvents(r, this._props.isContentActive, this.onPassiveWheel); - r?.addEventListener('mouseleave', this.onMouseLeave); - r?.addEventListener('mouseenter', this.onMouseEnter); - }} + ref={this.setRef} onWheel={this.onPointerWheel} onClick={this.onClick} onPointerDown={this.onPointerDown} @@ -2242,21 +2269,7 @@ export class CollectionFreeFormView extends CollectionSubView<Partial<collection width: `${100 / this.nativeDimScaling}%`, height: this._props.getScrollHeight?.() ?? `${100 / this.nativeDimScaling}%`, }}> - {Doc.ActiveTool === InkTool.Eraser && Doc.ActiveEraser === InkEraserTool.Radius && this._showEraserCircle && ( - <div - onPointerMove={this.onCursorMove} - style={{ - position: 'fixed', - left: this._eraserX, - top: this._eraserY, - width: (ActiveEraserWidth() + 5) * 2, - height: (ActiveEraserWidth() + 5) * 2, - borderRadius: '50%', - border: '1px solid gray', - transform: 'translate(-50%, -50%)', - }} - /> - )} + {this.inkEraser} {this.paintFunc ? ( <FormattedTextBox {...this.props} /> // need this so that any live dashfieldviews will update the underlying text that the code eval reads ) : this._lightboxDoc ? ( diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss index 0a001d84c..d0685e419 100644 --- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss +++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.scss @@ -2,7 +2,7 @@ display: flex; height: max-content; flex-direction: column; - top: 0; + top: 0px; position: absolute; width: 100%; height: 100%; @@ -31,9 +31,9 @@ } .face-document-top { position: relative; - top: 0; + top: 0px; width: 100%; - left: 0; + left: 0px; } .face-document-image-container { @@ -69,8 +69,8 @@ .remove-item { position: absolute; - bottom: -5; - right: -5; + bottom: -5px; + right: -5px; background-color: rgba(0, 0, 0, 0.5); // Optional: to add a background behind the icon for better visibility border-radius: 30%; width: 10px; // Adjust size as needed @@ -98,7 +98,7 @@ .faceCollectionBox { width: 100%; height: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; position: absolute; } diff --git a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx index 142085e14..c31558dff 100644 --- a/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx +++ b/src/client/views/collections/collectionFreeForm/FaceCollectionBox.tsx @@ -150,6 +150,7 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() { FaceRecognitionHandler.UniqueFaceRemoveFaceImage(imgDoc, this.Document); }, 'remove doc from face'); + setRef = (r: HTMLDivElement | null) => this.fixWheelEvents(r, this._props.isContentActive); render() { return ( <div className="face-document-item" ref={ele => this.createDropTarget(ele!)}> @@ -176,7 +177,7 @@ export class UniqueFaceBox extends ViewBoxBaseComponent<FieldViewProps>() { style={{ pointerEvents: this._props.isContentActive() ? undefined : 'none', }} - ref={r => this.fixWheelEvents(r, this._props.isContentActive)}> + ref={this.setRef}> {FaceRecognitionHandler.UniqueFaceImages(this.Document).map((doc, i) => { const [name, type] = ImageCastToNameType(doc?.[Doc.LayoutDataKey(doc)]) ?? ['-missing-', '.png']; return ( diff --git a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx index ff9fb14e7..e3a3f9b05 100644 --- a/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx +++ b/src/client/views/collections/collectionFreeForm/ImageLabelBox.tsx @@ -160,15 +160,16 @@ export class ImageLabelBox extends ViewBoxBaseComponent<FieldViewProps>() { classifyImagesInBox = async () => { this.startLoading(); + const selectedImages = this._selectedImages; // Converts the images into a Base64 format, afterwhich the information is sent to GPT to label them. - const imageInfos = this._selectedImages.map(async doc => { + const imageInfos = selectedImages.map(async doc => { if (!doc.$tags_chat) { const url = ImageCastWithSuffix(doc[Doc.LayoutDataKey(doc)], '_o') ?? ''; return imageUrlToBase64(url).then(hrefBase64 => !hrefBase64 ? undefined : - gptImageLabel(hrefBase64,'Give three labels to describe this image.').then(labels => + gptImageLabel(hrefBase64, 'Give three labels to describe this image.').then(labels => ({ doc, labels }))) ; // prettier-ignore } }); diff --git a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx index abd828945..2ec59e5d5 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeOptionsMenu.tsx @@ -16,6 +16,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> { public createCollection: (e: KeyboardEvent | React.PointerEvent | undefined, group?: boolean, selection?: Doc[]) => Doc | void = unimplementedFunction; public delete: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; public summarize: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; + public generateScrapbook: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; public showMarquee: () => void = unimplementedFunction; public hideMarquee: () => void = unimplementedFunction; public pinWithView: (e: KeyboardEvent | React.PointerEvent | undefined) => void = unimplementedFunction; @@ -38,6 +39,7 @@ export class MarqueeOptionsMenu extends AntimodeMenu<AntimodeMenuProps> { <IconButton tooltip="Create a Collection" onPointerDown={this.createCollection} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} /> <IconButton tooltip="Create a Grouping" onPointerDown={e => this.createCollection(e, true)} icon={<FontAwesomeIcon icon="layer-group" />} color={this.userColor} /> <IconButton tooltip="Summarize Documents" onPointerDown={this.summarize} icon={<FontAwesomeIcon icon="compress-arrows-alt" />} color={this.userColor} /> + <IconButton tooltip="Generate Scrapbook" onPointerDown={this.generateScrapbook} icon={<FontAwesomeIcon icon="palette" />} color={this.userColor} /> <IconButton tooltip="Delete Documents" onPointerDown={this.delete} icon={<FontAwesomeIcon icon="trash-alt" />} color={this.userColor} /> <IconButton tooltip="Pin selected region" onPointerDown={this.pinWithView} icon={<FontAwesomeIcon icon="map-pin" />} color={this.userColor} /> <IconButton tooltip="Classify and Sort Images" onPointerDown={this.classifyImages} icon={<FontAwesomeIcon icon="object-group" />} color={this.userColor} /> diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.scss b/src/client/views/collections/collectionFreeForm/MarqueeView.scss index 7c9d0f6e1..135f4deac 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.scss +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.scss @@ -1,7 +1,7 @@ .marqueeView { position: inherit; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; overflow: hidden; @@ -20,7 +20,7 @@ pointer-events: none; .marquee-legend { bottom: -18px; - left: 0; + left: 0px; position: absolute; font-size: 9; white-space: nowrap; @@ -28,4 +28,4 @@ .marquee-legend::after { content: 'Press <space> for lasso'; } -} +}
\ No newline at end of file diff --git a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx index 3cc7c0f2d..4191aaca8 100644 --- a/src/client/views/collections/collectionFreeForm/MarqueeView.tsx +++ b/src/client/views/collections/collectionFreeForm/MarqueeView.tsx @@ -28,6 +28,9 @@ import { FormattedTextBox } from '../../nodes/formattedText/FormattedTextBox'; import { SubCollectionViewProps } from '../CollectionSubView'; import { ImageLabelBoxData } from './ImageLabelBox'; import { MarqueeOptionsMenu } from './MarqueeOptionsMenu'; +import { StrListCast } from '../../../../fields/Doc'; +import { requestAiGeneratedPreset, DocumentDescriptor } from '../../nodes/scrapbook/AIPresetGenerator'; +import { buildPlaceholdersFromConfigs, slotRealDocIntoPlaceholders } from '../../nodes/scrapbook/ScrapbookBox'; import './MarqueeView.scss'; interface MarqueeViewProps { @@ -76,6 +79,11 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps @observable _labelsVisibile: boolean = false; @observable _lassoPts: [number, number][] = []; @observable _lassoFreehand: boolean = false; + // ─── New Observables for “Pick 1 of N AI Scrapbook” ─── + @observable aiChoices: Doc[] = []; // temporary hidden Scrapbook docs + @observable pickerX = 0; // popup x coordinate + @observable pickerY = 0; // popup y coordinate + @observable pickerVisible = false; // show/hide ScrapbookPicker @computed get Transform() { return this._props.getTransform(); @@ -190,6 +198,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps FormattedTextBox.SelectOnLoadChar = Doc.UserDoc().defaultTextLayout && !this._props.childLayoutString ? e.key : ''; FormattedTextBox.LiveTextUndo = UndoManager.StartBatch('type new note'); this._props.addLiveTextDocument(DocUtils.GetNewTextDoc('-typed text-', x, y, 200, 100)); + setTimeout(() => FormattedTextBox.LiveTextUndo?.end(), 100); e.stopPropagation(); } }; @@ -275,6 +284,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps MarqueeOptionsMenu.Instance.createCollection = this.collection; MarqueeOptionsMenu.Instance.delete = this.delete; MarqueeOptionsMenu.Instance.summarize = this.summary; + MarqueeOptionsMenu.Instance.generateScrapbook = this.generateScrapbook; MarqueeOptionsMenu.Instance.showMarquee = this.showMarquee; MarqueeOptionsMenu.Instance.hideMarquee = this.hideMarquee; MarqueeOptionsMenu.Instance.jumpTo(e.clientX, e.clientY); @@ -372,7 +382,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps ? creator(selected, { title: 'nested stack' }) : ((doc: Doc) => { doc.$data = new List<Doc>(selected); - doc.$isGroup = makeGroup; + doc.$freeform_isGroup = makeGroup; doc.$title = makeGroup ? 'grouping' : 'nested freeform'; doc._freeform_panX = doc._freeform_panY = 0; return doc; @@ -508,7 +518,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps _layout_showSidebar: true, title: 'overview', }); - const portal = Docs.Create.FreeformDocument(selected, { title: 'summarized documents', x: this.Bounds.left + 200, y: this.Bounds.top, isGroup: true, backgroundColor: 'transparent' }); + const portal = Docs.Create.FreeformDocument(selected, { title: 'summarized documents', x: this.Bounds.left + 200, y: this.Bounds.top, freeform_isGroup: true, backgroundColor: 'transparent' }); DocUtils.MakeLink(summary, portal, { link_relationship: 'summary of:summarized by' }); portal.hidden = true; @@ -517,6 +527,77 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps MarqueeOptionsMenu.Instance.fadeOut(true); }); + getAiPresetsDescriptors = (): DocumentDescriptor[] => + this.marqueeSelect(false).map(doc => ({ + type: typeof doc.$type === 'string' ? doc.$type : 'UNKNOWN', + tags: Array.from(new Set(StrListCast(doc.$tags_chat))), + })); + + generateScrapbook = action(async () => { + const selectedDocs = this.marqueeSelect(false); + if (!selectedDocs.length) return; + + const descriptors = this.getAiPresetsDescriptors(); + if (descriptors.length === 0) { + alert('No documents selected to generate a scrapbook from!'); + return; + } + + const aiPreset = await requestAiGeneratedPreset(descriptors); + if (!aiPreset.length) { + alert('Failed to generate preset'); + return; + } + const scrapbookPlaceholders: Doc[] = buildPlaceholdersFromConfigs(aiPreset); + /* + const scrapbookPlaceholders: Doc[] = aiPreset.map(cfg => { + const placeholderDoc = Docs.Create.TextDocument(cfg.tag); + placeholderDoc.placeholder_docType = cfg.type as DocumentType; + placeholderDoc.placeholder_acceptTags = new List<string>(cfg.acceptTags ?? [cfg.tag]); + + const placeholder = new Doc(); + placeholder.proto = placeholderDoc; + placeholder.original = placeholderDoc; + placeholder.x = cfg.x; + placeholder.y = cfg.y; + if (cfg.width != null) placeholder._width = cfg.width; + if (cfg.height != null) placeholder._height = cfg.height; + + return placeholder; + });*/ + + const scrapbook = Docs.Create.ScrapbookDocument(scrapbookPlaceholders, { + backgroundColor: '#e2ad32', + x: this.Bounds.left, + y: this.Bounds.top, + _width: 500, + _height: 500, + title: 'AI-generated Scrapbook', + }); + + // 3) Now grab that new scrapbook’s flat placeholders + const allPlaceholders = DocUtils.unwrapPlaceholders(scrapbookPlaceholders); + + // 4) Slot each selectedDocs[i] into the first matching placeholder + selectedDocs.forEach(realDoc => slotRealDocIntoPlaceholders(realDoc, allPlaceholders)); + + const selected = selectedDocs.map(d => { + this._props.removeDocument?.(d); + d.x = NumCast(d.x) - this.Bounds.left; + d.y = NumCast(d.y) - this.Bounds.top; + return d; + }); + + this._props.addDocument?.(scrapbook); + const portal = Docs.Create.FreeformDocument(selected, { title: 'docs in scrapbook', x: this.Bounds.left + 200, y: this.Bounds.top, isGroup: true, backgroundColor: 'transparent' }); + DocUtils.MakeLink(scrapbook, portal, { link_relationship: 'scrapbook of:in scrapbook' }); + + portal.hidden = true; + this._props.addDocument?.(portal); + MarqueeOptionsMenu.Instance.fadeOut(true); + this.hideMarquee(); + }); + @action marqueeCommand = (e: KeyboardEvent) => { const ee = e as unknown as KeyboardEvent & { propagationIsStopped?: boolean }; @@ -538,6 +619,7 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps if (e.key === 'g') this.collection(e, true); if (e.key === 'c' || e.key === 't') this.collection(e); if (e.key === 's' || e.key === 'S') this.summary(); + if (e.key === 'g' || e.key === 'G') this.generateScrapbook(); // ← scrapbook shortcut if (e.key === 'p') this.pileup(); this.cleanupInteractions(false); } @@ -680,22 +762,21 @@ export class MarqueeView extends ObservableReactComponent<SubCollectionViewProps } e.stopPropagation(); }; + setRef = (r: HTMLDivElement | null) => { + r?.addEventListener('dashDragMovePause', this.onDragMovePause as EventListenerOrEventListenerObject); + this.MarqueeRef = r; + }; render() { return ( <div className="marqueeView" - ref={r => { - r?.addEventListener('dashDragMovePause', this.onDragMovePause as EventListenerOrEventListenerObject); - this.MarqueeRef = r; - }} + ref={this.setRef} style={{ overflow: StrCast(this._props.Document._overflow), cursor: Doc.ActiveTool === InkTool.Ink || this._visible ? 'crosshair' : 'pointer', }} onDragOver={e => e.preventDefault()} - onScroll={e => { - e.currentTarget.scrollTop = e.currentTarget.scrollLeft = 0; - }} + onScroll={e => (e.currentTarget.scrollTop = (e.currentTarget.scrollLeft = 0))} // prettier-ignore onClick={this.onClick} onPointerDown={this.onPointerDown}> {this._visible ? this.marqueeDiv : null} diff --git a/src/client/views/collections/collectionGrid/CollectionGridView.scss b/src/client/views/collections/collectionGrid/CollectionGridView.scss index 4edaf9745..b95d3ea44 100644 --- a/src/client/views/collections/collectionGrid/CollectionGridView.scss +++ b/src/client/views/collections/collectionGrid/CollectionGridView.scss @@ -39,8 +39,8 @@ background: #d3d3d3; position: absolute; - height: 3; - left: 5; + height: 3px; + left: 5px; transform-origin: left; transform: rotate(90deg); outline: none; @@ -133,7 +133,7 @@ input::-webkit-outer-spin-button, input::-webkit-inner-spin-button { -webkit-appearance: none; - margin: 0; + margin: 0px; } /* Firefox */ diff --git a/src/client/views/collections/collectionLinear/CollectionLinearView.scss b/src/client/views/collections/collectionLinear/CollectionLinearView.scss index 0dfaed38a..0053d3e60 100644 --- a/src/client/views/collections/collectionLinear/CollectionLinearView.scss +++ b/src/client/views/collections/collectionLinear/CollectionLinearView.scss @@ -47,7 +47,7 @@ background: global.$medium-blue; display: flex; border-radius: 10px; - height: 35; + height: 35px; transform: translate3d(6px, 0px, 0px); align-content: center; justify-content: center; @@ -95,11 +95,11 @@ pointer-events: all; cursor: pointer; background-color: global.$medium-blue; - padding: 5; + padding: 5px; border-radius: 2px; height: 100%; - min-width: 25; - margin: 0; + min-width: 25px; + margin: 0px; color: global.$white; display: flex; font-weight: 100; diff --git a/src/client/views/collections/collectionLinear/CollectionLinearView.tsx b/src/client/views/collections/collectionLinear/CollectionLinearView.tsx index d0a1e6f0d..435f618d9 100644 --- a/src/client/views/collections/collectionLinear/CollectionLinearView.tsx +++ b/src/client/views/collections/collectionLinear/CollectionLinearView.tsx @@ -158,14 +158,13 @@ export class CollectionLinearView extends CollectionSubView() { let dref: Opt<HTMLDivElement>; const docXf = () => this.getTransform(dref); + const setRef = (r: HTMLDivElement | null) => (dref = r || undefined); // const scalable = pair.layout.onClick || pair.layout.onDragStart; return hidden ? null : ( <div className={preview ? 'preview' : `collectionLinearView-docBtn`} key={doc[Id]} - ref={r => { - dref = r || undefined; - }} + ref={setRef} style={{ pointerEvents: 'all', width: NumCast(doc._width), diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss index 9ed247d50..1dc46102f 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss +++ b/src/client/views/collections/collectionMulticolumn/CollectionMulticolumnView.scss @@ -1,8 +1,8 @@ .collectionMulticolumnView_drop { height: 100%; width: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; position: absolute; .collectionMulticolumnView_contents { @@ -18,8 +18,8 @@ align-items: center; position: relative; > .iconButton-container { - top: 0; - left: 0; + top: 0px; + left: 0px; position: absolute; } diff --git a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss index 91779065d..eb157d155 100644 --- a/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss +++ b/src/client/views/collections/collectionMulticolumn/CollectionMultirowView.scss @@ -1,8 +1,8 @@ .collectionMultirowView_drop { height: 100%; width: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; position: absolute; .collectionMultirowView_contents { diff --git a/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx b/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx index 10a6fa2e9..2ff99f134 100644 --- a/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx +++ b/src/client/views/collections/collectionMulticolumn/MulticolumnResizer.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import { action } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; diff --git a/src/client/views/collections/collectionMulticolumn/MultirowHeightLabel.tsx b/src/client/views/collections/collectionMulticolumn/MultirowHeightLabel.tsx index 66215f109..4f57e1656 100644 --- a/src/client/views/collections/collectionMulticolumn/MultirowHeightLabel.tsx +++ b/src/client/views/collections/collectionMulticolumn/MultirowHeightLabel.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import { computed } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; diff --git a/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx b/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx index 918365700..1954b4743 100644 --- a/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx +++ b/src/client/views/collections/collectionMulticolumn/MultirowResizer.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import { action } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; @@ -66,7 +65,7 @@ export default class ResizeBar extends React.Component<ResizerProps> { style={{ pointerEvents: this.props.isContentActive?.() ? 'all' : 'none', height: this.props.height, - backgroundColor: !this.props.isContentActive?.() ? '' : this.props.styleProvider?.(undefined, undefined, StyleProp.WidgetColor) as string, + backgroundColor: !this.props.isContentActive?.() ? '' : (this.props.styleProvider?.(undefined, undefined, StyleProp.WidgetColor) as string), }}> <div className="multiRowResizer-hdl" onPointerDown={e => this.registerResizing(e)} /> </div> diff --git a/src/client/views/collections/collectionSchema/CollectionSchemaView.scss b/src/client/views/collections/collectionSchema/CollectionSchemaView.scss index 53c0823ea..e975ae6f6 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaView.scss +++ b/src/client/views/collections/collectionSchema/CollectionSchemaView.scss @@ -18,7 +18,7 @@ .schema-add { position: relative; - height: 35; + height: 35px; display: flex; align-items: center; top: -10px; @@ -147,7 +147,7 @@ flex-direction: row; justify-content: space-between; align-items: center; - padding: 0; + padding: 0px; z-index: 1; border: 1px solid global.$medium-gray; @@ -231,7 +231,7 @@ overflow-x: hidden; overflow-y: auto; display: inline-flex; - padding: 0; + padding: 0px; align-items: center; input[type='text'] { border: unset; @@ -272,8 +272,8 @@ .row-menu-infos { position: absolute; - top: 3; - left: 3; + top: 3px; + left: 3px; z-index: 1; display: flex; justify-content: flex-end; diff --git a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx index 6442385c0..2576bdf9b 100644 --- a/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx +++ b/src/client/views/collections/collectionSchema/CollectionSchemaView.tsx @@ -1043,17 +1043,16 @@ export class CollectionSchemaView extends CollectionSubView() { if (!this._oldKeysWheel?.scrollTop && e.deltaY <= 0) e.preventDefault(); e.stopPropagation(); }; + setRef = (r: HTMLDivElement | null) => { + this._oldKeysWheel?.removeEventListener('wheel', this.onKeysPassiveWheel); + this._oldKeysWheel = r; + r?.addEventListener('wheel', this.onKeysPassiveWheel, { passive: false }); + }; _oldKeysWheel: HTMLDivElement | null = null; @computed get keysDropdown() { return ( <div className="schema-key-search"> - <div - className="schema-key-list" - ref={r => { - this._oldKeysWheel?.removeEventListener('wheel', this.onKeysPassiveWheel); - this._oldKeysWheel = r; - r?.addEventListener('wheel', this.onKeysPassiveWheel, { passive: false }); - }}> + <div className="schema-key-list" ref={this.setRef}> {this._menuKeys.map(key => ( <div key={key} @@ -1294,6 +1293,9 @@ export class CollectionSchemaView extends CollectionSubView() { screenToLocal = () => this.ScreenToLocalBoxXf().translate(-this.tableWidth, 0); previewWidthFunc = () => this.previewWidth; displayedDocsFunc = () => this.docsWithDrag.docs; + setColHdrRef = (r: SchemaColumnHeader | null) => r && this._headerRefs.push(r); + setPreviewRef = (r: HTMLDivElement | null) => (this._previewRef = r); + render() { return ( <div @@ -1331,7 +1333,7 @@ export class CollectionSchemaView extends CollectionSubView() { {this.columnKeys.map((key, index) => ( <SchemaColumnHeader //cleanupField={this.cleanupComputedField} - ref={r => r && this._headerRefs.push(r)} + ref={this.setColHdrRef} keysDropdown={this.keysDropdown} schemaView={this} columnWidth={() => CollectionSchemaView._minColWidth} //TODO: update @@ -1379,11 +1381,7 @@ export class CollectionSchemaView extends CollectionSubView() { </div> {this.previewWidth > 0 && <div className="schema-preview-divider" style={{ width: CollectionSchemaView._previewDividerWidth }} onPointerDown={this.onDividerDown} />} {this.previewWidth > 0 && ( - <div - style={{ width: `${this.previewWidth}px` }} - ref={ref => { - this._previewRef = ref; - }}> + <div style={{ width: `${this.previewWidth}px` }} ref={this.setPreviewRef}> {Array.from(this._selectedDocs).lastElement() && ( <DocumentView Document={Array.from(this._selectedDocs).lastElement()} diff --git a/src/client/views/collections/collectionSchema/SchemaCellField.tsx b/src/client/views/collections/collectionSchema/SchemaCellField.tsx index 9ad94cb31..412daa105 100644 --- a/src/client/views/collections/collectionSchema/SchemaCellField.tsx +++ b/src/client/views/collections/collectionSchema/SchemaCellField.tsx @@ -341,12 +341,14 @@ export class SchemaCellField extends ObservableReactComponent<SchemaCellFieldPro return <span className="editableView-static">{this._props.fieldContents ? <FieldView {...this._props.fieldContents} /> : ''}</span>; }; + setRef = (r: HTMLDivElement | null) => (this._inputref = r); + renderEditor = () => { return ( <div contentEditable className="schemaField-editing" - ref={r => (this._inputref = r)} + ref={this.setRef} style={{ minHeight: `min(100%, ${(this._props.GetValue()?.split('\n').length || 1) * 15})`, minWidth: 20 }} onBlur={() => (this._props.refSelectModeInfo.enabled ? setTimeout(() => this.setIsFocused(true), 1000) : this.finalizeEdit(false, true, false))} onInput={this.onChange} diff --git a/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx b/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx index 134f2ed31..64bfab856 100644 --- a/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx +++ b/src/client/views/collections/collectionSchema/SchemaColumnHeader.tsx @@ -118,6 +118,10 @@ export class SchemaColumnHeader extends ObservableReactComponent<SchemaColumnHea return { color, fieldProps, cursor }; }; + setRef = (r: EditableView | null) => { + this._inputRef = r; + this._props.autoFocus && r?.setIsFocused(true); + }; @computed get editableView() { const { color, fieldProps } = this.renderProps(this._props); @@ -133,10 +137,7 @@ export class SchemaColumnHeader extends ObservableReactComponent<SchemaColumnHea width: '100%', }}> <EditableView - ref={r => { - this._inputRef = r; - this._props.autoFocus && r?.setIsFocused(true); - }} + ref={this.setRef} oneLine={true} allowCRs={false} contents={''} diff --git a/src/client/views/collections/collectionSchema/SchemaTableCell.tsx b/src/client/views/collections/collectionSchema/SchemaTableCell.tsx index 8b34b4139..02e0d8100 100644 --- a/src/client/views/collections/collectionSchema/SchemaTableCell.tsx +++ b/src/client/views/collections/collectionSchema/SchemaTableCell.tsx @@ -185,6 +185,7 @@ export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellPro return eqSymbol + modField; }; + setRef = (r: SchemaCellField | null) => selectedCell(this._props) && this._props.autoFocus && r?.setIsFocused(true); @computed get defaultCellContent() { const { color, textDecoration, fieldProps, pointerEvents } = SchemaTableCell.renderProps(this._props); @@ -204,7 +205,7 @@ export class SchemaTableCell extends ObservableReactComponent<SchemaTableCellPro Doc={this._props.Doc} highlightCells={(text: string) => this._props.highlightCells(this.adjustSelfReference(text))} getCells={(text: string) => this._props.eqHighlightFunc(this.adjustSelfReference(text))} - ref={r => selectedCell(this._props) && this._props.autoFocus && r?.setIsFocused(true)} + ref={this.setRef} oneLine={this._props.oneLine} contents={undefined} fieldContents={fieldProps} diff --git a/src/client/views/global/globalCssVariables.module.scss b/src/client/views/global/globalCssVariables.module.scss index 82f6caa52..7641d4929 100644 --- a/src/client/views/global/globalCssVariables.module.scss +++ b/src/client/views/global/globalCssVariables.module.scss @@ -75,7 +75,7 @@ $CAROUSEL3D_CENTER_SCALE: 1.3; $CAROUSEL3D_SIDE_SCALE: 0.6; $CAROUSEL3D_TOP: 15; -$DATA_VIZ_TABLE_ROW_HEIGHT: 30; +$DATA_VIZ_TABLE_ROW_HEIGHT: 30px; :export { contextMenuZindex: $contextMenu-zindex; diff --git a/src/client/views/global/globalScripts.ts b/src/client/views/global/globalScripts.ts index cb3adae10..e098d50d8 100644 --- a/src/client/views/global/globalScripts.ts +++ b/src/client/views/global/globalScripts.ts @@ -408,7 +408,7 @@ function setActiveTool(tool: InkTool | InkEraserTool | InkInkTool | Gestures, ke } runInAction(() => { const eraserTool = tool === InkTool.Eraser ? Doc.ActiveEraser : [InkEraserTool.Stroke, InkEraserTool.Radius, InkEraserTool.Segment].includes(tool as InkEraserTool) ? (tool as InkEraserTool) : undefined; - const inkTool = tool === InkTool.Ink ? Doc.ActiveInk : [InkInkTool.Pen, InkInkTool.Write, InkInkTool.Highlight].includes(tool as InkInkTool) ? (tool as InkInkTool) : undefined; + const inkTool = tool === InkTool.Ink ? Doc.ActiveInk : [InkInkTool.Pen, InkInkTool.Write, InkInkTool.Math, InkInkTool.Highlight].includes(tool as InkInkTool) ? (tool as InkInkTool) : undefined; if (GestureOverlay.Instance) { SnappingManager.SetKeepGestureMode(keepPrim); } diff --git a/src/client/views/linking/LinkMenuItem.scss b/src/client/views/linking/LinkMenuItem.scss index 3cd60c87f..cc8c168cf 100644 --- a/src/client/views/linking/LinkMenuItem.scss +++ b/src/client/views/linking/LinkMenuItem.scss @@ -114,7 +114,7 @@ .linkMenu-deleteButton { width: 20px; height: 20px; - margin: 0; + margin: 0px; margin-right: 4px; padding-right: 6px; border-radius: 50%; @@ -134,7 +134,7 @@ } &:last-child { - margin-right: 0; + margin-right: 0px; } &:hover { diff --git a/src/client/views/linking/LinkMenuItem.tsx b/src/client/views/linking/LinkMenuItem.tsx index c984a7053..6c1ad568d 100644 --- a/src/client/views/linking/LinkMenuItem.tsx +++ b/src/client/views/linking/LinkMenuItem.tsx @@ -105,11 +105,7 @@ export class LinkMenuItem extends ObservableReactComponent<LinkMenuItemProps> { LinkManager.Instance.currentLinkAnchor = LinkManager.Instance.currentLink ? this.sourceAnchor : undefined; if ((SnappingManager.PropertiesWidth ?? 0) < 100) { - setTimeout( - action(() => { - SnappingManager.SetPropertiesWidth(250); - }) - ); + setTimeout(action(() => SnappingManager.SetPropertiesWidth(250))); } } }) @@ -136,14 +132,14 @@ export class LinkMenuItem extends ObservableReactComponent<LinkMenuItemProps> { this._props.itemHandler?.(this._props.linkDoc); } else { const focusDoc = - Cast(this._props.linkDoc.link_anchor_1, Doc, null)?.annotationOn === this._props.sourceDoc - ? Cast(this._props.linkDoc.link_anchor_1, Doc, null) - : Cast(this._props.linkDoc.link_anchor_2, Doc, null)?.annotationOn === this._props.sourceDoc - ? Cast(this._props.linkDoc.link_anchor_12, Doc, null) - : undefined; + DocCast(this._props.linkDoc.link_anchor_1)?.annotationOn === this._props.sourceDoc + ? DocCast(this._props.linkDoc.link_anchor_1) + : DocCast(this._props.linkDoc.link_anchor_2)?.annotationOn === this._props.sourceDoc + ? DocCast(this._props.linkDoc.link_anchor_2) + : undefined; // prettier-ignore if (focusDoc) this._props.docView._props.focus(focusDoc, { instant: true }); - DocumentView.FollowLink(this._props.linkDoc, this._props.sourceDoc, false); + DocumentView.FollowLink(this._props.linkDoc, focusDoc ?? this._props.sourceDoc, false); } } ); diff --git a/src/client/views/linking/LinkPopup.scss b/src/client/views/linking/LinkPopup.scss index 4bfb4b0b9..f8d724767 100644 --- a/src/client/views/linking/LinkPopup.scss +++ b/src/client/views/linking/LinkPopup.scss @@ -1,7 +1,9 @@ .linkPopup-container { background: white; - box-shadow: 0 10px 20px rgba(0, 0, 0, 0.19), 0 6px 6px rgba(0, 0, 0, 0.23); - top: 0; + box-shadow: + 0 10px 20px rgba(0, 0, 0, 0.19), + 0 6px 6px rgba(0, 0, 0, 0.23); + top: 0px; height: 200px; width: 200px; // padding: 15px; diff --git a/src/client/views/linking/LinkPopup.tsx b/src/client/views/linking/LinkPopup.tsx index b654f9bd0..760850241 100644 --- a/src/client/views/linking/LinkPopup.tsx +++ b/src/client/views/linking/LinkPopup.tsx @@ -1,4 +1,3 @@ -/* eslint-disable react/require-default-props */ import { observer } from 'mobx-react'; import * as React from 'react'; import { returnEmptyFilter, returnFalse, returnTrue } from '../../../ClientUtils'; diff --git a/src/client/views/newlightbox/NewLightboxView.scss b/src/client/views/newlightbox/NewLightboxView.scss index 76c34bcf9..c76a7d60d 100644 --- a/src/client/views/newlightbox/NewLightboxView.scss +++ b/src/client/views/newlightbox/NewLightboxView.scss @@ -2,8 +2,8 @@ .newLightboxView-frame { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; background: #474545bb; diff --git a/src/client/views/newlightbox/components/Recommendation/Recommendation.scss b/src/client/views/newlightbox/components/Recommendation/Recommendation.scss index cf6b5ccb1..09d3ccc62 100644 --- a/src/client/views/newlightbox/components/Recommendation/Recommendation.scss +++ b/src/client/views/newlightbox/components/Recommendation/Recommendation.scss @@ -135,7 +135,7 @@ font-size: 10px; width: 100%; background: newstyles.$blue-l1; - border-radius: 0; + border-radius: 0px; padding: 10px; .concepts-container { diff --git a/src/client/views/nodes/AudioBox.scss b/src/client/views/nodes/AudioBox.scss index 933a383ea..c25c09af9 100644 --- a/src/client/views/nodes/AudioBox.scss +++ b/src/client/views/nodes/AudioBox.scss @@ -138,7 +138,7 @@ input[type='range']::-webkit-slider-thumb { box-shadow: 0; - border: 0; + border: 0px; height: 10px; width: 10px; border-radius: 10px; @@ -168,7 +168,7 @@ .audiobox-button { width: 15px; height: 15px; - margin: 0; + margin: 0px; svg { width: 10px; diff --git a/src/client/views/nodes/CollectionFreeFormDocumentView.scss b/src/client/views/nodes/CollectionFreeFormDocumentView.scss index 7f0a39550..300533df8 100644 --- a/src/client/views/nodes/CollectionFreeFormDocumentView.scss +++ b/src/client/views/nodes/CollectionFreeFormDocumentView.scss @@ -3,7 +3,7 @@ position: absolute; background-color: transparent; touch-action: manipulation; - top: 0; - left: 0; + top: 0px; + left: 0px; pointer-events: none; } diff --git a/src/client/views/nodes/ComparisonBox.scss b/src/client/views/nodes/ComparisonBox.scss index d2ba9796b..cbbd6bde3 100644 --- a/src/client/views/nodes/ComparisonBox.scss +++ b/src/client/views/nodes/ComparisonBox.scss @@ -18,7 +18,7 @@ } .input-box { position: absolute; - top: 50; + top: 50px; padding: 10px; width: 100%; height: 70%; @@ -33,7 +33,7 @@ padding-right: 5px; border-radius: 2px; height: 17%; - bottom: 0; + bottom: 0px; overflow: hidden; display: flex; width: 100%; @@ -101,7 +101,7 @@ position: absolute; display: inline-block; margin-top: 150px; - bottom: 0; + bottom: 0px; } .dropup-content { @@ -145,8 +145,8 @@ .clip-div { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; height: 100%; overflow: hidden; @@ -180,8 +180,8 @@ .afterBox-cont { position: absolute; - top: 0; - right: 0; + top: 0px; + right: 0px; height: 100%; width: 100%; overflow: hidden; @@ -331,8 +331,8 @@ justify-content: space-between; height: max-content; position: absolute; - bottom: 0; - right: 2; + bottom: 0px; + right: 2px; flex-direction: row-reverse; display: flex; cursor: pointer; diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.scss b/src/client/views/nodes/DataVizBox/DataVizBox.scss index 9825d926f..32a01355e 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.scss +++ b/src/client/views/nodes/DataVizBox/DataVizBox.scss @@ -37,25 +37,25 @@ margin-left: 10px; margin-bottom: -10px; } - + .displaySchemaLive { margin-bottom: 20px; } .dataviz-sidebar { position: absolute; - right: 0; - top: 0; + right: 0px; + top: 0px; height: 100%; } .button-container { pointer-events: unset; } - .dataVizBox-annotationLayer{ + .dataVizBox-annotationLayer { position: absolute; transform-origin: left top; - top: 0; + top: 0px; width: 100%; pointer-events: none; mix-blend-mode: multiply; diff --git a/src/client/views/nodes/DataVizBox/DataVizBox.tsx b/src/client/views/nodes/DataVizBox/DataVizBox.tsx index 9369ff98a..0b7033e57 100644 --- a/src/client/views/nodes/DataVizBox/DataVizBox.tsx +++ b/src/client/views/nodes/DataVizBox/DataVizBox.tsx @@ -420,6 +420,7 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } }; + setRef = (r: LineChart | Histogram | PieChart | null) => (this._vizRenderer = r ?? undefined); // toggles for user to decide which chart type to view the data in @computed get renderVizView() { const scale = this._props.NativeDimScaling?.() || 1; @@ -437,9 +438,9 @@ export class DataVizBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { if (!this.records.length) return 'no data/visualization'; switch (this.dataVizView) { case DataVizView.TABLE: return <TableBox {...sharedProps} Doc={this.Document} specHighlightedRow={this._specialHighlightedRow} docView={this.DocumentView} selectAxes={this.selectAxes} selectTitleCol={this.selectTitleCol}/>; - case DataVizView.LINECHART: return <LineChart {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} vizBox={this} />; - case DataVizView.HISTOGRAM: return <Histogram {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} />; - case DataVizView.PIECHART: return <PieChart {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={r => {this._vizRenderer = r ?? undefined;}} + case DataVizView.LINECHART: return <LineChart {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={this.setRef} vizBox={this} />; + case DataVizView.HISTOGRAM: return <Histogram {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={this.setRef} />; + case DataVizView.PIECHART: return <PieChart {...sharedProps} Doc={this.Document} dataDoc={this.dataDoc} fieldKey={this.fieldKey} ref={this.setRef} margin={{ top: 10, right: 15, bottom: 15, left: 15 }} />; default: } // prettier-ignore diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateManager.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateManager.ts new file mode 100644 index 000000000..526fcf9c4 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateManager.ts @@ -0,0 +1,91 @@ +import { action, makeAutoObservable } from 'mobx'; +import { Col } from '../DocCreatorMenu'; +import { FieldSettings, TemplateField } from '../TemplateFieldTypes/TemplateField'; +import { Template } from '../Template'; +import { NumListCast } from '../../../../../../fields/Doc'; +import { DataVizBox } from '../../DataVizBox'; +import { TemplateFieldType } from '../TemplateBackend'; +import { TemplateMenuAIUtils } from './TemplateMenuAIUtils'; + +export type Conditional = { + field: string; + operator: '=' | '>' | '<' | 'contains'; + condition: string; + target: string; + attribute: string; + value: string; +}; + +export class TemplateManager { + _templates: Template[] = []; + + _conditionalFieldLogic: Record<string, Conditional[]> = {}; + + constructor(templateSettings: FieldSettings[]) { + makeAutoObservable(this); + this._templates = templateSettings.map(settings => new Template(settings)); + } + + getValidTemplates = (cols: Col[]) => this._templates.filter(template => template.isValidTemplate(cols)); + + addTemplate = (newTemplate: Template) => this._templates.push(newTemplate); + + removeTemplate = (template: Template) => { + if (this._templates.includes(template)) { + this._templates.splice(this._templates.indexOf(template), 1); + } + template.cleanup(); + }; + + addFieldCondition = (fieldTitle: string, condition: Conditional) => { + if (this._conditionalFieldLogic[fieldTitle] === undefined) { + this._conditionalFieldLogic[fieldTitle] = [condition]; + } else { + this._conditionalFieldLogic[fieldTitle].push(condition); + } + }; + + removeFieldCondition = (fieldTitle: string, condition: Conditional) => (this._conditionalFieldLogic[fieldTitle] = this._conditionalFieldLogic[fieldTitle]?.filter(cond => cond !== condition)); + + addDataField = (title: string) => this._templates.forEach(template => template.addDataField(title)); + + removeDataField = (title: string) => this._templates.forEach(template => template.removeDataField(title)); + + createDocsFromTemplate = action((dv: DataVizBox, template: Template, cols: Col[], debug: boolean = false) => { + const csvFields = Array.from(Object.keys(dv.records[0])); + + const processContent = (content: { [title: string]: string }) => { + const templateCopy = template.clone(); + + csvFields + .filter(title => title) + .forEach(title => { + const field = templateCopy.getFieldByTitle(title); + field?.setContent(content[title], field.viewType); + }); + + const gptFunc = (type: TemplateFieldType) => (type === TemplateFieldType.VISUAL ? TemplateMenuAIUtils.renderGPTImageCall : TemplateMenuAIUtils.renderGPTTextCall); + + const generateGptContent = cols + .map(field => ({ field, templateField: field?.AIGenerated && templateCopy.getFieldByTitle(field.title) })) + .filter(({ templateField }) => templateField instanceof TemplateField) + .map(({ field, templateField }) => gptFunc(field.type)(templateCopy, field, (templateField as TemplateField).getID)); + + return Promise.all(generateGptContent).then(() => templateCopy.applyConditionalLogic(this._conditionalFieldLogic)); + }; + + const rowContents = debug + ? [{}, {}, {}, {}] + : NumListCast(dv.layoutDoc.dataViz_selectedRows).map(row => + csvFields.reduce( + (values, col) => { + values[col] = dv.records[row][col]; + return values; + }, + {} as { [title: string]: string } + ) + ); + + return Promise.all(rowContents.map(processContent)); + }); +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateMenuAIUtils.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateMenuAIUtils.ts new file mode 100644 index 000000000..08818dd6c --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Backend/TemplateMenuAIUtils.ts @@ -0,0 +1,122 @@ +import { ClientUtils } from '../../../../../../ClientUtils'; +import { Networking } from '../../../../../Network'; +import { gptImageCall, gptAPICall, GPTCallType } from '../../../../../apis/gpt/GPT'; +import { Col } from '../DocCreatorMenu'; +import { TemplateFieldSize, TemplateFieldType } from '../TemplateBackend'; +import { ViewType } from '../TemplateFieldTypes/TemplateField'; +import { Template } from '../Template'; +import { Upload } from '../../../../../../server/SharedMediaTypes'; + +export class TemplateMenuAIUtils { + public static generateGPTImage = async (prompt: string): Promise<string | undefined> => { + try { + const res = await gptImageCall(prompt); + + if (res) { + const result = (await Networking.PostToServer('/uploadRemoteImage', { sources: res })) as Upload.FileInformation[]; + const source = ClientUtils.prepend(result[0].accessPaths.agnostic.client); + return source; + } + } catch (e) { + console.log(e); + } + }; + + public static renderGPTImageCall = async (template: Template, col: Col, fieldNumber: number): Promise<boolean> => { + const generateAndLoadImage = async (id: number, prompt: string) => { + const field = template.getFieldByID(id); + const url = await this.generateGPTImage(prompt); + field?.setContent(url ?? '', ViewType.IMG); + field?.setTitle(col.title); + }; + + const fieldContent: string = template.compiledContent; + + try { + const sysPrompt = + `#${Math.random() * 100}: Your job is to create a prompt for an AI image generator to help it generate an image based on existing content in a template and a user prompt. Your prompt should focus heavily on visual elements to help the image generator; avoid unecessary info that might distract it. ONLY INCLUDE THE PROMPT, NO OTHER TEXT OR EXPLANATION. The existing content is as follows: ` + + fieldContent + + ' **** The user prompt is: ' + + col.desc; + + const prompt = await gptAPICall(sysPrompt, GPTCallType.COMPLETEPROMPT); + + await generateAndLoadImage(fieldNumber, prompt); + } catch (e) { + console.log(e); + } + return true; + }; + + public static renderGPTTextCall = async (template: Template, col: Col, fieldNum: number | undefined): Promise<boolean> => { + const wordLimit = (size: TemplateFieldSize) => { + switch (size) { + case TemplateFieldSize.TINY: + return 2; + case TemplateFieldSize.SMALL: + return 5; + case TemplateFieldSize.MEDIUM: + return 20; + case TemplateFieldSize.LARGE: + return 50; + case TemplateFieldSize.HUGE: + return 100; + default: + return 10; + } + }; + + const textAssignment = `--- title: ${col.title}, prompt: ${col.desc}, word limit: ${wordLimit(col.sizes[0])} words, assigned field: ${fieldNum} ---`; + + const fieldContent: string = template.compiledContent; + + try { + const prompt = fieldContent + textAssignment; + + const res = await gptAPICall(`${Math.random() * 100000}: ${prompt}`, GPTCallType.FILL); + + if (res) { + const assignments: { [title: string]: { number: string; content: string } } = JSON.parse(res); + Object.entries(assignments).forEach(([, /* title */ info]) => { + const field = template.getFieldByID(Number(info.number)); + + field?.setContent(info.content ?? '', ViewType.TEXT); + field?.setTitle(col.title); + }); + } + } catch (err) { + console.log(err); + } + + return true; + }; + + /** + * Populates a preset template framework with content from a datavizbox or any AI-generated content. + * @param template the preloaded template framework being filled in + * @param assignments a list of template field numbers (from top to bottom) and their assigned columns from the linked dataviz + * @returns a doc containing the fully rendered template + */ + public static applyGPTContentToTemplate = async (template: Template, assignments: { [field: string]: Col }): Promise<Template | undefined> => { + const GPTTextCalls = Object.entries(assignments).filter(([, col]) => col.type === TemplateFieldType.TEXT && col.AIGenerated); + const GPTIMGCalls = Object.entries(assignments).filter(([, col]) => col.type === TemplateFieldType.VISUAL && col.AIGenerated); + + if (GPTTextCalls.length) { + const promises = GPTTextCalls.map(([id, col]) => { + return TemplateMenuAIUtils.renderGPTTextCall(template, col, Number(id)); + }); + + await Promise.all(promises); + } + + if (GPTIMGCalls.length) { + const promises = GPTIMGCalls.map(async ([id, col]) => { + return TemplateMenuAIUtils.renderGPTImageCall(template, col, Number(id)); + }); + + await Promise.all(promises); + } + + return template; + }; +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.scss b/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.scss index 57f4a1e94..e2261b9e2 100644 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.scss +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.scss @@ -9,10 +9,10 @@ position: absolute; z-index: 1000; // box-shadow: 0px 3px 4px rgba(0, 0, 0, 30%); - // background: whitesmoke; + // background: whitesmoke; // color: black; border-radius: 3px; -} +} .docCreatorMenu-menu { display: flex; @@ -24,57 +24,39 @@ .docCreatorMenu-menu-button { width: 25px; height: 25px; - background: whitesmoke; + background: whitesmoke; background-color: rgb(50, 50, 50); border-radius: 5px; - border: 1px solid rgb(180, 180, 180); padding: 0px; font-size: 13px; //box-shadow: 3px 3px rgb(29, 29, 31); &:hover { box-shadow: none; - } - - &.right{ - margin-left: 0px; - font-size: 12px; + background-color: rgb(60, 60, 65); } - &.close-menu { - font-size: 12px; - width: 18px; - height: 18px; - font-size: 12px; - margin-left: auto; - margin-right: 5px; - margin-bottom: 3px; + &.no-margin { + margin: 0px; } - &.options { - margin-left: 0px; + &.border { + border: 1px solid rgb(180, 180, 180); } - &:hover { - background-color: rgb(60, 60, 65); + &.float-right { + float: right; + margin-left: auto; } - &.top-bar { - border-bottom: 25px solid #555; - border-left: 12px solid transparent; - border-right: 12px solid transparent; - // border-top-left-radius: 5px; - // border-top-right-radius: 5px; - border-radius: 0px; - height: 0; - width: 50px; + &.absolute-right { + position: absolute; + right: 0px; } - &.preview-toggle { - margin: 0px; - border-top-left-radius: 0px; - border-bottom-left-radius: 0px; - border-left: 0px; + &.right { + margin-left: 0px; + font-size: 12px; } } @@ -121,31 +103,31 @@ } } - &:hover::before{ + &:hover::before { border-bottom: 20px solid rgb(82, 82, 82); } &::before { - content: ""; + content: ''; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; border-bottom: 20px solid rgb(50, 50, 50); border-left: 12px solid transparent; border-right: 12px solid transparent; - height: 0; + height: 0px; width: 50px; } &::after { - content: ""; + content: ''; position: absolute; top: -1px; left: -1px; border-bottom: 22px solid rgb(180, 180, 180); border-left: 12px solid transparent; border-right: 12px solid transparent; - height: 0; + height: 0px; width: 52px; z-index: -1; } @@ -161,7 +143,7 @@ color: white; } -.docCreatorMenu-menu-hr{ +.docCreatorMenu-menu-hr { margin-top: 0px; margin-bottom: 0px; color: rgb(180, 180, 180); @@ -191,14 +173,14 @@ align-items: center; width: 40px; height: 40px; - background-color: rgb(99, 148, 238); + background-color: rgb(99, 148, 238); border: 2px solid rgb(80, 107, 152); border-radius: 5px; margin-bottom: 20px; font-size: 25px; - &:hover{ - background-color: rgb(59, 128, 255); + &:hover { + background-color: rgb(59, 128, 255); border: 2px solid rgb(53, 80, 127); } } @@ -206,7 +188,7 @@ .docCreatorMenu-create-docs-button { width: 40px; height: 40px; - background-color: rgb(176, 229, 149); + background-color: rgb(176, 229, 149); border: 2px solid rgb(126, 219, 80); border-radius: 5px; padding: 0px; @@ -217,7 +199,7 @@ &:hover { background-color: rgb(129, 223, 83); - border: 2px solid rgb(80, 185, 28); + border: 2px solid rgb(80, 185, 28); } } @@ -230,6 +212,14 @@ &.full { width: 100%; } + + &.no-margin-bottom { + margin-bottom: 0px; + } + + &.no-margin-top { + margin-top: 0px; + } } //------------------------------------------------------------------------------------------------------------------------------------------ @@ -240,17 +230,22 @@ position: absolute; background-color: none; - &.top, &.bottom { + &.top, + &.bottom { height: 10px; cursor: ns-resize; } - &.right, &.left { + &.right, + &.left { width: 10px; cursor: ew-resize; } - &.topRight, &.topLeft, &.bottomRight, &.bottomLeft { + &.topRight, + &.topLeft, + &.bottomRight, + &.bottomLeft { height: 15px; width: 15px; background-color: none; @@ -273,22 +268,10 @@ height: calc(100% - 30px); border: 1px solid rgb(180, 180, 180); border-radius: 5px; - -ms-overflow-style: none; + -ms-overflow-style: none; scrollbar-width: none; } -.docCreatorMenu-preview-container { - display: grid; - grid-template-columns: repeat(2, 140px); - grid-template-rows: 140px; - grid-auto-rows: 141px; - overflow-y: scroll; - margin: 0px; - margin-top: 0px; - width: 100%; - height: 100%; -} - .docCreatorMenu-expanded-template-preview { display: flex; flex-direction: column; @@ -297,8 +280,9 @@ position: relative; width: 100%; height: 100%; + flex-grow: 1; - .top-panel{ + .top-panel { width: 100%; height: 10px; } @@ -307,7 +291,7 @@ display: flex; flex-direction: column; justify-content: flex-start; - height: 100%; + height: fit-content; position: absolute; right: 0px; top: 0px; @@ -322,17 +306,15 @@ display: flex; justify-content: center; align-items: center; - width: 113px; - height: 113px; - margin-top: 10px; - margin-left: 10px; + height: 100%; + width: 100%; + aspect-ratio: 1; color: none; border: 1px solid rgb(163, 163, 163); border-radius: 5px; box-shadow: 5px 5px rgb(29, 29, 31); - flex: 0 0 auto; - &:hover{ + &:hover { background-color: rgb(72, 72, 73); } @@ -377,21 +359,18 @@ &:hover .option-button { display: block; } - } -.docCreatorMenu-preview-image{ +.docCreatorMenu-preview-image { background-color: transparent; - height: 100px; - width: 100px; + height: 100%; display: block; object-fit: contain; border-radius: 5px; +} - &.expanded { - height: 100%; - width: 100%; - } +.docCreatorMenu-variations-tab { + flex-grow: 0.5; } .docCreatorMenu-section { @@ -399,12 +378,12 @@ flex-direction: column; align-items: center; position: relative; + flex-grow: 1; + height: 100%; + width: 100%; margin: 0px; margin-top: 0px; margin-bottom: 0px; - width: 100%; - height: 200; - flex: 0 0 auto; } .docCreatorMenu-GPT-options-container { @@ -412,34 +391,35 @@ flex-direction: row; justify-content: center; align-items: center; - position: relative; - width: auto; + position: absolute; + left: 50%; + bottom: 0px; margin: 0px; + margin-bottom: 10px; margin-top: 5px; padding: 0px; } .docCreatorMenu-templates-preview-window { - display: flex; - flex-direction: row; - //justify-content: center; - align-items: center; - overflow-y: scroll; - position: relative; - color: black; - height: 125px; + display: grid; + justify-content: space-evenly; + row-gap: 2rem; + grid-template-columns: repeat(auto-fill, minmax(150px, 50%)); + margin: 5px; width: calc(100% - 10px); - -ms-overflow-style: none; - scrollbar-width: none; + height: 100%; + padding-bottom: 40px; - .loading-spinner { - justify-self: center; + &.scrolling { + overflow-y: scroll; + max-height: 300px; + padding-bottom: 0px; } } -.divvv{ - width: 200; - height: 200; +.div { + width: 200px; + height: 200px; border: solid 1px white; } @@ -447,20 +427,14 @@ position: relative; display: flex; flex-direction: row; + color: whitesmoke; width: 100%; } -.section-reveal-options { - margin-top: 0px; - margin-bottom: 0px; - margin-right: 0px; - margin-left: auto; - border: 0px; - background: none; - - &.float-right { - float: right; - } +.docCreatorMenu-templates-displays { + display: flex; + flex-direction: column; + height: 100%; } .docCreatorMenu-section-title { @@ -478,7 +452,7 @@ .docCreatorMenu-GPT-generate { height: 30px; width: 30px; - background-color: rgb(176, 229, 149); + background-color: rgb(176, 229, 149); border: 1px solid rgb(126, 219, 80); border-radius: 5px; padding: 0px; @@ -489,7 +463,7 @@ &:hover { background-color: rgb(129, 223, 83); - border: 2px solid rgb(80, 185, 28); + border: 2px solid rgb(80, 185, 28); } } @@ -507,7 +481,7 @@ // DocCreatorMenu options CSS //-------------------------------------------------------------------------------------------------------------------------------------------- -.docCreatorMenu-option-container{ +.docCreatorMenu-option-container { display: flex; width: 180px; height: 30px; @@ -517,16 +491,16 @@ margin-top: 10px; margin-bottom: 10px; - &.layout{ + &.layout { z-index: 5; } } -.docCreatorMenu-option-title{ +.docCreatorMenu-option-title { display: flex; width: 140px; height: 30px; - background: whitesmoke; + background: whitesmoke; background-color: rgb(34, 34, 37); border-radius: 5px; border: 1px solid rgb(180, 180, 180); @@ -543,7 +517,7 @@ border-radius: 0px; width: auto; text-transform: none; - + &.small { height: 20px; transform: translateY(-5px); @@ -614,7 +588,7 @@ } .docCreatorMenu-configuration-bar { - width: 200; + width: 200px; gap: 5px; display: flex; flex-direction: row; @@ -637,38 +611,38 @@ height: calc(100% - 30px); border: 1px solid rgb(180, 180, 180); border-radius: 5px; - -ms-overflow-style: none; + -ms-overflow-style: none; scrollbar-width: none; - .docCreatorMenu-option-container{ + .docCreatorMenu-option-container { width: 180px; height: 30px; .docCreatorMenu-dropdown-hoverable { width: 140px; height: 30px; - + &:hover .docCreatorMenu-dropdown-content { display: block; } - + &:hover .docCreatorMenu-option-title { border-bottom-left-radius: 0px; border-bottom-right-radius: 0px; } - + .docCreatorMenu-dropdown-content { display: none; min-width: 100px; height: 75px; overflow-y: scroll; - -ms-overflow-style: none; + -ms-overflow-style: none; scrollbar-width: none; border-bottom: 1px solid rgb(180, 180, 180); border-bottom-left-radius: 5px; border-bottom-right-radius: 5px; - - .docCreatorMenu-dropdown-option{ + + .docCreatorMenu-dropdown-option { display: flex; background-color: rgb(42, 42, 46); border-left: 1px solid rgb(180, 180, 180); @@ -679,17 +653,30 @@ justify-content: center; justify-items: center; padding-top: 3px; - + &:hover { background-color: rgb(68, 68, 74); cursor: pointer; } } - } + } } } } +.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; +} + .docCreatorMenu-layout-preview-window-wrapper { flex: 0 0 auto; display: flex; @@ -722,17 +709,17 @@ width: 100%; aspect-ratio: 1; //height: auto; - // max-width: 240; - // max-height: 240; + // max-width: 240px; + // max-height: 240px; border: 1px solid rgb(180, 180, 180); border-radius: 5px; background-color: rgb(34, 34, 37); - -ms-overflow-style: none; + -ms-overflow-style: none; scrollbar-width: none; &.small { - max-width: 100; - max-height: 100; + max-width: 100px; + max-height: 100px; } .docCreatorMenu-layout-preview-item { @@ -759,10 +746,10 @@ z-index: 999; } - .docCreatorMenu-zoom-button{ + .docCreatorMenu-zoom-button { width: 15px; height: 15px; - background: whitesmoke; + background: whitesmoke; background-color: rgb(34, 34, 37); border-radius: 3px; border: 1px solid rgb(180, 180, 180); @@ -793,10 +780,11 @@ height: calc(100% - 30px); border: 1px solid rgb(180, 180, 180); border-radius: 5px; - -ms-overflow-style: none; + -ms-overflow-style: none; scrollbar-width: none; .panels-container { + display: flex; height: 100%; width: 100%; flex-direction: column; @@ -810,114 +798,12 @@ background-color: rgb(50, 50, 50); } -// .field-panel { -// position: relative; -// display: flex; -// // align-items: flex-start; -// flex-direction: column; -// gap: 5px; -// padding: 5px; -// height: 100px; -// //width: 100%; -// border: 1px solid rgb(180, 180, 180); -// margin: 5px; -// margin-top: 0px; -// border-radius: 3px; -// flex: 0 0 auto; - -// .properties-wrapper { -// display: flex; -// flex-direction: row; -// align-items: flex-start; -// gap: 5px; - -// .field-property-container { -// background-color: rgb(40, 40, 40); -// border: 1px solid rgb(100, 100, 100); -// border-radius: 3px; -// width: 30%; -// height: 25px; -// padding-left: 3px; -// align-items: center; -// color: whitesmoke; -// } - -// .field-type-selection-container { -// display: flex; -// flex-direction: row; -// align-items: center; -// background-color: rgb(40, 40, 40); -// border: 1px solid rgb(100, 100, 100); -// border-radius: 3px; -// width: 31%; -// height: 25px; -// padding-left: 3px; -// color: whitesmoke; - -// .placeholder { -// color: gray; -// } - -// &:hover .placeholder { -// display: none; -// } - -// .bubbles { -// display: none; -// } - -// .text { -// margin-top: 5px; -// margin-bottom: 5px; -// } - -// &:hover .bubbles { -// display: flex; -// flex-direction: row; -// align-items: flex-start; -// } - -// &:hover .type-display { -// display: none; -// } - -// .bubble { -// margin: 5px; -// } - -// &:hover .bubble { -// margin-top: 7px; -// } -// } -// } - -// .field-description-container { -// background-color: rgb(40, 40, 40); -// border: 1px solid rgb(100, 100, 100); -// border-radius: 3px; -// width: 100%; -// height: 100%; -// resize: none; - -// ::-webkit-scrollbar-track { -// background: none; -// } -// } - -// .top-right { -// position: absolute; -// top: 0px; -// right: 0px; -// } -// } -// } - .field-panel { display: flex; flex-direction: column; align-items: center; justify-content: flex-start; - height: 285px; + height: fit-content; width: calc(100% - 10px); border: 1px solid rgb(180, 180, 180); margin: 5px; @@ -938,15 +824,22 @@ border-top-right-radius: 5px; border-top-left-radius: 5px; width: 100%; - height: 20px; + height: fit-content; background-color: rgb(50, 50, 50); color: rgb(168, 167, 167); + font-size: medium; .field-title { color: whitesmoke; + font-size: large; } - } - + + &:hover { + background-color: rgb(72, 72, 72); + cursor: pointer; + } + } + .opts-bar { display: flex; flex-direction: row; @@ -989,11 +882,11 @@ flex-direction: row; align-items: flex-start; } - + &:hover .type-display { display: none; } - + .bubble { margin: 3px; } @@ -1028,7 +921,7 @@ flex-direction: row; align-items: center; } - + .bubble { margin: 3px; margin-right: 4px; @@ -1038,23 +931,255 @@ .desc-box { width: 88%; - height: 50px; + height: fit-content; border: 1px solid rgb(180, 180, 180); border-radius: 5px; background-color: rgb(50, 50, 50); box-shadow: 5px 5px rgb(29, 29, 31); .content { - height: calc(100% - 20px); + height: fit-content; width: 100%; background-color: rgb(50, 50, 50); border-bottom-right-radius: 5px; border-bottom-left-radius: 5px; resize: none; + } + } + } + .conditionals-section { + display: flex; + flex-direction: column; + justify-content: flex-start; + align-items: center; + width: 100%; + + .conditionals-title { + display: flex; + flex-direction: row; + width: 100%; + justify-content: center; + align-items: center; + margin: 5px; + margin-bottom: 20px; + font-size: large; + } + } + + .form-row { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: flex-start; + color: whitesmoke; + width: 100%; + height: fit-content; + margin-bottom: 15px; + flex-wrap: wrap; + gap: 5px; + + .form-row-plain-text { + display: flex; + justify-content: center; + align-items: center; + height: 100%; + width: fit-content; + padding-top: 2px; + padding-bottom: 2px; + } + + .operator-options-dropdown { + display: flex; + flex-direction: column; + height: fit-content; + + .operator-dropdown-option { + display: none; + } + + .operator-dropdown-current { + border-radius: 5px; + background-color: rgb(50, 50, 50); + border: 1px solid rgb(180, 180, 180); + text-align: center; + padding: 2.25px; + padding-left: 4px; + padding-right: 4px; + } + + &:hover .operator-dropdown-current { + border-bottom-right-radius: 0px; + border-bottom-left-radius: 0px; + } + + &:hover .operator-dropdown-option { + display: flex; + height: fit-content; + align-items: center; + border: 1px solid rgb(180, 180, 180); + background-color: rgb(50, 50, 50); + padding: 2.25px; + padding-left: 8px; + padding-right: 8px; + text-align: center; + + &:hover { + background-color: rgb(70, 70, 70); + cursor: pointer; + } } } + .form-row-textarea { + height: 24px; + width: 110px; + border-radius: 5px; + background-color: rgb(50, 50, 50); + border: 1px solid rgb(180, 180, 180); + resize: none; + overflow-y: scroll; + white-space: nowrap; + } + } + + .form { + display: flex; + flex-direction: row; + justify-content: space-between; + align-items: flex-start; + width: 80%; + + .form-action-button { + display: flex; + justify-content: center; + align-items: center; + margin: 3px; + cursor: pointer; + } } +} + +//------------------------------------------------------------------------------------------------------------------------------------------ +// EditingWindow CSS +//-------------------------------------------------------------------------------------------------------------------------------------------- +.docCreatorMenu-editing-firefly-section { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + width: 100%; + padding: 5px; +} + +.docCreatorMenu-firefly-options { + display: flex; + flex-direction: column; + align-items: center; + justify-content: flex-end; + height: fit-content; + width: 100%; +} + +.docCreatorMenu-variation-prompt-row { + display: flex; + flex-direction: row; + justify-content: flex-start; + align-items: center; + gap: 15px; + height: fit-content; + width: 100%; +} + +.docCreatorMenu-variation-prompt-input-textbox { + height: 40px; + width: 80%; + color: white; + margin-top: 1%; + margin-bottom: 1%; + margin-left: 5%; + background-color: rgb(50, 50, 50); + border-radius: 5px; + overflow: hidden; + resize: none; +} + +.options‑menu { + display: flex; + align-items: center; + justify-content: center; + gap: 2rem; + padding: 0.5rem 1rem; + background: rgb(50, 50, 50); + color: whitesmoke; + font-family: system-ui, sans-serif; + font-size: 0.9rem; + flex-wrap: wrap; +} + +.menu‑item { + display: flex; + align-items: center; + gap: 0.5rem; + white-space: nowrap; +} + +.menu‑item input[type='range'] { + width: 7rem; + accent-color: whitesmoke; +} + +.value { + min-width: 2ch; + text-align: right; +} + +.switch { + gap: 0.75rem; + margin-bottom: 0px; +} + +.switch .slider { + position: relative; + width: 2.2rem; + height: 1.1rem; + background: whitesmoke; + border-radius: 1rem; + cursor: pointer; + transition: background 0.2s; +} + +.switch .slider::before { + content: ''; + position: absolute; + top: 0.1rem; + left: 0.1rem; + width: 0.9rem; + height: 0.9rem; + background: rgb(50, 50, 50); + border-radius: 50%; + transition: transform 0.2s; +} + +.switch input { + display: none; +} + +.switch input:checked + .slider { + background: #78c2f1; +} + +.switch input:checked + .slider::before { + transform: translateX(1.1rem); +} + +.firefly-option-label { + padding: 0.2em 0.6em 0.3em; + font-size: 100%; + color: whitesmoke; + text-align: center; + margin-bottom: 0px; + font-weight: 500; } diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.tsx index 64416c26d..8f6ecab57 100644 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.tsx +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/DocCreatorMenu.tsx @@ -1,36 +1,32 @@ +import { IconProp } from '@fortawesome/fontawesome-svg-core'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import { Colors } from '@dash/components'; import { action, computed, makeObservable, observable, runInAction } from 'mobx'; import { observer } from 'mobx-react'; -import { IDisposer } from 'mobx-utils'; import * as React from 'react'; -import ReactLoading from 'react-loading'; -import { ClientUtils, returnEmptyFilter, returnFalse, setupMoveUpEvents } from '../../../../../ClientUtils'; +import { returnFalse, setupMoveUpEvents } from '../../../../../ClientUtils'; import { emptyFunction } from '../../../../../Utils'; -import { Doc, NumListCast, StrListCast, returnEmptyDoclist } from '../../../../../fields/Doc'; +import { Doc, StrListCast } from '../../../../../fields/Doc'; import { Id } from '../../../../../fields/FieldSymbols'; -import { ImageCast, StrCast } from '../../../../../fields/Types'; -import { ImageField } from '../../../../../fields/URLField'; -import { Networking } from '../../../../Network'; -import { GPTCallType, gptAPICall, gptImageCall } from '../../../../apis/gpt/GPT'; -import { Docs, DocumentOptions } from '../../../../documents/Documents'; +import { GPTCallType, gptAPICall } from '../../../../apis/gpt/GPT'; import { DragManager } from '../../../../util/DragManager'; import { SnappingManager } from '../../../../util/SnappingManager'; import { UndoManager, undoable } from '../../../../util/UndoManager'; import { ObservableReactComponent } from '../../../ObservableReactComponent'; import { CollectionFreeFormView } from '../../../collections/collectionFreeForm/CollectionFreeFormView'; -import { DocumentView, DocumentViewInternal } from '../../DocumentView'; +import { DocumentView } from '../../DocumentView'; import { OpenWhere } from '../../OpenWhere'; import { DataVizBox } from '../DataVizBox'; import './DocCreatorMenu.scss'; -import { DefaultStyleProvider } from '../../../StyleProvider'; -import { Transform } from '../../../../util/Transform'; -import { TemplateFieldSize, TemplateFieldType, TemplateLayouts } from './TemplateBackend'; -import { TemplateManager } from './TemplateManager'; +import { ViewType } from './TemplateFieldTypes/TemplateField'; import { Template } from './Template'; -import { Field, FieldContentType } from './FieldTypes/Field'; -import { IconProp } from '@fortawesome/fontawesome-svg-core'; -import { Upload } from '../../../../../server/SharedMediaTypes'; +import { TemplateFieldSize, TemplateFieldType, TemplateLayouts } from './TemplateBackend'; +import { TemplateManager } from './Backend/TemplateManager'; +import { TemplateMenuAIUtils } from './Backend/TemplateMenuAIUtils'; +import { TemplatePreviewGrid } from './Menu/TemplatePreviewGrid'; +import { FireflyStructureOptions, TemplateEditingWindow } from './Menu/TemplateEditingWindow'; +import { DocCreatorMenuButton } from './Menu/DocCreatorMenuButton'; +import { TemplatesRenderPreviewWindow } from './Menu/TemplateRenderPreviewWindow'; +import { TemplateMenuFieldOptions } from './Menu/TemplateMenuFieldOptions'; export enum LayoutType { FREEFORM = 'Freeform', @@ -61,6 +57,7 @@ export type Col = { title: string; type: TemplateFieldType; defaultContent?: string; + AIGenerated?: boolean; }; interface DocCreateMenuProps { @@ -72,33 +69,20 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> // eslint-disable-next-line no-use-before-define static Instance: DocCreatorMenu; - private _disposers: { [name: string]: IDisposer } = {}; - + DEBUG_MODE: boolean = false; private _ref: HTMLDivElement | null = null; - private templateManager: TemplateManager; - @observable _fullyRenderedDocs: Doc[] = []; - @observable _renderedDocCollectionPreview: Doc | undefined = undefined; - @observable _renderedDocCollection: Doc | undefined = undefined; - @observable _docsRendering: boolean = false; + @observable _docsRendering: boolean = false; // dictates loading symbol - @observable _userTemplates: { template: Template; doc: Doc }[] = []; //!!! used to keep track of all templates, should be refactored to work with actual templates and not docs + @observable _userTemplates: Template[] = []; @observable _selectedTemplate: Template | undefined = undefined; @observable _currEditingTemplate: Template | undefined = undefined; + @observable _editedTemplateTrail: Template[] = []; @observable _userCreatedFields: Col[] = []; - @observable _selectedCols: { title: string; type: string; desc: string }[] | undefined = []; - - @observable _layout: { type: LayoutType; yMargin: number; xMargin: number; columns?: number; repeat: number } = { type: LayoutType.FREEFORM, yMargin: 10, xMargin: 10, columns: 3, repeat: 0 }; - @observable _layoutPreviewScale: number = 1; - @observable _savedLayouts: DataVizTemplateLayout[] = []; - @observable _expandedPreview: Doc | undefined = undefined; @observable _suggestedTemplates: Template[] = []; - @observable _suggestedTemplatePreviews: { doc: Doc; template: Template }[] = []; - @observable _GPTOpt: boolean = false; - @observable _callCount: number = 0; @observable _GPTLoading: boolean = false; @observable _pageX: number = 0; @@ -110,9 +94,8 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> @observable _startPos?: { x: number; y: number }; @observable _shouldDisplay: boolean = false; - @observable _menuContent: 'templates' | 'options' | 'saved' | 'dashboard' = 'templates'; + @observable _menuContent: 'templates' | 'renderPreview' | 'saved' | 'dashboard' | 'templateEditing' = 'templates'; @observable _dragging: boolean = false; - @observable _draggingIndicator: boolean = false; @observable _dataViz?: DataVizBox; @observable _interactionLock: boolean | undefined; @observable _snapPt: { x: number; y: number } = { x: 0, y: 0 }; @@ -122,7 +105,8 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> @observable _resizeUndo: UndoManager.Batch | undefined = undefined; @observable _initDimensions: { width: number; height: number; x?: number; y?: number } = { width: 300, height: 400, x: undefined, y: undefined }; @observable _menuDimensions: { width: number; height: number } = { width: 400, height: 400 }; - @observable _editing: boolean = false; + + @observable _variations: Template[] = []; constructor(props: DocCreateMenuProps) { super(props); @@ -134,56 +118,13 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> @action setDataViz = (dataViz: DataVizBox) => { this._dataViz = dataViz; this._selectedTemplate = undefined; - this._renderedDocCollection = undefined; - this._renderedDocCollectionPreview = undefined; - this._fullyRenderedDocs = []; - this._suggestedTemplatePreviews = []; this._suggestedTemplates = []; this._userCreatedFields = []; }; - @action addUserTemplate = (template: Template) => { - this._userTemplates.push({ template: template.cloneBase(), doc: template.getRenderedDoc() }); - }; - @action removeUserTemplate = (template: Template) => { - this._userTemplates = this._userTemplates.filter(info => info.template !== template); - }; - @action updateTemplatePreview = (template: Template) => { - template.renderUpdates(); - const preview = { template: template, doc: template.getRenderedDoc() }; - this._suggestedTemplatePreviews = this._suggestedTemplatePreviews.map(t => { return t.template === preview.template ? preview : t }); //prettier-ignore - this._userTemplates = this._userTemplates.map(t => { return t.template === preview.template ? preview : t }); //prettier-ignore - }; @action setSuggestedTemplates = (templates: Template[]) => { - this._suggestedTemplates = templates; - this._suggestedTemplatePreviews = templates.map(template => {return {template: template, doc: template.getRenderedDoc()}}); //prettier-ignore + this._suggestedTemplates = templates; //prettier-ignore }; - @computed get docsToRender() { - return this._selectedTemplate ? NumListCast(this._dataViz?.layoutDoc.dataViz_selectedRows) : []; - } - - @computed get rowsCount() { - switch (this._layout.type) { - case LayoutType.FREEFORM: - return Math.ceil(this.docsToRender.length / (this._layout.columns ?? 1)) ?? 0; - case LayoutType.CAROUSEL3D: - return 1.8; - default: - return 1; - } - } - - @computed get columnsCount() { - switch (this._layout.type) { - case LayoutType.FREEFORM: - return this._layout.columns ?? 0; - case LayoutType.CAROUSEL3D: - return 3; - default: - return 1; - } - } - @computed get selectedFields() { return StrListCast(this._dataViz?.layoutDoc._dataViz_axes); } @@ -210,10 +151,6 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> .concat(this._userCreatedFields); } - @computed get canMakeDocs() { - return this._selectedTemplate !== undefined && this._layout !== undefined; - } - get bounds(): { t: number; b: number; l: number; r: number } { const rect = this._ref?.getBoundingClientRect(); const bounds = { t: rect?.top ?? 0, b: rect?.bottom ?? 0, l: rect?.left ?? 0, r: rect?.right ?? 0 }; @@ -221,17 +158,11 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> } setUpButtonClick = (e: React.PointerEvent, func: () => void) => { - setupMoveUpEvents( - this, - e, - returnFalse, - emptyFunction, - undoable(clickEv => { - clickEv.stopPropagation(); - clickEv.preventDefault(); - func(); - }, 'create docs') - ); + setupMoveUpEvents(this, e, returnFalse, emptyFunction, clickEv => { + clickEv.stopPropagation(); + clickEv.preventDefault(); + undoable(func, 'create docs')(); + }); }; @action @@ -269,7 +200,6 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> } componentWillUnmount() { - Object.values(this._disposers).forEach(disposer => disposer?.()); document.removeEventListener('pointerdown', this.onPointerDown, true); document.removeEventListener('pointerup', this.onPointerUp); } @@ -319,10 +249,10 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> const { scale, refPt, transl } = this.getResizeVals(thisPt, dragHdl); !this._interactionLock && runInAction(async () => { // resize selected docs if we're not in the middle of a resize (ie, throttle input events to frame rate) - this._interactionLock = true; - const scaleAspect = {x: scale.x, y: scale.y}; - this.resizeView(refPt, scaleAspect, transl); // prettier-ignore - await new Promise<boolean | undefined>(res => { setTimeout(() => { res(this._interactionLock = undefined)})}); + this._interactionLock = true; + const scaleAspect = {x: scale.x, y: scale.y}; + this.resizeView(refPt, scaleAspect, transl); // prettier-ignore + await new Promise<boolean | undefined>(res => { setTimeout(() => { res(this._interactionLock = undefined)})}); }); // prettier-ignore return true; }; @@ -365,66 +295,29 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> this._pageY = y + translation.y; }; - async getIcon(doc: Doc) { - const docView = DocumentView.getDocumentView(doc); - if (docView) { - docView.ComponentView?.updateIcon?.(); - return new Promise<ImageField | undefined>(res => setTimeout(() => res(ImageCast(docView.Document.icon)), 500)); - } - return undefined; + async createDocsForPreview() { + return this._dataViz && this._selectedTemplate ? ((await this.templateManager.createDocsFromTemplate(this._dataViz, this._selectedTemplate, this.fieldsInfos, this.DEBUG_MODE)).filter(doc => doc).map(doc => doc!) ?? []) : []; } - @action updateSelectedTemplate = async (template: Template) => { - if (this._selectedTemplate === template) { - this._selectedTemplate = undefined; - return; - } else { - this._selectedTemplate = template; - template.renderUpdates(); - this._fullyRenderedDocs = (await this.createDocsFromTemplate(template)) ?? []; - this.updateRenderedDocCollection(); - } - }; - - @action updateSelectedSavedLayout = (layout: DataVizTemplateLayout) => { - this._layout.xMargin = layout.layout.xMargin; - this._layout.yMargin = layout.layout.yMargin; - this._layout.type = layout.layout.type; - this._layout.columns = layout.columns; - }; - - isSelectedLayout = (layout: DataVizTemplateLayout) => { - return this._layout.xMargin === layout.layout.xMargin && this._layout.yMargin === layout.layout.yMargin && this._layout.type === layout.layout.type && this._layout.columns === layout.columns; + @action updateSelectedTemplate = (template: Template) => { + this._selectedTemplate = this._selectedTemplate === template ? undefined : template; // toggle selection }; - editTemplate = (doc: Doc) => { - DocumentViewInternal.addDocTabFunc(doc, OpenWhere.addRight); - DocumentView.DeselectAll(); - Doc.UnBrushDoc(doc); - }; + // testTemplate = async () => { + // this._suggestedTemplates = this.templateManager.templates; //prettier-ignore + // }; @action addField = () => { - const newFields: Col[] = this._userCreatedFields.concat([{ title: '', type: TemplateFieldType.UNSET, desc: '', sizes: [] }]); - this._userCreatedFields = newFields; + this._userCreatedFields = this._userCreatedFields.concat([{ title: '', type: TemplateFieldType.UNSET, desc: '', sizes: [], AIGenerated: true }]); }; @action removeField = (field: { title: string; type: string; desc: string }) => { if (this._dataViz?.axes.includes(field.title)) { this._dataViz.selectAxes(this._dataViz.axes.filter(col => col !== field.title)); } else { - const toRemove = this._userCreatedFields.filter(f => f === field); - if (!toRemove) return; - - if (toRemove.length > 1) { - while (toRemove.length > 1) { - toRemove.pop(); - } - } - - if (this._userCreatedFields.length === 1) { - this._userCreatedFields = []; - } else { - this._userCreatedFields.splice(this._userCreatedFields.indexOf(toRemove[0]), 1); + const toRemove = this._userCreatedFields.findIndex(f => f === field); + if (toRemove !== -1) { + this._userCreatedFields.splice(toRemove, 1); } } }; @@ -439,11 +332,18 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> }; @action setColType = (column: Col, type: TemplateFieldType) => { + if (type === TemplateFieldType.DATA) { + this.templateManager.addDataField(column.title); + } else if (column.type === TemplateFieldType.DATA) { + this.templateManager.removeDataField(column.title); + } + if (this.selectedFields.includes(column.title)) { this._dataViz?.setColumnType(column.title, type); } else { column.type = type; } + this.forceUpdate(); }; @@ -469,81 +369,22 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> this.forceUpdate(); }; - generateGPTImage = async (prompt: string): Promise<string | undefined> => { - try { - const res = await gptImageCall(prompt); - - if (res) { - const result = (await Networking.PostToServer('/uploadRemoteImage', { sources: res })) as Upload.FileInformation[]; - const source = ClientUtils.prepend(result[0].accessPaths.agnostic.client); - return source; - } - } catch (e) { - console.log(e); - } - }; - - /** - * Populates a preset template framework with content from a datavizbox or any AI-generated content. - * @param template the preloaded template framework being filled in - * @param assignments a list of template field numbers (from top to bottom) and their assigned columns from the linked dataviz - * @returns a doc containing the fully rendered template - */ - applyGPTContentToTemplate = async (template: Template, assignments: { [field: string]: Col }): Promise<Template | undefined> => { - const GPTTextCalls = Object.entries(assignments).filter(([, col]) => col.type === TemplateFieldType.TEXT && this._userCreatedFields.includes(col)); - const GPTIMGCalls = Object.entries(assignments).filter(([, col]) => col.type === TemplateFieldType.VISUAL && this._userCreatedFields.includes(col)); - - if (GPTTextCalls.length) { - const promises = GPTTextCalls.map(([str, col]) => { - return this.renderGPTTextCall(template, col, Number(str)); - }); - - await Promise.all(promises); - } - - if (GPTIMGCalls.length) { - const promises = GPTIMGCalls.map(async ([fieldNum, col]) => { - return this.renderGPTImageCall(template, col, Number(fieldNum)); - }); - - await Promise.all(promises); - } - - return template; - }; - - compileFieldDescriptions = (templates: Template[]): string => { - let descriptions: string = ''; - templates.forEach(template => { - descriptions += `---------- NEW TEMPLATE TO INCLUDE: The title is: ${template.mainField.getTitle()}. Its fields are: `; - descriptions += template.descriptionSummary; - }); + compileFieldDescriptions = (templates: Template[]) => + templates.map(template => `---------- NEW TEMPLATE TO INCLUDE: The title is: ${template.title}. Its fields are: ` + template.descriptionSummary).join(''); // prettier-ignore - return descriptions; - }; + compileColDescriptions = (cols: Col[]) => + ' ------------- COL DESCRIPTIONS START HERE:' + cols.map(col => `{title: ${col.title}, sizes: ${String(col.sizes)}, type: ${col.type}, descreiption: ${col.desc}} `).join(''); // prettier-ignore - compileColDescriptions = (cols: Col[]): string => { - let descriptions: string = ' ------------- COL DESCRIPTIONS START HERE:'; - cols.forEach(col => (descriptions += `{title: ${col.title}, sizes: ${String(col.sizes)}, type: ${col.type}, descreiption: ${col.desc}} `)); - - return descriptions; - }; - - getColByTitle = (title: string) => { - return this.fieldsInfos.filter(col => col.title === title)[0]; - }; + getColByTitle = (title: string): Col | undefined => this.fieldsInfos.filter(col => col.title === title)[0]; @action assignColsToFields = async (templates: Template[], cols: Col[]): Promise<[Template, { [field: number]: Col }][]> => { - const fieldDescriptions: string = this.compileFieldDescriptions(templates); - const colDescriptions: string = this.compileColDescriptions(cols); + const fieldDescriptions = this.compileFieldDescriptions(templates); + const colDescriptions = this.compileColDescriptions(cols); const inputText = fieldDescriptions.concat(colDescriptions); - ++this._callCount; - const origCount = this._callCount; - - const prompt: string = `(${origCount}) ${inputText}`; + const prompt = `(${Math.random() * 100000}) ${inputText}`; this._GPTLoading = true; @@ -555,24 +396,26 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> const brokenDownAssignments: [Template, { [fieldID: number]: Col }][] = []; Object.entries(assignments).forEach(([tempTitle, assignment]) => { - const template = templates.filter(t => t.mainField.getTitle() === tempTitle)[0]; - if (!template) return; - const toObj = Object.entries(assignment).reduce( - (a, [fieldID, colTitle]) => { - const col = this.getColByTitle(colTitle); - if (!this._userCreatedFields.includes(col)) { - // do the following for any fields not added by the user; will change in the future, for now only GPT content works with user-added fields - const field = template.getFieldByID(Number(fieldID)); - field.setContent(col.defaultContent ?? '', col.type === TemplateFieldType.VISUAL ? FieldContentType.IMAGE : FieldContentType.STRING); - field.setTitle(col.title); - } else { - a[Number(fieldID)] = this.getColByTitle(colTitle); - } - return a; - }, - {} as { [field: number]: Col } - ); - brokenDownAssignments.push([template, toObj]); + const template = templates.filter(temp => temp.title === tempTitle)[0]; + if (template) { + const toObj = Object.entries(assignment).reduce( + (a, [fieldID, colTitle]) => { + const col = this.getColByTitle(colTitle); + if (col) { + if (!col.AIGenerated) { + const field = template.getFieldByID(Number(fieldID)); + field?.setContent(col.defaultContent ?? '', col.type === TemplateFieldType.VISUAL ? ViewType.IMG : ViewType.TEXT); + field?.setTitle(col.title); + } else { + a[Number(fieldID)] = col; + } + } + return a; + }, + {} as { [field: number]: Col } + ); + brokenDownAssignments.push([template, toObj]); + } }); return brokenDownAssignments; @@ -584,777 +427,100 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> return []; }; - generatePresetTemplates = async () => { - this._dataViz?.updateColDefaults(); - - const cols = this.fieldsInfos; - const templates = this.templateManager.getValidTemplates(cols); - - const assignments: [Template, { [field: number]: Col }][] = await this.assignColsToFields(templates, cols); - - const renderedTemplatePromises: Promise<Template | undefined>[] = assignments.map(([template, asns]) => this.applyGPTContentToTemplate(template, asns)); - - await Promise.all(renderedTemplatePromises); - - setTimeout(() => { - this.setSuggestedTemplates(templates); + generatePresetTemplates = action(() => { + if (this.DEBUG_MODE) { + this.setSuggestedTemplates(this.templateManager._templates); this._GPTLoading = false; - }); - }; - - renderGPTImageCall = async (template: Template, col: Col, fieldNumber: number): Promise<boolean> => { - const generateAndLoadImage = async (fieldNum: string, column: Col, prompt: string) => { - const url = await this.generateGPTImage(prompt); - const field: Field = template.getFieldByID(Number(fieldNum)); - - field.setContent(url ?? '', FieldContentType.IMAGE); - field.setTitle(column.title); - }; - - const fieldContent: string = template.compiledContent; - - try { - const sysPrompt = - 'Your job is to create a prompt for an AI image generator to help it generate an image based on existing content in a template and a user prompt. Your prompt should focus heavily on visual elements to help the image generator; avoid unecessary info that might distract it. ONLY INCLUDE THE PROMPT, NO OTHER TEXT OR EXPLANATION. The existing content is as follows: ' + - fieldContent + - ' **** The user prompt is: ' + - col.desc; - - const prompt = await gptAPICall(sysPrompt, GPTCallType.COMPLETEPROMPT); - - await generateAndLoadImage(String(fieldNumber), col, prompt); - } catch (e) { - console.log(e); + } else { + this._dataViz?.updateColDefaults(); + const contentFields = this.fieldsInfos.filter(field => field.type !== TemplateFieldType.DATA); + const templates = this.templateManager.getValidTemplates(contentFields); + + return this.assignColsToFields(templates, contentFields) + .then(pairs => + Promise.all(pairs.map(([templ, assgns]) => TemplateMenuAIUtils.applyGPTContentToTemplate(templ, assgns)))) + .then(action(() => { + this.setSuggestedTemplates(templates); + this._GPTLoading = false; + })); // prettier-ignore } - return true; - }; - - renderGPTTextCall = async (template: Template, col: Col, fieldNum: number): Promise<boolean> => { - const wordLimit = (size: TemplateFieldSize) => { - switch (size) { - case TemplateFieldSize.TINY: - return 2; - case TemplateFieldSize.SMALL: - return 5; - case TemplateFieldSize.MEDIUM: - return 20; - case TemplateFieldSize.LARGE: - return 50; - case TemplateFieldSize.HUGE: - return 100; - default: - return 10; - } - }; - - const textAssignment = `--- title: ${col.title}, prompt: ${col.desc}, word limit: ${wordLimit(col.sizes[0])} words, assigned field: ${fieldNum} ---`; + }); - const fieldContent: string = template.compiledContent; - - try { - const prompt = fieldContent + textAssignment; + // eslint-disable-next-line @typescript-eslint/no-unused-vars + generateVariations = async (onDoc: Doc, prompt: string, options: FireflyStructureOptions) => { + // const { numVariations, temperature, useStyleRef } = options; + this.variations = []; + const mainCollection = this._dataViz?.DocumentView?.().containerViewPath?.().lastElement()?.ComponentView as CollectionFreeFormView; - const res = await gptAPICall(`${++this._callCount}: ${prompt}`, GPTCallType.FILL); + const clone = Doc.MakeClone(onDoc).clone; + mainCollection.addDocument(clone); + clone.x = 10000; + clone.y = 10000; - if (res) { - const assignments: { [title: string]: { number: string; content: string } } = JSON.parse(res); - Object.entries(assignments).forEach(([title, info]) => { - const field: Field = template.getFieldByID(Number(info.number)); - const column = this.getColByTitle(title); - - field.setContent(info.content ?? '', FieldContentType.STRING); - field.setTitle(column.title); - }); - } - } catch (err) { - console.log(err); - } + // await DrawingFillHandler.drawingToImage(clone, 100 - temperature, prompt, useStyleRef ? clone : undefined, this, numVariations) - return true; + return this.variations; }; - createDocsFromTemplate = async (template: Template) => { - const dv = this._dataViz; - - if (!dv) return; - - this._docsRendering = true; - - const fields: string[] = Array.from(Object.keys(dv.records[0])); - const selectedRows = NumListCast(dv.layoutDoc.dataViz_selectedRows); - - const rowContents: { [title: string]: string }[] = selectedRows.map(row => { - const values: { [title: string]: string } = {}; - fields.forEach(col => { - values[col] = dv.records[row][col]; - }); + variations: string[] = []; - return values; - }); - - const processContent = async (content: { [title: string]: string }) => { - const templateCopy = template.cloneBase(); - - fields - .filter(title => title) - .forEach(title => { - const field = templateCopy.getFieldByTitle(title); - if (field === undefined) { - return; - } - field.setContent(content[title]); - }); - - const gptPromises = this._userCreatedFields - .filter(field => field.type === TemplateFieldType.TEXT) - .map(field => { - const title = field.title; - const templateField = templateCopy.getFieldByTitle(title); - if (templateField === undefined) { - return; - } - const templatefieldID = templateField.getID; - - return this.renderGPTTextCall(templateCopy, field, templatefieldID); - }); - - const imagePromises = this._userCreatedFields - .filter(field => field.type === TemplateFieldType.VISUAL) - .map(field => { - const title = field.title; - const templateField = templateCopy.getFieldByTitle(title); - if (templateField === undefined) { - return; - } - const templatefieldID = templateField.getID; - - return this.renderGPTImageCall(templateCopy, field, templatefieldID); - }); - - await Promise.all(gptPromises); - - await Promise.all(imagePromises); - - return templateCopy.getRenderedDoc(); - }; - - const promises = rowContents.map(content => processContent(content)); - - const renderedDocs = await Promise.all(promises); - - this._docsRendering = false; + @action addVariation = (url: string) => { + this.variations.push(url); + }; - return renderedDocs; + addRenderedCollectionToMainview = (collection: Doc) => { + if (collection) { + const mainCollection = this._dataViz?.DocumentView?.().containerViewPath?.().lastElement()?.ComponentView as CollectionFreeFormView; + collection.x = this._pageX - this._menuDimensions.width; + collection.y = this._pageY - this._menuDimensions.height; + mainCollection?.addDocument(collection); + this.closeMenu(); + } }; - addRenderedCollectionToMainview = () => { - const collection = this._renderedDocCollection; - if (!collection) return; - const mainCollection = this._dataViz?.DocumentView?.().containerViewPath?.().lastElement()?.ComponentView as CollectionFreeFormView; - collection.x = this._pageX - this._menuDimensions.width; - collection.y = this._pageY - this._menuDimensions.height; - mainCollection.addDocument(collection); - this.closeMenu(); + @action editLastTemplate = () => { + if (this._editedTemplateTrail.length) this._currEditingTemplate = this._editedTemplateTrail.pop(); }; @action setExpandedView = (template: Template | undefined) => { if (template) { - this._currEditingTemplate = template; - this._expandedPreview = template.mainField.renderedDoc(); //Docs.Create.FreeformDocument([doc], { _height: NumListCast(doc._height)[0], _width: NumListCast(doc._width)[0], title: ''}); + this._menuContent = 'templateEditing'; + this._currEditingTemplate && this._editedTemplateTrail.push(this._currEditingTemplate); } else { - this._currEditingTemplate = undefined; - this._expandedPreview = undefined; - } - }; - - get editingWindow() { - const rendered = !this._expandedPreview ? null : ( - <div className="docCreatorMenu-expanded-template-preview"> - <DocumentView - Document={this._expandedPreview} - isContentActive={emptyFunction} - addDocument={returnFalse} - moveDocument={returnFalse} - removeDocument={returnFalse} - PanelWidth={() => this._menuDimensions.width - 10} - PanelHeight={() => this._menuDimensions.height - 60} - ScreenToLocalTransform={() => new Transform(-this._pageX - 5, -this._pageY - 35, 1)} - renderDepth={5} - whenChildContentsActiveChanged={emptyFunction} - focus={emptyFunction} - styleProvider={DefaultStyleProvider} - addDocTab={DocumentViewInternal.addDocTabFunc} - pinToPres={() => undefined} - childFilters={returnEmptyFilter} - childFiltersByRanges={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - fitContentsToBox={returnFalse} - fitWidth={returnFalse} - /> - </div> - ); - - return ( - <div className="docCreatorMenu-expanded-template-preview"> - <div className="top-panel" /> - {rendered} - <div className="right-buttons-panel"> - <button - className="docCreatorMenu-menu-button section-reveal-options top-right" - onPointerDown={e => - this.setUpButtonClick(e, () => { - this._currEditingTemplate && this.updateTemplatePreview(this._currEditingTemplate); - this.setExpandedView(undefined); - }) - }> - <FontAwesomeIcon icon="minimize" /> - </button> - <button - className="docCreatorMenu-menu-button section-reveal-options top-right-lower" - onPointerDown={e => - this.setUpButtonClick(e, () => { - this._currEditingTemplate?.resetToBase(); - this.setExpandedView(this._currEditingTemplate); - }) - }> - <FontAwesomeIcon icon="arrows-rotate" color="white" /> - </button> - </div> - </div> - ); - } - - get templatesPreviewContents() { - const GPTOptions = <div></div>; - - return ( - <div className={`docCreatorMenu-templates-view`}> - {this._expandedPreview ? ( - this.editingWindow - ) : ( - <div> - <div className="docCreatorMenu-section" style={{ height: this._GPTOpt ? 200 : 200 }}> - <div className="docCreatorMenu-section-topbar"> - <div className="docCreatorMenu-section-title">Suggested Templates</div> - <button className="docCreatorMenu-menu-button section-reveal-options" onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => (this._menuContent = 'dashboard')))}> - <FontAwesomeIcon icon="gear" /> - </button> - </div> - <div className="docCreatorMenu-templates-preview-window" style={{ justifyContent: this._GPTLoading || this._menuDimensions.width > 400 ? 'center' : '' }}> - {this._GPTLoading ? ( - <div className="loading-spinner"> - <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> - </div> - ) : ( - this._suggestedTemplatePreviews.map(({ doc, template }) => ( - <div - className="docCreatorMenu-preview-window" - key="0" - style={{ - border: this._selectedTemplate === template ? `solid 3px ${Colors.MEDIUM_BLUE}` : '', - boxShadow: this._selectedTemplate === template ? `0 0 15px rgba(68, 118, 247, .8)` : '', - }} - onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this.updateSelectedTemplate(template)))}> - <button - className="option-button left" - onPointerDown={e => - this.setUpButtonClick(e, () => { - this.setExpandedView(template); - }) - }> - <FontAwesomeIcon icon="magnifying-glass" color="white" /> - </button> - <button className="option-button right" onPointerDown={e => this.setUpButtonClick(e, () => this.addUserTemplate(template))}> - <FontAwesomeIcon icon="plus" color="white" /> - </button> - <DocumentView - Document={doc} - isContentActive={emptyFunction} // !!! should be return false - addDocument={returnFalse} - moveDocument={returnFalse} - removeDocument={returnFalse} - PanelWidth={() => (this._selectedTemplate === template ? 104 : 111)} - PanelHeight={() => (this._selectedTemplate === template ? 104 : 111)} - ScreenToLocalTransform={() => new Transform(-this._pageX - 5, -this._pageY - 35, 1)} - renderDepth={1} - whenChildContentsActiveChanged={emptyFunction} - focus={emptyFunction} - styleProvider={DefaultStyleProvider} - addDocTab={this._props.addDocTab} - pinToPres={() => undefined} - childFilters={returnEmptyFilter} - childFiltersByRanges={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - fitContentsToBox={returnFalse} - fitWidth={returnFalse} - hideDecorations={true} - /> - </div> - )) - )} - </div> - <div className="docCreatorMenu-GPT-options"> - <div className="docCreatorMenu-GPT-options-container"> - <button className="docCreatorMenu-menu-button" onPointerDown={e => this.setUpButtonClick(e, () => this.generatePresetTemplates())}> - <FontAwesomeIcon icon="arrows-rotate" /> - </button> - </div> - {this._GPTOpt ? GPTOptions : null} - </div> - </div> - <hr className="docCreatorMenu-option-divider full no-margin" /> - <div className="docCreatorMenu-section"> - <div className="docCreatorMenu-section-topbar"> - <div className="docCreatorMenu-section-title">Your Templates</div> - <button className="docCreatorMenu-menu-button section-reveal-options" onPointerDown={e => this.setUpButtonClick(e, () => (this._GPTOpt = !this._GPTOpt))}> - <FontAwesomeIcon icon="gear" /> - </button> - </div> - <div className="docCreatorMenu-templates-preview-window" style={{ justifyContent: this._menuDimensions.width > 400 ? 'center' : '' }}> - <div className="docCreatorMenu-preview-window empty"> - <FontAwesomeIcon icon="plus" color="rgb(160, 160, 160)" /> - </div> - {this._userTemplates.map(({ template, doc }) => ( - <div - className="docCreatorMenu-preview-window" - key="0" - style={{ - border: this._selectedTemplate === template ? `solid 3px ${Colors.MEDIUM_BLUE}` : '', - boxShadow: this._selectedTemplate === template ? `0 0 15px rgba(68, 118, 247, .8)` : '', - }} - onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => this.updateSelectedTemplate(template)))}> - <button - className="option-button left" - onPointerDown={e => - this.setUpButtonClick(e, () => { - this.setExpandedView(template); - }) - }> - <FontAwesomeIcon icon="magnifying-glass" color="white" /> - </button> - <button className="option-button right" onPointerDown={e => this.setUpButtonClick(e, () => this.removeUserTemplate(template))}> - <FontAwesomeIcon icon="minus" color="white" /> - </button> - <DocumentView - Document={doc} - isContentActive={emptyFunction} // !!! should be return false - addDocument={returnFalse} - moveDocument={returnFalse} - removeDocument={returnFalse} - PanelWidth={() => (this._selectedTemplate === template ? 104 : 111)} - PanelHeight={() => (this._selectedTemplate === template ? 104 : 111)} - ScreenToLocalTransform={() => new Transform(-this._pageX - 5, -this._pageY - 35, 1)} - renderDepth={1} - whenChildContentsActiveChanged={emptyFunction} - focus={emptyFunction} - styleProvider={DefaultStyleProvider} - addDocTab={this._props.addDocTab} - pinToPres={() => undefined} - childFilters={returnEmptyFilter} - childFiltersByRanges={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - fitContentsToBox={returnFalse} - fitWidth={returnFalse} - hideDecorations={true} - /> - </div> - ))} - </div> - </div> - </div> - )} - </div> - ); - } - - @action updateXMargin = (input: string) => { - this._layout.xMargin = Number(input); - setTimeout(() => { - if (!this._renderedDocCollection || !this._fullyRenderedDocs) return; - this.applyLayout(this._renderedDocCollection, this._fullyRenderedDocs); - }); - }; - @action updateYMargin = (input: string) => { - this._layout.yMargin = Number(input); - setTimeout(() => { - if (!this._renderedDocCollection || !this._fullyRenderedDocs) return; - this.applyLayout(this._renderedDocCollection, this._fullyRenderedDocs); - }); - }; - @action updateColumns = (input: string) => { - this._layout.columns = Number(input); - this.updateRenderedDocCollection(); - }; - - get layoutConfigOptions() { - const optionInput = (icon: string, func: (input: string) => void, def?: number, key?: string, noMargin?: boolean) => { - return ( - <div className="docCreatorMenu-option-container small no-margin" key={key} style={{ marginTop: noMargin ? '0px' : '' }}> - <div className="docCreatorMenu-option-title config layout-config"> - <FontAwesomeIcon icon={icon as IconProp} /> - </div> - <input defaultValue={def} onInput={e => func(e.currentTarget.value)} className="docCreatorMenu-input config layout-config" /> - </div> - ); - }; - - switch (this._layout.type) { - case LayoutType.FREEFORM: - return ( - <div className="docCreatorMenu-configuration-bar"> - {optionInput('arrows-up-down', this.updateYMargin, this._layout.xMargin, '2')} - {optionInput('arrows-left-right', this.updateXMargin, this._layout.xMargin, '3')} - {optionInput('table-columns', this.updateColumns, this._layout.columns, '4', true)} - </div> - ); - default: - break; + this._menuContent = 'templates'; } - } - applyLayout = (collection: Doc, docs: Doc[]) => { - const { horizontalSpan, verticalSpan } = this.previewInfo; - collection._height = verticalSpan; - collection._width = horizontalSpan; - - const layout = this._layout; - const columns: number = layout.columns ?? this.columnsCount; - const xGap: number = layout.xMargin; - const yGap: number = layout.yMargin; - // const repeat: number = templateInfo.layout.repeat; - const startX: number = -Number(collection._width) / 2; - const startY: number = -Number(collection._height) / 2; - const docHeight: number = Number(docs[0]._height); - const docWidth: number = Number(docs[0]._width); - - if (columns === 0 || docs.length === 0) { - return; - } + this._currEditingTemplate = template; - let i: number = 0; - let docsChanged: number = 0; - let curX: number = startX; - let curY: number = startY; - - while (docsChanged < docs.length) { - while (i < columns && docsChanged < docs.length) { - docs[docsChanged].x = curX; - docs[docsChanged].y = curY; - curX += docWidth + xGap; - ++docsChanged; - ++i; - } - i = 0; - curX = startX; - curY += docHeight + yGap; - } + //Docs.Create.FreeformDocument([doc], { _height: NumListCast(doc._height)[0], _width: NumListCast(doc._width)[0], title: ''}); }; @computed - get previewInfo() { - const docHeight: number = Number(this._fullyRenderedDocs[0]._height); - const docWidth: number = Number(this._fullyRenderedDocs[0]._width); - const layout = this._layout; - return { - docHeight: docHeight, - docWidth: docWidth, - horizontalSpan: (docWidth + layout.xMargin) * this.columnsCount - layout.xMargin, - verticalSpan: (docHeight + layout.yMargin) * this.rowsCount - layout.yMargin, - }; - } - - /** - * Updates the preview that shows how all docs will be rendered in the chosen collection type. - @type the type of collection the docs should render to (ie. freeform, carousel, card) - */ - updateRenderedDocCollection = () => { - if (!this._fullyRenderedDocs) return; - - const { horizontalSpan, verticalSpan } = this.previewInfo; - - const collectionFactory = (): ((docs: Doc[], options: DocumentOptions) => Doc) => { - switch (this._layout.type) { - case LayoutType.CAROUSEL3D: - return Docs.Create.Carousel3DDocument; - case LayoutType.FREEFORM: - return Docs.Create.FreeformDocument; - case LayoutType.CARD: - return Docs.Create.CardDeckDocument; - case LayoutType.MASONRY: - return Docs.Create.MasonryDocument; - case LayoutType.CAROUSEL: - return Docs.Create.CarouselDocument; - default: - return Docs.Create.FreeformDocument; - } - }; - - const collection: Doc = collectionFactory()(this._fullyRenderedDocs, { - isDefaultTemplateDoc: true, - _height: verticalSpan, - _width: horizontalSpan, - title: 'title', - backgroundColor: 'gray', - }); - - this.applyLayout(collection, this._fullyRenderedDocs); - - this._renderedDocCollection = collection; - }; - - layoutPreviewContents = () => { - return this._docsRendering ? ( - <div className="docCreatorMenu-layout-preview-window-wrapper loading"> - <div className="loading-spinner"> - <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> - </div> - </div> - ) : !this._renderedDocCollection ? null : ( - <div className="docCreatorMenu-layout-preview-window-wrapper"> - <DocumentView - Document={this._renderedDocCollection} - isContentActive={emptyFunction} - addDocument={returnFalse} - moveDocument={returnFalse} - removeDocument={returnFalse} - PanelWidth={() => this._menuDimensions.width - 80} - PanelHeight={() => this._menuDimensions.height - 105} - ScreenToLocalTransform={() => new Transform(-this._pageX - 5, -this._pageY - 35, 1)} - renderDepth={5} - whenChildContentsActiveChanged={emptyFunction} - focus={emptyFunction} - styleProvider={DefaultStyleProvider} - addDocTab={this._props.addDocTab} - pinToPres={() => undefined} - childFilters={returnEmptyFilter} - childFiltersByRanges={returnEmptyFilter} - searchFilterDocs={returnEmptyDoclist} - fitContentsToBox={returnFalse} - fitWidth={returnFalse} - hideDecorations={true} - /> - </div> - ); - }; - - get optionsMenuContents() { - const layoutOption = (option: LayoutType, optStyle?: object, specialFunc?: () => void) => { - return ( - <div - className="docCreatorMenu-dropdown-option" - style={optStyle} - onPointerDown={e => - this.setUpButtonClick(e, () => { - specialFunc?.(); - runInAction(() => { - this._layout.type = option; - this.updateRenderedDocCollection(); - }); - }) - }> - {option} - </div> - ); - }; - - const selectionBox = (width: number, height: number, icon: string, specClass?: string, options?: JSX.Element[], manual?: boolean): JSX.Element => { - return ( - <div className="docCreatorMenu-option-container"> - <div className={`docCreatorMenu-option-title config ${specClass}`} style={{ width: width * 0.4, height: height }}> - <FontAwesomeIcon icon={icon as IconProp} /> - </div> - {manual ? ( - <input className={`docCreatorMenu-input config ${specClass}`} style={{ width: width * 0.6, height: height }} /> - ) : ( - <select className={`docCreatorMenu-input config ${specClass}`} style={{ width: width * 0.6, height: height }}> - {options} - </select> - )} - </div> - ); - }; - - const repeatOptions = [0, 1, 2, 3, 4, 5]; - + get templatesView() { return ( - <div className="docCreatorMenu-menu-container"> - <div className="docCreatorMenu-option-container layout"> - <div className="docCreatorMenu-dropdown-hoverable"> - <div className="docCreatorMenu-option-title">{this._layout.type ? this._layout.type.toUpperCase() : 'Choose Layout'}</div> - <div className="docCreatorMenu-dropdown-content"> - {layoutOption(LayoutType.FREEFORM, undefined, () => { - if (!this._layout.columns) this._layout.columns = Math.ceil(Math.sqrt(this.docsToRender.length)); - })} - {layoutOption(LayoutType.CAROUSEL)} - {layoutOption(LayoutType.CAROUSEL3D)} - {layoutOption(LayoutType.MASONRY)} + <div className="docCreatorMenu-templates-view"> + <div className="docCreatorMenu-templates-displays"> + <TemplatePreviewGrid title={'Suggested Templates'} menu={this} loading={this._GPTLoading} optionsButtonOpts={this.optionsButtonOpts} templates={this._suggestedTemplates} /> + <div className="docCreatorMenu-GPT-options"> + <div className="docCreatorMenu-GPT-options-container"> + <DocCreatorMenuButton icon={'arrows-rotate'} styles={'border'} function={this.generatePresetTemplates} /> </div> </div> </div> - {this._layout.type ? this.layoutConfigOptions : null} - {this.layoutPreviewContents()} - {selectionBox( - 60, - 20, - 'repeat', - undefined, - repeatOptions.map(num => <option key={num} onPointerDown={() => (this._layout.repeat = num)}>{`${num}x`}</option>) - )} - <hr className="docCreatorMenu-option-divider" /> - <div className="docCreatorMenu-general-options-container"> - <button - className="docCreatorMenu-save-layout-button" - onPointerDown={e => - setupMoveUpEvents( - this, - e, - returnFalse, - emptyFunction, - undoable(clickEv => { - clickEv.stopPropagation(); - if (!this._selectedTemplate) return; - const layout: DataVizTemplateLayout = { - template: this._selectedTemplate.getRenderedDoc(), - layout: { type: this._layout.type, xMargin: this._layout.xMargin, yMargin: this._layout.yMargin, repeat: 0 }, - columns: this.columnsCount, - rows: this.rowsCount, - docsNumList: this.docsToRender, - }; - if (!this._savedLayouts.includes(layout)) { - this._savedLayouts.push(layout); - } - }, 'make docs') - ) - }> - <FontAwesomeIcon icon="floppy-disk" /> - </button> - <button - className="docCreatorMenu-create-docs-button" - style={{ backgroundColor: this.canMakeDocs ? '' : 'rgb(155, 155, 155)', border: this.canMakeDocs ? '' : 'solid 2px rgb(180, 180, 180)' }} - onPointerDown={e => - setupMoveUpEvents( - this, - e, - returnFalse, - emptyFunction, - undoable(clickEv => { - clickEv.stopPropagation(); - if (!this._selectedTemplate) return; - this.addRenderedCollectionToMainview(); - }, 'make docs') - ) - }> - <FontAwesomeIcon icon="plus" /> - </button> - </div> </div> ); } - get dashboardContents() { - const sizes: string[] = ['tiny', 'small', 'medium', 'large', 'huge']; - - const fieldPanel = (field: Col, id: number) => { - return ( - <div className="field-panel" key={id}> - <div className="top-bar"> - <span className="field-title">{`${field.title} Field`}</span> - <button className="docCreatorMenu-menu-button section-reveal-options no-margin" onPointerDown={e => this.setUpButtonClick(e, () => this.removeField(field))} style={{ position: 'absolute', right: '0px' }}> - <FontAwesomeIcon icon="minus" /> - </button> - </div> - <div className="opts-bar"> - <div className="opt-box"> - <div className="top-bar"> Title </div> - <textarea className="content" style={{ width: '100%', height: 'calc(100% - 20px)' }} value={field.title} placeholder={'Enter title'} onChange={e => this.setColTitle(field, e.target.value)} /> - </div> - <div className="opt-box"> - <div className="top-bar"> Type </div> - <div className="content"> - <span className="type-display">{field.type === TemplateFieldType.TEXT ? 'Text Field' : field.type === TemplateFieldType.VISUAL ? 'File Field' : ''}</span> - <div className="bubbles"> - <input - className="bubble" - type="radio" - name="type" - onClick={() => { - this.setColType(field, TemplateFieldType.TEXT); - }} - /> - <div className="text">Text</div> - <input - className="bubble" - type="radio" - name="type" - onClick={() => { - this.setColType(field, TemplateFieldType.VISUAL); - }} - /> - <div className="text">File</div> - </div> - </div> - </div> - </div> - <div className="sizes-box"> - <div className="top-bar"> Valid Sizes </div> - <div className="content"> - <div className="bubbles"> - {sizes.map(size => ( - <> - <input - className="bubble" - type="checkbox" - name="type" - checked={field.sizes.includes(size as TemplateFieldSize)} - onChange={e => { - this.modifyColSizes(field, size as TemplateFieldSize, e.target.checked); - }} - /> - <div className="text">{size}</div> - </> - ))} - </div> - </div> - </div> - <div className="desc-box"> - <div className="top-bar"> Prompt </div> - <textarea - className="content" - onChange={e => this.setColDesc(field, e.target.value)} - defaultValue={field.desc === this._dataViz?.GPTSummary?.get(field.title)?.desc ? '' : field.desc} - placeholder={this._dataViz?.GPTSummary?.get(field.title)?.desc ?? 'Add a description/prompt to help with template generation.'} - /> - </div> - </div> - ); - }; - - return ( - <div className="docCreatorMenu-dashboard-view"> - <div className="topbar"> - <button className="docCreatorMenu-menu-button section-reveal-options" onPointerDown={e => this.setUpButtonClick(e, this.addField)}> - <FontAwesomeIcon icon="plus" /> - </button> - <button className="docCreatorMenu-menu-button section-reveal-options float-right" onPointerDown={e => this.setUpButtonClick(e, () => runInAction(() => (this._menuContent = 'templates')))}> - <FontAwesomeIcon icon="arrow-left" /> - </button> - </div> - <div className="panels-container">{this.fieldsInfos.map((field, i) => fieldPanel(field, i))}</div> - </div> - ); - } + private optionsButtonOpts: [IconProp, () => void] = ['gear', () => (this._menuContent = 'dashboard')]; get renderSelectedViewType() { switch (this._menuContent) { - case 'templates': - return this.templatesPreviewContents; - case 'options': - return this.optionsMenuContents; - case 'dashboard': - return this.dashboardContents; - default: - return undefined; - } + case 'templates': return this.templatesView; + case 'templateEditing': return <TemplateEditingWindow template={this._currEditingTemplate as Template} menu={this} />; + case 'renderPreview': return <TemplatesRenderPreviewWindow menu={this}/>; + case 'dashboard': return <TemplateMenuFieldOptions menu={this} templateManager={this.templateManager}/>; + } // prettier-ignore + return undefined; } get resizePanes() { @@ -1374,42 +540,26 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> ]; //prettier-ignore } + setRef = (r: HTMLDivElement) => (this._ref = r); render() { - const topButton = (icon: string, opt: string, func: () => void, tag: string) => { - return ( - <div className={`top-button-container ${tag} ${opt === this._menuContent ? 'selected' : ''}`}> - <div - className="top-button-content" - onPointerDown={e => - this.setUpButtonClick(e, () => - runInAction(() => { - func(); - }) - ) - }> - <FontAwesomeIcon icon={icon as IconProp} /> - </div> + const topButton = (icon: string, opt: string, func: () => void, tag: string) => ( + <div className={`top-button-container ${tag} ${opt === this._menuContent ? 'selected' : ''}`}> + <div className="top-button-content" onPointerDown={e => this.setUpButtonClick(e, action(func))}> + <FontAwesomeIcon icon={icon as IconProp} /> </div> - ); - }; + </div> + ); - const onPreviewSelected = () => { - this._menuContent = 'templates'; - }; - const onSavedSelected = () => { - this._menuContent = 'dashboard'; - }; - const onOptionsSelected = () => { - this._menuContent = 'options'; - if (!this._layout.columns) this._layout.columns = Math.ceil(Math.sqrt(this.docsToRender.length)); - }; + const onPreviewSelected = () => (this._menuContent = 'templates'); + const onSavedSelected = () => (this._menuContent = 'dashboard'); + const onOptionsSelected = () => (this._menuContent = 'renderPreview'); return ( <div className="docCreatorMenu"> {!this._shouldDisplay ? undefined : ( <div className="docCreatorMenu-cont" - ref={r => (this._ref = r)} + ref={this.setRef} style={{ display: '', left: this._pageX, @@ -1435,9 +585,7 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> return true; }, emptyFunction, - undoable(clickEv => { - clickEv.stopPropagation(); - }, 'drag menu') + undoable(clickEv => clickEv.stopPropagation(), 'drag menu') ) }> <div className="docCreatorMenu-top-buttons-container"> @@ -1445,9 +593,7 @@ export class DocCreatorMenu extends ObservableReactComponent<DocCreateMenuProps> {topButton('magnifying-glass', 'options', onOptionsSelected, 'middle')} {topButton('bars', 'saved', onSavedSelected, 'right')} </div> - <button className="docCreatorMenu-menu-button close-menu" onPointerDown={e => this.setUpButtonClick(e, this.closeMenu)}> - <FontAwesomeIcon icon={'minus'} /> - </button> + <DocCreatorMenuButton icon={'minus'} styles={'float-right'} function={this.closeMenu} /> </div> {this.renderSelectedViewType} </div> diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/DynamicField.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/DynamicField.tsx deleted file mode 100644 index c5254c17d..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/DynamicField.tsx +++ /dev/null @@ -1,117 +0,0 @@ -import { Doc } from "../../../../../../fields/Doc"; -import { Docs } from "../../../../../documents/Documents"; -import { Field, FieldDimensions, FieldSettings, ViewType } from "./Field"; -import { FieldUtils } from "./FieldUtils"; -import { StaticField } from "./StaticField"; - -export class DynamicField implements Field { - private subfields: Field[] = []; - - private id: number; - private settings: FieldSettings; - private title: string = ''; - - private parent: Field; - private dimensions: FieldDimensions; - - constructor(settings: FieldSettings, id: number, parent?: Field) { - this.id = id; - this.settings = settings; - if (settings.title) { this.title = settings.title }; - if (!parent) { - this.parent = this; - this.dimensions = {width: this.settings.br[0] - this.settings.tl[0], height: this.settings.br[1] - this.settings.tl[1], coord: {x: this.settings.tl[0], y: this.settings.tl[1]}}; - } else { - this.parent = parent; - this.dimensions = FieldUtils.getLocalDimensions({tl: settings.tl, br: settings.br}, this.parent.getDimensions); - } - this.subfields = this.setupSubfields(); - } - - setContent = () => { return }; - getContent = () => { return '' }; - - setTitle = (title: string) => { this.title = title }; - getTitle = () => { return this.title }; - - get getSubfields() { return this.subfields }; - get getAllSubfields() { - let fields: Field[] = []; - this.subfields?.forEach(field => { - fields.push(field); - fields = fields.concat(field.getAllSubfields) - }); - return fields; - }; - - get getDimensions() { return this.dimensions }; - get getID() { return this.id }; - get getViewType() { return this.settings.viewType }; - - get getDescription(): string { - return this.settings.description ?? ''; - } - - matches = (): Array<number> => { - return []; - } - - updateRenderedDoc = () => { - return new Doc(); - } - - setupSubfields = (): Field[] => { - const fields: Field[] = []; - this.settings.subfields?.forEach((fieldSettings, index) => { - let field: Field; - const type = fieldSettings.viewType; - - const id = Number(String(this.id) + String(index)); - - if (type == ViewType.CAROUSEL3D || type === ViewType.FREEFORM) { - field = new DynamicField(fieldSettings, id, this); - } else { - field = new StaticField(fieldSettings, this, id); - } - fields.push(field); - }); - return fields; - } - - applyAttributes = (field: Field) => { - field.setTitle(this.title); - field.updateRenderedDoc(this.renderedDoc()); - } - - getChildDimensions = (coords: { tl: [number, number]; br: [number, number] }): FieldDimensions => { - const l = (coords.tl[0] * this.dimensions.height) / 2; - const t = coords.tl[1] * this.dimensions.width / 2; //prettier-ignore - const r = (coords.br[0] * this.dimensions.height) / 2; - const b = coords.br[1] * this.dimensions.width / 2; //prettier-ignore - const width = r - l; - const height = b - t; - const coord = { x: l, y: t }; - return { width, height, coord }; - }; - - renderedDoc = (): Doc => { - let doc: Doc; - switch (this.settings.viewType) { - case ViewType.CAROUSEL3D: - doc = Docs.Create.Carousel3DDocument(this.subfields.map(field => field.renderedDoc()), { - title: this.title, - }); - FieldUtils.applyBasicOpts(doc, this.dimensions, this.settings); - return doc; - case ViewType.FREEFORM: - doc = Docs.Create.FreeformDocument(this.subfields.map(field => field.renderedDoc()), { - title: this.title, - }); - FieldUtils.applyBasicOpts(doc, this.dimensions, this.settings); - return doc; - default: - return new Doc(); - } - } - -} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/Field.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/Field.tsx deleted file mode 100644 index ea9b566b3..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/Field.tsx +++ /dev/null @@ -1,66 +0,0 @@ -import { Doc } from "../../../../../../fields/Doc"; -import { Col } from "../DocCreatorMenu"; -import { TemplateFieldSize, TemplateFieldType } from "../TemplateBackend"; - -export enum FieldContentType { - STRING = 'string', - IMAGE = 'image', -} - -export enum ViewType { - CAROUSEL3D = 'carousel3d', - FREEFORM = 'freeform', - STATIC = 'static', - DEC = 'decoration' -} - -export type FieldDimensions = { - width: number; - height: number; - coord: {x: number, y: number}; -} - -export interface FieldOpts { - backgroundColor?: string; - color?: string; - cornerRounding?: number; - borderWidth?: string; - borderColor?: string; - contentXCentering?: 'h-left' | 'h-center' | 'h-right'; - contentYCentering?: 'top' | 'center' | 'bottom'; - opacity?: number; - rotation?: number; - fontBold?: boolean; - fontTransform?: 'uppercase' | 'lowercase'; - fieldViewType?: 'freeform' | 'stacked'; -} - -export type FieldSettings = { - tl: [number, number]; - br: [number, number]; - opts: FieldOpts; - viewType: ViewType; - title?: string; - subfields?: FieldSettings[]; - types?: TemplateFieldType[]; - sizes?: TemplateFieldSize[]; - description?: string; -}; - -export interface Field { - getContent: () => string; - setContent: (content: string, type?: FieldContentType) => void; - getDimensions: FieldDimensions; - getSubfields: Field[]; - getAllSubfields: Field[]; - getID: number; - getViewType: ViewType; - getDescription: string; - getTitle: () => string; - setTitle: (title: string) => void; - setupSubfields: () => Field[]; - applyAttributes: (field: Field) => void; - renderedDoc: () => Doc; - matches: (cols: Col[]) => number[]; - updateRenderedDoc: (oldDoc?: Doc) => Doc; -}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/FieldUtils.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/FieldUtils.tsx deleted file mode 100644 index 3886774d2..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/FieldUtils.tsx +++ /dev/null @@ -1,79 +0,0 @@ -import { Doc } from "../../../../../../fields/Doc"; -import { ComputedField, ScriptField } from "../../../../../../fields/ScriptField"; -import { Col } from "../DocCreatorMenu"; -import { TemplateFieldSize, TemplateFieldType, TemplateLayouts } from "../TemplateBackend"; -import { FieldDimensions, FieldSettings } from "./Field"; - -export class FieldUtils { - public static getLocalDimensions = (coords: { tl: [number, number]; br: [number, number] }, parentDimensions: FieldDimensions): FieldDimensions => { - const l = (coords.tl[0] * parentDimensions.width) / 2; - const t = coords.tl[1] * parentDimensions.height / 2; //prettier-ignore - const r = (coords.br[0] * parentDimensions.width) / 2; - const b = coords.br[1] * parentDimensions.height / 2; //prettier-ignore - const width = r - l; - const height = b - t; - const coord = { x: l, y: t }; - return { width, height, coord }; - }; - - public static applyBasicOpts = (doc: Doc, parentDimensions: FieldDimensions, settings: FieldSettings, oldDoc?: Doc) => { - const opts = settings.opts; - doc.isDefaultTemplateDoc = oldDoc ? oldDoc.isDefaultTemplateDoc : true; - doc._layout_hideScroll = oldDoc ? oldDoc._layout_hideScroll : true; - doc.x = oldDoc ? oldDoc.x : parentDimensions.coord.x; - doc.y = oldDoc ? oldDoc.y : parentDimensions.coord.y; - doc._height = oldDoc ? oldDoc.height : parentDimensions.height; - doc._width = oldDoc ? oldDoc.width : parentDimensions.width; - doc.backgroundColor = oldDoc ? oldDoc.backgroundColor : opts.backgroundColor ?? ''; - doc._layout_borderRounding = !opts.cornerRounding ? '0px' : ScriptField.MakeFunction(`${opts.cornerRounding} * this.width + 'px'`); - doc.borderColor = oldDoc ? oldDoc.borderColor : opts.borderColor; - doc.borderWidth = oldDoc ? oldDoc.borderWidth : opts.borderWidth; - doc.opacity = oldDoc ? oldDoc.opacity : opts.opacity; - doc._rotation = oldDoc ? oldDoc._rotation : opts.rotation; - doc.hCentering = oldDoc ? oldDoc.hCentering : opts.contentXCentering; - doc.nativeWidth = parentDimensions.width; - doc.nativeHeight = parentDimensions.height; - doc._layout_nativeDimEditable = true; - }; - - public static calculateFontSize = (contWidth: number, contHeight: number, text: string, uppercase: boolean): number => { - const words: string[] = text.split(/\s+/).filter(Boolean); - - let currFontSize = 1; - let rowsCount = 1; - let currTextHeight = currFontSize * rowsCount * 2; - - while (currTextHeight <= contHeight) { - let wordIndex = 0; - let currentRowWidth = 0; - let wordsInCurrRow = 0; - rowsCount = 1; - - while (wordIndex < words.length) { - const word = words[wordIndex]; - const wordWidth = word.length * currFontSize * 0.7; - - if (currentRowWidth + wordWidth <= contWidth) { - currentRowWidth += wordWidth; - ++wordsInCurrRow; - } else { - if (words.length !== 1 && words.length > wordsInCurrRow) { - rowsCount++; - currentRowWidth = wordWidth; - wordsInCurrRow = 1; - } else { - break; - } - } - - wordIndex++; - } - - currTextHeight = rowsCount * currFontSize * 2; - - currFontSize += 1; - } - - return currFontSize - 1; - }; -}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/StaticField.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/StaticField.tsx deleted file mode 100644 index 47b43f051..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/FieldTypes/StaticField.tsx +++ /dev/null @@ -1,147 +0,0 @@ -import { Doc } from "../../../../../../fields/Doc"; -import { Docs } from "../../../../../documents/Documents"; -import { Col } from "../DocCreatorMenu"; -import { DynamicField } from "./DynamicField"; -import { FieldUtils } from "./FieldUtils"; -import { Field, FieldContentType, FieldDimensions, FieldSettings, ViewType } from "./Field"; - -export class StaticField { - private content: string; - private contentType: FieldContentType | undefined; - private subfields: Field[] = []; - private renderedDocument: Doc; - - private id: number; - private title: string = ''; - - private settings: FieldSettings; - - private parent: Field; - private dimensions: FieldDimensions; - - constructor(settings: FieldSettings, parent: Field, id: number) { - this.settings = settings; - if (settings.title) { this.title = settings.title }; - this.id = id; - this.parent = parent; - this.dimensions = FieldUtils.getLocalDimensions({tl: settings.tl, br: settings.br}, this.parent.getDimensions); - this.content = ''; - this.subfields = this.setupSubfields(); - this.renderedDocument = this.updateRenderedDoc(); - }; - - get getSubfields(): Field[] { return this.subfields ?? []; }; - - get getAllSubfields(): Field[] { - let fields: Field[] = []; - this.subfields?.forEach(field => { - fields.push(field); - fields = fields.concat(field.getAllSubfields); - }); - return fields; - }; - - get getDimensions() { return this.dimensions }; - get getID() { return this.id }; - get getViewType() { return this.settings.viewType }; - - get getDescription(): string { - return this.settings.description ?? ''; - } - - renderedDoc = () => { - return this.renderedDocument; - } - - setContent = (newContent: string, type?: FieldContentType) => { - this.content = newContent; - if (type) this.contentType = type; - this.updateRenderedDoc(this.renderedDocument); - }; - getContent() { return this.content }; - - setTitle = (title: string) => { - this.title = title; - this.renderedDocument.title = title; - this.updateRenderedDoc(this.renderedDocument); - }; - getTitle = () => { return this.title }; - - applyAttributes = (field: Field) => { //!!! can be updated later for more robust clonign; this is all ythat's needed now - field.setTitle(this.title); - field.setContent('', this.contentType); - field.updateRenderedDoc(this.renderedDoc()); - } - - setupSubfields = (): Field[] => { - const fields: Field[] = []; - this.settings.subfields?.forEach((fieldSettings, index) => { - let field: Field; - const type = fieldSettings.viewType; - - const id = Number(String(this.id) + String(index)); - - if (type === ViewType.FREEFORM || type === ViewType.CAROUSEL3D) { - field = new DynamicField(fieldSettings, id, this); - } else { - field = new StaticField(fieldSettings, this, id); - }; - - fields.push(field); - }); - return fields; - }; - - matches = (cols: Col[]): number[] => { - const colMatchesField = (col: Col) => { - const isMatch: boolean = ( - this.settings.sizes?.some(size => col.sizes?.includes(size)) - && this.settings.types?.includes(col.type)) - ?? false; - return isMatch; - } - - const matches: Array<number> = []; - - cols.forEach((col, v) => { - if (colMatchesField(col)) { - matches.push(v); - } - }); - - return matches; - }; - - updateRenderedDoc = (oldDoc?: Doc): Doc => { - const opts = this.settings.opts; - - if (!this.contentType) { this.contentType = FieldContentType.STRING }; - - let doc: Doc; - - switch (this.contentType) { - case FieldContentType.STRING: - doc = Docs.Create.TextDocument(String(this.content), { - title: this.title, - text_fontColor: oldDoc ? String(oldDoc.color) : opts.color, - contentBold: oldDoc ? Boolean(oldDoc.fontBold) : opts.fontBold, - textTransform: oldDoc ? String(oldDoc.fontTransform) : opts.fontTransform, - color: oldDoc ? String(oldDoc.color) : opts.color, - _text_fontSize: `${FieldUtils.calculateFontSize(this.dimensions.width, this.dimensions.height, String(this.content), true)}` - }); - FieldUtils.applyBasicOpts(doc, this.dimensions, this.settings, oldDoc); - break; - case FieldContentType.IMAGE: - doc = Docs.Create.ImageDocument(String(this.content), { - title: this.title, - _layout_fitWidth: false, - }); - FieldUtils.applyBasicOpts(doc, this.dimensions, this.settings, oldDoc); - break; - } - - this.renderedDocument = doc; - - return doc; - }; -}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/ConditionalsTextarea.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/ConditionalsTextarea.tsx new file mode 100644 index 000000000..89c2e44ff --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/ConditionalsTextarea.tsx @@ -0,0 +1,65 @@ +import { observer } from "mobx-react"; +import { ObservableReactComponent } from "../../../../ObservableReactComponent"; +import { Conditional } from "../Backend/TemplateManager"; +import { action, makeObservable, observable, runInAction } from "mobx"; +import React from "react"; + +interface ConditionalsTextAreaProps { + conditional: Conditional; + property: keyof Conditional; +} + +@observer +export class ConditionalsTextArea extends ObservableReactComponent<ConditionalsTextAreaProps> { + + private mirrorRef: HTMLSpanElement | null = null; + + @observable private inputWidth: string = '60px'; + + constructor(props: ConditionalsTextAreaProps) { + super(props); + makeObservable(this); + } + + setMirrorRef: React.LegacyRef<HTMLSpanElement> = (node) => { this.mirrorRef = node } + + @action updateInputWidth() { + const mirror = this.mirrorRef; + if (mirror) { + const width = mirror.offsetWidth; + if ( width + 8 > 60) this.inputWidth = `${width + 8}px`; + } + } + + render() { + return ( + <div style={{ display: 'inline-block', position: 'relative' }}> + <span + ref={this.setMirrorRef} + style={{ + position: 'absolute', + visibility: 'hidden', + whiteSpace: 'pre', + font: 'inherit', + padding: 0, + }} + > + {this._props.conditional[this._props.property] || ' '} + </span> + <input + className="form-row-input" + value={this.props.conditional[this.props.property] ?? ''} + onChange={e => { + runInAction(() => { + this.props.conditional[this.props.property] = e.target.value as "=" | ">" | "<" | "contains"; + }); + this.updateInputWidth(); + }} + style={{ width: this.inputWidth }} + placeholder={this.props.property} + /> + </div> + ); + } + +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/DocCreatorMenuButton.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/DocCreatorMenuButton.tsx new file mode 100644 index 000000000..48d2de4de --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/DocCreatorMenuButton.tsx @@ -0,0 +1,42 @@ +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { ObservableReactComponent } from "../../../../ObservableReactComponent"; +import React from "react"; +import { FontAwesomeIcon } from "@fortawesome/react-fontawesome"; +import { setupMoveUpEvents, returnFalse } from "../../../../../../ClientUtils"; +import { emptyFunction } from "../../../../../../Utils"; +import { undoable } from "../../../../../util/UndoManager"; +import { observer } from "mobx-react"; + +interface DocCreatorMenuButtonProps { + icon: IconProp; + // eslint-disable-next-line + function: () => any; + styles?: string; +} + +@observer +export class DocCreatorMenuButton extends ObservableReactComponent<DocCreatorMenuButtonProps> { + // eslint-disable-next-line + setupButtonClick = (e: React.PointerEvent, func: (...args: any) => void) => { + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + clickEv.preventDefault(); + func(); + }, 'create docs') + ); + }; + + render() { + + return ( + <button className={`docCreatorMenu-menu-button ${this._props.styles}`} onPointerDown={e => this.setupButtonClick(e, async () => this._props.function())}> + <FontAwesomeIcon icon={this._props.icon} /> + </button> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateEditingWindow.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateEditingWindow.tsx new file mode 100644 index 000000000..c35099e82 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateEditingWindow.tsx @@ -0,0 +1,220 @@ +import { action, makeObservable, observable, reaction } from 'mobx'; +import React from 'react'; +import { returnFalse, returnEmptyFilter } from '../../../../../../ClientUtils'; +import { emptyFunction } from '../../../../../../Utils'; +import { returnEmptyDoclist } from '../../../../../../fields/Doc'; +import { DefaultStyleProvider } from '../../../../StyleProvider'; +import { DocumentView, DocumentViewInternal } from '../../../DocumentView'; +import { DocCreatorMenu } from '../DocCreatorMenu'; +import { TemplatePreviewGrid } from './TemplatePreviewGrid'; +import { observer } from 'mobx-react'; +import { Transform } from '../../../../../util/Transform'; +import { Template } from '../Template'; +import { ObservableReactComponent } from '../../../../ObservableReactComponent'; +import { IDisposer } from 'mobx-utils'; +import { DocCreatorMenuButton } from './DocCreatorMenuButton'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; + +export type FireflyStructureOptions = { + numVariations: number; + temperature: number; + useStyleRef: boolean; +}; + +interface FireflyVariationsTabProps { + menu: DocCreatorMenu; + template: Template; +} + +@observer +export class FireflyVariationsTab extends ObservableReactComponent<FireflyVariationsTabProps> { + private _prompt: string = 'Use this template to generate an empty baseball card template.'; + private _optionsButtonOpts: [IconProp, () => void] = ['gear', emptyFunction]; + private _previewBoxRightButtonOpts: [IconProp, () => void] = ['gear', () => this.forceUpdate()]; + + @observable _fireflyOptions: FireflyStructureOptions = { numVariations: 3, temperature: 0, useStyleRef: false }; + @observable _promptInput: HTMLTextAreaElement | null = null; + @observable _loading: boolean = false; + @observable _variationsTabOpen: boolean = false; + @observable _variationURLs: string[] = []; + + constructor(props: FireflyVariationsTabProps) { + super(props); + makeObservable(this); + } + + generateVariations = action(async () => { + this._props.menu._variations = []; + this._loading = true; + const cloneTemplate = this._props.template.clone(false); + cloneTemplate.setMatteBackground(); + const doc = cloneTemplate.getRenderedDoc()!; + this._props.menu.generateVariations(doc, this._prompt, this._fireflyOptions).then( + action((urls: string[]) => { + (this._variationURLs = urls).forEach(url => { + const template = this._props.template.clone(true); + template.setImageAsBackground(url, true); + this._props.menu._variations.push(template); + }); + this._loading = false; + }) + ); + }); + + render() { + return ( + <div className="docCreatorMenu-editing-firefly-section"> + <div className="docCreatorMenu-option-divider full no-margin-bottom" /> + <TemplatePreviewGrid + menu={this._props.menu} + title="Generate Variations" + loading={this._loading} + styles="scrolling" + templates={this._props.menu._variations} + optionsButtonOpts={this._optionsButtonOpts} + previewBoxRightButtonOpts={this._previewBoxRightButtonOpts} + /> + <div className="docCreatorMenu-firefly-options"> + <div className="docCreatorMenu-variation-prompt-row"> + <textarea + className="docCreatorMenu-variation-prompt-input-textbox" + ref={action((node: HTMLTextAreaElement | null) => (this._promptInput = node))} + onChange={e => (this._prompt = e.target.value)} + onInput={() => { + if (this._promptInput !== null) { + this._promptInput.style.height = 'auto'; + this._promptInput.style.height = this._promptInput.scrollHeight + 'px'; + } + }} + defaultValue="" + placeholder="Enter a custom prompt here (optional)" + /> + <DocCreatorMenuButton icon={'arrows-rotate'} styles={'border'} function={this.generateVariations} /> + </div> + <nav className="options‑menu"> + <label className="menu‑item switch"> + <input type="checkbox" checked={this._fireflyOptions.useStyleRef} onChange={action(e => (this._fireflyOptions.useStyleRef = e.target.checked))} /> + <span className="slider round"></span> + <span className="firefly-option-label">Use template as style guide</span> + </label> + <div className="menu‑item"> + <span className="firefly-option-label">Variations</span> + <input type="range" id="variations" min="1" max="5" value={this._fireflyOptions.numVariations} onChange={action(e => (this._fireflyOptions.numVariations = Number(e.target.value)))} /> + <span className="value" id="varVal"> + {this._fireflyOptions.numVariations} + </span> + </div> + <div className="menu‑item"> + <span className="firefly-option-label">Temperature</span> + <input type="range" id="temperature" min="1" max="100" value={this._fireflyOptions.temperature} onChange={action(e => (this._fireflyOptions.temperature = Number(e.target.value)))} /> + <span className="value" id="tempVal"> + {this._fireflyOptions.temperature} + </span> + </div> + </nav> + </div> + </div> + ); + } +} + +interface TemplateEditingWindowProps { + menu: DocCreatorMenu; + template: Template; +} + +@observer +export class TemplateEditingWindow extends ObservableReactComponent<TemplateEditingWindowProps> { + private disposers: { [name: string]: IDisposer } = {}; + + @observable private _previewWindow: HTMLDivElement | null = null; + @observable _variationsTabOpen: boolean = false; + + constructor(props: TemplateEditingWindowProps) { + super(props); + makeObservable(this); + } + + componentDidMount(): void { + this.disposers.windowDimensions = reaction( + () => this._props.menu._resizing, + () => this.forceUpdate(), + { fireImmediately: true } + ); + } + + componentWillUnmount() { + Object.values(this.disposers).forEach(disposer => disposer?.()); + } + + @action setVariationTab = (open: boolean) => { + this._variationsTabOpen = open; + if (this._previewWindow && open) { + this._previewWindow.style.height = String(Number(this._previewWindow.clientHeight) * 0.6); + } else if (this._previewWindow && !open) { + this._previewWindow.style.height = String((Number(this._previewWindow.clientHeight) * 5) / 3); + } + }; + + previewPanelWidth = () => this._previewWindow?.clientWidth ?? 500; + previewPanelHeight = () => this._previewWindow?.clientHeight ?? 500; + previewScreenToLocalXf = () => new Transform(-this._props.menu._pageX - 5, -this._props.menu._pageY - 35, 1); + get renderedDocPreview() { + const doc = this._props.template.getRenderedDoc(); + return ( + <div className="docCreatorMenu-expanded-template-preview" ref={action((node: HTMLDivElement | null) => (this._previewWindow = node))}> + {this._previewWindow && doc ? ( + <DocumentView + Document={doc} + isContentActive={emptyFunction} + addDocument={returnFalse} + moveDocument={returnFalse} + removeDocument={returnFalse} + PanelWidth={this.previewPanelWidth} + PanelHeight={this.previewPanelHeight} + ScreenToLocalTransform={this.previewScreenToLocalXf} + renderDepth={5} + whenChildContentsActiveChanged={emptyFunction} + focus={emptyFunction} + styleProvider={DefaultStyleProvider} + addDocTab={DocumentViewInternal.addDocTabFunc} + pinToPres={emptyFunction} + childFilters={returnEmptyFilter} + childFiltersByRanges={returnEmptyFilter} + searchFilterDocs={returnEmptyDoclist} + // fitContentsToBox={returnFalse} + // fitWidth={returnFalse} + /> + ) : null} + </div> + ); + } + + expandFunc = () => { + // if (this._props.template === this._props.menu._selectedTemplate) { + // this._props.menu.updateRenderedPreviewCollection(this._props.template); + // } + this._props.menu.setExpandedView(undefined); + }; + lastFunc = () => { + this._props.menu.editLastTemplate(); + this.forceUpdate(); + }; + variationFunc = () => this.setVariationTab(!this._variationsTabOpen); + render() { + return ( + <div className="docCreatorMenu-templates-view"> + <div className="docCreatorMenu-expanded-template-preview"> + <div className="top-panel" /> + {this.renderedDocPreview} + {this._variationsTabOpen ? <FireflyVariationsTab menu={this._props.menu} template={this._props.template} /> : null} + <div className="right-buttons-panel"> + <DocCreatorMenuButton icon="minimize" function={this.expandFunc} /> + <DocCreatorMenuButton icon="lightbulb" function={this.variationFunc} /> + <DocCreatorMenuButton icon="arrow-rotate-backward" function={this.lastFunc} /> + </div> + </div> + </div> + ); + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateMenuFieldOptions.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateMenuFieldOptions.tsx new file mode 100644 index 000000000..f0e20837c --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateMenuFieldOptions.tsx @@ -0,0 +1,185 @@ +import { action, makeObservable, observable } from 'mobx'; +import { observer } from 'mobx-react'; +import { ObservableReactComponent } from '../../../../ObservableReactComponent'; +import { Col, DocCreatorMenu } from '../DocCreatorMenu'; +import React from 'react'; +import { Conditional, TemplateManager } from '../Backend/TemplateManager'; +import { TemplateFieldType, TemplateFieldSize } from '../TemplateBackend'; +import { DocCreatorMenuButton } from './DocCreatorMenuButton'; + +interface TemplateMenuFieldOptionsProps { + menu: DocCreatorMenu; + templateManager: TemplateManager; +} + +@observer +export class TemplateMenuFieldOptions extends ObservableReactComponent<TemplateMenuFieldOptionsProps> { + @observable _collapsedCols: string[] = []; //any columns whose options panels are hidden + + constructor(props: TemplateMenuFieldOptionsProps) { + super(props); + makeObservable(this); + } + + @observable private _newCondCache: Record<string, Conditional> = {}; + + getParams = (title: string, parameters?: Conditional): Conditional => { + if (parameters) return parameters; + + if (!this._newCondCache[title]) { + this._newCondCache[title] = observable<Conditional>({ + field: title, + operator: '=', + condition: '', + target: 'Own', + attribute: '', + value: '', + }); + } + return this._newCondCache[title]; + }; + + conditionForm = (title: string, parameters?: Conditional, empty: boolean = false) => { + const contentFieldTitles = this._props.menu.fieldsInfos + .filter(field => field.type !== TemplateFieldType.DATA) + .map(field => field.title) + .concat('Template'); + const params: Conditional = this.getParams(title, parameters); + + return ( + <div className="form"> + <div className="form-row"> + <div className="form-row-plain-text">If</div> + <div className="form-row-plain-text">{title}</div> + <div className="operator-options-dropdown"> + <span className="operator-dropdown-current">{params.operator ?? '='}</span> + <div className="operator-dropdown-option" onPointerDown={() => (params.operator = '=')}> + {'='} + </div> + </div> + <input className="form-row-textarea" onChange={action(e => (params.condition = e.target.value))} placeholder="value" value={params.condition} /> + <div className="form-row-plain-text">then</div> + <div className="operator-options-dropdown"> + <span className="operator-dropdown-current">{params.target ?? 'Own'}</span> + {contentFieldTitles.map((fieldTitle, i) => ( + <div className="operator-dropdown-option" key={i} onPointerDown={() => (params.target = fieldTitle)}> + {fieldTitle === title ? 'Own' : fieldTitle} + </div> + ))} + </div> + <input className="form-row-textarea" onChange={action(e => (params.attribute = e.target.value))} placeholder="attribute" value={params.attribute} /> + <div className="form-row-plain-text">{'becomes'}</div> + <input className="form-row-textarea" onChange={action(e => (params.value = e.target.value))} placeholder="value" value={params.value} /> + </div> + {empty ? ( + <DocCreatorMenuButton + icon={'plus'} + styles={'float-right border'} + function={() => { + this._newCondCache[title] = observable<Conditional>({ + field: title, + operator: '=', + condition: '', + target: 'Own', + attribute: '', + value: '', + }); + this._props.templateManager.addFieldCondition(title, params); + }} + /> + ) : ( + <DocCreatorMenuButton icon={'minus'} styles={'float-right border'} function={() => this._props.templateManager.removeFieldCondition(title, params)} /> + )} + </div> + ); + }; + + fieldPanel = (field: Col, id: number) => ( + <div className="field-panel" key={id}> + <div + className="top-bar" + onPointerDown={e => + this._props.menu.setUpButtonClick( + e, + action(() => { + if (this._collapsedCols.includes(field.title)) { + this._collapsedCols = this._collapsedCols.filter(col => col !== field.title); + } else { + this._collapsedCols.push(field.title); + } + }) + ) + }> + <span className="field-title">{`${field.title} Field`}</span> + <DocCreatorMenuButton icon={'minus'} styles={'no-margin absolute-right'} function={() => this._props.menu.removeField(field)} /> + </div> + {this._collapsedCols.includes(field.title) ? null : ( + <> + <div className="opts-bar"> + <div className="opt-box"> + <div className="top-bar"> Title </div> + <textarea className="content" style={{ width: '100%', height: 'calc(100% - 20px)' }} value={field.title} placeholder={'Enter title'} onChange={e => this._props.menu.setColTitle(field, e.target.value)} /> + </div> + <div className="opt-box"> + <div className="top-bar"> Type </div> + <div className="content"> + <span className="type-display">{field.type === TemplateFieldType.TEXT ? 'Text Field' : field.type === TemplateFieldType.VISUAL ? 'File Field' : field.type === TemplateFieldType.DATA ? 'Data Field' : ''}</span> + <div className="bubbles"> + <input className="bubble" type="radio" name="type" onClick={() => this._props.menu.setColType(field, TemplateFieldType.TEXT)} /> + <div className="text">Text</div> + <input className="bubble" type="radio" name="type" onClick={() => this._props.menu.setColType(field, TemplateFieldType.VISUAL)} /> + <div className="text">File</div> + <input className="bubble" type="radio" name="type" onClick={() => this._props.menu.setColType(field, TemplateFieldType.DATA)} /> + <div className="text">Data</div> + </div> + </div> + </div> + </div> + {field.type === TemplateFieldType.DATA ? null : ( + <> + <div className="sizes-box"> + <div className="top-bar"> Valid Sizes </div> + <div className="content"> + <div className="bubbles"> + {Object.values(TemplateFieldSize).map(size => ( + <div key={field + size}> + <input className="bubble" type="checkbox" name="type" checked={field.sizes.includes(size)} onChange={e => this._props.menu.modifyColSizes(field, size, e.target.checked)} /> + <div className="text">{size}</div> + </div> + ))} + </div> + </div> + </div> + <div className="desc-box"> + <div className="top-bar"> Prompt </div> + <textarea + className="content" + onChange={e => this._props.menu.setColDesc(field, e.target.value)} + defaultValue={field.desc === this._props.menu._dataViz?.GPTSummary?.get(field.title)?.desc ? '' : field.desc} + placeholder={this._props.menu._dataViz?.GPTSummary?.get(field.title)?.desc ?? 'Add a description/prompt to help with template generation.'} + /> + </div> + </> + )} + <div className="conditionals-section"> + <span className="conditionals-title">Conditional Logic</span> + {this.conditionForm(field.title, undefined, true)} + {this._props.templateManager._conditionalFieldLogic[field.title]?.map(condition => this.conditionForm(condition.field, condition))} + </div> + </> + )} + </div> + ); + + render() { + return ( + <div className="docCreatorMenu-dashboard-view"> + <div className="topbar"> + <DocCreatorMenuButton icon="plus" function={this._props.menu.addField} /> + <DocCreatorMenuButton icon="arrow-left" styles="float-right" function={action(() => (this._props.menu._menuContent = 'templates'))} /> + </div> + <div className="panels-container">{this._props.menu.fieldsInfos.map((field, i) => this.fieldPanel(field, i))}</div> + </div> + ); + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewBox.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewBox.tsx new file mode 100644 index 000000000..7d02fff12 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewBox.tsx @@ -0,0 +1,89 @@ +import { Colors } from '@dash/components/src'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { Template } from '../Template'; +import { action, makeObservable, observable } from 'mobx'; +import React from 'react'; +import { ObservableReactComponent } from '../../../../ObservableReactComponent'; +import { DocCreatorMenu } from '../DocCreatorMenu'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { DocumentView } from '../../../DocumentView'; +import { emptyFunction } from '../../../../../../Utils'; +import { returnEmptyFilter, returnFalse } from '../../../../../../ClientUtils'; +import { Transform } from '../../../../../util/Transform'; +import { DefaultStyleProvider } from '../../../../StyleProvider'; +import { Doc, returnEmptyDoclist } from '../../../../../../fields/Doc'; +import { observer } from 'mobx-react'; + +export interface TemplatePreviewBoxProps { + template: Template; + menu: DocCreatorMenu; // eslint-disable-next-line + leftButtonOpts?: [icon: IconProp, func: (...args: any) => void]; // eslint-disable-next-line + rightButtonOpts?: [icon: IconProp, func: (...args: any) => void]; +} + +@observer +export class TemplatePreviewBox extends ObservableReactComponent<TemplatePreviewBoxProps> { + @observable private previewWindow: HTMLDivElement | null = null; + + constructor(props: TemplatePreviewBoxProps) { + super(props); + makeObservable(this); + } + + get doc() { + return this._props.template.getRenderedDoc() as Doc; + } + + docPanelWidth = () => this.previewWindow?.clientWidth ?? this._props.menu._menuDimensions.height * 0.3; + docPanelHeight = () => this.previewWindow?.clientHeight ?? this._props.menu._menuDimensions.height * 0.3; + docScreenToLocalXf = () => new Transform(-this._props.menu._pageX - 5, -this._props.menu._pageY - 35, 1); + + render() { + const template = this._props.template; + + return ( + <div + key={template.title} + className="docCreatorMenu-preview-window" + ref={action((node: HTMLDivElement | null) => (this.previewWindow = node))} + style={{ + border: this._props.menu._selectedTemplate === template ? `solid 3px ${Colors.MEDIUM_BLUE}` : '', + boxShadow: this._props.menu._selectedTemplate === template ? `0 0 15px rgba(68, 118, 247, .8)` : '', + }} + onPointerDown={e => this._props.menu.setUpButtonClick(e, () => this._props.menu.updateSelectedTemplate(template))}> + {this._props.leftButtonOpts ? ( + <button className="option-button left" onPointerDown={e => this._props.menu.setUpButtonClick(e, () => this._props.leftButtonOpts)}> + <FontAwesomeIcon icon={this._props.leftButtonOpts![0]} color="white" /> + </button> + ) : null} + {this._props.rightButtonOpts ? ( + <button className="option-button right" onPointerDown={e => this._props.menu.setUpButtonClick(e, () => this._props.rightButtonOpts)}> + <FontAwesomeIcon icon={this._props.rightButtonOpts![0]} color="white" /> + </button> + ) : null} + <DocumentView + Document={this.doc} + isContentActive={emptyFunction} // !!! should be return false + addDocument={returnFalse} + moveDocument={returnFalse} + removeDocument={returnFalse} + PanelWidth={this.docPanelWidth} + PanelHeight={this.docPanelHeight} + ScreenToLocalTransform={this.docScreenToLocalXf} + renderDepth={1} + whenChildContentsActiveChanged={emptyFunction} + focus={emptyFunction} + styleProvider={DefaultStyleProvider} + addDocTab={this._props.menu._props.addDocTab} + pinToPres={emptyFunction} + childFilters={returnEmptyFilter} + childFiltersByRanges={returnEmptyFilter} + searchFilterDocs={returnEmptyDoclist} + // fitContentsToBox={returnFalse} + // fitWidth={returnFalse} + hideDecorations={true} + /> + </div> + ); + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewGrid.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewGrid.tsx new file mode 100644 index 000000000..da4851f84 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplatePreviewGrid.tsx @@ -0,0 +1,60 @@ +import { makeObservable, runInAction } from "mobx"; +import React from "react"; +import ReactLoading from "react-loading"; +import { Doc } from "../../../../../../fields/Doc"; +import { StrCast } from "../../../../../../fields/Types"; +import { ObservableReactComponent } from "../../../../ObservableReactComponent"; +import { Template } from "../Template"; +import { observer } from "mobx-react"; +import { DocCreatorMenu } from "../DocCreatorMenu"; +import { TemplatePreviewBox } from "./TemplatePreviewBox"; +import { IconProp } from "@fortawesome/fontawesome-svg-core"; +import { DocCreatorMenuButton } from "./DocCreatorMenuButton"; + +export interface SuggestedTemplatesProps { + menu: DocCreatorMenu; + loading?: boolean; + templates: Template[]; + title: string; + styles?: string; // eslint-disable-next-line + optionsButtonOpts?: [IconProp, (...args: any) => any]; // eslint-disable-next-line + previewBoxLeftButtonOpts?: [IconProp, (...args: any) => any]; // eslint-disable-next-line + previewBoxRightButtonOpts?: [IconProp, (...args: any) => any]; +} + +@observer +export class TemplatePreviewGrid extends ObservableReactComponent<SuggestedTemplatesProps> { + + constructor(props: SuggestedTemplatesProps) { + super(props); + makeObservable(this); + } + + render() { + return ( + <div className="docCreatorMenu-section"> + <div className="docCreatorMenu-section-topbar"> + <div className="docCreatorMenu-section-title">{this.props.title}</div> + {this._props.optionsButtonOpts ? + <DocCreatorMenuButton icon={this._props.optionsButtonOpts[0] as IconProp} styles={'float-right'} function={() => runInAction(this._props.optionsButtonOpts![1])}/> + : null} + </div> + <div className={"docCreatorMenu-templates-preview-window " + this._props.styles}> + {this._props.loading ? + (<div className="loading-spinner"> + <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> + </div>) + : this.props.templates.map((template, i) => ( + <TemplatePreviewBox + key={i} + template={template} + menu={this.props.menu} + leftButtonOpts={["magnifying-glass", (template: Template) => { this.props.menu.setExpandedView(template); this.forceUpdate(); }]} + rightButtonOpts={this._props.previewBoxRightButtonOpts} + /> + ))} + </div> + </div> + ); + } +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateRenderPreviewWindow.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateRenderPreviewWindow.tsx new file mode 100644 index 000000000..9222d7349 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Menu/TemplateRenderPreviewWindow.tsx @@ -0,0 +1,346 @@ +import { action, computed, makeObservable, observable, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import { ObservableReactComponent } from '../../../../ObservableReactComponent'; +import { DocCreatorMenu, LayoutType } from '../DocCreatorMenu'; +import React from 'react'; +import { IconProp } from '@fortawesome/fontawesome-svg-core'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { setupMoveUpEvents, returnFalse, returnEmptyFilter } from '../../../../../../ClientUtils'; +import { emptyFunction } from '../../../../../../Utils'; +import { undoable } from '../../../../../util/UndoManager'; +import ReactLoading from 'react-loading'; +import { Doc, NumListCast, returnEmptyDoclist } from '../../../../../../fields/Doc'; +import { NumCast, StrCast } from '../../../../../../fields/Types'; +import { DefaultStyleProvider } from '../../../../StyleProvider'; +import { DocumentView } from '../../../DocumentView'; +import { Transform } from '../../../../../util/Transform'; +import { Docs, DocumentOptions } from '../../../../../documents/Documents'; + +interface TemplatesRenderPreviewWindowProps { + menu: DocCreatorMenu; +} + +@observer +export class TemplatesRenderPreviewWindow extends ObservableReactComponent<TemplatesRenderPreviewWindowProps> { + @observable private _layout: { type: LayoutType; yMargin: number; xMargin: number; columns?: number; repeat: number } = { type: LayoutType.FREEFORM, yMargin: 10, xMargin: 10, columns: 1, repeat: 0 }; + + @observable private renderedDocs: Doc[] = []; + @observable private renderedDocCollection: Doc | undefined = undefined; + + @observable private loading: boolean = false; + + constructor(props: TemplatesRenderPreviewWindowProps) { + super(props); + makeObservable(this); + this.updateRenderedPreviewCollection(); + } + + @computed get canMakeDocs() { + return this._props.menu._selectedTemplate !== undefined && this._layout !== undefined; + } + + @computed get docsToRender() { + if (this._props.menu.DEBUG_MODE) { + return [1, 2, 3, 4]; + } else { + return NumListCast(this._props.menu._dataViz?.layoutDoc.dataViz_selectedRows); + } + } + + @computed get rowsCount() { + switch (this._layout.type) { + case LayoutType.FREEFORM: + return Math.ceil(this.docsToRender.length / (this._layout.columns ?? 1)) ?? 0; + case LayoutType.CAROUSEL3D: + return 1.8; + default: + return 1; + } + } + + @computed get columnsCount() { + switch (this._layout.type) { + case LayoutType.FREEFORM: + return this._layout.columns ?? 1; + case LayoutType.CAROUSEL3D: + return 3; + default: + return 1; + } + } + + @action updateRenderedPreviewCollection = async () => { + this.loading = true; + this.renderedDocs = await this._props.menu.createDocsForPreview(); + this.updateRenderedDocCollection(); + }; + + /** + * Updates the preview that shows how all docs will be rendered in the chosen collection type. + @type the type of collection the docs should render to (ie. freeform, carousel, card) + */ + updateRenderedDocCollection = () => { + if (!this.renderedDocs) return; + + const collectionFactory = (): ((docs: Doc[], options: DocumentOptions) => Doc) => { + switch (this._layout.type) { + case LayoutType.CAROUSEL3D: return Docs.Create.Carousel3DDocument; + case LayoutType.FREEFORM: return Docs.Create.FreeformDocument; + case LayoutType.CARD: return Docs.Create.CardDeckDocument; + case LayoutType.MASONRY: return Docs.Create.MasonryDocument; + case LayoutType.CAROUSEL: return Docs.Create.CarouselDocument; + default: return Docs.Create.FreeformDocument; + } // prettier-ignore + }; + + const collection = collectionFactory()(this.renderedDocs, { + isDefaultTemplateDoc: true, + title: 'title', + backgroundColor: 'gray', + x: 200, + y: 200, + _width: 4000, + _height: 4000, + }); + + this.applyLayout(collection, this.renderedDocs); + + this.renderedDocCollection = collection; + + this.loading = false; + + this.forceUpdate(); + }; + + @action updateMargin = (input: string, xOrY: 'x' | 'y') => { + this._layout[`${xOrY}Margin`] = Number(input); + setTimeout(() => { + if (!this.renderedDocCollection || !this.renderedDocs) return; + this.applyLayout(this.renderedDocCollection, this.renderedDocs); + }); + }; + + @action updateColumns = (input: string) => { + this._layout.columns = Number(input); + this.updateRenderedDocCollection(); + }; + + applyLayout = (collection: Doc, docs: Doc[]) => { + const { horizontalSpan, verticalSpan } = this.previewInfo; + collection._height = verticalSpan; + collection._width = horizontalSpan; + collection.layout_fitWidth = true; + collection.freeform_fitContentsToBox = true; + + const columns = (this._layout.columns ?? this.columnsCount) || 1; + const xGap = this._layout.xMargin; + const yGap = this._layout.yMargin; + const startX = -collection._width / 2; + const startY = -collection._height / 2; + const docHeight = NumCast(docs[0]?._height); + const docWidth = NumCast(docs[0]?._width); + + let i = 0; + let docsChanged = 0; + let curX = startX; + let curY = startY; + + while (docsChanged < docs.length) { + while (i < columns && docsChanged < docs.length) { + docs[docsChanged].x = curX; + docs[docsChanged].y = curY; + docs[docsChanged].layout_fitWidth = false; + curX += docWidth + xGap; + ++docsChanged; + ++i; + } + i = 0; + curX = startX; + curY += docHeight + yGap; + } + }; + + @computed + get previewInfo() { + const docHeight = NumCast(this.renderedDocs[0]?._height); + const docWidth = NumCast(this.renderedDocs[0]?._width); + const layout = this._layout; + return { + docHeight: docHeight, + docWidth: docWidth, + horizontalSpan: (docWidth + layout.xMargin) * this.columnsCount - layout.xMargin, + verticalSpan: (docHeight + layout.yMargin) * this.rowsCount - layout.yMargin, + }; + } + + get layoutConfigOptions() { + const optionInput = (icon: string, func: (input: string) => void, def?: number, key?: string, noMargin?: boolean) => { + return ( + <div className="docCreatorMenu-option-container small no-margin" key={key} style={{ marginTop: noMargin ? '0px' : '' }}> + <div className="docCreatorMenu-option-title config layout-config"> + <FontAwesomeIcon icon={icon as IconProp} /> + </div> + <input defaultValue={def} onInput={e => func(e.currentTarget.value)} className="docCreatorMenu-input config layout-config" /> + </div> + ); + }; + + switch (this._layout.type) { + case LayoutType.FREEFORM: + return ( + <div className="docCreatorMenu-configuration-bar"> + {optionInput('arrows-up-down', (input: string) => this.updateMargin(input, 'y'), this._layout.xMargin, '2')} + {optionInput('arrows-left-right', (input: string) => this.updateMargin(input, 'x'), this._layout.xMargin, '3')} + {optionInput('table-columns', this.updateColumns, this._layout.columns, '4', true)} + </div> + ); + default: + break; + } + } + + layoutPanelWidth = () => this._props.menu._menuDimensions.width - 80; + layoutPanelHeight = () => this._props.menu._menuDimensions.height - 105; + layoutScreenToLocalXf = () => new Transform(-this._props.menu._pageX - 5, -this._props.menu._pageY - 35, 1); + + layoutPreviewContents = action(() => { + return this.loading ? ( + <div className="docCreatorMenu-layout-preview-window-wrapper loading"> + <div className="loading-spinner"> + <ReactLoading type="spin" color={StrCast(Doc.UserDoc().userVariantColor)} height={30} width={30} /> + </div> + </div> + ) : !this.renderedDocCollection ? null : ( + <div className="docCreatorMenu-layout-preview-window-wrapper"> + <DocumentView + Document={this.renderedDocCollection} + isContentActive={emptyFunction} + addDocument={returnFalse} + moveDocument={returnFalse} + removeDocument={returnFalse} + PanelWidth={this.layoutPanelWidth} + PanelHeight={this.layoutPanelHeight} + ScreenToLocalTransform={this.layoutScreenToLocalXf} + renderDepth={5} + whenChildContentsActiveChanged={emptyFunction} + focus={emptyFunction} + styleProvider={DefaultStyleProvider} + addDocTab={this._props.menu._props.addDocTab} + pinToPres={emptyFunction} + childFilters={returnEmptyFilter} + childFiltersByRanges={returnEmptyFilter} + searchFilterDocs={returnEmptyDoclist} + hideDecorations={true} + /> + </div> + ); + }); + + selectionBox = (width: number, height: number, icon: string, specClass?: string, options?: JSX.Element[], manual?: boolean): JSX.Element => { + return ( + <div className="docCreatorMenu-option-container"> + <div className={`docCreatorMenu-option-title config ${specClass}`} style={{ width: width * 0.4, height: height }}> + <FontAwesomeIcon icon={icon as IconProp} /> + </div> + {manual ? ( + <input className={`docCreatorMenu-input config ${specClass}`} style={{ width: width * 0.6, height: height }} /> + ) : ( + <select className={`docCreatorMenu-input config ${specClass}`} style={{ width: width * 0.6, height: height }}> + {options} + </select> + )} + </div> + ); + }; + + layoutOption = (option: LayoutType, optStyle?: object, specialFunc?: () => void) => { + return ( + <div + className="docCreatorMenu-dropdown-option" + style={optStyle} + onPointerDown={e => + this._props.menu.setUpButtonClick(e, () => { + specialFunc?.(); + runInAction(() => { + this._layout.type = option; + this.updateRenderedDocCollection(); + }); + }) + }> + {option} + </div> + ); + }; + + get optionsMenuContents() { + const repeatOptions = [0, 1, 2, 3, 4, 5]; + + return ( + <div className="docCreatorMenu-menu-container"> + <div className="docCreatorMenu-option-container layout"> + <div className="docCreatorMenu-dropdown-hoverable"> + <div className="docCreatorMenu-option-title">{this._layout.type ? this._layout.type.toUpperCase() : 'Choose Layout'}</div> + <div className="docCreatorMenu-dropdown-content"> + {this.layoutOption(LayoutType.FREEFORM, undefined, () => { + if (!this._layout.columns) this._layout.columns = Math.ceil(Math.sqrt(this.docsToRender.length)); + })} + {this.layoutOption(LayoutType.CAROUSEL)} + {this.layoutOption(LayoutType.CAROUSEL3D)} + {this.layoutOption(LayoutType.MASONRY)} + </div> + </div> + </div> + {this._layout.type ? this.layoutConfigOptions : null} + {this.layoutPreviewContents()} + {this.selectionBox( + 60, + 20, + 'repeat', + undefined, + repeatOptions.map(num => <option key={num} onPointerDown={() => (this._layout.repeat = num)}>{`${num}x`}</option>) + )} + <hr className="docCreatorMenu-option-divider" /> + <div className="docCreatorMenu-general-options-container"> + <button + className="docCreatorMenu-save-layout-button" + onPointerDown={e => + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + //previous implementation deprecated; return later to add or scrap + return; + }, 'save layout') + ) + }> + <FontAwesomeIcon icon="floppy-disk" /> + </button> + <button + className="docCreatorMenu-create-docs-button" + style={{ backgroundColor: this.canMakeDocs ? '' : 'rgb(155, 155, 155)', border: this.canMakeDocs ? '' : 'solid 2px rgb(180, 180, 180)' }} + onPointerDown={e => + setupMoveUpEvents( + this, + e, + returnFalse, + emptyFunction, + undoable(clickEv => { + clickEv.stopPropagation(); + this.renderedDocCollection && this._props.menu.addRenderedCollectionToMainview(this.renderedDocCollection); + }, 'make docs') + ) + }> + <FontAwesomeIcon icon="plus" /> + </button> + </div> + </div> + ); + } + + render() { + return this.optionsMenuContents; + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.ts new file mode 100644 index 000000000..e2a2a3c1c --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.ts @@ -0,0 +1,179 @@ +import { makeAutoObservable } from 'mobx'; +import { Col } from './DocCreatorMenu'; +import { TemplateFieldType, TemplateLayouts } from './TemplateBackend'; +import { DynamicField } from './TemplateFieldTypes/DynamicField'; +import { FieldSettings, TemplateField, ViewType } from './TemplateFieldTypes/TemplateField'; +import { Conditional } from './Backend/TemplateManager'; +import { TemplateDataField } from './TemplateFieldTypes/DataField'; + +export class Template { + _mainField: DynamicField; + + private _dataFields: TemplateDataField[] = []; + + /** + * A Template can be created from a description of its fields (FieldSettings) or from a DynamicField + * @param definition definition of template as settings or DynamicField + */ + constructor(definition: FieldSettings | DynamicField) { + makeAutoObservable(this); + this._mainField = definition instanceof DynamicField ? definition : this.setupMainField(definition); + } + + get childFields() { return this._mainField?.getSubfields ?? []; } // prettier-ignore + get allFields() { return this._mainField?.getAllSubfields ?? []; } // prettier-ignore + get contentFields() { return this.allFields.filter(field => field.isContentField); } // prettier-ignore + get doc() { return this._mainField?.renderedDoc; } // prettier-ignore + get title() { return this._mainField?.getTitle(); } // prettier-ignore + get descriptionSummary() { return this.contentFields.map(f => `--- Field #${f.getID} (title: ${f.getTitle()}): ${f.getDescription ?? ''} ---`).join(); } // prettier-ignore + get compiledContent() { return this.contentFields.map(f => `--- Field #${f.getID} (title: ${f.getTitle()}): ${f.getContent() ?? ''} ---`).join(); } // prettier-ignore + + cleanup = () => { + //dispose each subfields disposers, etc. + }; + + clone = (withContent: boolean = false) => { + const clone = new Template(this._mainField?.makeClone(undefined, withContent) ?? TemplateLayouts.BasicSettings); + this._dataFields.forEach(field => clone.addDataField(field.title)); + return clone; + }; + + getRenderedDoc = () => this.doc; + + getFieldByID = (id: number): TemplateField | undefined => this.allFields.filter(field => field.getID === id)[0]; + + getFieldByTitle = (title: string) => [...this.allFields, ...this._dataFields].filter(field => field.getTitle() === title)[0]; + + setupMainField = (templateInfo: FieldSettings) => TemplateField.CreateField(templateInfo, 1, undefined) as DynamicField; + + assignColToField = (fieldID: number, col: Col) => { + const field = this.getFieldByID(fieldID); + field?.setContent(col.defaultContent ?? '', col.type === TemplateFieldType.VISUAL ? ViewType.IMG : ViewType.TEXT); + field?.setTitle(col.title); + }; + + addDataField = (title: string, content?: string) => this._dataFields.push(new TemplateDataField(title, content)); + + removeDataField = (title: string) => (this._dataFields = this._dataFields.filter(field => field.title !== title)); + + isValidTemplate = (cols: Col[]) => this.title !== 'template_framework' && this.maxMatches(this.getMatches(cols)) === this.contentFields.length; + + applyConditionalLogicToField = (field: TemplateField | TemplateDataField, logic: Record<string, Conditional[]>) => { + if (field instanceof DynamicField) return; + const fieldStatements = logic[field.getTitle()]; + const content = field.getContent(); + fieldStatements?.forEach(statement => { + if (content === statement.condition) { + if (statement.target === 'Template') { + if (this._mainField.renderedDoc) { + this._mainField.renderedDoc[statement.attribute] = statement.value; + Object.assign(this._mainField.settings.opts, { [statement.attribute]: statement.value }); + } + } else { + const targetField = this.getFieldByTitle(statement.target); + if (targetField instanceof TemplateField && targetField.renderedDoc) { + targetField.renderedDoc[statement.attribute] = statement.value; + Object.assign(targetField.settings.opts, { [statement.attribute]: statement.value }); + } + } + } + }); + }; + + applyConditionalLogic = (logic: Record<string, Conditional[]>) => { + [...this.allFields, ...this._dataFields].forEach(field => this.applyConditionalLogicToField(field, logic)); + return this.getRenderedDoc(); + }; + + setImageAsBackground(url: string, makeTransparent: boolean = false) { + const fieldSettings: FieldSettings = { + tl: [-1, -1], + br: [1, 1], + opts: {}, + viewType: ViewType.IMG, + }; + + const field = TemplateField.CreateField(fieldSettings, Math.random() * 100 + 100, this._mainField); + field.setContent(url); + + if (makeTransparent) { + this.allFields.forEach(aField => { + aField.updateDocSetting('backgroundColor', 'transparent'); + aField.updateDocSetting('borderWidth', '0'); + }); + } + + this._mainField.makeBackgroundField(field); + } + + /** + * This function is just a hack for now to get around weird document icon stuff (specifically it misses the background) + */ + setMatteBackground(makeTransparent: boolean = false) { + if (this._mainField.hasBackground) { + return; + } + + const fieldSettings: FieldSettings = { + tl: [-1, -1], + br: [1, 1], + opts: { backgroundColor: String(this._mainField.renderedDoc!.backgroundColor) }, + viewType: ViewType.TEXT, + }; + + const field = TemplateField.CreateField(fieldSettings, Math.random() * 100 + 100, this._mainField); + + makeTransparent && + this.allFields.forEach(aField => { + aField.updateDocSetting('backgroundColor', 'transparent'); + aField.updateDocSetting('borderWidth', '0'); + }); + + this._mainField.makeBackgroundField(field); + } + + getMatches = (cols: Col[]): number[][] => { + const numFields = this.contentFields.length; + + if (cols.length !== numFields) return []; + + const matches = Array<number[]>(numFields); + + this.contentFields.forEach((field, i) => (matches[i] = field.matches(cols))); + + return matches; + }; + + maxMatches = (matches: number[][]) => { + if (matches.length === 0) return 0; + + const fieldsCt = this.contentFields.length; + const used = Array<boolean>(fieldsCt).fill(false); + const mt = Array<number>(fieldsCt).fill(-1); + + const augmentingPath = (v: number): boolean => { + if (!used[v]) { + used[v] = true; + + for (const to of matches[v]) { + if (mt[to] === -1 || augmentingPath(mt[to])) { + mt[to] = v; + return true; + } + } + } + return false; + }; + + for (let v = 0; v < fieldsCt; ++v) { + used.fill(false); + augmentingPath(v); + } + + let count: number = 0; + for (let i = 0; i < fieldsCt; ++i) { + if (mt[i] !== -1) ++count; + } + return count; + }; +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.tsx deleted file mode 100644 index 0a5097d4a..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/Template.tsx +++ /dev/null @@ -1,139 +0,0 @@ -import { Doc, FieldType } from "../../../../../fields/Doc"; -import { Col } from "./DocCreatorMenu"; -import { DynamicField } from "./FieldTypes/DynamicField"; -import { Field, FieldSettings, ViewType } from "./FieldTypes/Field"; -import { } from "./FieldTypes/FieldUtils"; -import { } from "./FieldTypes/StaticField"; - -export class Template { - - mainField: DynamicField; - settings: FieldSettings; - - constructor(templateInfo: FieldSettings) { - this.mainField = this.setupMainField(templateInfo); - this.settings = templateInfo; - } - - get childFields(): Field[] { return this.mainField.getSubfields }; - get allFields(): Field[] { return this.mainField.getAllSubfields }; - get contentFields(): Field[] { return this.allFields.filter(field => field.getViewType === ViewType.STATIC) }; - get doc(){ return this.mainField.renderedDoc(); }; - - cloneBase = () => { - const clone: Template = new Template(this.settings); - clone.allFields.forEach(field => { - const matchingField: Field = this.allFields.filter(f => f.getID === field.getID)[0]; - matchingField.applyAttributes(field); - }) - return clone; - } - - getRenderedDoc = () => { - const doc: Doc = this.mainField.renderedDoc(); - this.contentFields.forEach(field => { - const title: string = field.getTitle(); - const val: FieldType = field.getContent() as FieldType; - if (!title || !val) return; - doc[title] = val; - }); - return doc; - } - - getFieldByID = (id: number): Field => { - return this.allFields.filter(field => field.getID === id)[0]; - } - - getFieldByTitle = (title: string) => { - return this.allFields.filter(field => field.getTitle() === title)[0]; - } - - setupMainField = (templateInfo: FieldSettings) => { - return new DynamicField(templateInfo, 1); - } - - get descriptionSummary(): string { - let summary: string = ''; - this.contentFields.forEach(field => { - summary += `--- Field #${field.getID} (title: ${field.getTitle()}): ${field.getDescription ?? ''} ---`; - }); - return summary; - } - - get compiledContent(): string { - let summary: string = ''; - this.contentFields.forEach(field => { - summary += `--- Field #${field.getID} (title: ${field.getTitle()}): ${field.getContent() ?? ''} ---`; - }); - return summary; - } - - renderUpdates = () => { - this.allFields.forEach(field => { - field.updateRenderedDoc(field.renderedDoc()); - }); - }; - - resetToBase = () => { - this.allFields.forEach(field => { - field.updateRenderedDoc(); - }) - } - - isValidTemplate = (cols: Col[]) => { - const matches: number[][] = this.getMatches(cols); - const maxMatches: number = this.maxMatches(matches); - return maxMatches === this.contentFields.length; - } - - getMatches = (cols: Col[]): number[][] => { - const numFields = this.contentFields.length; - - if (cols.length !== numFields) return []; - - const matches: number[][] = Array(numFields) - .fill([]) - .map(() => []); - - this.contentFields.forEach((field, i) => { - matches[i] = (field.matches(cols)); - }); - - return matches; - } - - maxMatches = (matches: number[][]) => { - if (matches.length === 0) return 0; - - const fieldsCt = this.contentFields.length; - const used: boolean[] = Array(fieldsCt).fill(false); - const mt: number[] = Array(fieldsCt).fill(-1); - - const augmentingPath = (v: number): boolean => { - if (used[v]) return false; - used[v] = true; - - for (const to of matches[v]) { - if (mt[to] === -1 || augmentingPath(mt[to])) { - mt[to] = v; - return true; - } - } - return false; - }; - - for (let v = 0; v < fieldsCt; ++v) { - used.fill(false); - augmentingPath(v); - } - - let count: number = 0; - - for (let i = 0; i < fieldsCt; ++i) { - if (mt[i] !== -1) ++count; - } - - return count; - }; - -}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateBackend.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateBackend.ts index d3282eda3..26fd3a8fc 100644 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateBackend.tsx +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateBackend.ts @@ -1,10 +1,10 @@ -import { FieldSettings, ViewType } from "./FieldTypes/Field"; -import { } from "./FieldTypes/StaticField"; +import { FieldSettings, ViewType } from './TemplateFieldTypes/TemplateField'; export enum TemplateFieldType { TEXT = 'text', VISUAL = 'visual', UNSET = 'unset', + DATA = 'data', } export enum TemplateFieldSize { @@ -20,6 +20,14 @@ export class TemplateLayouts { return Object.values(TemplateLayouts); } + public static BasicSettings: FieldSettings = { + title: 'template_framework', + tl: [0, 0], + br: [400, 700], + viewType: ViewType.FREEFORM, + opts: {}, + }; + public static FourField001: FieldSettings = { title: 'fourfield001', tl: [0, 0], @@ -27,7 +35,7 @@ export class TemplateLayouts { viewType: ViewType.FREEFORM, opts: { backgroundColor: '#C0B887', - cornerRounding: .05, + _layout_borderRounding: '.05', //borderColor: '#6B461F', //borderWidth: '12', }, @@ -41,9 +49,9 @@ export class TemplateLayouts { description: 'A title field for very short text that contextualizes the content.', opts: { backgroundColor: 'transparent', - color: '#F1F0E9', - contentXCentering: 'h-center', - fontBold: true, + text_fontColor: '#F1F0E9', + hCentering: 'h-center', + contentBold: true, }, }, { @@ -54,9 +62,9 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'The main focus of the template; could be an image, long text, etc.', opts: { - cornerRounding: .05, + _layout_borderRounding: '.05', borderColor: '#8F5B25', - borderWidth: '6', + borderWidth: 6, backgroundColor: '#CECAB9', }, }, @@ -69,8 +77,8 @@ export class TemplateLayouts { description: 'A caption for field #2, very short text.', opts: { backgroundColor: 'transparent', - contentXCentering: 'h-center', - color: '#F1F0E9', + hCentering: 'h-center', + text_fontColor: '#F1F0E9', }, }, { @@ -81,9 +89,9 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium-sized field for medium/long text.', opts: { - cornerRounding: .05, + _layout_borderRounding: '.05', borderColor: '#8F5B25', - borderWidth: '6', + borderWidth: 6, backgroundColor: '#CECAB9', }, }, @@ -93,7 +101,7 @@ export class TemplateLayouts { public static FourField002: FieldSettings = { title: 'fourfield002', viewType: ViewType.FREEFORM, - tl: [0,0], + tl: [0, 0], br: [425, 778], opts: { backgroundColor: '#242425', @@ -107,10 +115,10 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], description: 'A medium to large-sized field suitable for an image or longer text that should be the main focus.', opts: { - borderWidth: '8', + borderWidth: 8, borderColor: '#F8E71C', backgroundColor: '#242425', - color: 'white', + text_fontColor: 'white', }, }, { @@ -122,9 +130,9 @@ export class TemplateLayouts { description: 'A tiny field for just a word or two of plain text.', opts: { backgroundColor: 'transparent', - color: 'white', - contentXCentering: 'h-center', - fontTransform: 'uppercase', + text_fontColor: 'white', + hCentering: 'h-center', + text_transform: 'uppercase', }, }, { @@ -136,9 +144,9 @@ export class TemplateLayouts { description: 'A tiny field for just a word or two of plain text.', opts: { backgroundColor: 'transparent', - color: 'white', - contentXCentering: 'h-center', - fontTransform: 'uppercase', + text_fontColor: 'white', + hCentering: 'h-center', + text_transform: 'uppercase', }, }, { @@ -149,9 +157,9 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium to large-sized field suitable for longer text that should contextualize field 1.', opts: { - borderWidth: '8', + borderWidth: 8, borderColor: '#F8E71C', - color: 'white', + text_fontColor: 'white', backgroundColor: '#242425', }, }, @@ -161,7 +169,7 @@ export class TemplateLayouts { br: [-0.525, 0.075], opts: { backgroundColor: '#F8E71C', - rotation: 45, + _rotation: 45, }, }, { @@ -170,7 +178,7 @@ export class TemplateLayouts { br: [-0.2175, 0.0245], opts: { backgroundColor: '#F8E71C', - rotation: 45, + _rotation: 45, }, }, { @@ -179,7 +187,7 @@ export class TemplateLayouts { br: [0.045, 0.0245], opts: { backgroundColor: '#F8E71C', - rotation: 45, + _rotation: 45, }, }, { @@ -188,7 +196,7 @@ export class TemplateLayouts { br: [0.3075, 0.0245], opts: { backgroundColor: '#F8E71C', - rotation: 45, + _rotation: 45, }, }, { @@ -197,7 +205,7 @@ export class TemplateLayouts { br: [0.8, 0.075], opts: { backgroundColor: '#F8E71C', - rotation: 45, + _rotation: 45, }, }, ], @@ -266,8 +274,8 @@ export class TemplateLayouts { public static FourField004: FieldSettings = { title: 'fourfield04', viewType: ViewType.FREEFORM, - tl: [0,0], - br: [414,583], + tl: [0, 0], + br: [414, 583], opts: { backgroundColor: '#6CCAF0', //borderColor: '#1088C3', @@ -283,9 +291,9 @@ export class TemplateLayouts { description: 'A tiny field for just a word or two of plain text.', opts: { backgroundColor: '#E2B4F5', - borderWidth: '9', + borderWidth: 9, borderColor: '#9222F1', - contentXCentering: 'h-center', + hCentering: 'h-center', }, }, { @@ -297,9 +305,9 @@ export class TemplateLayouts { description: 'A tiny field for just a word or two of plain text.', opts: { backgroundColor: '#F5B4DD', - borderWidth: '9', + borderWidth: 9, borderColor: '#E260F3', - contentXCentering: 'h-center', + hCentering: 'h-center', }, }, { @@ -310,7 +318,7 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A large to huge field for visual content that is the main content of the template.', opts: { - borderWidth: '16', + borderWidth: 16, borderColor: '#A2BD77', }, }, @@ -322,7 +330,7 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], description: 'A medium to large field for text that describes the visual content above', opts: { - borderWidth: '9', + borderWidth: 9, borderColor: '#F0D601', backgroundColor: '#F3F57D', }, @@ -334,7 +342,7 @@ export class TemplateLayouts { opts: { backgroundColor: 'transparent', borderColor: '#007C0C', - borderWidth: '10', + borderWidth: 10, }, }, ], @@ -343,218 +351,229 @@ export class TemplateLayouts { public static FourField005: FieldSettings = { title: 'fourfield05', viewType: ViewType.FREEFORM, - tl: [0,0], - br: [400,550], + tl: [0, 0], + br: [400, 514], opts: { backgroundColor: '#95A575', }, subfields: [ { viewType: ViewType.STATIC, - tl: [-0.9, -.925], - br: [-.075, -.775], + tl: [-0.9, -0.925], + br: [-0.075, -0.775], types: [TemplateFieldType.TEXT], sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], description: 'A small text field for a title or word(s) that categorize the rest of the content.', opts: { borderColor: '#3B4A2C', - borderWidth: '8', - contentXCentering: "h-center", + borderWidth: 8, + hCentering: 'h-center', backgroundColor: '#B8DC90', }, }, { viewType: ViewType.STATIC, - tl: [.075, -.925], - br: [.9, -.775], + tl: [0.075, -0.925], + br: [0.9, -0.775], types: [TemplateFieldType.TEXT], sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], description: 'A small text field for a title that categorizes the rest of the content.', opts: { borderColor: '#3B4A2C', - borderWidth: '8', - contentXCentering: "h-center", + borderWidth: 8, + hCentering: 'h-center', backgroundColor: '#B8DC90', }, }, { viewType: ViewType.DEC, - tl: [-.82, -.4], - br: [-.5, -.2], + tl: [-0.82, -0.4], + br: [-0.5, -0.2], opts: { backgroundColor: '#94B058', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.STATIC, - tl: [-0.66, -.65], - br: [0.66, .25], + tl: [-0.66, -0.65], + br: [0.66, 0.25], types: [TemplateFieldType.VISUAL], sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], description: 'A medium to large field in the center of the template, for the main visual content.', opts: { borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, backgroundColor: '#B8DC90', }, }, { viewType: ViewType.STATIC, - tl: [-.875, .425], - br: [0.875, .925], + tl: [-0.875, 0.425], + br: [0.875, 0.925], types: [TemplateFieldType.TEXT], sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], description: 'A medium to large field at the bottom of the template, for the main text content.', opts: { borderColor: '#3B4A2C', - borderWidth: '8', - contentXCentering: "h-center", + borderWidth: 8, + hCentering: 'h-center', backgroundColor: '#B8DC90', }, }, { viewType: ViewType.DEC, - tl: [-1.1, -.62], - br: [-.9, -.5], + tl: [-1.1, -0.62], + br: [-0.9, -0.5], opts: { backgroundColor: '#7A9D31', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.DEC, tl: [-1.1, 0], - br: [-.9, .15], + br: [-0.9, 0.15], opts: { backgroundColor: '#94B058', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.DEC, - tl: [-.93, -.265], - br: [-.715, -.125], + tl: [-0.93, -0.265], + br: [-0.715, -0.125], opts: { backgroundColor: '#728745', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.DEC, - tl: [.7, -.45], - br: [.85, -.3], + tl: [0.7, -0.45], + br: [0.85, -0.3], opts: { backgroundColor: '#7A9D31', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.DEC, - tl: [.8, .03], - br: [1.2, .33], + tl: [0.8, 0.03], + br: [1.2, 0.33], opts: { backgroundColor: '#728745', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, { viewType: ViewType.DEC, - tl: [.875, -.13], - br: [1.2, .12], + tl: [0.875, -0.13], + br: [1.2, 0.12], opts: { backgroundColor: '#94B058', borderColor: '#3B4A2C', - borderWidth: '8', + borderWidth: 8, }, }, - ] - } + ], + }; public static FourFieldCarousel: FieldSettings = { title: 'title_fourfieldcarousel', viewType: ViewType.FREEFORM, - tl:[0,0], - br:[500, 600], + tl: [0, 0], + br: [500, 600], opts: { - backgroundColor: '#DDD3A9', + backgroundColor: '#D7CBAB', }, subfields: [ { viewType: ViewType.STATIC, - tl: [-0.8, -.9], - br: [0.8, -.5], + tl: [-0.8, -0.9], + br: [0.8, -0.5], types: [TemplateFieldType.TEXT], sizes: [TemplateFieldSize.TINY, TemplateFieldSize.SMALL], description: 'A small text field for a title that categorizes the rest of the content.', opts: { - borderColor: 'yellow', - borderWidth: '8', - contentXCentering: "h-center", + hCentering: 'h-center', backgroundColor: 'transparent', + text_transform: 'uppercase', }, }, { viewType: ViewType.CAROUSEL3D, - tl: [-0.9, -.3], - br: [0.9, .9], + tl: [-0.9, -0.5], + br: [0.9, 0.25], opts: { - borderColor: 'yellow', - borderWidth: '8', - backgroundColor: 'transparent', + borderColor: '#847F69', + borderWidth: 8, + backgroundColor: '#C8BA94', }, subfields: [ { viewType: ViewType.STATIC, - tl: [-.3, -.6], - br: [.3, .6], + tl: [-0.4, -0.6], + br: [0.4, 0.6], types: [TemplateFieldType.VISUAL, TemplateFieldType.TEXT], sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium to large field for content that will share central focus with other content in the carousel.', opts: { - borderColor: 'yellow', - borderWidth: '8', + //borderColor: 'yellow', + //borderWidth: '8', }, }, { viewType: ViewType.STATIC, - tl: [-.3, -.6], - br: [.3, .6], + tl: [-0.4, -0.6], + br: [0.4, 0.6], types: [TemplateFieldType.VISUAL, TemplateFieldType.TEXT], sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium to large field for content that will share central focus with other content in the carousel.', opts: { - borderColor: 'black', - borderWidth: '8', + //borderColor: 'black', + //borderWidth: '8', }, }, { viewType: ViewType.STATIC, - tl: [-.3, -.6], - br: [.3, .6], + tl: [-0.4, -0.6], + br: [0.4, 0.6], types: [TemplateFieldType.VISUAL, TemplateFieldType.TEXT], sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium to large field for content that will share central focus with other content in the carousel.', opts: { - borderColor: 'yellow', - borderWidth: '8', + //borderColor: 'yellow', + //borderWidth: '8', }, }, - ] + ], }, - ] - } + { + viewType: ViewType.STATIC, + tl: [-0.9, 0.35], + br: [0.9, 0.9], + types: [TemplateFieldType.TEXT], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE], + description: 'A medium text field for a description of the content in the carousel.', + opts: { + hCentering: 'h-center', + backgroundColor: 'transparent', + }, + }, + ], + }; public static ThreeField001: FieldSettings = { title: 'threefield001', viewType: ViewType.FREEFORM, - tl: [0,0], + tl: [0, 0], br: [575, 770], opts: { backgroundColor: '#DDD3A9', @@ -567,23 +586,23 @@ export class TemplateLayouts { description: 'A medium to large field for visual content that is the central focus.', opts: { borderColor: 'yellow', - borderWidth: '8', + borderWidth: 8, backgroundColor: '#DDD3A9', - rotation: 45, + _rotation: 45, }, subfields: [ { - viewType: ViewType.STATIC, - tl: [-1.25, -1.25], - br: [1.25, 1.25], - types: [TemplateFieldType.VISUAL], - sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], - description: 'A medium to large field for visual content that is the central focus.', - opts: { - rotation: -45, + viewType: ViewType.STATIC, + tl: [-1.25, -1.25], + br: [1.25, 1.25], + types: [TemplateFieldType.VISUAL], + sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], + description: 'A medium to large field for visual content that is the central focus.', + opts: { + _rotation: -45, + }, }, - }, - ] + ], }, { viewType: ViewType.STATIC, @@ -594,7 +613,7 @@ export class TemplateLayouts { description: 'A very small text field for one to a few words. A good caption for the image.', opts: { backgroundColor: 'transparent', - contentXCentering: 'h-center', + hCentering: 'h-center', }, }, { @@ -606,7 +625,7 @@ export class TemplateLayouts { description: 'A medium to large text field for a thorough description of the image. ', opts: { backgroundColor: 'transparent', - color: 'white', + text_fontColor: 'white', }, }, { @@ -615,18 +634,18 @@ export class TemplateLayouts { br: [1.8, -0.66], opts: { backgroundColor: '#CEB155', - rotation: 45, + _rotation: 45, }, subfields: [ { viewType: ViewType.DEC, - tl: [-1, -.7], - br: [1, -.625], + tl: [-1, -0.7], + br: [1, -0.625], opts: { backgroundColor: 'yellow', }, }, - ] + ], }, { viewType: ViewType.FREEFORM, @@ -634,18 +653,18 @@ export class TemplateLayouts { br: [-0.2, -0.66], opts: { backgroundColor: '#CEB155', - rotation: 135, + _rotation: 135, }, subfields: [ { viewType: ViewType.DEC, - tl: [-1, -.7], - br: [1, -.625], + tl: [-1, -0.7], + br: [1, -0.625], opts: { backgroundColor: 'yellow', }, }, - ] + ], }, { viewType: ViewType.FREEFORM, @@ -653,18 +672,18 @@ export class TemplateLayouts { br: [1.66, 1.25], opts: { backgroundColor: '#CEB155', - rotation: 135, + _rotation: 135, }, subfields: [ { viewType: ViewType.DEC, - tl: [-1, -.7], - br: [1, -.625], + tl: [-1, -0.7], + br: [1, -0.625], opts: { backgroundColor: 'yellow', }, }, - ] + ], }, { viewType: ViewType.FREEFORM, @@ -672,18 +691,18 @@ export class TemplateLayouts { br: [-0.33, 1.25], opts: { backgroundColor: '#CEB155', - rotation: 45, + _rotation: 45, }, subfields: [ { viewType: ViewType.DEC, - tl: [-1, -.7], - br: [1, -.625], + tl: [-1, -0.7], + br: [1, -0.625], opts: { backgroundColor: 'yellow', }, }, - ] + ], }, ], }; @@ -691,7 +710,7 @@ export class TemplateLayouts { public static ThreeField002: FieldSettings = { title: 'threefield002', viewType: ViewType.FREEFORM, - tl: [0,0], + tl: [0, 0], br: [477, 662], opts: { backgroundColor: '#9E9C95', @@ -705,7 +724,7 @@ export class TemplateLayouts { sizes: [TemplateFieldSize.MEDIUM, TemplateFieldSize.LARGE, TemplateFieldSize.HUGE], description: 'A medium to large visual field for the main content of the template', opts: { - borderWidth: '15', + borderWidth: 15, borderColor: '#E0E0DA', }, }, @@ -718,10 +737,10 @@ export class TemplateLayouts { description: 'A very small text field for one to a few words. The content should represent a general categorization of the image.', opts: { backgroundColor: 'transparent', - color: '#AF0D0D', - fontTransform: 'uppercase', - fontBold: true, - contentXCentering: 'h-left', + text_fontColor: '#AF0D0D', + text_transform: 'uppercase', + contentBold: true, + hCentering: 'h-left', }, }, { @@ -733,8 +752,8 @@ export class TemplateLayouts { description: 'A very small text field for one to a few words. The content should contextualize field 2.', opts: { backgroundColor: 'transparent', - color: 'black', - contentXCentering: 'h-right', + text_fontColor: 'black', + hCentering: 'h-right', }, }, { @@ -747,6 +766,4 @@ export class TemplateLayouts { }, ], }; -} - - +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DataField.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DataField.ts new file mode 100644 index 000000000..6b4086483 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DataField.ts @@ -0,0 +1,20 @@ + +import { ViewType } from "./TemplateField"; + +export class TemplateDataField { + + viewType: ViewType = ViewType.NONE; + + title: string = ''; + content: string | undefined; + + constructor(title: string, content?: string) { + this.title = title; + this.content = content; + } + + setContent(content: string, viewType?: ViewType) { this.content = content } + getContent() { return this.content } + + getTitle() { return this.title } +}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DecorationField.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DecorationField.ts new file mode 100644 index 000000000..98a9dc7a6 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DecorationField.ts @@ -0,0 +1,3 @@ +import { DynamicField } from './DynamicField'; + +export class DecorationField extends DynamicField {} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DynamicField.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DynamicField.ts new file mode 100644 index 000000000..b9042258b --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/DynamicField.ts @@ -0,0 +1,131 @@ +import { IDisposer } from 'mobx-utils'; +import { Doc } from '../../../../../../fields/Doc'; +import { DocData } from '../../../../../../fields/DocSymbols'; +import { List } from '../../../../../../fields/List'; +import { NumCast } from '../../../../../../fields/Types'; +import { Docs } from '../../../../../documents/Documents'; +import { DocumentType } from '../../../../../documents/DocumentTypes'; +import { FieldSettings, TemplateField, ViewType } from './TemplateField'; + +export class DynamicField extends TemplateField { + protected _disposers: { [name: string]: IDisposer } = {}; + protected _subfields: TemplateField[] = []; + protected backgroundField: TemplateField | undefined; + + get getSubfields() { + return this._subfields; + } + get getAllSubfields(): TemplateField[] { + return this.getSubfields.flatMap(field => [field, ...((field as DynamicField).getAllSubfields ?? [])]); + } + + get hasBackground() { + return this.backgroundField !== undefined; + } + + handleFieldUpdate = (newDocsList: Doc[]) => { + const currRenderedDocs = new Set(this.getSubfields.filter(field => field.renderedDoc).map(field => field.renderedDoc!)); + newDocsList.forEach(doc => !currRenderedDocs.has(doc) && this.addFieldFromDoc(doc)); + currRenderedDocs.forEach(doc => { + if (!newDocsList.includes(doc)) { + this._subfields.forEach(field => field.renderedDoc === doc && this.removeField(field)); + } + }); + }; + + addFieldFromDoc = (doc: Doc) => { + const par = this._renderDoc; + const settings: FieldSettings = { + tl: [Number(doc._x) / NumCast(par?._width, 1), Number(doc?._y) / NumCast(par?._height, 1)], + br: [(Number(doc._x) + Number(doc._width)) / NumCast(par?._width, 1), (Number(doc._y) + Number(doc._height)) / NumCast(par?._height, 1)], + viewType: doc.type === DocumentType.COL ? ViewType.FREEFORM : ViewType.STATIC, + opts: {}, + }; + + this._subfields.push(TemplateField.CreateField(settings, this._subfields.length, this)); + }; + + addField = (field: TemplateField, layer: number = 0) => { + if (!this._subfields.includes(field)) { + this._subfields.splice(layer, 0, field); + } + }; + + dispose = () => Object.values(this._disposers).forEach(disposer => disposer?.()); + + removeField = (field: TemplateField) => { + // field.renderedDoc && this._renderDoc && Doc.RemoveDocFromList(this._renderDoc, undefined, field.renderedDoc); + this._subfields.splice(this._subfields.indexOf(field), 1); + (field as DynamicField).dispose?.(); + }; + + // implement Field's abstract method for replacing a subfield with a new one + exchangeFields(newField: TemplateField, oldField: TemplateField) { + this._subfields.splice(this._subfields.indexOf(oldField), 1, newField); + this.refreshRenderedDoc(); + } + + get isContentField(): boolean { + return false; + } + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + setContent(content: string, type: ViewType) {} + + getContent = () => ''; + + addChildToDocument = (doc: Doc) => this._renderDoc && Doc.SetContainer(doc, this._renderDoc); + + makeBackgroundField = (field: TemplateField) => { + if (this.backgroundField && this.backgroundField !== field) { + this.removeField(this.backgroundField); + this.backgroundField = undefined; + } + if (field && field !== this.backgroundField) { + this.addField(field); + this.backgroundField = field; + } + this.refreshRenderedDoc(); + } + + matches = (): Array<number> => []; + + makeClone(parent?: DynamicField, withContent: boolean = false) { + const dynClone = super.makeClone(parent) as DynamicField; + dynClone._subfields = this.getSubfields.map(field => { + if (field === this.backgroundField) { + const backgroundField: TemplateField = field.makeClone(dynClone, true); + dynClone.makeBackgroundField(backgroundField); + return backgroundField; + } else { + return field.makeClone(dynClone, withContent) + } + }); + if (dynClone._renderDoc) { + dynClone._renderDoc[DocData].data = new List<Doc>(dynClone.getSubfields.filter(sub => sub.renderedDoc).map(sub => sub.renderedDoc!)); + } + return dynClone; + } + + initRenderDoc = (settings: FieldSettings) => { + //this._disposers.fieldList = reaction(() => DocListCast(this._renderDoc?.[Doc.LayoutFieldKey(this._renderDoc)]), this.handleFieldUpdate); + this._subfields = settings.subfields?.map((fieldSettings, index) => {return TemplateField.CreateField(fieldSettings, index, this)}) || []; + const renderedSubfields = this._subfields.filter(field => field.renderedDoc).map(field => field.renderedDoc!); + settings.opts.title = settings.title; + this._renderDoc = (() => { switch (settings.viewType) { + case ViewType.CAROUSEL3D: return Docs.Create.Carousel3DDocument(renderedSubfields, settings.opts); + case ViewType.FREEFORM: + default: return Docs.Create.FreeformDocument(renderedSubfields, settings.opts); + }})(); // prettier-ignore + return this; + }; + + refreshRenderedDoc = () => { + const renderedSubfields = this._subfields.filter(field => field.renderedDoc).map(field => field.renderedDoc!); + this._renderDoc = (() => { switch (this.settings.viewType) { + case ViewType.CAROUSEL3D: return Docs.Create.Carousel3DDocument(renderedSubfields, this.settings.opts); + case ViewType.FREEFORM: + default: return Docs.Create.FreeformDocument(renderedSubfields, this.settings.opts); + }})(); + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/StaticContentField.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/StaticContentField.ts new file mode 100644 index 000000000..569c43af4 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/StaticContentField.ts @@ -0,0 +1,62 @@ +import { FieldResult } from '../../../../../../fields/Doc'; +import { DocData } from '../../../../../../fields/DocSymbols'; +import { RichTextField } from '../../../../../../fields/RichTextField'; +import { ImageField } from '../../../../../../fields/URLField'; +import { Docs } from '../../../../../documents/Documents'; +import { FieldSettings, TemplateField, ViewType } from './TemplateField'; +import { TemplateFieldUtils } from './TemplateFieldUtils'; + +export abstract class StaticContentField extends TemplateField { + protected _content: string = ''; + + getContent = () => this._content ?? 'unset'; + get isContentField(): boolean { + return true; + } + protected setDataContent(viewType: ViewType, fieldKey: string, data: FieldResult, content: string, type?: ViewType) { + super.setContent(content, type); + + if (type === viewType || type === undefined) { + this._content = content; + this._renderDoc && (this._renderDoc[DocData][fieldKey] = data); + } else { + this.changeFieldType(type).setContent(content, type); + } + } +} + +export class ImageTemplateField extends StaticContentField { + setContent(url: string, type?: ViewType) { + this.setDataContent(ViewType.IMG, 'data', new ImageField(url), url, type); + this._renderDoc!['backgroundColor'] = 'white'; + } + + initRenderDoc(settings: FieldSettings) { + settings.opts.title = settings.title ?? ''; + settings.opts._layout_fitWidth = false; + this._renderDoc = Docs.Create.ImageDocument(this._content, settings.opts); + return this; + } + + updateDocSetting(setting: string, newVal: string) { + if (this._renderDoc) this._renderDoc[setting] = newVal; + if (setting !== 'backgroundColor') { + const settings: {[s: string]: string } = {[setting]: newVal} + Object.assign(this.settings.opts, settings); + } + } +} + +export class TextTemplateField extends StaticContentField { + setContent(text: string, type?: ViewType) { + const fontSize: number = TemplateFieldUtils.calculateFontSize(this._dimensions?.width ?? 10, this._dimensions?.height ?? 10, text, true); + this.setDataContent(ViewType.TEXT, 'text', RichTextField.textToRtf(text, undefined, {fontSize: fontSize}), text, type); + } + + initRenderDoc(settings: FieldSettings) { + settings.opts.title = settings.title ?? ''; + settings.opts.text_fontSize = TemplateFieldUtils.calculateFontSize(this._dimensions?.width ?? 10, this._dimensions?.height ?? 10, '', true) + ''; + this._renderDoc = Docs.Create.TextDocument(this._content, settings.opts); + return this; + } +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateField.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateField.ts new file mode 100644 index 000000000..091ef834a --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateField.ts @@ -0,0 +1,172 @@ +/* eslint-disable no-use-before-define */ +import { Doc } from '../../../../../../fields/Doc'; +import { DocumentOptions } from '../../../../../documents/Documents'; +import { Col } from '../DocCreatorMenu'; +import { Template } from '../Template'; +import { TemplateFieldSize, TemplateFieldType } from '../TemplateBackend'; + +export abstract class TemplateField { + /** + * Creates and initializes a new TemplateField based on the settings and parameters + * + * implemented in FieldUtils and assigned in main (to avoid import cycles) + * + * @param settings - specification of the field type and other parameters + * @param index - + * @param parent - TemplateField that contains the new field + * @param sameId - + * @returns TemplateField + */ + static CreateField: (settings: FieldSettings, index: number, parent: TemplateField | undefined, sameId?: boolean) => TemplateField; + + protected _parent?: TemplateField; + protected _id: number; + protected _title: string = ''; + protected _settings: FieldSettings; + protected _renderDoc: Doc | undefined; + protected _dimensions: FieldDimensions | undefined; + + constructor(settings: FieldSettings, id: number = 1, parent?: TemplateField) { + this._id = id; + this._parent = parent; + this._settings = settings; + this._title = settings.title ?? ''; + this._dimensions = this.getLocalDimensions(this._settings, this._parent?.getDimensions); + this.applyBasicOpts(this._dimensions, settings); + return this; + } + + get renderedDoc() { + return this._renderDoc; + } + get getDimensions() { + return this._dimensions; + } + get getID() { + return this._id; + } + get getDescription(): string { + return this._settings?.description ?? ''; + } + get viewType(): ViewType | undefined { + return this._settings?.viewType; + } + + get settings(): FieldSettings { + return this._settings; + } + + abstract get isContentField(): boolean; + abstract initRenderDoc(settings: FieldSettings): TemplateField; + abstract getContent(): string; + + setContent(content: string, type?: ViewType) { + if (type) this._settings.viewType = type; + } + + setTitle = (title: string) => { + this._title = title; + this.settings.title = title; + if (this._renderDoc) this._renderDoc.title = title; + }; + getTitle = () => this._title; + + updateDocSetting(setting: string, newVal: string) { + if (this._renderDoc) this._renderDoc[setting] = newVal; + const settings: { [s: string]: string } = { [setting]: newVal }; + Object.assign(this.settings.opts, settings); + } + + makeClone(parent?: TemplateField, withContent: boolean = false) { + const settings: FieldSettings = structuredClone(this._settings); + const cloned = TemplateField.CreateField(settings, this._id, parent, true); // create a value for this.Document/subfields that we want to ignore + cloned.renderedDoc!.width = this.renderedDoc!.width; + cloned.renderedDoc!.height = this.renderedDoc!.height; + cloned.renderedDoc!.x = this.renderedDoc!.x; + cloned.renderedDoc!.y = this.renderedDoc!.y; + cloned.renderedDoc!.backgroundColor = this.renderedDoc!.backgroundColor; + cloned.setTitle(this._title); + cloned._dimensions = this._dimensions; + withContent && cloned.setContent(this.getContent()); + return cloned; + } + + exchangeFields(newField: TemplateField, oldField: TemplateField) { + throw new Error('Only DynamicField can exchange fields.' + newField._title + ' ' + oldField._title); + } + + changeFieldType = (newType: ViewType): TemplateField => { + this._settings.viewType = newType; + const newField = TemplateField.CreateField(this._settings, this._id, this._parent, true); + this._parent?.exchangeFields(newField, this); + return newField; + }; + + matches = (cols: Col[]): number[] => { + const colMatchesField = (col: Col) => (this._settings?.sizes?.some(size => col.sizes?.includes(size)) && this._settings.types?.includes(col.type)) ?? false; + + const matches: Array<number> = []; + + cols.forEach((col, v) => { + if (colMatchesField(col)) { + matches.push(v); + } + }); + + return matches; + }; + + private getLocalDimensions = (coords: { tl: [number, number]; br: [number, number] }, parentDimensions?: FieldDimensions): FieldDimensions => { + if (!parentDimensions) { + return { width: coords.br[0] - coords.tl[0], height: coords.br[1] - coords.tl[1], coord: { x: coords.tl[0], y: coords.tl[1] } }; + } + const l = (coords.tl[0] * parentDimensions.width) / 2; + const t = coords.tl[1] * parentDimensions.height / 2; //prettier-ignore + const r = (coords.br[0] * parentDimensions.width) / 2; + const b = coords.br[1] * parentDimensions.height / 2; //prettier-ignore + return { width: r-l, height: b-t, coord: { x: l, y: t } }; //prettier-ignore + }; + + private applyBasicOpts = (dimensions: FieldDimensions, settings: FieldSettings | undefined) => { + const opts: DocumentOptions = settings?.opts ?? {}; + opts.isDefaultTemplateDoc ??= true; + opts._layout_hideScroll ??= true; + opts.x ??= dimensions.coord.x; + opts.y ??= dimensions.coord.y; + opts._height ??= dimensions.height; + opts._width ??= dimensions.width; + opts._nativeWidth ??= dimensions.width; + opts._nativeHeight ??= dimensions.height; + opts._layout_nativeDimEditable ??= true; + opts.layout_boxShadow = 'none'; + }; +} + +export type FieldSettings = { + tl: [number, number]; + br: [number, number]; + opts: DocumentOptions; + types?: TemplateFieldType[]; + sizes?: TemplateFieldSize[]; + title?: string; + viewType: ViewType; + template?: Template; + subfields?: FieldSettings[]; + description?: string; +}; + +export enum ViewType { + CAROUSEL3D = 'carousel3d', + FREEFORM = 'freeform', + STATIC = 'static', + DEC = 'decoration', + IMG = 'image', + TEXT = 'text', + NONE = 'none', +} + +export type FieldDimensions = { + width: number; + height: number; + coord: { x: number; y: number }; +}; diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateFieldUtils.ts b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateFieldUtils.ts new file mode 100644 index 000000000..b0b531b57 --- /dev/null +++ b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateFieldTypes/TemplateFieldUtils.ts @@ -0,0 +1,71 @@ +import { DecorationField } from './DecorationField'; +import { DynamicField } from './DynamicField'; +import { ImageTemplateField, TextTemplateField } from './StaticContentField'; +import { FieldSettings, TemplateField, ViewType } from './TemplateField'; + +export class TemplateFieldUtils { + /** + * Creates and initializes a new TemplateField based on the settings and parameters + * + * implements Field.initField ... see main.tsx + * + * @param settings - specification of the field type and other parameters + * @param index - + * @param parent - optional TemplateField that contains the new field + * @param sameId - + * @returns TemplateField + */ + public static CreateField = (settings: FieldSettings, index: number, parent?: TemplateField, sameId: boolean = false): TemplateField => + ((...args) => { + switch (settings?.viewType) { + case ViewType.FREEFORM: + case ViewType.CAROUSEL3D: return new DynamicField(...args).initRenderDoc(settings); + case ViewType.IMG: return new ImageTemplateField(...args).initRenderDoc(settings); + case ViewType.TEXT: return new TextTemplateField(...args).initRenderDoc(settings); + case ViewType.DEC: return new DecorationField(...args).initRenderDoc(settings); + default: return new TextTemplateField(...args).initRenderDoc(settings); + } // prettier-ignore + })(settings, sameId ? index : parent ? Number(`${parent.getID}${index}`) : 1, parent); + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + public static calculateFontSize = (contWidth: number, contHeight: number, text: string, uppercase: boolean): number => { + const words: string[] = text.split(/\s+/).filter(Boolean); + + let currFontSize = 1; + let rowsCount = 1; + let currTextHeight = currFontSize * rowsCount * 2; + + while (currTextHeight <= contHeight) { + let wordIndex = 0; + let currentRowWidth = 0; + let wordsInCurrRow = 0; + rowsCount = 1; + + while (wordIndex < words.length) { + const word = words[wordIndex]; + const wordWidth = word.length * currFontSize * 0.7; + + if (currentRowWidth + wordWidth <= contWidth) { + currentRowWidth += wordWidth; + ++wordsInCurrRow; + } else { + if (words.length !== 1 && words.length > wordsInCurrRow) { + rowsCount++; + currentRowWidth = wordWidth; + wordsInCurrRow = 1; + } else { + break; + } + } + + wordIndex++; + } + + currTextHeight = rowsCount * currFontSize * 2; + + currFontSize += 1; + } + + return currFontSize - 1; + }; +} diff --git a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateManager.tsx b/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateManager.tsx deleted file mode 100644 index 50ae4d72a..000000000 --- a/src/client/views/nodes/DataVizBox/DocCreatorMenu/TemplateManager.tsx +++ /dev/null @@ -1,22 +0,0 @@ -import { Col } from "./DocCreatorMenu"; -import { FieldSettings } from "./FieldTypes/Field"; -import { Template } from "./Template"; - -export class TemplateManager { - - templates: Template[] = []; - - constructor(templateSettings: FieldSettings[]) { - this.templates = this.initializeTemplates(templateSettings); - } - - initializeTemplates = (templateSettings: FieldSettings[]): Template[] => { - const initializedTemplates: Template[] = []; - templateSettings.forEach(settings => initializedTemplates.push(new Template(settings))); - return initializedTemplates; - } - - getValidTemplates = (cols: Col[]): Template[] => { - return this.templates.filter(template => template.isValidTemplate(cols)); - } -}
\ No newline at end of file diff --git a/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.scss b/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.scss index 63a693918..0acc2c847 100644 --- a/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.scss +++ b/src/client/views/nodes/DataVizBox/SchemaCSVPopUp.scss @@ -13,7 +13,7 @@ $highlightedText: #82e0ff; min-height: 200px; border-radius: 15px; padding: 15px; - padding-bottom: 0; + padding-bottom: 0px; z-index: 999; display: flex; flex-direction: column; @@ -40,7 +40,7 @@ $highlightedText: #82e0ff; font-size: 12px; font-weight: 400; letter-spacing: 1px; - margin: 0; + margin: 0px; padding-right: 5px; } @@ -124,8 +124,8 @@ $highlightedText: #82e0ff; .img-container::after { content: ''; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); diff --git a/src/client/views/nodes/DataVizBox/components/Chart.scss b/src/client/views/nodes/DataVizBox/components/Chart.scss index ff1fa343d..a22e1153c 100644 --- a/src/client/views/nodes/DataVizBox/components/Chart.scss +++ b/src/client/views/nodes/DataVizBox/components/Chart.scss @@ -91,7 +91,7 @@ margin: 5px; margin-left: 25px; margin-right: 10px; - margin-bottom: 0; + margin-bottom: 0px; .tableBox-table { height: 100%; width: 100%; @@ -101,7 +101,7 @@ text-overflow: ellipsis; width: 100%; white-space: pre; - max-width: 150; + max-width: 150px; overflow: hidden; margin-left: 2px; } diff --git a/src/client/views/nodes/DataVizBox/components/Histogram.tsx b/src/client/views/nodes/DataVizBox/components/Histogram.tsx index a7c4a00b0..f51683991 100644 --- a/src/client/views/nodes/DataVizBox/components/Histogram.tsx +++ b/src/client/views/nodes/DataVizBox/components/Histogram.tsx @@ -413,6 +413,10 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { }); }; + setChartRef = (r: HTMLDivElement | null) => { + this._histogramRef = r; + r && this.drawChart(this._histogramData, this.width, this.height); + }; render() { if (!this.selectedBins) this.layoutDoc.dataViz_histogram_selectedBins = new List<string>(); @@ -446,12 +450,7 @@ export class Histogram extends ObservableReactComponent<HistogramProps> { size={Size.XSMALL} /> </div> - <div - ref={r => { - this._histogramRef = r; - r && this.drawChart(this._histogramData, this.width, this.height); - }} - /> + <div ref={this.setChartRef} /> {selected !== 'none' ? ( <div className="selected-data"> Selected: {selected} diff --git a/src/client/views/nodes/DataVizBox/components/LineChart.tsx b/src/client/views/nodes/DataVizBox/components/LineChart.tsx index 80fadf178..732681e05 100644 --- a/src/client/views/nodes/DataVizBox/components/LineChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/LineChart.tsx @@ -347,6 +347,10 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { .style('pointer-events', 'none') .style('transform', `translate(${xScale(d0.x) - this.width}px,${yScale(d0.y)}px)`); } + setLineRef = (r: HTMLDivElement | null) => { + this._lineChartRef = r; + this.drawChart([this._lineChartData], this.rangeVals, this.width, this.height); + }; render() { const selectedPt = this._currSelected ? `{ ${this._props.axes[0]}: ${this._currSelected.x} ${this._props.axes[1]}: ${this._currSelected.y} }` : 'none'; @@ -378,12 +382,7 @@ export class LineChart extends ObservableReactComponent<LineChartProps> { fillWidth /> </div> - <div - ref={r => { - this._lineChartRef = r; - this.drawChart([this._lineChartData], this.rangeVals, this.width, this.height); - }} - /> + <div ref={this.setLineRef} /> {selectedPt !== 'none' ? ( <div className="selected-data"> {`Selected: ${selectedPt}`} diff --git a/src/client/views/nodes/DataVizBox/components/PieChart.tsx b/src/client/views/nodes/DataVizBox/components/PieChart.tsx index 0ae70786f..cf476b8d0 100644 --- a/src/client/views/nodes/DataVizBox/components/PieChart.tsx +++ b/src/client/views/nodes/DataVizBox/components/PieChart.tsx @@ -373,10 +373,10 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { const sliceName = StrCast(sliceTitle) ? StrCast(sliceTitle).replace(/\$/g, '').replace(/%/g, '').replace(/#/g, '').replace(/</g, '') : sliceTitle; const sliceColors = Cast(this._props.layoutDoc.dataViz_pie_sliceColors, listSpec('string'), null); - sliceColors.forEach(each => { + sliceColors?.forEach(each => { if (each.split('::')[0] === sliceName) sliceColors.splice(sliceColors.indexOf(each), 1); }); - sliceColors.push(StrCast(sliceName + '::' + color)); + sliceColors?.push(StrCast(sliceName + '::' + color)); }; @action changeHistogramCheckBox = () => { @@ -384,6 +384,10 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { this.drawChart(this._pieChartData, this.width, this.height); }; + setChartRef = (r: HTMLDivElement | null) => { + this._piechartRef = r; + this.drawChart(this._pieChartData, this.width, this.height); + }; render() { let titleAccessor = 'dataViz_pie_title'; if (this._props.axes.length === 2) titleAccessor = titleAccessor + this._props.axes[0] + '-' + this._props.axes[1]; @@ -443,12 +447,7 @@ export class PieChart extends ObservableReactComponent<PieChartProps> { Organize data as histogram </div> ) : null} - <div - ref={r => { - this._piechartRef = r; - this.drawChart(this._pieChartData, this.width, this.height); - }} - /> + <div ref={this.setChartRef} /> {selected !== 'none' ? ( <div className="selected-data"> Selected: {selected} diff --git a/src/client/views/nodes/DataVizBox/components/TableBox.tsx b/src/client/views/nodes/DataVizBox/components/TableBox.tsx index ad2731109..cc08cf269 100644 --- a/src/client/views/nodes/DataVizBox/components/TableBox.tsx +++ b/src/client/views/nodes/DataVizBox/components/TableBox.tsx @@ -100,7 +100,7 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { return this._props.docView?.()?.screenToViewTransform().Scale || 1; } @computed get rowHeight() { - return (this.viewScale * this._tableHeight) / this._tableDataIds.length; + return (this.viewScale * this._tableHeight) / (this._tableDataIds.length + 1); // add 1 for header row } @computed get startID() { return this.rowHeight ? Math.max(Math.floor(this._scrollTop / this.rowHeight) - 1, 0) : 0; @@ -400,12 +400,12 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { this._tableHeight = r?.getBoundingClientRect().height ?? 0; } })}> - <div style={{ height: this.startID * Number(DATA_VIZ_TABLE_ROW_HEIGHT) }} /> <thead> + <tr style={{ height: this.startID * Number(DATA_VIZ_TABLE_ROW_HEIGHT.replace("px","")) }} /> <tr> - {this.columns.map(col => ( + {this.columns.map((col, i) => ( <th - key={this.columns.indexOf(col)} + key={i} style={{ color: this._props.axes.slice().reverse().lastElement() === col @@ -440,7 +440,7 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { <tbody> {this._tableDataIds .filter((rowId, i) => this.startID - 2 <= i && i <= this.endID + 2) - ?.map(rowId => ( + .map(rowId => ( <tr key={rowId} className={`tableBox-row ${this.columns[0]}`} @@ -456,22 +456,22 @@ export class TableBox extends ObservableReactComponent<TableBoxProps> { : '', border: rowId === this._props.specHighlightedRow ? `solid 3px ${Colors.MEDIUM_BLUE}` : '', }}> - {this.columns.map(col => { + {this.columns.map((col, i) => { let colSelected = false; if (this._props.axes.length > 2) colSelected = this._props.axes[0] === col || this._props.axes[1] === col || this._props.axes[2] === col; else if (this._props.axes.length > 1) colSelected = this._props.axes[0] === col || this._props.axes[1] === col; else if (this._props.axes.length > 0) colSelected = this._props.axes[0] === col; if (this._props.titleCol === col) colSelected = true; return ( - <td key={this.columns.indexOf(col)} style={{ border: colSelected ? '3px solid black' : '1px solid black', fontWeight: colSelected ? 'bolder' : 'normal' }}> + <td key={i} style={{ border: (colSelected ? '3' : '1') + 'px solid black', fontWeight: colSelected ? 'bolder' : 'normal' }}> <div className="tableBox-cell">{this._props.records[rowId][col] as string | number}</div> </td> ); })} </tr> ))} + <tr style={{ display: this._tableDataIds.length - this.endID ? undefined : 'none', height: (this._tableDataIds.length - this.endID) * Number(DATA_VIZ_TABLE_ROW_HEIGHT.replace("px","")) }} /> </tbody> - <div style={{ height: (this._tableDataIds.length - this.endID) * Number(DATA_VIZ_TABLE_ROW_HEIGHT) }} /> </table> </div> </div> diff --git a/src/client/views/nodes/DiagramBox.tsx b/src/client/views/nodes/DiagramBox.tsx index 7cfccf0dc..6a31f64ce 100644 --- a/src/client/views/nodes/DiagramBox.tsx +++ b/src/client/views/nodes/DiagramBox.tsx @@ -185,6 +185,8 @@ export class DiagramBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return '( )'; }; + setRef = (r: HTMLDivElement | null) => this.fixWheelEvents(r, this._props.isContentActive); + setDiagramBoxRef = (r: HTMLDivElement | null) => r && this.renderMermaidAsync.call(this, this.removeWords(this.mermaidcode), r); render() { return ( <div @@ -192,7 +194,7 @@ export class DiagramBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { style={{ pointerEvents: this._props.isContentActive() ? undefined : 'none', }} - ref={r => this.fixWheelEvents(r, this._props.isContentActive)}> + ref={this.setRef}> <div className="DIYNodeBox-searchbar"> <input type="text" value={this._inputValue} onKeyDown={action(e => e.key === 'Enter' && this.generateMermaidCode())} onChange={action(e => (this._inputValue = e.target.value))} /> <button type="button" onClick={this.generateMermaidCode}> @@ -208,7 +210,7 @@ export class DiagramBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { ) : this._generating ? ( <div className="loading-circle" /> ) : ( - <div className="diagramBox" ref={r => r && this.renderMermaidAsync.call(this, this.removeWords(this.mermaidcode), r)}> + <div className="diagramBox" ref={this.setDiagramBoxRef}> {this._errorMessage || 'Type a prompt to generate a diagram'} </div> )} diff --git a/src/client/views/nodes/DocumentContentsView.tsx b/src/client/views/nodes/DocumentContentsView.tsx index 504c1491e..32741a0fe 100644 --- a/src/client/views/nodes/DocumentContentsView.tsx +++ b/src/client/views/nodes/DocumentContentsView.tsx @@ -261,9 +261,7 @@ export class DocumentContentsView extends ObservableReactComponent<DocumentConte jsx={layoutFrame} showWarnings // eslint-disable-next-line @typescript-eslint/no-explicit-any - onError={(test: any) => { - console.log('DocumentContentsView:' + test, bindings, layoutFrame); - }} + onError={(test: any) => console.log('DocumentContentsView:' + test, bindings, layoutFrame)} /> ); } diff --git a/src/client/views/nodes/DocumentLinksButton.scss b/src/client/views/nodes/DocumentLinksButton.scss index e1b83dc59..43b1e083f 100644 --- a/src/client/views/nodes/DocumentLinksButton.scss +++ b/src/client/views/nodes/DocumentLinksButton.scss @@ -17,8 +17,8 @@ } .documentLinksButton-cont { - min-width: 20; - min-height: 20; + min-width: 20px; + min-height: 20px; position: absolute; } diff --git a/src/client/views/nodes/DocumentLinksButton.tsx b/src/client/views/nodes/DocumentLinksButton.tsx index c35a329c9..d30f00829 100644 --- a/src/client/views/nodes/DocumentLinksButton.tsx +++ b/src/client/views/nodes/DocumentLinksButton.tsx @@ -38,7 +38,6 @@ export class DocButtonState { // eslint-disable-next-line no-use-before-define public static _instance: DocButtonState | undefined; public static get Instance() { - // eslint-disable-next-line no-return-assign return DocButtonState._instance ?? (DocButtonState._instance = new DocButtonState()); } constructor() { diff --git a/src/client/views/nodes/DocumentView.scss b/src/client/views/nodes/DocumentView.scss index c4351a200..98ca76339 100644 --- a/src/client/views/nodes/DocumentView.scss +++ b/src/client/views/nodes/DocumentView.scss @@ -1,6 +1,7 @@ @use '../global/globalCssVariables.module.scss' as global; .documentView-effectsWrapper { + height: 100%; border-radius: inherit; transition: inherit; } @@ -14,13 +15,13 @@ width: 100%; height: 100%; position: absolute; - top: 0; + top: 0px; } .documentView-node { position: inherit; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; border-radius: inherit; @@ -55,7 +56,7 @@ .documentView-htmlOverlay { position: absolute; display: flex; - top: 0; + top: 0px; height: 100%; width: 100%; .documentView-htmlOverlayInner { @@ -79,9 +80,9 @@ .documentView-audioBackground { display: inline-block; width: 25px; - height: 25; + height: 25px; position: absolute; - top: 0; + top: 0px; left: 50%; border-radius: 25px; background: white; @@ -130,7 +131,7 @@ width: 30px; border-radius: 50%; position: absolute; - right: -15; + right: -15px; opacity: 0.9; pointer-events: auto; background-color: #9dca96; @@ -147,8 +148,8 @@ .documentView-anchorCont { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; display: inline-block; @@ -160,8 +161,8 @@ position: absolute; width: 100%; height: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; } .documentView-styleWrapper { @@ -183,9 +184,9 @@ .documentView-titleWrapper-hover { color: global.$black; transform-origin: top left; - top: 0; + top: 0px; width: 100%; - height: 14; + height: 14px; opacity: 0.5; text-align: center; text-overflow: ellipsis; @@ -211,7 +212,7 @@ .documentView-captionWrapper { position: absolute; - bottom: 0; + bottom: 0px; width: 100%; overflow-y: auto; transform-origin: bottom left; @@ -275,20 +276,20 @@ .documentView-noAIWidgets { transform-origin: top left; position: absolute; - bottom: 0; + bottom: 0px; pointer-events: none; } .documentView-widgetDecorations { transform-origin: top right; position: absolute; - top: 0; - right: 0; + top: 0px; + right: 0px; } .documentView-editorView-history { position: absolute; transform-origin: top right; - right: 0; + right: 0px; top: 0; overflow-y: scroll; scrollbar-width: thin; diff --git a/src/client/views/nodes/DocumentView.tsx b/src/client/views/nodes/DocumentView.tsx index 05706fe6b..bd71115db 100644 --- a/src/client/views/nodes/DocumentView.tsx +++ b/src/client/views/nodes/DocumentView.tsx @@ -701,16 +701,18 @@ export class DocumentViewInternal extends DocComponent<DocumentViewProps & Field aiContentsWidth = () => (this.aiContentsHeight() * (this._props.NativeWidth?.() || 1)) / (this._props.NativeHeight?.() || 1); aiContentsHeight = () => Math.max(10, this._props.PanelHeight() - (this._aiWinHeight + (this.tagsOverlayFunc() ? 22 : 0)) * this.uiBtnScaling); + setAiRef = action((r: HTMLDivElement | null) => this.historyRef(this._oldHistoryWheel, (this._oldHistoryWheel = r))); + @computed get aiEditor() { return ( <div className="documentView-editorView" + ref={this.setAiRef} style={{ background: SnappingManager.userVariantColor, width: `${100 / this.uiBtnScaling}%`, // transform: `scale(${this.uiBtnScaling})`, - }} - ref={r => this.historyRef(this._oldHistoryWheel, (this._oldHistoryWheel = r))}> + }}> <div className="documentView-editorView-resizer" /> {this._componentView?.componentAIView?.() ?? null} {this._props.DocumentView?.() ? <TagsView background={this.backgroundBoxColor} Views={[this._props.DocumentView?.()]} /> : null} @@ -720,7 +722,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewProps & Field @computed get tagsOverlay() { return ( <div - className="documentView-noAiWidgets" + className="documentView-noAIWidgets" style={{ width: `${100 / this.uiBtnScaling}%`, // transform: `scale(${this.uiBtnScaling})`, @@ -744,7 +746,7 @@ export class DocumentViewInternal extends DocComponent<DocumentViewProps & Field @computed get viewBoxContents() { TraceMobx(); const isInk = this.layoutDoc._layout_isSvg && !this._props.LayoutTemplateString; - const noBackground = this.Document.isGroup && !this._componentView?.isUnstyledView?.() && (!this.layoutDoc.backgroundColor || this.layoutDoc.backgroundColor === 'transparent'); + const noBackground = Doc.IsFreeformGroup(this.Document) && !this._componentView?.isUnstyledView?.() && (!this.layoutDoc.backgroundColor || this.layoutDoc.backgroundColor === 'transparent'); return ( <> <div @@ -1412,7 +1414,7 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { } public static setDefaultImageTemplate(checkResult?: boolean) { if (checkResult) return Doc.UserDoc().defaultImageLayout; - const view = DocumentView.Selected()[0]?._props.renderDepth > 0 ? DocumentView.Selected()[0] : undefined; + const view = DocumentView.Selected()[0]?._props.renderDepth > 0 || DocumentView.Selected()[0]?.Document.isTemplateDoc ? DocumentView.Selected()[0] : undefined; undoable(() => { const tempDoc = DocumentView.getTemplate(view); Doc.UserDoc().defaultImageLayout = tempDoc ? new PrefetchProxy(tempDoc) : undefined; @@ -1507,18 +1509,16 @@ export class DocumentView extends DocComponent<DocumentViewProps>() { .translate(-(this._docViewInternal?.aiShift() ?? 0), 0) .scale((this._docViewInternal?.aiScale() ?? 1) / this.nativeScaling); + setHtmlOverlayRef = (r: HTMLDivElement | null) => { + const val = r?.style.display !== 'none'; // if the outer overlay has been displayed, trigger the innner div to start it's opacity fade in transition + if (r && val !== this._enableHtmlOverlayTransitions) { + setTimeout(action(() => (this._enableHtmlOverlayTransitions = val))); + } + }; htmlOverlay = () => { const effect = StrCast(this._htmlOverlayEffect?.presentation_effect, StrCast(this._htmlOverlayEffect?.followLinkAnimEffect)); return ( - <div - className="documentView-htmlOverlay" - ref={r => { - const val = r?.style.display !== 'none'; // if the outer overlay has been displayed, trigger the innner div to start it's opacity fade in transition - if (r && val !== this._enableHtmlOverlayTransitions) { - setTimeout(action(() => (this._enableHtmlOverlayTransitions = val))); - } - }} - style={{ display: !this._htmlOverlayText ? 'none' : undefined }}> + <div className="documentView-htmlOverlay" ref={this.setHtmlOverlayRef} style={{ display: !this._htmlOverlayText ? 'none' : undefined }}> <div className="documentView-htmlOverlayInner" style={{ transition: `all 500ms`, opacity: this._enableHtmlOverlayTransitions ? 0.9 : 0 }}> {DocumentViewInternal.AnimationEffect( <div className="webBox-textHighlight"> diff --git a/src/client/views/nodes/EquationBox.tsx b/src/client/views/nodes/EquationBox.tsx index 3cacb6692..2ce24b688 100644 --- a/src/client/views/nodes/EquationBox.tsx +++ b/src/client/views/nodes/EquationBox.tsx @@ -6,7 +6,7 @@ import { TraceMobx } from '../../../fields/util'; import { DocUtils } from '../../documents/DocUtils'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; -import { undoBatch } from '../../util/UndoManager'; +import { undoBatch, UndoManager } from '../../util/UndoManager'; import { ViewBoxBaseComponent } from '../DocComponent'; import { StyleProp } from '../StyleProp'; import { DocumentView } from './DocumentView'; @@ -14,6 +14,7 @@ import './EquationBox.scss'; import { FieldView, FieldViewProps } from './FieldView'; import EquationEditor from './formattedText/EquationEditor'; import { FormattedTextBox } from './formattedText/FormattedTextBox'; +import { Doc } from '../../../fields/Doc'; @observer export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { @@ -21,6 +22,7 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { return FieldView.LayoutString(EquationBox, fieldKey); } _ref: React.RefObject<EquationEditor> = React.createRef(); + _liveTextUndo: UndoManager.Batch | undefined; // captured undo batch when typing a new text note into a collection constructor(props: FieldViewProps) { super(props); @@ -29,12 +31,17 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { componentDidMount() { this._props.setContentViewBox?.(this); - if (DocumentView.SelectOnLoad === this.Document && (!DocumentView.LightboxDoc() || DocumentView.LightboxContains(this.DocumentView?.()))) { + if (DocumentView.SelectOnLoad === this.rootDoc && (!DocumentView.LightboxDoc() || DocumentView.LightboxContains(this.DocumentView?.()))) { + this._liveTextUndo = FormattedTextBox.LiveTextUndo; + FormattedTextBox.LiveTextUndo = undefined; this._props.select(false); + this.dataDoc[Doc.LayoutDataKey(this.Document)] = FormattedTextBox.SelectOnLoadChar ?? ''; + this._ref.current?.mathField.focus(); - this.dataDoc.text === 'x' && this._ref.current?.mathField.select(); + this.dataDoc[Doc.LayoutDataKey(this.Document)] === 'x' && this._ref.current?.mathField.select(); DocumentView.SetSelectOnLoad(undefined); + FormattedTextBox.SelectOnLoadChar = ''; } reaction( () => this._props.isSelected(), @@ -53,7 +60,7 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { @action keyPressed = (e: KeyboardEvent) => { if (e.key === 'Enter') { - const nextEq = Docs.Create.EquationDocument(e.shiftKey ? StrCast(this.dataDoc.text) : '', { + const nextEq = Docs.Create.EquationDocument(e.shiftKey ? StrCast(this.dataDoc[Doc.LayoutDataKey(this.Document)]) : '', { title: '# math', _width: NumCast(this.layoutDoc._width), _height: NumCast(this.layoutDoc._height), @@ -70,9 +77,10 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { e.stopPropagation(); } if (e.key === 'Tab') { + const target = this.Document.isTemplateDoc ? this.rootDoc : this.Document; const graph = Docs.Create.FunctionPlotDocument([this.Document], { - x: NumCast(this.layoutDoc.x) + NumCast(this.layoutDoc._width), - y: NumCast(this.layoutDoc.y), + x: NumCast(target.x) + NumCast(this.layoutDoc._width), + y: NumCast(target.y), _width: 400, _height: 300, backgroundColor: 'white', @@ -82,11 +90,11 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { link && this._props.addDocument?.(link); e.stopPropagation(); } - if (e.key === 'Backspace' && !this.dataDoc.text) this._props.removeDocument?.(this.Document); + if (e.key === 'Backspace' && !this.dataDoc[Doc.LayoutDataKey(this.Document)]) this._props.removeDocument?.(this.Document); }; @undoBatch onChange = (str: string) => { - this.dataDoc.text = str; + this.dataDoc[Doc.LayoutDataKey(this.Document)] = str; }; updateSize = (mathSpan: HTMLSpanElement) => { @@ -103,19 +111,22 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { this.layoutDoc._width = mathWidth * nScale; this.layoutDoc._height = mathHeight * nScale; + if (this.layoutDoc._nativeWidth) { + this.layoutDoc._nativeWidth = mathWidth; + this.layoutDoc._nativeHeight = mathHeight; + } }; + setRef = (r: HTMLDivElement) => r && this._ref.current?.element.current && this.updateSize(this._ref.current?.element.current); render() { TraceMobx(); const scale = this._props.NativeDimScaling?.() || 1; return ( <div - ref={r => r && this._ref.current?.element.current && this.updateSize(this._ref.current?.element.current)} + ref={this.setRef} className="equationBox-cont" onKeyDown={e => e.stopPropagation()} onPointerDown={e => !e.ctrlKey && e.stopPropagation()} - onBlur={() => { - FormattedTextBox.LiveTextUndo?.end(); - }} + onBlur={() => this._liveTextUndo?.end()} style={{ transform: `scale(${scale})`, minWidth: `${100 / scale}%`, @@ -128,7 +139,14 @@ export class EquationBox extends ViewBoxBaseComponent<FieldViewProps>() { paddingTop: NumCast(this.layoutDoc.yMargin), paddingBottom: NumCast(this.layoutDoc.yMargin), }}> - <EquationEditor ref={this._ref} value={StrCast(this.dataDoc.text, '')} spaceBehavesLikeTab onChange={this.onChange} autoCommands="pi theta sqrt sum prod alpha beta gamma rho" autoOperatorNames="sin cos tan" /> + <EquationEditor + ref={this._ref} + value={StrCast(this.dataDoc[Doc.LayoutDataKey(this.Document)], '')} + spaceBehavesLikeTab + onChange={this.onChange} + autoCommands="pi theta sqrt sum prod alpha beta gamma rho" + autoOperatorNames="sin cos tan" + /> </div> ); } diff --git a/src/client/views/nodes/FontIconBox/FontIconBadge.scss b/src/client/views/nodes/FontIconBox/FontIconBadge.scss index 2ff5c651f..e741936db 100644 --- a/src/client/views/nodes/FontIconBox/FontIconBadge.scss +++ b/src/client/views/nodes/FontIconBox/FontIconBadge.scss @@ -6,7 +6,7 @@ color: black; display: block; position: absolute; - right: 5; + right: 5px; border-radius: 50%; text-align: center; } diff --git a/src/client/views/nodes/FontIconBox/FontIconBox.scss b/src/client/views/nodes/FontIconBox/FontIconBox.scss index 8bc68c131..52eebba54 100644 --- a/src/client/views/nodes/FontIconBox/FontIconBox.scss +++ b/src/client/views/nodes/FontIconBox/FontIconBox.scss @@ -42,7 +42,7 @@ letter-spacing: normal; background-color: inherit; border-radius: 8px; - padding: 0; + padding: 0px; width: 100%; font-family: 'system-ui'; text-transform: uppercase; @@ -96,22 +96,22 @@ display: inline-block; width: 100%; height: 25px; - margin: 0; + margin: 0px; } .switch input { opacity: 0; - width: 0; - height: 0; + width: 0px; + height: 0px; } .slider { position: absolute; cursor: pointer; - top: 0; - left: 0; - right: 0; - bottom: 0; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; background-color: lightgrey; -webkit-transition: 0.4s; transition: 0.4s; @@ -223,7 +223,7 @@ height: fit-content; color: black; top: 100%; - left: 0; + left: 0px; z-index: 21; background-color: #e3e3e3; box-shadow: 0px 3px 4px rgba(0, 0, 0, 0.3); @@ -430,7 +430,7 @@ border-radius: 0px 7px 7px 0px; width: 13px; height: 100%; - right: 0; + right: 0px; } .menuButton-dropdown-header { diff --git a/src/client/views/nodes/FunctionPlotBox.tsx b/src/client/views/nodes/FunctionPlotBox.tsx index 8e4b64851..e4d37e006 100644 --- a/src/client/views/nodes/FunctionPlotBox.tsx +++ b/src/client/views/nodes/FunctionPlotBox.tsx @@ -121,11 +121,12 @@ export class FunctionPlotBox extends ViewBoxAnnotatableComponent<FieldViewProps> } // if (this.layout_autoHeight) this.tryUpdateScrollHeight(); }; + setRef = (r: HTMLDivElement | null) => r && this.createGraph(r); @computed get theGraph() { return ( <div id={`${this._plotId}`} - ref={r => r && this.createGraph(r)} + ref={this.setRef} style={{ position: 'absolute', width: '100%', height: '100%' }} onPointerDown={e => { e.stopPropagation(); diff --git a/src/client/views/nodes/IconTagBox.scss b/src/client/views/nodes/IconTagBox.scss index d6cf95958..c0977dfc5 100644 --- a/src/client/views/nodes/IconTagBox.scss +++ b/src/client/views/nodes/IconTagBox.scss @@ -15,7 +15,7 @@ width: 20px; height: 20px; margin: auto; - padding: 0; + padding: 0px; border-radius: 50%; background-color: global.$dark-gray; background-color: transparent; diff --git a/src/client/views/nodes/ImageBox.scss b/src/client/views/nodes/ImageBox.scss index 5a6292fab..90ede69dc 100644 --- a/src/client/views/nodes/ImageBox.scss +++ b/src/client/views/nodes/ImageBox.scss @@ -3,14 +3,14 @@ width: 100%; height: 100%; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; transform-origin: top left; .imageBox-annotationLayer { position: absolute; transform-origin: left top; - top: 0; + top: 0px; width: 100%; pointer-events: none; mix-blend-mode: multiply; // bcz: makes text fuzzy! @@ -24,8 +24,8 @@ #upload-icon { position: absolute; - bottom: 0; - right: 0; + bottom: 0px; + right: 0px; width: 20px; height: 20px; } @@ -51,8 +51,8 @@ .imageBox-dot { position: absolute; - bottom: 10; - left: 0; + bottom: 10px; + left: 0px; border-radius: 10px; width: 20px; height: 20px; @@ -131,8 +131,8 @@ position: absolute; color: white; background: black; - right: 0; - bottom: 0; + right: 0px; + bottom: 0px; z-index: 2; transform-origin: bottom right; cursor: default; @@ -142,13 +142,13 @@ } } .imageBox-regenerateDropTarget { - right: 35; + right: 35px; transform-origin: 70px 35px; } .imageBox-fader img { position: absolute; - left: 0; + left: 0px; } .imageBox-fadeBlocker-hover { @@ -223,7 +223,7 @@ max-width: 90%; width: 100%; .imageBox-aiView-similarity { - max-width: 65; + max-width: 65px; overflow: hidden; text-overflow: ellipsis; width: 100%; @@ -250,7 +250,7 @@ z-index: 10000; h3 { - margin-top: 0; + margin-top: 0px; } input { diff --git a/src/client/views/nodes/ImageBox.tsx b/src/client/views/nodes/ImageBox.tsx index f7ad5c7e2..78bacdcac 100644 --- a/src/client/views/nodes/ImageBox.tsx +++ b/src/client/views/nodes/ImageBox.tsx @@ -8,7 +8,7 @@ import { extname } from 'path'; import * as React from 'react'; import { AiOutlineSend } from 'react-icons/ai'; import ReactLoading from 'react-loading'; -import { ClientUtils, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, UpdateIcon } from '../../../ClientUtils'; +import { ClientUtils, imageUrlToBase64, DashColor, returnEmptyString, returnFalse, returnOne, returnZero, setupMoveUpEvents, UpdateIcon, returnTrue } from '../../../ClientUtils'; import { Doc, DocListCast, Opt } from '../../../fields/Doc'; import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; @@ -16,7 +16,7 @@ import { InkTool } from '../../../fields/InkField'; import { List } from '../../../fields/List'; import { ObjectField } from '../../../fields/ObjectField'; import { ComputedField } from '../../../fields/ScriptField'; -import { Cast, DocCast, ImageCast, NumCast, RTFCast, StrCast } from '../../../fields/Types'; +import { Cast, DocCast, ImageCast, NumCast, RTFCast, StrCast, ImageCastWithSuffix } from '../../../fields/Types'; import { ImageField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction } from '../../../Utils'; @@ -45,6 +45,7 @@ import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; import './ImageBox.scss'; import { OpenWhere } from './OpenWhere'; +import { gptImageLabel } from '../../apis/gpt/GPT'; const DefaultPath = '/assets/unknown-file-icon-hi.png'; export class ImageEditorData { @@ -71,7 +72,7 @@ export class ImageEditorData { 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'; +const UNSPLASH_API = 'https://api.unsplash.com/search/photos'; @observer export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { public static LayoutString(fieldKey: string) { @@ -112,11 +113,69 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this._props.setContentViewBox?.(this); } + @computed get outpaintOriginalSize(): { width: number; height: number } { + return { + width: NumCast(this.Document[this.fieldKey + '_outpaintOriginalWidth']), + height: NumCast(this.Document[this.fieldKey + '_outpaintOriginalHeight']), + }; + } + set outpaintOriginalSize(prop: { width: number; height: number } | undefined) { + this.Document[this.fieldKey + '_outpaintOriginalWidth'] = prop?.width; + this.Document[this.fieldKey + '_outpaintOriginalHeight'] = prop?.height; + } + + @computed get imgNativeSize() { + return { + nativeWidth: NumCast(this.dataDoc[this.fieldKey + '_nativeWidth'], NumCast(this.layoutDoc[this.fieldKey + '_nativeWidth'], 500)), + nativeHeight: NumCast(this.dataDoc[this.fieldKey + '_nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '_nativeHeight'], 500)), + }; + } + set imgNativeSize(prop: { nativeWidth: number; nativeHeight: number }) { + this.dataDoc[this.fieldKey + '_nativeWidth'] = prop.nativeWidth; + this.dataDoc[this.fieldKey + '_nativeHeight'] = prop.nativeHeight; + } + protected createDropTarget = (ele: HTMLDivElement) => { this._mainCont = ele; this._dropDisposer?.(); ele && (this._dropDisposer = DragManager.MakeDropTarget(ele, this.drop.bind(this), this.Document)); }; + + autoTag = async () => { + if (this.Document.$tags_chat) return; + try { + // 1) grab the full-size URL + const layoutKey = Doc.LayoutDataKey(this.Document); + const url = ImageCastWithSuffix(this.Document[layoutKey], '_o'); + if (!url) throw new Error('No image URL found'); + + // 2) convert to base64 + const base64 = await imageUrlToBase64(url); + if (!base64) throw new Error('Failed to load image data'); + + // 3) ask GPT for labels one label: PERSON or LANDSCAPE + const label = await gptImageLabel( + base64, + `Classify this image as PERSON or LANDSCAPE. You may only respond with one of these two options. + Then provide five additional descriptive tags to describe the image for a total of 6 words outputted, delimited by spaces. + For example: "LANDSCAPE BUNNY NATURE FOREST PEACEFUL OUTDOORS". + Then add one final lengthier summary tag (separated by underscores) that describes the image.` + ).then(raw => raw.trim().toUpperCase()); + + const { nativeWidth, nativeHeight } = this.nativeSize; + const aspectRatio = ((nativeWidth || 1) / (nativeHeight || 1)).toFixed(2); + + // 5) stash it on the Doc + // overwrite any old tags so re-runs still work + this.Document.$tags_chat = new List<string>([...label.split(/\s+/), `ASPECT_${aspectRatio}`]); + + // 6) flip on “show tags” in the layout + this.Document._layout_showTags = true; + } catch (err) { + console.error('autoTag failed:', err); + } + }; + getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const visibleAnchor = this._getAnchor?.(this._savedAnnotations, true); // use marquee anchor, otherwise, save zoom/pan as anchor const anchor = @@ -153,7 +212,8 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { () => ({ nativeSize: this.nativeSize, width: NumCast(this.layoutDoc._width), height: this.layoutDoc._height }), ({ nativeSize, width, height }) => { if (!this.layoutDoc._layout_nativeDimEditable || !height || this.layoutDoc.layout_resetNativeDim) { - this.layoutDoc.layout_resetNativeDim = undefined; // template images need to reset their dimensions when they are rendered with content. afterwards, remove this flag. + if (!this._props.TemplateDataDocument) this.layoutDoc._nativeWidth = this.layoutDoc._nativeHeight = undefined; + this.layoutDoc.layout_resetNativeDim = undefined; // reset dimensions of templates rendered with content or if image changes. afterwards, remove this flag. this.layoutDoc._height = (width * nativeSize.nativeHeight) / nativeSize.nativeWidth; } }, @@ -170,7 +230,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { { fireImmediately: true } ); this._disposers.outpaint = reaction( - () => this.Document[this.fieldKey + '_outpaintOriginalWidth'] !== undefined && !SnappingManager.ShiftKey, + () => this.outpaintOriginalSize?.width && !SnappingManager.ShiftKey, complete => complete && this.openOutpaintPrompt(), { fireImmediately: true } ); @@ -185,7 +245,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { */ 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 { data } = await axios.get(`${UNSPLASH_API}?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), @@ -202,10 +262,6 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } }; - handleSelection = async (selection: string) => { - this._searchInput = selection; - }; - drop = undoable( action((e: Event, de: DragManager.DropEvent) => { if (de.complete.docDragData && !this._props.rejectDrop?.(de, this.DocumentView?.())) { @@ -231,14 +287,15 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { DrawingFillHandler.drawingToImage(this.Document, 90, newPrompt(descText), drag)?.then(action(() => (this._regenerateLoading = false))); added = false; } else if (de.altKey || !this.dataDoc[this.fieldKey]) { - const layoutDoc = de.complete.docDragData?.draggedDocuments[0]; - const targetField = Doc.LayoutDataKey(layoutDoc); - const targetDoc = layoutDoc[DocData]; - if (targetDoc[targetField] instanceof ImageField) { + const dropDoc = de.complete.docDragData?.draggedDocuments[0]; + const dropDocFieldKey = Doc.LayoutDataKey(dropDoc); + const dropDataDoc = dropDoc[DocData]; + if (dropDataDoc[dropDocFieldKey] instanceof ImageField) { added = true; - this.dataDoc[this.fieldKey] = ObjectField.MakeCopy(targetDoc[targetField] as ImageField); - Doc.SetNativeWidth(this.dataDoc, Doc.NativeWidth(targetDoc), this.fieldKey); - Doc.SetNativeHeight(this.dataDoc, Doc.NativeHeight(targetDoc), this.fieldKey); + this.dataDoc.layout_resetNativeDim = true; + this.dataDoc[this.fieldKey] = ObjectField.MakeCopy(dropDataDoc[dropDocFieldKey] as ImageField); + Doc.SetNativeWidth(this.dataDoc, Doc.NativeWidth(dropDataDoc), this.fieldKey); + Doc.SetNativeHeight(this.dataDoc, Doc.NativeHeight(dropDataDoc), this.fieldKey); } } added === false && e.preventDefault(); @@ -257,18 +314,17 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @undoBatch setNativeSize = action(() => { - const oldnativeWidth = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']); + const oldnativeWidth = this.imgNativeSize.nativeWidth; const nscale = NumCast(this._props.PanelWidth()) * NumCast(this.layoutDoc._freeform_scale, 1); const nw = nscale / oldnativeWidth; - this.dataDoc[this.fieldKey + '_nativeHeight'] = NumCast(this.dataDoc[this.fieldKey + '_nativeHeight']) * nw; - this.dataDoc[this.fieldKey + '_nativeWidth'] = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']) * nw; + this.imgNativeSize = { nativeWidth: this.imgNativeSize.nativeWidth * nw, nativeHeight: this.imgNativeSize.nativeHeight * nw }; this.dataDoc.freeform_panX = nw * NumCast(this.dataDoc.freeform_panX); this.dataDoc.freeform_panY = nw * NumCast(this.dataDoc.freeform_panY); this.dataDoc.freeform_panX_max = this.dataDoc.freeform_panX_max ? nw * NumCast(this.dataDoc.freeform_panX_max) : undefined; this.dataDoc.freeform_panX_min = this.dataDoc.freeform_panX_min ? nw * NumCast(this.dataDoc.freeform_panX_min) : undefined; this.dataDoc.freeform_panY_max = this.dataDoc.freeform_panY_max ? nw * NumCast(this.dataDoc.freeform_panY_max) : undefined; this.dataDoc.freeform_panY_min = this.dataDoc.freeform_panY_min ? nw * NumCast(this.dataDoc.freeform_panY_min) : undefined; - const newnativeWidth = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']); + const newnativeWidth = this.imgNativeSize.nativeWidth; DocListCast(this.dataDoc[this.annotationKey]).forEach(doc => { doc.x = (NumCast(doc.x) / oldnativeWidth) * newnativeWidth; doc.y = (NumCast(doc.y) / oldnativeWidth) * newnativeWidth; @@ -280,13 +336,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }); @undoBatch rotate = action(() => { - const nw = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']); - const nh = NumCast(this.dataDoc[this.fieldKey + '_nativeHeight']); + const nativeSize = this.imgNativeSize; const w = this.layoutDoc._width; const h = this.layoutDoc._height; this.dataDoc[this.fieldKey + '_rotation'] = (NumCast(this.dataDoc[this.fieldKey + '_rotation']) + 90) % 360; - this.dataDoc[this.fieldKey + '_nativeWidth'] = nh; - this.dataDoc[this.fieldKey + '_nativeHeight'] = nw; + this.imgNativeSize = { nativeWidth: nativeSize.nativeHeight, nativeHeight: nativeSize.nativeWidth }; // swap width and height this.layoutDoc._width = h; this.layoutDoc._height = w; }); @@ -302,7 +356,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const anchy = NumCast(cropping.y); const anchw = NumCast(cropping._width); const anchh = NumCast(cropping._height); - const viewScale = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth']) / anchw; + const viewScale = this.nativeSize.nativeWidth / anchw; cropping.x = NumCast(this.Document.x) + NumCast(this.layoutDoc._width); cropping.y = NumCast(this.Document.y); cropping.onClick = undefined; @@ -364,18 +418,14 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @action cancelOutpaintPrompt = () => { - const origWidth = NumCast(this.Document[this.fieldKey + '_outpaintOriginalWidth']); - const origHeight = NumCast(this.Document[this.fieldKey + '_outpaintOriginalHeight']); - this.Document._width = origWidth; - this.Document._height = origHeight; + [this.Document._width, this.Document._height] = [this.outpaintOriginalSize.width, this.outpaintOriginalSize.height]; this._outpaintingInProgress = false; + this.outpaintOriginalSize = undefined; this.closeOutpaintPrompt(); }; @action - handlePromptChange = (val: string | number) => { - this._outpaintPromptInput = '' + val; - }; + handlePromptChange = (val: string | number) => (this._outpaintPromptInput = '' + val); @action submitOutpaintPrompt = () => { @@ -416,8 +466,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { loadingOverlay.innerHTML = '<div style="color: white; font-size: 16px;">Generating outpainted image...</div>'; this._mainCont?.appendChild(loadingOverlay); - const origWidth = NumCast(this.Document[this.fieldKey + '_outpaintOriginalWidth']); - const origHeight = NumCast(this.Document[this.fieldKey + '_outpaintOriginalHeight']); + const { width: origWidth, height: origHeight } = this.outpaintOriginalSize; const response = await Networking.PostToServer('/outpaintImage', { imageUrl: currentPath, prompt: customPrompt, @@ -454,8 +503,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this.Document.$ai = true; this.Document.$ai_outpainted = true; this.Document.$ai_outpaint_prompt = customPrompt; - this.Document[this.fieldKey + '_outpaintOriginalWidth'] = undefined; - this.Document[this.fieldKey + '_outpaintOriginalHeight'] = undefined; + this.outpaintOriginalSize = undefined; } else { this.cancelOutpaintPrompt(); alert('Failed to receive a valid image URL from server.'); @@ -478,6 +526,8 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return this._props.PanelWidth() / this._props.PanelHeight() < this.nativeSize.nativeWidth / this.nativeSize.nativeHeight; } + isOutpaintable = () => true; + componentUI = (/* boundsLeft: number, boundsTop: number*/) => !this._showOutpaintPrompt ? null : ( <div @@ -668,8 +718,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @computed get nativeSize() { TraceMobx(); if (this.paths.length && this.paths[0].includes(DefaultPath)) return { nativeWidth: NumCast(this.layoutDoc._width), nativeHeight: NumCast(this.layoutDoc._height), nativeOrientation: 0 }; - const nativeWidth = NumCast(this.dataDoc[this.fieldKey + '_nativeWidth'], NumCast(this.layoutDoc[this.fieldKey + '_nativeWidth'], 500)); - const nativeHeight = NumCast(this.dataDoc[this.fieldKey + '_nativeHeight'], NumCast(this.layoutDoc[this.fieldKey + '_nativeHeight'], 500)); + const { nativeWidth, nativeHeight } = this.imgNativeSize; const nativeOrientation = NumCast(this.dataDoc[this.fieldKey + '_nativeOrientation'], 1); return { nativeWidth, nativeHeight, nativeOrientation }; } @@ -689,7 +738,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @computed get overlayImageIcon() { const usePath = this.layoutDoc[`_${this.fieldKey}_usePath`]; - return ( + return this._regenerateLoading ? null : ( <Tooltip title={ <div className="dash-tooltip"> @@ -731,7 +780,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { ); } @computed get regenerateImageIcon() { - return ( + return this._regenerateLoading ? null : ( <Tooltip title={'click to show AI generations. Drop an image on to create a new generation'}> <div className="imageBox-regenerateDropTarget" @@ -820,7 +869,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { transform, transformOrigin, width: this._outpaintAlign ? 'max-content' : this._outpaintAlign ? '100%' : undefined, - height: this._outpaintVAlign ? 'max-content' : this.Document[this.fieldKey + '_outpaintOriginalWidth'] !== undefined ? '100%' : undefined, + height: this._outpaintVAlign ? 'max-content' : this.outpaintOriginalSize?.width ? '100%' : undefined, }} onError={action(e => (this._error = e.toString()))} draggable={false} @@ -943,10 +992,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return { width, height }; }; savedAnnotations = () => this._savedAnnotations; + showBorderRounding = returnTrue; + rejectDrop = (de: DragManager.DropEvent, subView?: DocumentView | undefined) => (this.dataDoc[this.fieldKey] === undefined ? true : (this._props.rejectDrop?.(de, subView) ?? false)); render() { TraceMobx(); - const borderRad = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BorderRounding) as string; - const borderRadius = borderRad?.includes('px') ? `${Number(borderRad.split('px')[0]) / (this._props.NativeDimScaling?.() || 1)}px` : borderRad; + const borderRadius = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.BorderRounding) as string; return ( <> <div @@ -986,7 +1036,7 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { ScreenToLocalTransform={this.screenToLocalTransform} select={emptyFunction} focus={this.focus} - rejectDrop={this._props.rejectDrop} + rejectDrop={this.rejectDrop} getScrollHeight={this.getScrollHeight} NativeDimScaling={returnOne} isAnyChildContentActive={returnFalse} @@ -1048,8 +1098,11 @@ export class ImageBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { if (result instanceof Error) { alert('Error uploading files - possibly due to unsupported file types'); } else { - this.dataDoc[this.fieldKey] = new ImageField(result.accessPaths.agnostic.client); - !(result instanceof Error) && DocUtils.assignUploadInfo(result, this.dataDoc); + runInAction(() => { + this.dataDoc.layout_resetNativeDim = true; + !(result instanceof Error) && DocUtils.assignUploadInfo(result, this.dataDoc, this.fieldKey); + this.dataDoc[this.fieldKey] = new ImageField(result.accessPaths.agnostic.client); + }); } disposer(); } else { diff --git a/src/client/views/nodes/KeyValueBox.scss b/src/client/views/nodes/KeyValueBox.scss index 441fceba4..80ace6ae0 100644 --- a/src/client/views/nodes/KeyValueBox.scss +++ b/src/client/views/nodes/KeyValueBox.scss @@ -93,9 +93,9 @@ $header-height: 30px; height: 30px; width: 5px; z-index: 20; - right: 0; - top: 0; - border-radius: 0; + right: 0px; + top: 0px; + border-radius: 0px; background: black; pointer-events: all; } @@ -105,8 +105,8 @@ $header-height: 30px; float: left; height: 37px; z-index: 20; - right: 0; - top: 0; + right: 0px; + top: 0px; background: transparent; pointer-events: none; } diff --git a/src/client/views/nodes/KeyValueBox.tsx b/src/client/views/nodes/KeyValueBox.tsx index aa66b5ba9..606f63d6d 100644 --- a/src/client/views/nodes/KeyValueBox.tsx +++ b/src/client/views/nodes/KeyValueBox.tsx @@ -273,15 +273,15 @@ export class KeyValueBox extends ViewBoxBaseComponent<FieldViewProps>() { }; createFieldView = (templateDoc: Doc, row: KeyValuePair) => { - const metaKey = row._props.keyName; - const fieldTempDoc = Doc.IsDelegateField(templateDoc, metaKey) ? Doc.MakeDelegate(templateDoc) : Doc.MakeEmbedding(templateDoc); - fieldTempDoc.title = metaKey; + const keyName = row._props.keyName; + const fieldTempDoc = Doc.IsDelegateField(templateDoc, keyName) ? Doc.MakeDelegate(templateDoc) : Doc.MakeEmbedding(templateDoc); + fieldTempDoc.title = keyName; fieldTempDoc.layout_fitWidth = true; fieldTempDoc._xMargin = 10; fieldTempDoc._yMargin = 10; fieldTempDoc._width = 100; fieldTempDoc._height = 40; - fieldTempDoc.layout = this.inferType(templateDoc[metaKey], metaKey); + fieldTempDoc.layout = this.inferType(templateDoc[keyName], keyName); return fieldTempDoc; }; diff --git a/src/client/views/nodes/KeyValuePair.scss b/src/client/views/nodes/KeyValuePair.scss index 913ab641c..154fbdcfa 100644 --- a/src/client/views/nodes/KeyValuePair.scss +++ b/src/client/views/nodes/KeyValuePair.scss @@ -17,7 +17,7 @@ } .keyValuePair-td-key-check { position: relative; - margin: 0; + margin: 0px; } .keyValuePair-keyField { width: 100%; diff --git a/src/client/views/nodes/LabelBox.scss b/src/client/views/nodes/LabelBox.scss index 889cdc0ca..e1974d6a0 100644 --- a/src/client/views/nodes/LabelBox.scss +++ b/src/client/views/nodes/LabelBox.scss @@ -24,8 +24,8 @@ .answer-icon { position: absolute; - right: 8; - bottom: 5; + right: 8px; + bottom: 5px; color: black; display: inline-block; font-size: 10px; @@ -36,8 +36,8 @@ .q-icon { position: absolute; - right: 6; - bottom: 5; + right: 6px; + bottom: 5px; color: white; display: inline-block; font-size: 10px; @@ -48,8 +48,8 @@ .edit-icon { position: absolute; - right: 20; - bottom: 5; + right: 20px; + bottom: 5px; display: inline-block; font-size: 10px; cursor: pointer; diff --git a/src/client/views/nodes/LabelBox.tsx b/src/client/views/nodes/LabelBox.tsx index 4cbe01b82..c5948cbbd 100644 --- a/src/client/views/nodes/LabelBox.tsx +++ b/src/client/views/nodes/LabelBox.tsx @@ -9,7 +9,7 @@ import { TraceMobx } from '../../../fields/util'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { DragManager } from '../../util/DragManager'; -import { undoable } from '../../util/UndoManager'; +import { undoable, UndoManager } from '../../util/UndoManager'; import { ViewBoxBaseComponent } from '../DocComponent'; import { PinDocView, PinProps } from '../PinFuncs'; import { StyleProp } from '../StyleProp'; @@ -28,6 +28,7 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { private _timeout: NodeJS.Timeout | undefined; private _divRef: HTMLDivElement | null = null; private _disposers: { [key: string]: IReactionDisposer } = {}; + private _liveTextUndo: UndoManager.Batch | undefined; // captured undo batch when typing a new text note into a collection constructor(props: FieldViewProps) { super(props); @@ -168,6 +169,25 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { } }; + setRef = (r: HTMLDivElement | null) => { + this._divRef?.removeEventListener('beforeinput', this.beforeInput); + this._divRef = r; + if (this._divRef) { + this._divRef.addEventListener('beforeinput', this.beforeInput); + + if (DocumentView.SelectOnLoad === this.Document) { + DocumentView.SetSelectOnLoad(undefined); + this._liveTextUndo = FormattedTextBox.LiveTextUndo; + FormattedTextBox.LiveTextUndo = undefined; + this._divRef.focus(); + } + this.fitTextToBox(this._divRef); + if (this.Title) { + this.resetCursor(); + } + } else this._timeout && clearTimeout(this._timeout); + }; + render() { TraceMobx(); const boxParams = this.fitTextToBox(undefined); // this causes mobx to trigger re-render when data changes @@ -220,30 +240,14 @@ export class LabelBox extends ViewBoxBaseComponent<FieldViewProps>() { this.setText(this._divRef?.innerText ?? ''); if (!FormattedTextBox.tryKeepingFocus(e.relatedTarget, () => this._divRef?.focus())) { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, undefined); - FormattedTextBox.LiveTextUndo?.end(); - FormattedTextBox.LiveTextUndo = undefined; + this._liveTextUndo?.end(); } }} dangerouslySetInnerHTML={{ __html: `<span class="textFitted textFitAlignVert" style="display: inline-block; text-align: center; font-size: 100px; height: 0px;">${this.Title?.startsWith('#') ? '' : (this.Title ?? '')}</span>`, }} contentEditable={this._props.onClickScript?.() ? undefined : true} - ref={r => { - this._divRef?.removeEventListener('beforeinput', this.beforeInput); - this._divRef = r; - if (this._divRef) { - this._divRef.addEventListener('beforeinput', this.beforeInput); - - if (DocumentView.SelectOnLoad === this.Document) { - DocumentView.SetSelectOnLoad(undefined); - this._divRef.focus(); - } - this.fitTextToBox(this._divRef); - if (this.Title) { - this.resetCursor(); - } - } else this._timeout && clearTimeout(this._timeout); - }} + ref={this.setRef} /> </div> </div> diff --git a/src/client/views/nodes/LinkBox.tsx b/src/client/views/nodes/LinkBox.tsx index 78c8a686c..d31fadf77 100644 --- a/src/client/views/nodes/LinkBox.tsx +++ b/src/client/views/nodes/LinkBox.tsx @@ -99,6 +99,7 @@ export class LinkBox extends ViewBoxBaseComponent<FieldViewProps>() { } }; + setRef = (r: HTMLDivElement | null) => (this._divRef = r); render() { TraceMobx(); @@ -149,28 +150,18 @@ export class LinkBox extends ViewBoxBaseComponent<FieldViewProps>() { const aid = targetAhyperlinks?.find(alink => container?.contains(alink))?.id ?? targetAhyperlinks?.lastElement()?.id; const bid = targetBhyperlinks?.find(blink => container?.contains(blink))?.id ?? targetBhyperlinks?.lastElement()?.id; if (!aid || !bid) { - setTimeout( - action(() => { - this._forceAnimate += 0.01; - }) - ); + setTimeout(action(() => (this._forceAnimate += 0.01))); return null; } if (foundParent) { setTimeout( - action(() => { - this._forceAnimate += 0.01; - }), + action(() => (this._forceAnimate += 0.01)), 1 ); } - - if (at || bt) - setTimeout( - action(() => { - this._forceAnimate += 0.01; - }) - ); // this forces an update during a transition animation + if (at || bt) { + setTimeout(action(() => (this._forceAnimate += 0.01))); // this forces an update during a transition animation + } const highlight = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Highlighting) as { highlightStyle: string; highlightColor: string; highlightIndex: number; highlightStroke: boolean }; const highlightColor = highlight?.highlightIndex ? highlight?.highlightColor : undefined; const color = this._props.styleProvider?.(this.layoutDoc, this._props, StyleProp.Color) as string; @@ -217,7 +208,7 @@ export class LinkBox extends ViewBoxBaseComponent<FieldViewProps>() { id={this.DocumentView?.().DocUniqueId} className="linkBox-label" tabIndex={-1} - ref={r => (this._divRef = r)} + ref={this.setRef} onPointerDown={e => e.stopPropagation()} onFocus={() => { RichTextMenu.Instance?.updateMenu(undefined, undefined, undefined, this.dataDoc); diff --git a/src/client/views/nodes/LinkDocPreview.scss b/src/client/views/nodes/LinkDocPreview.scss index 28216394d..7d99247e7 100644 --- a/src/client/views/nodes/LinkDocPreview.scss +++ b/src/client/views/nodes/LinkDocPreview.scss @@ -42,7 +42,7 @@ .linkDocPreview-button { display: inline-flex; - margin: 0; + margin: 0px; margin-right: 3px; border-radius: 50%; pointer-events: auto; diff --git a/src/client/views/nodes/LinkDocPreview.tsx b/src/client/views/nodes/LinkDocPreview.tsx index 5026f52fb..1d6f41c65 100644 --- a/src/client/views/nodes/LinkDocPreview.tsx +++ b/src/client/views/nodes/LinkDocPreview.tsx @@ -40,7 +40,6 @@ export class LinkInfo { LinkInfo._instance = this; makeObservable(this); } - // eslint-disable-next-line no-use-before-define @observable public LinkInfo: Opt<LinkDocPreviewProps> = undefined; public static get Instance() { @@ -251,6 +250,10 @@ export class LinkDocPreview extends ObservableReactComponent<LinkDocPreviewProps ); } + setDocViewRef = (r: DocumentView | null) => { + const targetanchor = this._linkDoc && this._linkSrc && Doc.getOppositeAnchor(this._linkDoc, this._linkSrc); + targetanchor && this._targetDoc !== targetanchor && r?._props.focus?.(targetanchor, {}); + }; @computed get docPreview() { return (!this._linkDoc || !this._targetDoc || !this._linkSrc) && !this._toolTipText ? null : ( <div className="linkDocPreview-inner"> @@ -280,10 +283,7 @@ export class LinkDocPreview extends ObservableReactComponent<LinkDocPreviewProps this._toolTipText ) : ( <DocumentView - ref={r => { - const targetanchor = this._linkDoc && this._linkSrc && Doc.getOppositeAnchor(this._linkDoc, this._linkSrc); - targetanchor && this._targetDoc !== targetanchor && r?._props.focus?.(targetanchor, {}); - }} + ref={this.setDocViewRef} Document={this._targetDoc!} moveDocument={returnFalse} styleProvider={this._props.styleProvider} diff --git a/src/client/views/nodes/LoadingBox.scss b/src/client/views/nodes/LoadingBox.scss index cabd4de05..5a8f49000 100644 --- a/src/client/views/nodes/LoadingBox.scss +++ b/src/client/views/nodes/LoadingBox.scss @@ -26,7 +26,7 @@ } .loadingBox-spinner { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; } } diff --git a/src/client/views/nodes/LoadingBox.tsx b/src/client/views/nodes/LoadingBox.tsx index 325ab18b4..4220ce5f1 100644 --- a/src/client/views/nodes/LoadingBox.tsx +++ b/src/client/views/nodes/LoadingBox.tsx @@ -43,7 +43,7 @@ export class LoadingBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable progress = ''; componentDidMount() { if (!Doc.CurrentlyLoading?.includes(this.Document)) { - this.Document.loadingError = 'Upload interrupted, please try again'; + this.Document.loadingError ??= 'Upload interrupted, please try again'; } else { const updateFunc = async () => { const result = await Networking.QueryYoutubeProgress(StrCast(this.Document[Id])); // We use the guid of the overwriteDoc to track file uploads. diff --git a/src/client/views/nodes/MapBox/MapAnchorMenu.scss b/src/client/views/nodes/MapBox/MapAnchorMenu.scss index c36d98afe..217576203 100644 --- a/src/client/views/nodes/MapBox/MapAnchorMenu.scss +++ b/src/client/views/nodes/MapBox/MapAnchorMenu.scss @@ -6,19 +6,19 @@ } .anchorMenu-highlighter { padding-right: 5px; - .antimodeMenu-button { - padding: 0; - padding: 0; + .antimodeMenu-button { + padding: 0px; + padding: 0px; padding-right: 0px; padding-left: 0px; width: 5px; } } -.anchor-color-preview-button { - width: 25px !important; +.anchor-color-preview-button { + width: 25px !important; .anchor-color-preview { display: flex; - flex-direction: column; + flex-direction: column; padding-right: 3px; width: unset !important; .color-preview { @@ -72,12 +72,11 @@ } } - .MuiInputBase-input{ + .MuiInputBase-input { color: white !important; } - - - .css-1t8l2tu-MuiInputBase-input-MuiOutlinedInput-input.Mui-disabled{ + + .css-1t8l2tu-MuiInputBase-input-MuiOutlinedInput-input.Mui-disabled { -webkit-text-fill-color: #b3b2b2 !important; } @@ -91,7 +90,7 @@ gap: 5px; } - .selected-route-details-container{ + .selected-route-details-container { display: flex; flex-direction: column; gap: 3px; @@ -99,33 +98,25 @@ align-items: flex-start; padding: 5px; } - - } - .customized-marker-container{ + .customized-marker-container { display: flex; flex-direction: column; gap: 10px; - .current-marker-container{ + .current-marker-container { display: flex; align-items: center; gap: 5px; } - .all-markers-container{ + .all-markers-container { display: flex; - align-items: center; + align-items: center; gap: 10px; flex-wrap: wrap; max-width: 400px; } } - - - - } - - diff --git a/src/client/views/nodes/MapBox/MapBox.scss b/src/client/views/nodes/MapBox/MapBox.scss index bd4b51038..89d381070 100644 --- a/src/client/views/nodes/MapBox/MapBox.scss +++ b/src/client/views/nodes/MapBox/MapBox.scss @@ -21,7 +21,7 @@ .mapBox-infoWindow { background-color: white; opacity: 0.75; - padding: 12; + padding: 12px; font-size: 17; } .mapBox-searchbar { @@ -119,7 +119,7 @@ width: 100%; label { - margin-bottom: 0; + margin-bottom: 0px; } .speed-label { @@ -197,12 +197,12 @@ } .mapBox-sidebar { position: absolute; - right: 0; + right: 0px; height: 100%; } .mapBox-sidebar-handle { - top: 0; + top: 0px; //top: calc(50% - 17.5px); // use this to center vertically -- make sure it looks okay for slide views width: 10px; height: 100%; @@ -215,7 +215,7 @@ left: 50%; margin-left: 120px; right: unset !important; - margin-top: -10; + margin-top: -10px; height: max-content; } .searchbox { diff --git a/src/client/views/nodes/MapBox/MapBox.tsx b/src/client/views/nodes/MapBox/MapBox.tsx index a563b7c1b..a279ccc48 100644 --- a/src/client/views/nodes/MapBox/MapBox.tsx +++ b/src/client/views/nodes/MapBox/MapBox.tsx @@ -13,7 +13,7 @@ import { CirclePicker, ColorResult } from 'react-color'; import { Layer, MapProvider, MapRef, Map as MapboxMap, Marker, Source, ViewState, ViewStateChangeEvent } from 'react-map-gl/mapbox'; import { ClientUtils, setupMoveUpEvents } from '../../../../ClientUtils'; import { emptyFunction } from '../../../../Utils'; -import { Doc, DocListCast, Field, LinkedTo, Opt, StrListCast } from '../../../../fields/Doc'; +import { Doc, DocListCast, Field, LinkedTo, StrListCast } from '../../../../fields/Doc'; import { List } from '../../../../fields/List'; import { RichTextField } from '../../../../fields/RichTextField'; import { DocCast, NumCast, StrCast, toList } from '../../../../fields/Types'; @@ -111,12 +111,12 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { // this list contains pushpins and configs @computed get allAnnotations() { return DocListCast(this.dataDoc[this.annotationKey]); } // prettier-ignore - @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.SidebarKey]); } // prettier-ignore + @computed get allSidebarDocs() { return DocListCast(this.dataDoc[this.sidebarKey]); } // prettier-ignore @computed get allPushpins() { return this.allAnnotations.filter(anno => anno.type === DocumentType.PUSHPIN); } // prettier-ignore @computed get allRoutes() { return this.allAnnotations.filter(anno => anno.type === DocumentType.MAPROUTE); } // prettier-ignore @computed get SidebarShown() { return !!this.layoutDoc._layout_showSidebar; } // prettier-ignore @computed get sidebarWidthPercent() { return StrCast(this.layoutDoc._layout_sidebarWidthPercent, '0%'); } // prettier-ignore - @computed get SidebarKey() { return this.fieldKey + '_sidebar'; } // prettier-ignore + @computed get sidebarKey() { return this.fieldKey + '_sidebar'; } // prettier-ignore @computed get sidebarColor() { return StrCast(this.layoutDoc.sidebar_color, StrCast(this.layoutDoc[this._props.fieldKey + '_backgroundColor'], '#e4e4e4')); } @@ -260,7 +260,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { removeMapDocument = (doc: Doc | Doc[], annotationKey?: string) => { this.allAnnotations - .filter(anno => toList(doc).includes(DocCast(anno.mapPin))) + .filter(anno => toList(doc).includes(DocCast(anno.mapPin)!)) .forEach(anno => { anno.mapPin = undefined; }); @@ -339,27 +339,19 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { startAnchorDrag = (e: PointerEvent, ele: HTMLElement) => { e.preventDefault(); e.stopPropagation(); - - const sourceAnchorCreator = action(() => { - const note = this.getAnchor(true); - if (note && this._selectedPinOrRoute) { - note.latitude = this._selectedPinOrRoute.latitude; - note.longitude = this._selectedPinOrRoute.longitude; - note.map = this._selectedPinOrRoute.map; - } - return note as Doc; - }); - const targetCreator = (annotationOn: Doc | undefined) => { const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); + target.layout_fitWidth = true; DocumentView.SetSelectOnLoad(target); return target; }; + + const sourceAnchorCreator = () => this.getAnchor(true); const docView = this.DocumentView?.(); docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, sourceAnchorCreator, targetCreator), e.pageX, e.pageY, { dragComplete: dragEv => { - if (!dragEv.aborted && dragEv.annoDragData && dragEv.annoDragData.linkSourceDoc && dragEv.annoDragData.dropDocument && dragEv.linkDocument) { + if (!dragEv.aborted && dragEv.annoDragData?.linkSourceDoc && dragEv.annoDragData.dropDocument && dragEv.linkDocument) { dragEv.annoDragData.linkSourceDoc.followLinkToggle = dragEv.annoDragData.dropDocument.annotationOn === this.Document; dragEv.annoDragData.linkSourceDoc.followLinkZoom = false; } @@ -368,17 +360,7 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }; createNoteAnnotation = () => { - const createFunc = undoable( - action(() => { - const note = this._sidebarRef.current?.anchorMenuClick(this.getAnchor(true), ['latitude', 'longitude', LinkedTo]); - if (note && this._selectedPinOrRoute) { - note.latitude = this._selectedPinOrRoute.latitude; - note.longitude = this._selectedPinOrRoute.longitude; - note.map = this._selectedPinOrRoute.map; - } - }), - 'create note annotation' - ); + const createFunc = undoable(() => this._sidebarRef.current?.anchorMenuClick(this.getAnchor(true), ['latitude', 'longitude', LinkedTo]), 'create note annotation'); if (!this.layoutDoc.layout_showSidebar) { this.toggleSidebar(); setTimeout(createFunc); @@ -428,14 +410,11 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } }; - getView = (doc: Doc, options: FocusViewOptions) => { - if (this._sidebarRef?.current?.makeDocUnfiltered(doc) && !this.SidebarShown) { - this.toggleSidebar(); - options.didMove = true; + getView = async (doc: Doc, options: FocusViewOptions) => { + if (DocListCast(this.dataDoc[this.sidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) { + SidebarAnnos.getView(this._sidebarRef.current, this.SidebarShown, this.toggleSidebar, doc, options); } - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + return undefined; }; /* @@ -476,13 +455,14 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @action deleteSelectedPinOrRoute = undoable(() => { - if (this._selectedPinOrRoute) { + const selPin = DocCast(this._selectedPinOrRoute); + if (selPin) { // Removes filter - Doc.setDocFilter(this.Document, 'latitude', NumCast(this._selectedPinOrRoute.latitude), 'remove'); - Doc.setDocFilter(this.Document, 'longitude', NumCast(this._selectedPinOrRoute.longitude), 'remove'); - Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(DocCast(this._selectedPinOrRoute))}`, 'remove'); + Doc.setDocFilter(this.Document, 'latitude', NumCast(selPin.latitude), 'remove'); + Doc.setDocFilter(this.Document, 'longitude', NumCast(selPin.longitude), 'remove'); + Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(selPin)}`, 'remove'); - this.removePushpinOrRoute(this._selectedPinOrRoute); + this.removePushpinOrRoute(selPin); } MapAnchorMenu.Instance.fadeOut(true); document.removeEventListener('pointerdown', this.tryHideMapAnchorMenu, true); @@ -1299,7 +1279,6 @@ export class MapBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { <SidebarAnnos ref={this._sidebarRef} {...this._props} - fieldKey={this.fieldKey} Doc={this.Document} layoutDoc={this.layoutDoc} dataDoc={this.dataDoc} diff --git a/src/client/views/nodes/MapboxMapBox/MapboxContainer.tsx b/src/client/views/nodes/MapboxMapBox/MapboxContainer.tsx index e0efab576..0beefcb67 100644 --- a/src/client/views/nodes/MapboxMapBox/MapboxContainer.tsx +++ b/src/client/views/nodes/MapboxMapBox/MapboxContainer.tsx @@ -145,7 +145,7 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> removeMapDocument = (docsIn: Doc | Doc[], annotationKey?: string) => { const docs = toList(docsIn); this.allAnnotations - .filter(anno => docs.includes(DocCast(anno.mapPin))) + .filter(anno => docs.includes(DocCast(anno.mapPin)!)) .forEach(anno => { anno.mapPin = undefined; }); @@ -224,6 +224,12 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> startAnchorDrag = (e: PointerEvent, ele: HTMLElement) => { e.preventDefault(); e.stopPropagation(); + const targetCreator = (annotationOn: Doc | undefined) => { + const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); + target.layout_fitWidth = true; + DocumentView.SetSelectOnLoad(target); + return target; + }; const sourceAnchorCreator = action(() => { const note = this.getAnchor(true); @@ -235,11 +241,6 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> return note as Doc; }); - const targetCreator = (annotationOn: Doc | undefined) => { - const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); - DocumentView.SetSelectOnLoad(target); - return target; - }; const docView = this.DocumentView?.(); docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, sourceAnchorCreator, targetCreator), e.pageX, e.pageY, { @@ -362,22 +363,22 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> @action deselectPin = () => { - if (this.selectedPin) { + const selPin = DocCast(this.selectedPin); + if (selPin) { // Removes filter - Doc.setDocFilter(this.Document, 'latitude', NumCast(this.selectedPin.latitude), 'remove'); - Doc.setDocFilter(this.Document, 'longitude', NumCast(this.selectedPin.longitude), 'remove'); - Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(DocCast(this.selectedPin))}`, 'remove'); + Doc.setDocFilter(this.Document, 'latitude', NumCast(selPin.latitude), 'remove'); + Doc.setDocFilter(this.Document, 'longitude', NumCast(selPin.longitude), 'remove'); + Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(selPin)}`, 'remove'); - const temp = this.selectedPin; if (!this._unmounting) { - this._bingMap.current.entities.remove(this.map_docToPinMap.get(temp)); + this._bingMap.current.entities.remove(this.map_docToPinMap.get(selPin)); } - const newpin = new this.MicrosoftMaps.Pushpin(new this.MicrosoftMaps.Location(temp.latitude, temp.longitude)); - this.MicrosoftMaps.Events.addHandler(newpin, 'click', () => this.pushpinClicked(temp as Doc)); + const newpin = new this.MicrosoftMaps.Pushpin(new this.MicrosoftMaps.Location(selPin.latitude, selPin.longitude)); + this.MicrosoftMaps.Events.addHandler(newpin, 'click', () => this.pushpinClicked(selPin)); if (!this._unmounting) { this._bingMap.current.entities.push(newpin); } - this.map_docToPinMap.set(temp, newpin); + this.map_docToPinMap.set(selPin, newpin); this.selectedPin = undefined; this.bingSearchBarContents = this.Document.map; } @@ -388,9 +389,7 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> this.toggleSidebar(); options.didMove = true; } - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + return new Promise<Opt<DocumentView>>(res => DocumentView.addViewRenderedCb(doc, res)); }; /* * Pushpin onclick @@ -535,13 +534,14 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> @action deleteSelectedPin = undoable(() => { - if (this.selectedPin) { + const selPin = this.selectedPin; + if (selPin) { // Removes filter - Doc.setDocFilter(this.Document, 'latitude', NumCast(this.selectedPin.latitude), 'remove'); - Doc.setDocFilter(this.Document, 'longitude', NumCast(this.selectedPin.longitude), 'remove'); - Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(DocCast(this.selectedPin))}`, 'remove'); + Doc.setDocFilter(this.Document, 'latitude', NumCast(selPin.latitude), 'remove'); + Doc.setDocFilter(this.Document, 'longitude', NumCast(selPin.longitude), 'remove'); + Doc.setDocFilter(this.Document, LinkedTo, `mapPin=${Field.toScriptString(selPin)}`, 'remove'); - this.removePushpin(this.selectedPin); + this.removePushpin(selPin); } MapAnchorMenu.Instance.fadeOut(true); document.removeEventListener('pointerdown', this.tryHideMapAnchorMenu, true); @@ -638,7 +638,10 @@ export class MapBoxContainer extends ViewBoxAnnotatableComponent<FieldViewProps> this._disposers.highlight = reaction( () => this.allAnnotations.map(doc => doc[Highlight]), () => { - const allConfigPins = this.allAnnotations.map(doc => ({ doc, pushpin: DocCast(doc.mapPin) })).filter(pair => pair.pushpin); + const allConfigPins = this.allAnnotations + .map(doc => ({ doc, pushpin: DocCast(doc.mapPin) })) + .filter(pair => pair.pushpin) + .map(pair => ({ doc: pair.doc, pushpin: pair.pushpin! })); allConfigPins.forEach(({ pushpin }) => { if (!pushpin[Highlight] && this.map_pinHighlighted.get(pushpin)) { this.recolorPin(pushpin); diff --git a/src/client/views/nodes/PDFBox.scss b/src/client/views/nodes/PDFBox.scss index 44013a96d..c9edb2180 100644 --- a/src/client/views/nodes/PDFBox.scss +++ b/src/client/views/nodes/PDFBox.scss @@ -17,8 +17,8 @@ height: 100%; z-index: 1; pointer-events: none; - top: 0; - left: 0; + top: 0px; + left: 0px; // glr: This should really be the same component as text and PDFs .pdfBox-sidebarBtn { @@ -72,16 +72,16 @@ align-items: center; height: 20px; background: none; - padding: 0; + padding: 0px; position: absolute; pointer-events: all; color: white; - bottom: 0; - right: 0; + bottom: 0px; + right: 0px; .pdfBox-overlayButton-arrow { - width: 0; - height: 0; + width: 0px; + height: 0px; border-top: 10px solid transparent; border-bottom: 10px solid transparent; border-right: 15px solid #121721; @@ -122,8 +122,8 @@ .pdfBox-settingsCont { position: absolute; - right: 0; - top: 3; + right: 0px; + top: 3px; pointer-events: all; .pdfBox-settingsButton { @@ -133,11 +133,11 @@ align-items: center; height: 20px; background: none; - padding: 0; + padding: 0px; .pdfBox-settingsButton-arrow { - width: 0; - height: 0; + width: 0px; + height: 0px; border-top: 10px solid transparent; border-bottom: 10px solid transparent; border-right: 15px solid #121721; @@ -189,7 +189,7 @@ width: calc(100% - 40px); height: 20px; background: #121721; - bottom: 0; + bottom: 0px; display: flex; justify-content: center; align-items: center; @@ -198,7 +198,7 @@ pointer-events: all; .pdfBox-searchBar { - width: 70%; + width: calc(100% - 120px); // less size of search buttons font-size: 14px; } } @@ -253,13 +253,13 @@ .pdfBox-container { position: absolute; transform-origin: top left; - top: 0; + top: 0px; } .pdfBox-sidebarContainer { position: absolute; height: 100%; - right: 0; - top: 0; + right: 0px; + top: 0px; } .pdfBox-interactive { @@ -290,7 +290,7 @@ } .pdfBox-settingsButton-arrow { - height: 60; + height: 60px; border-top: 30px solid transparent; border-bottom: 30px solid transparent; border-right: 30px solid #121721; diff --git a/src/client/views/nodes/PDFBox.tsx b/src/client/views/nodes/PDFBox.tsx index 4ecbd65b6..34211fa07 100644 --- a/src/client/views/nodes/PDFBox.tsx +++ b/src/client/views/nodes/PDFBox.tsx @@ -10,7 +10,7 @@ import { DocData } from '../../../fields/DocSymbols'; import { Id } from '../../../fields/FieldSymbols'; import { InkTool } from '../../../fields/InkField'; import { ComputedField } from '../../../fields/ScriptField'; -import { Cast, FieldValue, NumCast, StrCast, toList } from '../../../fields/Types'; +import { Cast, DocCast, FieldValue, NumCast, StrCast, toList } from '../../../fields/Types'; import { ImageField, PdfField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction } from '../../../Utils'; @@ -27,13 +27,15 @@ import { Colors } from '../global/globalEnums'; import { PDFViewer } from '../pdf/PDFViewer'; import { PinDocView, PinProps } from '../PinFuncs'; import { SidebarAnnos } from '../SidebarAnnos'; -import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; import { ImageBox } from './ImageBox'; import { OpenWhere } from './OpenWhere'; import './PDFBox.scss'; import { CreateImage } from './WebBoxRenderer'; +import { gptAPICall } from '../../apis/gpt/GPT'; +import { List } from '../../../fields/List'; +import { GPTCallType } from '../../apis/gpt/GPT'; @observer export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @@ -57,6 +59,9 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable private _pdf: Opt<Pdfjs.PDFDocumentProxy> = undefined; @observable private _pageControls = false; + @computed get sidebarKey() { + return this.fieldKey + '_sidebar'; + } @computed get pdfUrl() { return Cast(this.dataDoc[this._props.fieldKey], PdfField); } @@ -77,6 +82,36 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } } + autoTag = async () => { + if (!this.Document.$tags_chat && this._pdf) { + if (!this.dataDoc.text) { + // 1) Extract text from the first few pages (e.g., first 2 pages) + const maxPages = Math.min(2, this._pdf.numPages); + const promises: Promise<string>[] = []; + for (let pageNum = 1; pageNum <= maxPages; pageNum++) { + promises.push( + this._pdf + .getPage(pageNum) + .then(page => page.getTextContent()) + .then(content => content.items.map(item => ('str' in item ? item.str : '')).join(' ')) + ); + } + this.dataDoc.text = (await Promise.all(promises)).join(' '); + } + + const text = StrCast(this.dataDoc.text).trim().slice(0, 2000); + if (text) { + // 2) Ask GPT to classify and provide descriptive tags, then normalize the results + const label = await gptAPICall(`"${text}"`, GPTCallType.CLASSIFYTEXTFULL).then(raw => raw.trim().toUpperCase()); + + this.Document.$tags_chat = new List<string>(label.split(/\s+/)); + + // 4) Show tags in layout + this.Document._layout_showTags = true; + } + } + }; + replaceCanvases = (oldDiv: HTMLElement, newDiv: HTMLElement) => { if (oldDiv.childNodes) { for (let i = 0; i < oldDiv.childNodes.length; i++) { @@ -233,14 +268,11 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return this._pdfViewer?.scrollFocus(anchor, NumCast(anchor.y, NumCast(anchor.config_scrollTop)), options); }; - getView = (doc: Doc, options: FocusViewOptions) => { - if (this._sidebarRef?.current?.makeDocUnfiltered(doc) && !this.SidebarShown) { - options.didMove = true; - this.toggleSidebar(false); + getView = async (doc: Doc, options: FocusViewOptions) => { + if (DocListCast(this.dataDoc[this.sidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) { + SidebarAnnos.getView(this._sidebarRef.current, this.SidebarShown, () => this.toggleSidebar(false), doc, options); } - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + return undefined; }; getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { @@ -406,7 +438,15 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { onKeyDown={e => ([KeyCodes.BACKSPACE, KeyCodes.DELETE].includes(e.keyCode) ? e.stopPropagation() : true)} onPointerDown={e => e.stopPropagation()} style={{ display: this._props.isContentActive() ? 'flex' : 'none' }}> - <div className="pdfBox-overlayCont" onPointerDown={e => e.stopPropagation()} style={{ left: `${this._searching ? 0 : 100}%` }}> + <div + className="pdfBox-overlayCont" + onPointerDown={e => e.stopPropagation()} + style={{ + transformOrigin: 'bottom left', + transform: `scale(${this._props.DocumentView?.().UIBtnScaling || 1})`, + width: `${100 / (this._props.DocumentView?.().UIBtnScaling || 1)}%`, + left: `${this._searching ? 0 : 100}%`, + }}> <button type="button" className="pdfBox-overlayButton" title={searchTitle} /> <input className="pdfBox-searchBar" @@ -435,17 +475,25 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { type="button" className="pdfBox-overlayButton" title={searchTitle} + style={{ + transformOrigin: 'bottom right', + transform: `scale(${this._props.DocumentView?.().UIBtnScaling || 1})`, + }} onClick={action(() => { this._searching = !this._searching; this.search('', true, true); })}> - <div className="pdfBox-overlayButton-arrow" onPointerDown={e => e.stopPropagation()} /> <div className="pdfBox-overlayButton-iconCont" onPointerDown={e => e.stopPropagation()}> <FontAwesomeIcon icon={this._searching ? 'times' : 'search'} size="lg" /> </div> </button> - <div className="pdfBox-pageNums"> + <div + className="pdfBox-pageNums" + style={{ + transformOrigin: 'top left', + transform: `scale(${this._props.DocumentView?.().UIBtnScaling || 1})`, + }}> <input value={curPage} style={{ width: `${curPage > 99 ? 4 : 3}ch`, pointerEvents: 'all' }} @@ -649,19 +697,11 @@ export class PDFBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { const pdfView = !this._pdf ? null : this.renderPdfView; const href = this.pdfUrl?.url.href; if (!pdfView && href) { - if (PDFBox.pdfcache.get(href)) - setTimeout( - action(() => { - this._pdf = PDFBox.pdfcache.get(href); - }) - ); + if (PDFBox.pdfcache.get(href)) setTimeout(action(() => (this._pdf = PDFBox.pdfcache.get(href)))); else { - if (!PDFBox.pdfpromise.get(href)) PDFBox.pdfpromise.set(href, Pdfjs.getDocument(href).promise); - PDFBox.pdfpromise.get(href)?.then( - action(pdf => { - PDFBox.pdfcache.set(href, (this._pdf = pdf)); - }) - ); + const pdfPromise = PDFBox.pdfpromise.get(href) ?? Pdfjs.getDocument(href).promise; + PDFBox.pdfpromise.set(href, pdfPromise); + pdfPromise.then(action(pdf => PDFBox.pdfcache.set(href, (this._pdf = pdf)))); } } return pdfView ?? this.renderTitleBox; diff --git a/src/client/views/nodes/PhysicsBox/PhysicsSimulationBox.scss b/src/client/views/nodes/PhysicsBox/PhysicsSimulationBox.scss index ac2c611c7..78aa526bf 100644 --- a/src/client/views/nodes/PhysicsBox/PhysicsSimulationBox.scss +++ b/src/client/views/nodes/PhysicsBox/PhysicsSimulationBox.scss @@ -29,8 +29,8 @@ .wedge { pointer-events: none; position: absolute; - left: 0; - top: 0; + left: 0px; + top: 0px; } } diff --git a/src/client/views/nodes/RecordingBox/ProgressBar.scss b/src/client/views/nodes/RecordingBox/ProgressBar.scss index 28ad25ffa..ec01f0241 100644 --- a/src/client/views/nodes/RecordingBox/ProgressBar.scss +++ b/src/client/views/nodes/RecordingBox/ProgressBar.scss @@ -1,36 +1,34 @@ - .progressbar { - touch-action: none; - vertical-align: middle; - text-align: center; - - align-items: center; - cursor: default; - - - position: absolute; - display: flex; - justify-content: flex-start; - bottom: 2px; - width: 99%; - height: 30px; - background-color: gray; - - &.done { - top: 0; - width: 0px; - height: 5px; - background-color: red; - z-index: 2; - } - - &.mark { - top: 0; - background-color: transparent; - border-right: 2px solid white; - z-index: 3; - pointer-events: none; - } + touch-action: none; + vertical-align: middle; + text-align: center; + + align-items: center; + cursor: default; + + position: absolute; + display: flex; + justify-content: flex-start; + bottom: 2px; + width: 99%; + height: 30px; + background-color: gray; + + &.done { + top: 0px; + width: 0px; + height: 5px; + background-color: red; + z-index: 2; + } + + &.mark { + top: 0px; + background-color: transparent; + border-right: 2px solid white; + z-index: 3; + pointer-events: none; + } } .progressbar-disabled { @@ -43,37 +41,41 @@ // citation: https://codepen.io/_Master_/pen/PRdjmQ @keyframes blinker { - from {opacity: 1.0;} - to {opacity: 0.0;} + from { + opacity: 1; + } + to { + opacity: 0; + } } .blink { - text-decoration: blink; - animation-name: blinker; - animation-duration: 0.6s; - animation-iteration-count:infinite; - animation-timing-function:ease-in-out; - animation-direction: alternate; + text-decoration: blink; + animation-name: blinker; + animation-duration: 0.6s; + animation-iteration-count: infinite; + animation-timing-function: ease-in-out; + animation-direction: alternate; } .segment { - border: 3px solid black; - background-color: red; - margin: 1px; - padding: 0; - cursor: pointer; - transition-duration: .5s; - user-select: none; - - vertical-align: middle; - text-align: center; + border: 3px solid black; + background-color: red; + margin: 1px; + padding: 0px; + cursor: pointer; + transition-duration: 0.5s; + user-select: none; + + vertical-align: middle; + text-align: center; } .segment-expanding { -border-color: red; - background-color: white; - transition-duration: 0s; - opacity: .75; - pointer-events: none; + border-color: red; + background-color: white; + transition-duration: 0s; + opacity: 0.75; + pointer-events: none; } .segment-expanding:hover { @@ -82,10 +84,10 @@ border-color: red; } .segment-disabled { - pointer-events: none; - opacity: 0.5; - transition-duration: 0s; - /* Hide the text. */ + pointer-events: none; + opacity: 0.5; + transition-duration: 0s; + /* Hide the text. */ text-indent: 100%; white-space: nowrap; overflow: hidden; @@ -99,25 +101,26 @@ border-color: red; } .segment:first-child { - margin-left: 2px; + margin-left: 2px; } .segment:last-child { - margin-right: 2px; + margin-right: 2px; } .segment:hover { background-color: white; } -.segment:hover, .segment-selected { - margin: 0px; - border: 4px solid red; - border-radius: 2px; +.segment:hover, +.segment-selected { + margin: 0px; + border: 4px solid red; + border-radius: 2px; } .segment-selected { - border: 4px solid #202020; - background-color: red; - opacity: .75; - cursor: grabbing; + border: 4px solid #202020; + background-color: red; + opacity: 0.75; + cursor: grabbing; } diff --git a/src/client/views/nodes/RecordingBox/RecordingView.scss b/src/client/views/nodes/RecordingBox/RecordingView.scss index f2d5a980d..15b48c111 100644 --- a/src/client/views/nodes/RecordingBox/RecordingView.scss +++ b/src/client/views/nodes/RecordingBox/RecordingView.scss @@ -28,7 +28,7 @@ video { justify-content: center; // overflow: hidden; border-radius: 10px; - margin: 0; + margin: 0px; } .video-wrapper:hover .controls { @@ -108,7 +108,7 @@ video { .timer { font-size: 15px; color: white; - margin: 0; + margin: 0px; } .dot { @@ -148,7 +148,7 @@ video { height: 80%; width: 80%; align-self: center; - margin: 0; + margin: 0px; &:hover { height: 85%; @@ -163,7 +163,7 @@ video { height: 70%; width: 70%; align-self: center; - margin: 0; + margin: 0px; // &:hover { // width: 40px; @@ -178,8 +178,8 @@ video { flex-direction: row; align-content: center; position: relative; - top: 0; - bottom: 0; + top: 0px; + bottom: 0px; &.video-edit-wrapper { // right: 50% - 15; diff --git a/src/client/views/nodes/ScreenshotBox.scss b/src/client/views/nodes/ScreenshotBox.scss index 1e9b64a0b..1714d87c2 100644 --- a/src/client/views/nodes/ScreenshotBox.scss +++ b/src/client/views/nodes/ScreenshotBox.scss @@ -32,10 +32,10 @@ .screenshotBox-uiButtons { position: absolute; - right: 25; - top: 0; - width: 22; - height: 25; + right: 25px; + top: 0px; + width: 22px; + height: 25px; .screenshotBox-recorder { color: white; diff --git a/src/client/views/nodes/ScreenshotBox.tsx b/src/client/views/nodes/ScreenshotBox.tsx index 603dcad5c..4677e0e61 100644 --- a/src/client/views/nodes/ScreenshotBox.tsx +++ b/src/client/views/nodes/ScreenshotBox.tsx @@ -171,19 +171,21 @@ export class ScreenshotBox extends ViewBoxAnnotatableComponent<FieldViewProps>() ContextMenu.Instance.addItem({ description: 'Options...', subitems, icon: 'video' }); }; + setRef = (r: HTMLVideoElement | null) => { + this._videoRef = r; + setTimeout(() => { + if (this.layoutDoc.mediaState === mediaState.PendingRecording && this._videoRef) { + this.toggleRecording(); + } + }, 100); + }; + @computed get content() { return ( <video className="videoBox-content" key="video" - ref={r => { - this._videoRef = r; - setTimeout(() => { - if (this.layoutDoc.mediaState === mediaState.PendingRecording && this._videoRef) { - this.toggleRecording(); - } - }, 100); - }} + ref={this.setRef} autoPlay={this._screenCapture} style={{ width: this._screenCapture ? '100%' : undefined, height: this._screenCapture ? '100%' : undefined }} onCanPlay={this.videoLoad} diff --git a/src/client/views/nodes/ScriptingBox.scss b/src/client/views/nodes/ScriptingBox.scss index 9789da55a..de70dbe74 100644 --- a/src/client/views/nodes/ScriptingBox.scss +++ b/src/client/views/nodes/ScriptingBox.scss @@ -82,14 +82,14 @@ } .rta__autocomplete--top { - margin-top: 0; + margin-top: 0px; margin-bottom: 1em; max-height: 100px; } .rta__list { - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; background: #fff; border: 1px solid #dfe2e5; border-radius: 3px; diff --git a/src/client/views/nodes/TaskBox.scss b/src/client/views/nodes/TaskBox.scss new file mode 100644 index 000000000..beee58697 --- /dev/null +++ b/src/client/views/nodes/TaskBox.scss @@ -0,0 +1,132 @@ +.task-manager-container { + color-scheme: light; + display: flex; + flex-direction: column; + padding: 8px; + gap: 10px; + width: 100%; + height: 100%; + box-sizing: border-box; +} + +.task-manager-title { + width: 100%; + font-size: 1.25rem; + font-weight: 600; + padding: 6px 10px; + border: 1px solid #ccc; + border-radius: 6px; + box-sizing: border-box; +} + +.task-manager-description { + width: 100%; + font-size: 1rem; + padding: 8px 10px; + border: 1px solid #ccc; + border-radius: 6px; + min-height: 40px; + box-sizing: border-box; + vertical-align: top; + text-align: start; + resize: none; + line-height: 1.4; + resize: none; + flex-grow: 1; +} + +.task-manager-checkboxes { + display: flex; + align-items: center; + gap: 16px; +} + +.task-manager-allday, +.task-manager-complete { + display: flex; + align-items: center; + gap: 6px; + font-size: 0.95rem; +} + +.task-manager-times { + display: flex; + flex-direction: column; + gap: 6px; + width: 100%; +} + +.task-manager-times label { + display: flex; + flex-direction: column; + font-size: 0.9rem; + font-weight: 500; + gap: 4px; +} + +input[type='datetime-local'] { + width: 100%; + font-size: 0.9rem; + padding: 6px 8px; + border: 1px solid #ccc; + border-radius: 6px; + box-sizing: border-box; +} + +.task-manager-checkboxes { + display: flex; + flex-wrap: wrap; /* allows wrapping on small screens */ + align-items: center; + gap: 16px; + row-gap: 8px; /* optional: tighter vertical spacing if it wraps */ +} + +.task-manager-google { + align-self: flex-start; + width: auto; + font-size: 0.85rem; + padding: 6px 12px; + border-radius: 6px; + background-color: #5e88c8; + color: white; + border: none; + cursor: pointer; + white-space: nowrap; + transition: background-color 0.2s ease; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.2); + + &:hover { + background-color: #4773b0; // darker shade of your base blue + color: white; + transform: scale(1.01); // subtle hover feel without real size change + } + + &:disabled { + opacity: 0.5; + cursor: not-allowed; + pointer-events: none; + } +} + +.task-box-blur-wrapper { + width: 100%; + height: 100%; + display: flex; + flex-direction: column; + min-width: 0; +} + +.task-manager-button-row { + display: flex; + flex-direction: row; + gap: 8px; +} + +.task-manager-delete { + @extend .task-manager-google; + background-color: #182430; + + &:hover { + background-color: #000000; + } +} diff --git a/src/client/views/nodes/TaskBox.tsx b/src/client/views/nodes/TaskBox.tsx new file mode 100644 index 000000000..ed5982c55 --- /dev/null +++ b/src/client/views/nodes/TaskBox.tsx @@ -0,0 +1,670 @@ +import { action, IReactionDisposer, makeObservable, observable, reaction, runInAction } from 'mobx'; +import { observer } from 'mobx-react'; +import * as React from 'react'; +import { DateField } from '../../../fields/DateField'; +import { BoolCast, DateCast, DocCast, NumCast, StrCast } from '../../../fields/Types'; +import { GoogleAuthenticationManager } from '../../apis/GoogleAuthenticationManager'; +import { Docs } from '../../documents/Documents'; +import { DocumentType } from '../../documents/DocumentTypes'; +import { ViewBoxBaseComponent } from '../DocComponent'; +import { FieldView, FieldViewProps } from './FieldView'; +import './TaskBox.scss'; +import { DocumentDecorations } from '../DocumentDecorations'; +import { Doc } from '../../../fields/Doc'; +import { DocumentView } from './DocumentView'; + +/** + * TaskBox class for adding task information + completing tasks + */ +@observer +export class TaskBox extends ViewBoxBaseComponent<FieldViewProps>() { + _googleTaskCreateDisposer?: IReactionDisposer; + _heightDisposer?: IReactionDisposer; + _widthDisposer?: IReactionDisposer; + @observable _needsSync = false; // Whether the task needs to be synced with Google Tasks + @observable _syncing = false; // Whether the task is currently syncing with Google Tasks + private _isFocused = false; // Whether the task box is currently focused + + // contains the last synced task information + private _lastSyncedTask: { + title: string; + text: string; + due?: string; + completed: boolean; + deleted?: boolean; + } = { + title: '', + text: '', + due: '', + completed: false, + deleted: false, + }; + + /** + * Getter for needsSync + */ + get needsSync() { + return this._needsSync; + } + + /** + * Constructor for the task box + * @param props - props containing the document reference + */ + constructor(props: FieldViewProps) { + super(props); + makeObservable(this); + } + + /** + * Return the JSX string that will create this component + * @param fieldStr the Doc field that contains the primary data for this component + * @returns + */ + public static LayoutString(fieldStr: string) { + return FieldView.LayoutString(TaskBox, fieldStr); + } + + /** + * Method to update the task description + * @param e - event of changing the description box input + */ + @action + updateText = (e: React.ChangeEvent<HTMLInputElement | HTMLTextAreaElement>) => { + this.Document[this.fieldKey] = e.target.value; + }; + + /** + * Method to update the task title + * @param e - event of changing the title box input + */ + @action + updateTitle = (e: React.ChangeEvent<HTMLInputElement>) => { + this.Document.title = e.target.value; + }; + + /** + * Method to update the all day status + * @param e - event of changing the all day checkbox + */ + @action + updateAllDay = (e: React.ChangeEvent<HTMLInputElement>) => { + this.Document.$task_allDay = e.target.checked; + + if (e.target.checked) { + delete this.Document.$task_startTime; + delete this.Document.$task_endTime; + } + + this.setTaskDateRange(); + }; + + /** + * Method to update the task start time + * @param e - event of changing the start time input + */ + @action + updateStart = (e: React.ChangeEvent<HTMLInputElement>) => { + const newStart = new Date(e.target.value); + + this.Document.$task_startTime = new DateField(newStart); + + const endDate = this.Document.$task_endTime instanceof DateField ? this.Document.$task_endTime.date : undefined; + if (endDate && newStart > endDate) { + // Alert user + alert('Start time cannot be after end time. End time has been adjusted.'); + + // Fix end time + const adjustedEnd = new Date(newStart.getTime() + 60 * 60 * 1000); + this.Document.$task_endTime = new DateField(adjustedEnd); + } + + this.setTaskDateRange(); + }; + + /** + * Method to update the task end time + * @param e - event of changing the end time input + */ + @action + updateEnd = (e: React.ChangeEvent<HTMLInputElement>) => { + const newEnd = new Date(e.target.value); + + this.Document.$task_endTime = new DateField(newEnd); + + const startDate = this.Document.$task_startTime instanceof DateField ? this.Document.$task_startTime.date : undefined; + if (startDate && newEnd < startDate) { + // Alert user + alert('End time cannot be before start time. Start time has been adjusted.'); + + // Fix start time + const adjustedStart = new Date(newEnd.getTime() - 60 * 60 * 1000); + this.Document.$task_startTime = new DateField(adjustedStart); + } + + this.setTaskDateRange(); + }; + + /** + * Method to update the task date range + */ + @action + setTaskDateRange() { + const doc = this.Document; + + if (doc.$task_allDay) { + const range = StrCast(doc.$task_dateRange).split('|'); + const dateStr = range[0] ?? new Date().toISOString().split('T')[0]; // default to today + + doc.$task_dateRange = `${dateStr}|${dateStr}`; + doc.$task_allDay = true; + } else { + const startField = doc.$task_startTime; + const endField = doc.$task_endTime; + const startDate = startField instanceof DateField ? startField.date : null; + const endDate = endField instanceof DateField ? endField.date : null; + + if (startDate && endDate && !isNaN(startDate.getTime()) && !isNaN(endDate.getTime())) { + doc.$task_dateRange = `${startDate.toISOString()}|${endDate.toISOString()}`; + doc.$task_allDay = false; + } + } + } + + /** + * Method to set task's completion status + * @param e - event of changing the "completed" input checkbox + */ + + @action + toggleComplete = (e: React.ChangeEvent<HTMLInputElement>) => { + this.Document.$task_completed = e.target.checked; + }; + + /** + * Computes due date for the task (for Google Tasks API) + * @returns - a string representing the due date in ISO format, or undefined if no valid date is found + */ + private computeDueDate(): string | undefined { + const doc = this.Document; + let due: string | undefined; + const allDay = !!doc.$task_allDay; + + if (allDay) { + const rawRange = StrCast(doc.$task_dateRange); + const datePart = rawRange.split('|')[0]; + + if (datePart && !isNaN(new Date(datePart).getTime())) { + // Set time to midnight UTC to represent the start of the all-day event + const baseDate = datePart.includes('T') ? datePart : datePart + 'T00:00:00Z'; + due = new Date(baseDate).toISOString(); + } else { + due = undefined; + } + } else if (doc.$task_endTime instanceof DateField && doc.$task_endTime.date) { + due = doc.$task_endTime.date.toISOString(); + } else if (doc.$task_startTime instanceof DateField && doc.$task_startTime.date) { + due = doc.$task_startTime.date.toISOString(); + } else { + due = undefined; + } + + return due; + } + + /** + * Builds the body for the Google Tasks API request + * @returns - an object containing the task details + */ + + private buildGoogleTaskBody(): Record<string, string | boolean | undefined> { + const doc = this.Document; + const title = StrCast(doc.title, 'Untitled Task'); + const notes = StrCast(doc[this.fieldKey]); + const due = this.computeDueDate(); + const completed = !!doc.$task_completed; + + const body: Record<string, string | boolean | undefined> = { + title, + notes, + due, + status: completed ? 'completed' : 'needsAction', + completed: completed ? new Date().toISOString() : undefined, + }; + + if (doc.$dashDeleted === true) { + body.deleted = true; + } else if (doc.$dashDeleted === false) { + body.deleted = false; + } + + return body; + } + + /** + * Handles the focus event for the task box (for auto-syncing) + */ + + handleFocus = () => { + if (!this._isFocused) { + this._isFocused = true; + this.syncWithGoogleTaskBidirectional(true); // silent sync + } + }; + + /** + * Handles the blur event for the task box (for auto-syncing) + * @param e - the focus event + */ + handleBlur = (e: React.FocusEvent<HTMLDivElement>) => { + // Check if focus is moving outside this component + if (!e.currentTarget.contains(e.relatedTarget)) { + this._isFocused = false; + this.syncWithGoogleTaskBidirectional(true); + } + }; + + /** + * Method to sync the task with Google Tasks bidirectionally + * (update Dash from Google and vice versa, based on which is newer) + * @param silent - whether to suppress UI prompts to connect to Google (default: false) + * @returns - a promise that resolves to true if sync was successful, false otherwise + */ + + syncWithGoogleTaskBidirectional = async (silent = false): Promise<boolean> => { + const doc = this.Document; + let token: string | undefined; + try { + token = silent ? await GoogleAuthenticationManager.Instance.fetchAccessTokenSilently() : await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(); + } catch (err) { + console.warn('Google auth failed:', err); + return false; + } + + if (!token) { + if (!silent) { + const listener = () => { + window.removeEventListener('focusin', listener); + if (confirm('✅ Authorization complete. Try syncing the task again?')) { + // try syncing again + this.syncWithGoogleTaskBidirectional(); + } + window.removeEventListener('focusin', listener); + }; + setTimeout(() => window.addEventListener('focusin', listener), 100); + } + return false; + } + + if (!doc.$googleTaskId) return false; + + runInAction(() => { + this._syncing = true; + }); + + try { + // Fetch current version of Google Task + const response = await fetch(`/googleTasks/${doc.$googleTaskId}`, { + method: 'GET', + headers: { + Authorization: `Bearer ${token}`, + }, + credentials: 'include', + }); + + const googleTask = await response.json(); + const googleUpdated = new Date(googleTask.updated); + const dashUpdated = new Date(StrCast(doc.$task_lastSyncedAt)); + + const dashChanged = + StrCast(doc.title) !== this._lastSyncedTask.title || + StrCast(doc[this.fieldKey]) !== this._lastSyncedTask.text || + this.computeDueDate() !== this._lastSyncedTask.due || + !!doc.$task_completed !== this._lastSyncedTask.completed || + !!doc.$dashDeleted !== this._lastSyncedTask.deleted; + + if (googleUpdated > dashUpdated && !dashChanged) { + // Google version is newer — update Dash + runInAction(() => { + doc.title = googleTask.title ?? doc.title; + doc[this.fieldKey] = googleTask.notes ?? doc[this.fieldKey]; + doc.$task_completed = googleTask.status === 'completed'; + + if (googleTask.due && googleTask.due.split('T')[0] !== this.computeDueDate()?.split('T')[0]) { + const dueDate = new Date(googleTask.due); + doc.$task_allDay = true; + doc.$task_dateRange = `${dueDate.toISOString().split('T')[0]}|${dueDate.toISOString().split('T')[0]}`; + } + + doc.$task_lastSyncedAt = googleTask.updated; + this._lastSyncedTask = { + title: StrCast(doc.title), + text: StrCast(doc[this.fieldKey]), + due: this.computeDueDate(), + completed: !!doc.$task_completed, + deleted: !!doc.$dashDeleted, + }; + this._needsSync = false; + }); + + console.log('Pulled newer version from Google'); + return true; + } else if (googleUpdated <= dashUpdated && !dashChanged) { + console.log('No changes to sync'); + return true; + } else { + // Dash version is newer — push update to Google + const body = this.buildGoogleTaskBody(); + const res = await fetch(`/googleTasks/${doc.$googleTaskId}`, { + method: 'PATCH', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + credentials: 'include', + body: JSON.stringify(body), + }); + + const result = await res.json(); + if (result?.id) { + doc.$task_lastSyncedAt = new Date().toISOString(); + this._lastSyncedTask = { + title: StrCast(doc.title), + text: StrCast(doc[this.fieldKey]), + due: this.computeDueDate(), + completed: !!doc.$task_completed, + deleted: !!doc.$dashDeleted, + }; + this._needsSync = false; + console.log('Pushed newer version to Google'); + return true; + } else { + console.warn('❌ Push failed:', result); + return false; + } + } + } catch (err) { + console.error('❌ Sync error:', err); + return false; + } finally { + runInAction(() => { + this._syncing = false; + }); + } + }; + + /** + * Method to set up the task box on mount + */ + componentDidMount() { + this.setTaskDateRange(); + const doc = this.Document; + + // adding task on creation to google + (async () => { + if (!doc.$googleTaskId && doc.title) { + try { + const token = await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(); + if (!token) return; + const body = this.buildGoogleTaskBody(); + + const res = await fetch('/googleTasks/create', { + method: 'POST', + credentials: 'include', + headers: { + 'Content-Type': 'application/json', + Authorization: `Bearer ${token}`, + }, + body: JSON.stringify(body), + }); + + const result = await res.json(); + if (result?.id) { + doc.$googleTaskId = result.id; + console.log('✅ Google Task created on mount:', result); + } else { + console.warn('❌ Google Task creation failed:', result); + } + } catch (err) { + console.warn('❌ Error creating Google Task:', err); + } + } else if (doc.$googleTaskId) { + await this.syncWithGoogleTaskBidirectional(); + } + })(); + + this._heightDisposer = reaction( + () => NumCast(doc._height), + height => { + const minHeight = NumCast(doc.height_min); + if (height < minHeight) { + doc._height = minHeight; + } + } + ); + + this._widthDisposer = reaction( + () => NumCast(doc._width), + width => { + const minWidth = NumCast(doc.width_min); + if (width < minWidth) { + doc._width = minWidth; + } + } + ); + + runInAction(() => { + const completed = BoolCast(doc.$task_completed); + const due = this.computeDueDate(); + + this._lastSyncedTask = { + title: StrCast(doc.title), + text: StrCast(doc[this.fieldKey]), + due, + completed, + deleted: !!doc.$dashDeleted, + }; + this._needsSync = false; + }); + + if (this.Document.$dashDeleted) { + runInAction(() => { + this.Document.$dashDeleted = false; + }); + } + + this._googleTaskCreateDisposer = reaction( + () => { + const completed = BoolCast(doc.$task_completed); + const due = this.computeDueDate(); + const dashDeleted = !!doc.$dashDeleted; + + return { title: StrCast(doc.title), text: StrCast(doc[this.fieldKey]), completed, due, dashDeleted }; + }, + ({ title, text, completed, due, dashDeleted }) => { + this._needsSync = title !== this._lastSyncedTask.title || text !== this._lastSyncedTask.text || due !== this._lastSyncedTask.due || completed !== this._lastSyncedTask.completed || dashDeleted !== this._lastSyncedTask.deleted; + }, + { fireImmediately: true } + ); + } + + /** + * Method to clean up the task box on unmount + */ + componentWillUnmount() { + const doc = this.Document; + this._googleTaskCreateDisposer?.(); + this._heightDisposer?.(); + this._widthDisposer?.(); + } + + /** + * Method to handle task deletion + * @returns - a promise that resolves when the task is deleted + */ + handleDeleteTask = async () => { + const doc = this.Document; + if (!doc.$googleTaskId) return; + if (!window.confirm('Are you sure you want to delete this task?')) return; + + doc.$dashDeleted = true; + this._needsSync = true; + + try { + const token = await GoogleAuthenticationManager.Instance.fetchOrGenerateAccessToken(); + if (!token) return; + + await fetch(`/googleTasks/${doc.$googleTaskId}`, { + method: 'DELETE', + credentials: 'include', + headers: { + Authorization: `Bearer ${token}`, + }, + }); + + const view = DocumentView.getDocumentView(this.Document); + if (view) { + DocumentView.SelectView(view, false); // select document + DocumentDecorations.Instance?.onCloseClick?.(true); // simulate clicking the close button + } + + // Remove the task from the recently closed list + Doc.MyRecentlyClosed && Doc.RemoveDocFromList(Doc.MyRecentlyClosed, undefined, this.Document); + console.log(`✅ Deleted Google Task ${doc.$googleTaskId}`); + } catch (err) { + console.warn('❌ Failed to delete Google Task:', err); + } + }; + + /** + * Method to render the task box + * @returns - HTML with taskbox components + */ + + render() { + function toLocalDateTimeString(date: Date): string { + const pad = (n: number) => n.toString().padStart(2, '0'); + return date.getFullYear() + '-' + pad(date.getMonth() + 1) + '-' + pad(date.getDate()) + 'T' + pad(date.getHours()) + ':' + pad(date.getMinutes()); + } + + const doc = this.Document; + + const taskDesc = StrCast(doc[this.fieldKey]); + const taskTitle = StrCast(doc.title); + const allDay = !!doc.$task_allDay; + const due = this.computeDueDate(); + const isCompleted = !!this.Document.$task_completed; + + const startTime = DateCast(doc.$task_startTime) ? toLocalDateTimeString(DateCast(doc.$task_startTime)!.date) : ''; + const endTime = DateCast(doc.$task_endTime) ? toLocalDateTimeString(DateCast(doc.$task_endTime)!.date) : ''; + + const handleGoogleTaskSync = async () => { + const success = await this.syncWithGoogleTaskBidirectional(); + + if (success) { + alert('✅ Task successfully synced!'); + } else { + alert('❌ Task sync failed. Try reloading.'); + } + }; + + return ( + <div className="task-box-blur-wrapper" tabIndex={0} onBlur={this.handleBlur} onFocus={this.handleFocus}> + <div className="task-manager-container"> + <input className="task-manager-title" type="text" placeholder="Task Title" value={taskTitle} onChange={this.updateTitle} disabled={isCompleted} style={{ opacity: isCompleted ? 0.7 : 1 }} /> + + <textarea className="task-manager-description" placeholder="What’s your task?" value={taskDesc} onChange={this.updateText} disabled={isCompleted} style={{ opacity: isCompleted ? 0.7 : 1 }} /> + + <div className="task-manager-checkboxes"> + <label className="task-manager-allday" style={{ opacity: isCompleted ? 0.7 : 1 }}> + <input type="checkbox" checked={allDay} onChange={this.updateAllDay} disabled={isCompleted} /> + All day + {allDay && ( + <input + type="date" + value={(() => { + const datePart = StrCast(doc.$task_dateRange).split('|')[0]; + if (!datePart) return ''; + const d = new Date(datePart); + return !isNaN(d.getTime()) ? d.toISOString().split('T')[0] : ''; + })()} + onChange={e => { + const newDate = new Date(e.target.value); + if (!isNaN(newDate.getTime())) { + const dateStr = e.target.value; + if (dateStr) { + doc.$task_dateRange = `${dateStr}T00:00:00|${dateStr}T00:00:00`; + } + } + }} + disabled={isCompleted} + style={{ marginLeft: '8px' }} + /> + )} + </label> + + <label className="task-manager-complete"> + <input type="checkbox" checked={isCompleted} onChange={this.toggleComplete} /> + Complete + </label> + </div> + + <div className="task-manager-button-row"> + <button + className="task-manager-google" + onClick={event => { + event.preventDefault(); + handleGoogleTaskSync(); + }}> + {this._syncing ? 'Syncing...' : this.needsSync ? 'Push Updates' : 'Sync Task'} + </button> + + <button + className="task-manager-delete" + onClick={event => { + event.preventDefault(); + this.handleDeleteTask(); + }}> + <svg xmlns="http://www.w3.org/2000/svg" x="0px" y="0px" width="12" height="12" viewBox="0 0 24 24" style={{ fill: 'white', marginRight: '6px', verticalAlign: 'middle', marginTop: '-2px' }}> + <path d="M 10 2 L 9 3 L 5 3 C 4.4 3 4 3.4 4 4 C 4 4.6 4.4 5 5 5 L 7 5 L 17 5 L 19 5 C 19.6 5 20 4.6 20 4 C 20 3.4 19.6 3 19 3 L 15 3 L 14 2 L 10 2 z M 5 7 L 5 20 C 5 21.1 5.9 22 7 22 L 17 22 C 18.1 22 19 21.1 19 20 L 19 7 L 5 7 z M 9 9 C 9.6 9 10 9.4 10 10 L 10 19 C 10 19.6 9.6 20 9 20 C 8.4 20 8 19.6 8 19 L 8 10 C 8 9.4 8.4 9 9 9 z M 15 9 C 15.6 9 16 9.4 16 10 L 16 19 C 16 19.6 15.6 20 15 20 C 14.4 20 14 19.6 14 19 L 14 10 C 14 9.4 14.4 9 15 9 z"></path> + </svg> + Delete + </button> + </div> + + {!allDay && ( + <div className="task-manager-times" style={{ opacity: isCompleted ? 0.7 : 1 }}> + <label> + Start: + <input type="datetime-local" value={startTime} onChange={this.updateStart} disabled={isCompleted} /> + </label> + <label> + End: + <input type="datetime-local" value={endTime} onChange={this.updateEnd} disabled={isCompleted} /> + </label> + </div> + )} + </div> + </div> + ); + } +} + +Docs.Prototypes.TemplateMap.set(DocumentType.TASK, { + layout: { view: TaskBox, dataField: 'text' }, + options: { + acl: '', + _height: 35, + _xMargin: 10, + _yMargin: 10, + _layout_autoHeight: true, + _layout_nativeDimEditable: true, + _layout_reflowVertical: true, + _layout_reflowHorizontal: true, + task: '', + defaultDoubleClick: 'ignore', + systemIcon: 'BsCheckSquare', + height_min: 300, + width_min: 300, + }, +}); diff --git a/src/client/views/nodes/VideoBox.scss b/src/client/views/nodes/VideoBox.scss index b5405f0fb..27f419198 100644 --- a/src/client/views/nodes/VideoBox.scss +++ b/src/client/views/nodes/VideoBox.scss @@ -3,8 +3,8 @@ .mini-viewer { cursor: grab; position: absolute; - right: 10; - top: 10; + right: 10px; + top: 10px; opacity: 0.1; transition: all 0.4s; color: white; @@ -38,7 +38,7 @@ .videoBox-annotationLayer { position: relative; transform-origin: left top; - top: 0; + top: 0px; width: 100%; pointer-events: none; mix-blend-mode: multiply; // bcz: makes text fuzzy! @@ -81,8 +81,8 @@ // } .videoBox-ui-wrapper { - width: 0; - height: 0; + width: 0px; + height: 0px; position: relative; z-index: 2000; } diff --git a/src/client/views/nodes/VideoBox.tsx b/src/client/views/nodes/VideoBox.tsx index fa099178c..f994bdbb5 100644 --- a/src/client/views/nodes/VideoBox.tsx +++ b/src/client/views/nodes/VideoBox.tsx @@ -30,6 +30,7 @@ import { StyleProp } from '../StyleProp'; import { DocumentView } from './DocumentView'; import { FieldView, FieldViewProps } from './FieldView'; import { FocusViewOptions } from './FocusViewOptions'; +import { gptImageLabel } from '../../apis/gpt/GPT'; import './VideoBox.scss'; /** @@ -109,6 +110,52 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { return this._videoRef; } + autoTag = async () => { + if (this.Document.$tags_chat) return; + try { + if (!this.player) throw new Error('Video element not available.'); + + // 1) Extract a frame at the video's midpoint + const videoDuration = this.player.duration; + const snapshotTime = videoDuration / 2; + + // Seek the video element to the midpoint + await new Promise<void>(resolve => { + const onSeeked = () => { + this.player!.removeEventListener('seeked', onSeeked); + resolve(); + }; + this.player!.addEventListener('seeked', onSeeked); + this.player!.currentTime = snapshotTime; + }); + + // 2) Draw the frame onto a canvas and get a base64 representation + const canvas = document.createElement('canvas'); + canvas.width = this.player.videoWidth; + canvas.height = this.player.videoHeight; + const ctx = canvas.getContext('2d'); + if (!ctx) throw new Error('Failed to create canvas context.'); + ctx.drawImage(this.player, 0, 0, canvas.width, canvas.height); + const base64Image = canvas.toDataURL('image/png'); + + // 3) Send the image data to GPT for classification and descriptive tags + const label = await gptImageLabel( + base64Image, + `Classify this video frame as either a PERSON or LANDSCAPE. + Then provide five additional descriptive tags (single words) separated by spaces. + Finally, add one detailed summary phrase using underscores.` + ).then(raw => raw.trim().toUpperCase()); + + // 4) Normalize and store labels in the Document's tags + const aspect = this.player!.videoWidth / (this.player!.videoHeight || 1); + this.Document.$tags_chat = new List<string>([...label.split(/\s+/), `ASPECT_${aspect}`]); + // 5) Turn on tag display in layout + this.Document._layout_showTags = true; + } catch (err) { + console.error('Video autoTag failed:', err); + } + }; + componentDidMount() { this.unmounting = false; this._props.setContentViewBox?.(this); // this tells the DocumentView that this VideoBox is the "content" of the document. this allows the DocumentView to indirectly call getAnchor() on the VideoBox when making a link. @@ -338,12 +385,17 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { getAnchor = (addAsAnnotation: boolean, pinProps?: PinProps) => { const timecode = Cast(this.layoutDoc._layout_currentTimecode, 'number', null); const marquee = AnchorMenu.Instance.GetAnchor?.(undefined, addAsAnnotation); + const docAnchor = () => + Docs.Create.ConfigDocument({ + title: '#' + timecode, + _timecodeToShow: timecode, + annotationOn: this.Document, + }); if (!addAsAnnotation && marquee) marquee.backgroundColor = 'transparent'; - const anchor = - addAsAnnotation && marquee - ? CollectionStackedTimeline.createAnchor(this.Document, this.dataDoc, this.annotationKey, timecode || undefined, undefined, marquee, addAsAnnotation) || this.Document - : Docs.Create.ConfigDocument({ title: '#' + timecode, _timecodeToShow: timecode, annotationOn: this.Document }); + const visibleAnchor = () => addAsAnnotation && marquee && (CollectionStackedTimeline.createAnchor(this.Document, this.dataDoc, this.annotationKey, timecode || undefined, undefined, marquee, addAsAnnotation) || this.Document); + const anchor = visibleAnchor() || docAnchor(); PinDocView(anchor, { pinDocLayout: pinProps?.pinDocLayout, pinData: { ...(pinProps?.pinData ?? {}), temporal: true, pannable: true } }, this.Document); + addAsAnnotation && this.addDocument(anchor); return anchor; }; @@ -376,9 +428,7 @@ export class VideoBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } return this._stackedTimeline.getView(doc, options); } - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + return new Promise<Opt<DocumentView>>(res => DocumentView.addViewRenderedCb(doc, res)); }; // extracts video thumbnails and saves them as field of doc diff --git a/src/client/views/nodes/WebBox.scss b/src/client/views/nodes/WebBox.scss index 77d7716f4..f1c964980 100644 --- a/src/client/views/nodes/WebBox.scss +++ b/src/client/views/nodes/WebBox.scss @@ -3,8 +3,8 @@ .webBox { height: 100%; width: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; position: relative; display: flex; overflow: hidden; @@ -28,8 +28,8 @@ height: 100%; z-index: 1; pointer-events: none; - top: 0; - left: 0; + top: 0px; + left: 0px; overflow: hidden; .webBox-overlayButton { @@ -39,16 +39,16 @@ align-items: center; height: 20px; background: none; - padding: 0; + padding: 0px; position: absolute; pointer-events: all; color: white; - bottom: 0; - right: 0; + bottom: 0px; + right: 0px; .webBox-overlayButton-arrow { - width: 0; - height: 0; + width: 0px; + height: 0px; border-top: 10px solid transparent; border-bottom: 10px solid transparent; border-right: 15px solid #121721; @@ -92,7 +92,7 @@ width: calc(100% - 40px); height: 20px; background: #121721; - bottom: 0; + bottom: 0px; display: flex; justify-content: center; align-items: center; @@ -137,7 +137,7 @@ .webBox-annotationLayer { position: absolute; transform-origin: left top; - top: 0; + top: 0px; width: 100%; pointer-events: none; mix-blend-mode: multiply; // bcz: makes text fuzzy! @@ -156,8 +156,8 @@ .webBox-htmlSpan { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; cursor: text; padding: 15px; height: 100%; @@ -171,8 +171,8 @@ .webBox-cont-interactive { padding: 0vw; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; transform-origin: top left; @@ -181,8 +181,8 @@ width: 100%; height: 100%; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; body { ::selection { color: white; @@ -203,8 +203,8 @@ height: 100%; position: absolute; transform-origin: top left; - top: 0; - left: 0; + top: 0px; + left: 0px; overflow: auto; .webBox-innerContent { @@ -224,7 +224,7 @@ } .webBox-buttons { - margin-left: 44; + margin-left: 44px; background: lightGray; width: 100%; } @@ -232,8 +232,8 @@ .webBox-annotationToggle { z-index: 901; position: absolute; - top: 2; - left: 2; + top: 2px; + left: 2px; cursor: pointer; box-shadow: black 0.3em 0.3em 1em; border-radius: 5px; diff --git a/src/client/views/nodes/WebBox.tsx b/src/client/views/nodes/WebBox.tsx index 1e158f484..881cdae37 100644 --- a/src/client/views/nodes/WebBox.tsx +++ b/src/client/views/nodes/WebBox.tsx @@ -13,7 +13,7 @@ import { InkTool } from '../../../fields/InkField'; import { List } from '../../../fields/List'; import { RefField } from '../../../fields/RefField'; import { listSpec } from '../../../fields/Schema'; -import { Cast, NumCast, StrCast, toList, WebCast } from '../../../fields/Types'; +import { Cast, DocCast, NumCast, StrCast, toList, WebCast } from '../../../fields/Types'; import { ImageField, WebField } from '../../../fields/URLField'; import { TraceMobx } from '../../../fields/util'; import { emptyFunction, stringHash } from '../../../Utils'; @@ -104,6 +104,9 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { @computed get webField() { return Cast(this.Document[this._props.fieldKey], WebField)?.url; } + @computed get sidebarKey() { + return this.fieldKey + '_sidebar'; + } constructor(props: FieldViewProps) { super(props); @@ -308,18 +311,17 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }; @action - getView = (doc: Doc /* , options: FocusViewOptions */) => { - if (Doc.AreProtosEqual(doc, this.Document)) - return new Promise<Opt<DocumentView>>(res => { - res(this.DocumentView?.()); - }); + getView = async (doc: Doc, options: FocusViewOptions) => { + if (Doc.AreProtosEqual(doc, this.Document)) return new Promise<Opt<DocumentView>>(res => res(this.DocumentView?.())); + if (this.Document.layout_fieldKey === 'layout_icon') this.DocumentView?.().iconify(); const webUrl = WebCast(doc.config_data)?.url; if (this._url && webUrl && webUrl.href !== this._url) this.setData(webUrl.href); - if (this._sidebarRef?.current?.makeDocUnfiltered(doc) && !this.SidebarShown) this.toggleSidebar(false); - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + + if (DocListCast(this.dataDoc[this.sidebarKey]).find(anno => Doc.AreProtosEqual(doc.layout_unrendered ? DocCast(doc.annotationOn) : doc, anno))) { + return SidebarAnnos.getView(this._sidebarRef.current, this.SidebarShown, () => this.toggleSidebar(false), doc, options); + } + return undefined; }; sidebarAddDocTab = (doc: Doc, where: OpenWhere) => { @@ -393,7 +395,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { .transformPoint(e.clientX, e.clientY - NumCast(this.layoutDoc.layout_scrollTop)); if (!this._marqueeref.current?.isEmpty) this._marqueeref.current?.onEnd(theclick[0], theclick[1]); else { - if (!(e.target as HTMLElement)?.tagName?.includes('INPUT')) this.finishMarquee(theclick[0], theclick[1]); + if (!(e.target as HTMLElement)?.tagName?.includes('INPUT') && !(e.target as HTMLElement)?.tagName?.includes('TEXTAREA')) this.finishMarquee(theclick[0], theclick[1]); this._getAnchor = AnchorMenu.Instance?.GetAnchor; this.marqueeing = undefined; } @@ -454,7 +456,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { iframeDown = (e: PointerEvent) => { this._textAnnotationCreator = undefined; const sel = this._url ? this._iframe?.contentDocument?.getSelection() : window.document.getSelection(); - if (sel?.empty && !(e.target as any).textContent) + if (sel?.empty && !(e.target as HTMLElement).textContent) sel.empty(); // Chrome else if (sel?.removeAllRanges) sel.removeAllRanges(); // Firefox @@ -465,6 +467,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { .transformPoint(e.clientX, e.clientY - NumCast(this.layoutDoc.layout_scrollTop)); MarqueeAnnotator.clearAnnotations(this._savedAnnotations); const target = e.target as HTMLElement; + if ((target as HTMLElement)?.tagName?.includes('INPUT') || (target as HTMLElement)?.tagName?.includes('TEXTAREA')) e.stopPropagation(); const word = target && getWordAtPoint(target, e.clientX, e.clientY); if (!word && !target?.className?.includes('rangeslider') && !target?.onclick && !target?.parentElement?.onclick) { this.marqueeing = theclick; @@ -657,7 +660,7 @@ export class WebBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { 'click', undoable( action((e: MouseEvent) => { - let eleHref = (e.target as any)?.outerHTML?.split('"="')[1]?.split('"')[0]; + let eleHref = (e.target as HTMLElement)?.outerHTML?.split('"="')[1]?.split('"')[0]; for (let ele = e.target as HTMLElement | Element | null; ele; ele = ele.parentElement) { if ('href' in ele) { eleHref = (typeof ele.href === 'string' ? ele.href : eleHref) || (ele.parentElement && 'href' in ele.parentElement ? (ele.parentElement.href as string) : eleHref); diff --git a/src/client/views/nodes/audio/AudioWaveform.scss b/src/client/views/nodes/audio/AudioWaveform.scss index 6cbd1759a..c6b0da9c8 100644 --- a/src/client/views/nodes/audio/AudioWaveform.scss +++ b/src/client/views/nodes/audio/AudioWaveform.scss @@ -1,17 +1,17 @@ -.audioWaveform { +.audioWaveform { position: relative; width: 100%; height: 200%; overflow: hidden; z-index: -1000; - bottom: 0; + bottom: 0px; pointer-events: none; div { height: 100% !important; - width: 100% !important; + width: 100% !important; } - canvas { + canvas { height: 100% !important; - width: 100% !important; + width: 100% !important; } } diff --git a/src/client/views/nodes/calendarBox/CalendarBox.scss b/src/client/views/nodes/calendarBox/CalendarBox.scss index f8ac4b2d1..89dc294a5 100644 --- a/src/client/views/nodes/calendarBox/CalendarBox.scss +++ b/src/client/views/nodes/calendarBox/CalendarBox.scss @@ -1,9 +1,12 @@ +.calendarBox-interactive, .calendarBox { display: flex; width: 100%; height: 100%; transform-origin: top left; - .calendarBox-wrapper { + overflow: auto; + > div { + pointer-events: none; width: 100%; height: 100%; .fc-timegrid-body { @@ -23,3 +26,36 @@ } } } + +// AARAV ADD + +/* Existing styles */ +.fc-event.mother { + font-weight: 500; + border-radius: 4px; + padding: 2px 4px; + border-width: 2px; + } + + /* New styles for completed tasks */ + .fc-event.completed-task { + opacity: 1; + filter: grayscale(70%) brightness(90%); + text-decoration: line-through; + color: #ffffff; + } + +.calendarBox-interactive { + > div { + pointer-events: unset; + } +} + +.custom-drag-mirror { + transition: none !important; + transform: none !important; +} + +.fc-event-dragging { + opacity: 0 !important; +} diff --git a/src/client/views/nodes/calendarBox/CalendarBox.tsx b/src/client/views/nodes/calendarBox/CalendarBox.tsx index 2b20a666d..a2fa83b5a 100644 --- a/src/client/views/nodes/calendarBox/CalendarBox.tsx +++ b/src/client/views/nodes/calendarBox/CalendarBox.tsx @@ -1,15 +1,17 @@ -import { Calendar, EventClickArg, EventDropArg, EventSourceInput } from '@fullcalendar/core'; +import { Calendar, DateSelectArg, EventClickArg, EventDropArg, EventMountArg, EventSourceInput } from '@fullcalendar/core'; +import { EventResizeDoneArg } from '@fullcalendar/interaction'; import dayGridPlugin from '@fullcalendar/daygrid'; import interactionPlugin from '@fullcalendar/interaction'; import multiMonthPlugin from '@fullcalendar/multimonth'; import timeGrid from '@fullcalendar/timegrid'; -import { IReactionDisposer, action, computed, makeObservable, observable, reaction } from 'mobx'; +import FullCalendar from '@fullcalendar/react'; +import { IReactionDisposer, action, computed, makeObservable, observable, reaction, untracked } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; import { dateRangeStrToDates } from '../../../../ClientUtils'; import { Doc } from '../../../../fields/Doc'; import { Id } from '../../../../fields/FieldSymbols'; -import { BoolCast, NumCast, StrCast } from '../../../../fields/Types'; +import { BoolCast, StrCast } from '../../../../fields/Types'; import { DocServer } from '../../../DocServer'; import { DragManager } from '../../../util/DragManager'; import { CollectionSubView, SubCollectionViewProps } from '../../collections/CollectionSubView'; @@ -17,26 +19,35 @@ import { ContextMenu } from '../../ContextMenu'; import { DocumentView } from '../DocumentView'; import { OpenWhere } from '../OpenWhere'; import './CalendarBox.scss'; +import { DateField } from '../../../../fields/DateField'; +import { undoable } from '../../../util/UndoManager'; +import { DocumentType } from '../../../documents/DocumentTypes'; +import { truncate } from 'fs/promises'; type CalendarView = 'multiMonth' | 'dayGridMonth' | 'timeGridWeek' | 'timeGridDay'; @observer export class CalendarBox extends CollectionSubView() { - _calendarRef: HTMLDivElement | null = null; + _calendarRef: FullCalendar | null = null; _calendar: Calendar | undefined; _observer: ResizeObserver | undefined; _eventsDisposer: IReactionDisposer | undefined; _selectDisposer: IReactionDisposer | undefined; + _isMultiMonth: boolean | undefined; + + @observable _multiMonth = 0; constructor(props: SubCollectionViewProps) { super(props); makeObservable(this); } - @observable _multiMonth = 0; - isMultiMonth: boolean | undefined; + @computed get calTypeFieldKey() { + return this.fieldKey + '_calendarType'; + } componentDidMount(): void { + this.Document.$calendar = ''; // needed only to make the keyvalue view look nice. this._props.setContentViewBox?.(this); this._eventsDisposer = reaction( () => ({ events: this.calendarEvents }), @@ -52,7 +63,7 @@ export class CalendarBox extends CollectionSubView() { type: 'CHANGE_DATE', dateMarker: state.dateEnv.createMarker(initialDate.start), }); - setTimeout(() => (initialDate.start.toISOString() !== initialDate.end.toISOString() ? this._calendar?.select(initialDate.start, initialDate.end) : this._calendar?.select(initialDate.start))); + setTimeout(() => initialDate.start.toISOString() !== initialDate.end.toISOString() && this._calendar?.select(initialDate.start, initialDate.end)); }, { fireImmediately: true } ); @@ -64,7 +75,20 @@ export class CalendarBox extends CollectionSubView() { @computed get calendarEvents(): EventSourceInput | undefined { return this.childDocs.map(doc => { - const { start, end } = dateRangeStrToDates(StrCast(doc.date_range)); + // const { start, end } = dateRangeStrToDates(StrCast(doc.$task_dateRange)); + const isCompleted = BoolCast(doc.$task_completed); + + const rangeStr = StrCast(doc.$task_dateRange); + const [startStr, endStr] = rangeStr.split('|'); + let start: string | Date, end: string | Date; + + if (BoolCast(doc.$task_allDay)) { + start = startStr; + end = endStr; + } else { + ({ start, end } = dateRangeStrToDates(rangeStr)); + } + return { title: StrCast(doc.title), start, @@ -72,8 +96,8 @@ export class CalendarBox extends CollectionSubView() { groupId: doc[Id], startEditable: true, endEditable: true, - allDay: BoolCast(doc.allDay), - classNames: ['mother'], // will determine the style + allDay: BoolCast(doc.$task_allDay), + classNames: ['mother', isCompleted ? 'completed-task' : ''], // will determine the style editable: true, // subject to change in the future backgroundColor: this.eventToColor(doc), borderColor: this.eventToColor(doc), @@ -86,16 +110,16 @@ export class CalendarBox extends CollectionSubView() { } @computed get dateRangeStrDates() { - return dateRangeStrToDates(StrCast(this.Document.date_range)); + return dateRangeStrToDates(StrCast(this.Document._calendar_dateRange)); } get dateSelect() { - return dateRangeStrToDates(StrCast(this.Document.date)); + return dateRangeStrToDates(StrCast(this.Document._calendar_date)); } // Choose a calendar view based on the date range @computed get calendarViewType(): CalendarView { - if (this.dataDoc[this.fieldKey + '_calendarType']) return StrCast(this.dataDoc[this.fieldKey + '_calendarType']) as CalendarView; - if (this.isMultiMonth) return 'multiMonth'; + if (this.dataDoc[this.calTypeFieldKey]) return StrCast(this.dataDoc[this.calTypeFieldKey]) as CalendarView; + if (this._isMultiMonth) return 'multiMonth'; const { start, end } = this.dateRangeStrDates; if (start.getFullYear() !== end.getFullYear() || start.getMonth() !== end.getMonth()) return 'multiMonth'; if (Math.abs(start.getDay() - end.getDay()) > 7) return 'dayGridMonth'; @@ -104,7 +128,9 @@ export class CalendarBox extends CollectionSubView() { // TODO: Return a different color based on the event type eventToColor = (event: Doc): string => { - return 'red' + event; + return StrCast(event.type) === DocumentType.TASK + ? '#20B2AA' // teal for tasks + : 'red'; }; // eslint-disable-next-line @typescript-eslint/no-unused-vars @@ -112,7 +138,7 @@ export class CalendarBox extends CollectionSubView() { if (!super.onInternalDrop(e, de)) return false; de.complete.docDragData?.droppedDocuments.forEach(doc => { const today = new Date().toISOString(); - if (!doc.date_range) doc.$date_range = `${today}|${today}`; + if (!doc.$task_dateRange) doc.$task_dateRange = `${today}|${today}`; }); return true; }; @@ -122,10 +148,31 @@ export class CalendarBox extends CollectionSubView() { return false; }; - handleEventDrop = (arg: EventDropArg) => { + handleEventDrop = undoable((arg: EventDropArg | EventResizeDoneArg) => { const doc = DocServer.GetCachedRefField(arg.event._def.groupId ?? ''); - doc && arg.event.start && (doc.date_range = arg.event.start?.toString() + '|' + (arg.event.end ?? arg.event.start).toString()); - }; + // doc && arg.event.start && (doc.$task_dateRange = arg.event.start?.toString() + '|' + (arg.event.end ?? arg.event.start).toString()); + if (!doc || !arg.event.start) return; + + // get the new start and end dates + const startDate = new Date(arg.event.start); + const endDate = new Date(arg.event.end ?? arg.event.start); + + // update date range, time range, and all day status + doc.$task_dateRange = `${startDate.toISOString()}|${endDate.toISOString()}`; + + const allDayStatus = arg.event.allDay ?? false; + if (doc.$task_allDay !== allDayStatus) { + doc.$task_allDay = allDayStatus; + } + + if (doc.$task_allDay) { + delete doc.$task_startTime; + delete doc.$task_endTime; + } else { + doc.$task_startTime = new DateField(startDate); + doc.$task_endTime = new DateField(endDate); + } + }, 'change event date'); handleEventClick = (arg: EventClickArg) => { const doc = DocServer.GetCachedRefField(arg.event._def.groupId ?? ''); @@ -144,90 +191,199 @@ export class CalendarBox extends CollectionSubView() { }; // https://fullcalendar.io - renderCalendar = () => { - const cal = !this._calendarRef - ? null - : (this._calendar = new Calendar(this._calendarRef, { - plugins: [multiMonthPlugin, dayGridPlugin, timeGrid, interactionPlugin], - headerToolbar: { - left: 'prev,next today', - center: 'title', - right: 'multiMonth dayGridMonth timeGridWeek timeGridDay', - }, - selectable: true, - initialView: this.calendarViewType === 'multiMonth' ? undefined : this.calendarViewType, - initialDate: this.dateSelect.start, - navLinks: true, - editable: false, - displayEventTime: false, - displayEventEnd: false, - select: info => { - const start = dateRangeStrToDates(info.startStr).start.toISOString(); - const end = dateRangeStrToDates(info.endStr).start.toISOString(); - this.dataDoc.date = start + '|' + end; - }, - aspectRatio: NumCast(this.Document.width) / NumCast(this.Document.height), - events: this.calendarEvents, - eventClick: this.handleEventClick, - eventDrop: this.handleEventDrop, - eventDidMount: arg => { - arg.el.addEventListener('pointerdown', ev => { - ev.button && ev.stopPropagation(); - }); - if (navigator.userAgent.includes('Macintosh')) { - arg.el.addEventListener('pointerup', ev => { - ev.button && ev.stopPropagation(); - ev.button && this.handleEventContextMenu(ev.pageX, ev.pageY, arg.event._def.groupId); - }); - } - arg.el.addEventListener('contextmenu', ev => { - if (!navigator.userAgent.includes('Macintosh')) { - this.handleEventContextMenu(ev.pageX, ev.pageY, arg.event._def.groupId); + @computed get renderCalendar() { + const availableWidth = this._props.PanelWidth() / (this._props.DocumentView?.().UIBtnScaling ?? 1); + const btn = (text: string, view: string | (() => void), hint: string) => ({ text, hint, click: typeof view === 'string' ? () => this._calendarRef?.getApi().changeView(view) : view }); + return ( + <FullCalendar + ref={(r: unknown) => (this._calendarRef = r as FullCalendar)} + customButtons={{ + nowBtn: btn('Now', () => this._calendarRef?.getApi().gotoDate(new Date()), 'Go to Today'), + multiBtn: btn('M+', 'multiMonth', 'Multiple Month View'), + monthBtn: btn('M', 'dayGridMonth', 'Month View'), + weekBtn: btn('W', 'timeGridWeek', 'Week View'), + dayBtn: btn('D', 'timeGridDay', 'Day View'), + }} + headerToolbar={ + availableWidth > 450 + ? { + left: 'prev,next nowBtn', + center: 'title', + right: 'multiBtn monthBtn weekBtn dayBtn', } - ev.stopPropagation(); - ev.preventDefault(); - }); - }, - })); - cal?.render(); - setTimeout(() => cal?.view.calendar.select(this.dateSelect.start, this.dateSelect.end)); - }; + : availableWidth > 300 + ? { + left: 'prev,next', + center: 'title', + right: '', + } + : { + left: '', + center: 'title', + right: '', + } + } + selectable={true} + initialView={this.calendarViewType === 'multiMonth' ? undefined : this.calendarViewType} + views={{ + multiMonth: { + type: 'multiMonth', + duration: { months: 12 }, + }, + }} + initialDate={untracked(() => this.dateSelect.start)} + navLinks={true} + editable={true} + // expandRows={true} + // handleWindowResize={true} + displayEventTime={false} + displayEventEnd={false} + plugins={[multiMonthPlugin, dayGridPlugin, timeGrid, interactionPlugin]} + aspectRatio={this._props.PanelWidth() / this._props.PanelHeight()} + weekends={true} + events={this.calendarEvents} + eventClick={this.handleEventClick} + eventDrop={this.handleEventDrop} + eventResize={this.handleEventDrop} + unselectAuto={false} + // unselect={() => {}} + select={(info: DateSelectArg) => { + const start = dateRangeStrToDates(info.startStr).start.toISOString(); + const end = info.allDay ? start : dateRangeStrToDates(info.endStr).start.toISOString(); + this.Document._calendar_date = start + '|' + end; + }} + // eventContent={() => { + // return null; + // }} + eventDidMount={(arg: EventMountArg) => { + const doc = DocServer.GetCachedRefField(arg.event._def.groupId ?? ''); + if (!doc) return; + + if (doc.type === DocumentType.TASK) { + const checkButton = document.createElement('button'); + checkButton.innerText = doc.$task_completed ? '✅' : '⬜'; + checkButton.style.position = 'absolute'; + checkButton.style.right = '5px'; + checkButton.style.top = '50%'; + checkButton.style.transform = 'translateY(-50%)'; + checkButton.style.background = 'transparent'; + checkButton.style.border = 'none'; + checkButton.style.cursor = 'pointer'; + checkButton.style.fontSize = '18px'; + checkButton.style.zIndex = '1000'; + checkButton.style.padding = '0'; + checkButton.style.margin = '0'; + + checkButton.onclick = ev => { + ev.stopPropagation(); + doc.$task_completed = !doc.$task_completed; + this._calendar?.refetchEvents(); + }; + arg.el.style.position = 'relative'; + arg.el.appendChild(checkButton); + } + arg.el.addEventListener('pointerdown', ev => ev.button && ev.stopPropagation()); + if (navigator.userAgent.includes('Macintosh')) { + arg.el.addEventListener('pointerup', ev => { + ev.button && ev.stopPropagation(); + ev.button && this.handleEventContextMenu(ev.pageX, ev.pageY, arg.event._def.groupId); + }); + } + arg.el.addEventListener('contextmenu', ev => { + if (!navigator.userAgent.includes('Macintosh')) { + this.handleEventContextMenu(ev.pageX, ev.pageY, arg.event._def.groupId); + } + ev.stopPropagation(); + ev.preventDefault(); + }); + }} + // for dragging and dropping (mirror) + + eventDragStart={arg => { + const mirror = arg.el.cloneNode(true) as HTMLElement; + const rect = arg.el.getBoundingClientRect(); + + mirror.style.position = 'fixed'; + mirror.style.pointerEvents = 'none'; + mirror.style.opacity = '0.8'; + mirror.style.zIndex = '10000'; + mirror.classList.add('custom-drag-mirror'); + mirror.style.width = `${rect.width}px`; + mirror.style.height = `${rect.height}px`; + + document.body.appendChild(mirror); + + const moveListener = (ev: MouseEvent) => { + mirror.style.left = `${ev.clientX}px`; + mirror.style.top = `${ev.clientY}px`; + }; + + window.addEventListener('mousemove', moveListener); + + // hide the actual box + arg.el.style.visibility = 'hidden'; + arg.el.style.opacity = '0'; + + (arg.el as any)._mirrorElement = mirror; + (arg.el as any)._moveListener = moveListener; + }} + eventDragStop={arg => { + const el = arg.el as any; + const mirror = el._mirrorElement; + const moveListener = el._moveListener; + + // show the actual box + el.style.visibility = 'visible'; + el.style.opacity = '1'; + + if (mirror) document.body.removeChild(mirror); + if (moveListener) window.removeEventListener('mousemove', moveListener); + }} + /> + ); + } + + setRef = (r: HTMLDivElement | null) => { + this.createDashEventsTarget(r); + this.fixWheelEvents(r, this._props.isContentActive); + }; render() { + const scale = this._props.ScreenToLocalTransform().Scale; + const scaledWidth = this._props.PanelWidth(); + const scaledHeight = this._props.PanelHeight(); + return ( <div key={this.calendarViewType} - className="calendarBox" + className={`calendarBox${this._props.isContentActive() ? '-interactive' : ''}`} + style={{ + width: scaledWidth, + height: scaledHeight, + overflow: 'hidden', + position: 'relative', + }} + ref={this.setRef} onPointerDown={e => { setTimeout( action(() => { const cname = (e.nativeEvent.target as HTMLButtonElement)?.className ?? ''; - if (cname.includes('multiMonth')) this.dataDoc[this.fieldKey + '_calendarType'] = 'multiMonth'; - if (cname.includes('dayGridMonth')) this.dataDoc[this.fieldKey + '_calendarType'] = 'dayGridMonth'; - if (cname.includes('timeGridWeek')) this.dataDoc[this.fieldKey + '_calendarType'] = 'timeGridWeek'; - if (cname.includes('timeGridDay')) this.dataDoc[this.fieldKey + '_calendarType'] = 'timeGridDay'; + if (cname.includes('multiMonth')) this.dataDoc[this.calTypeFieldKey] = 'multiMonth'; + if (cname.includes('dayGridMonth')) this.dataDoc[this.calTypeFieldKey] = 'dayGridMonth'; + if (cname.includes('timeGridWeek')) this.dataDoc[this.calTypeFieldKey] = 'timeGridWeek'; + if (cname.includes('timeGridDay')) this.dataDoc[this.calTypeFieldKey] = 'timeGridDay'; }) ); - }} - style={{ - width: this._props.PanelWidth() / this._props.ScreenToLocalTransform().Scale, - height: this._props.PanelHeight() / this._props.ScreenToLocalTransform().Scale, - transform: `scale(${this._props.ScreenToLocalTransform().Scale})`, - }} - ref={r => { - this.createDashEventsTarget(r); - this.fixWheelEvents(r, this._props.isContentActive); - - if (r) { - this._observer?.disconnect(); - (this._observer = new ResizeObserver(() => { - this._calendar?.setOption('aspectRatio', NumCast(this.Document.width) / NumCast(this.Document.height)); - this._calendar?.updateSize(); - })).observe(r); - this.renderCalendar(); - } }}> - <div className="calendarBox-wrapper" ref={r => (this._calendarRef = r)} /> + <div + style={{ + transform: `scale(${scale})`, + transformOrigin: 'top left', + width: scaledWidth / scale, + height: scaledHeight / scale, + }}> + {this.renderCalendar} + </div> </div> ); } diff --git a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.scss b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.scss index 8e00cbdb7..18a179c67 100644 --- a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.scss +++ b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.scss @@ -50,7 +50,7 @@ $font-size-xlarge: 18px; position: relative; h2 { - margin: 0; + margin: 0px; font-size: 1.25rem; font-weight: 600; letter-spacing: 0.01em; @@ -103,7 +103,7 @@ $font-size-xlarge: 18px; margin-bottom: 4px; &:last-child { - margin-bottom: 0; + margin-bottom: 0px; } &:hover { @@ -191,8 +191,8 @@ $font-size-xlarge: 18px; content: ''; position: absolute; top: -5px; - left: 0; - right: 0; + left: 0px; + right: 0px; height: 5px; background: linear-gradient(to top, rgba(0, 0, 0, 0.06), transparent); pointer-events: none; @@ -457,11 +457,11 @@ $font-size-xlarge: 18px; margin: 8px 0; &:first-child { - margin-top: 0; + margin-top: 0px; } &:last-child { - margin-bottom: 0; + margin-bottom: 0px; } } @@ -521,8 +521,8 @@ $font-size-xlarge: 18px; border-bottom: 1px dashed #e5e7eb; &:last-child { - margin-bottom: 0; - padding-bottom: 0; + margin-bottom: 0px; + padding-bottom: 0px; border-bottom: none; } @@ -581,7 +581,7 @@ $font-size-xlarge: 18px; .message-content { background-color: inherit; - padding: 0; + padding: 0px; border-radius: 8px; font-size: 14px; line-height: 1.6; @@ -708,10 +708,10 @@ $font-size-xlarge: 18px; .uploading-overlay { position: absolute; - top: 0; - left: 0; - right: 0; - bottom: 0; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; background-color: rgba(255, 255, 255, 0.92); display: flex; justify-content: center; @@ -807,7 +807,7 @@ $font-size-xlarge: 18px; @media (max-width: 768px) { .chat-box { - border-radius: 0; + border-radius: 0px; } .message { @@ -954,10 +954,10 @@ $font-size-xlarge: 18px; /* Tool Reload Modal Styles */ .tool-reload-modal-overlay { position: fixed; - top: 0; - left: 0; - right: 0; - bottom: 0; + top: 0px; + left: 0px; + right: 0px; + bottom: 0px; background-color: rgba(0, 0, 0, 0.5); display: flex; align-items: center; @@ -969,7 +969,7 @@ $font-size-xlarge: 18px; .tool-reload-modal { background: white; border-radius: 12px; - padding: 0; + padding: 0px; min-width: 400px; max-width: 500px; box-shadow: @@ -995,7 +995,7 @@ $font-size-xlarge: 18px; border-bottom: 1px solid #e2e8f0; h3 { - margin: 0; + margin: 0px; font-size: 18px; font-weight: 600; color: #1a202c; @@ -1019,7 +1019,7 @@ $font-size-xlarge: 18px; color: #4a5568; &:last-child { - margin-bottom: 0; + margin-bottom: 0px; } strong { diff --git a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx index df6c5627c..6e6ef6212 100644 --- a/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx +++ b/src/client/views/nodes/chatbot/chatboxcomponents/ChatBox.tsx @@ -15,7 +15,7 @@ import * as React from 'react'; import { v4 as uuidv4 } from 'uuid'; import { ClientUtils, OmitKeys } from '../../../../../ClientUtils'; import { Doc, DocListCast, Opt } from '../../../../../fields/Doc'; -import { DocData, DocLayout, DocViews } from '../../../../../fields/DocSymbols'; +import { DocData, DocViews } from '../../../../../fields/DocSymbols'; import { Id } from '../../../../../fields/FieldSymbols'; import { RichTextField } from '../../../../../fields/RichTextField'; import { ScriptField } from '../../../../../fields/ScriptField'; @@ -44,7 +44,6 @@ import './ChatBox.scss'; import MessageComponentBox from './MessageComponent'; import { OpenWhere } from '../../OpenWhere'; import { Upload } from '../../../../../server/SharedMediaTypes'; -import { DocumentMetadataTool } from '../tools/DocumentMetadataTool'; import { AgentDocumentManager } from '../utils/AgentDocumentManager'; dotenv.config(); @@ -497,7 +496,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { case supportedDocTypes.image: return Docs.Create.ImageDocument(data as string, options); case supportedDocTypes.equation: return Docs.Create.EquationDocument(data as string, options); case supportedDocTypes.notetaking: return Docs.Create.NoteTakingDocument([], options); - case supportedDocTypes.web: + case supportedDocTypes.web: { // Create web document with enhanced safety options const webOptions = { ...options, @@ -506,10 +505,11 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { // If iframe_sandbox was passed from AgentDocumentManager, add it to the options if ('_iframe_sandbox' in options) { - (webOptions as any)._iframe_sandbox = options._iframe_sandbox; + webOptions._iframe_sandbox = options._iframe_sandbox; } return Docs.Create.WebDocument(data as string, webOptions); + } case supportedDocTypes.dataviz: return Docs.Create.DataVizDocument('/users/rz/Downloads/addresses.csv', options); case supportedDocTypes.pdf: return Docs.Create.PdfDocument(data as string, options); case supportedDocTypes.video: return Docs.Create.VideoDocument(data as string, options); @@ -640,7 +640,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { handleCitationClick = async (citation: Citation) => { try { // Extract values from MobX proxy object if needed - const chunkId = typeof citation.chunk_id === 'object' ? (citation.chunk_id as any).toString() : citation.chunk_id; + const chunkId = typeof citation.chunk_id === 'object' ? (citation.chunk_id as unknown as object).toString() : citation.chunk_id; // For debugging console.log('Citation clicked:', { @@ -682,7 +682,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } this.handleOtherChunkTypes(foundChunk, citation, doc, dataDoc); // Show the chunk text in citation popup - let chunkText = citation.direct_text || 'Text content not available'; + const chunkText = citation.direct_text || 'Text content not available'; this.showCitationPopup(chunkText); // Also navigate to the document @@ -841,14 +841,16 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { } break; case CHUNK_TYPE.TEXT: - this._citationPopup = { text: citation.direct_text ?? 'No text available', visible: true }; - this.startCitationPopupTimer(); + { + this._citationPopup = { text: citation.direct_text ?? 'No text available', visible: true }; + this.startCitationPopupTimer(); - // Check if the document is a PDF (has a PDF viewer component) - const isPDF = PDFCast(dataDoc!.data) !== null || dataDoc!.type === DocumentType.PDF; + // Check if the document is a PDF (has a PDF viewer component) + const isPDF = PDFCast(dataDoc!.data) !== null || dataDoc!.type === DocumentType.PDF; - // First ensure document is fully visible before trying to access its views - this.ensureDocumentIsVisible(dataDoc!, isPDF, citation, foundChunk, doc); + // First ensure document is fully visible before trying to access its views + this.ensureDocumentIsVisible(dataDoc!, isPDF, citation, foundChunk, doc); + } break; case CHUNK_TYPE.CSV: case CHUNK_TYPE.URL: @@ -1163,6 +1165,9 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { this._inputValue = question; }; + _dictation: DictationButton | null = null; + setInputRef = (r: HTMLInputElement) => (this._textInputRef = r); + setDictationRef = (r: DictationButton) => (this._dictation = r); /** * Handles tool creation notification and shows the reload modal * @param toolName The name of the tool that was created @@ -1213,8 +1218,6 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { }, 100); }; - _dictation: DictationButton | null = null; - /** * Toggles the font size modal visibility */ @@ -1443,9 +1446,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { <form onSubmit={this.askGPT} className="chat-input"> <div className="input-container"> <input - ref={r => { - this._textInputRef = r; - }} + ref={this.setInputRef} type="text" name="messageInput" autoComplete="off" @@ -1465,13 +1466,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { </svg> )} </button> - <DictationButton - ref={r => { - this._dictation = r; - }} - setInput={this.setChatInput} - inputRef={this._textInputRef} - /> + <DictationButton ref={this.setDictationRef} setInput={this.setChatInput} inputRef={this._textInputRef} /> </form> {/* Popup for citation */} {this._citationPopup.visible && ( @@ -1501,7 +1496,7 @@ export class ChatBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { The tool <strong>{this._toolReloadModal.toolName}</strong> has been created and saved successfully. </p> <p>To make the tool available for future use, the page needs to be reloaded to rebuild the application bundle.</p> - <p>Click "Reload Page" to complete the tool installation.</p> + <p>Click "Reload Page" to complete the tool installation.</p> </div> <div className="tool-reload-modal-actions"> <button className="reload-button primary" onClick={this.handleReloadConfirmation}> diff --git a/src/client/views/nodes/formattedText/DailyJournal.tsx b/src/client/views/nodes/formattedText/DailyJournal.tsx index 871c556e6..564609494 100644 --- a/src/client/views/nodes/formattedText/DailyJournal.tsx +++ b/src/client/views/nodes/formattedText/DailyJournal.tsx @@ -9,14 +9,22 @@ import { gptAPICall, GPTCallType } from '../../../apis/gpt/GPT'; import { RichTextField } from '../../../../fields/RichTextField'; import { Plugin } from 'prosemirror-state'; import { RTFCast } from '../../../../fields/Types'; +import { Mark } from 'prosemirror-model'; +import { observer } from 'mobx-react'; export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() { @observable journalDate: string; - @observable typingTimeout: NodeJS.Timeout | null = null; // Track typing delay - @observable lastUserText: string = ''; // Store last user-entered text + @observable typingTimeout: NodeJS.Timeout | null = null; // track typing delay + @observable lastUserText: string = ''; // store last user-entered text + @observable isLoadingPrompts: boolean = false; // track if prompts are loading + @observable showPromptMenu = false; + @observable inlinePromptsEnabled = true; + @observable askPromptsEnabled = true; + _ref = React.createRef<FormattedTextBox>(); // reference to the formatted textbox predictiveTextRange: { from: number; to: number } | null = null; // where predictive text starts and ends private predictiveText: string | null = ' ... why?'; + private prePredictiveMarks: Mark[] = []; public static LayoutString(fieldStr: string) { return FieldView.LayoutString(DailyJournal, fieldStr); @@ -40,7 +48,7 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() month: 'long', day: 'numeric', }); - console.log('getFormattedDate():', date); + // console.log('getFormattedDate():', date); return date; } @@ -49,15 +57,15 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() */ @action setDailyTitle() { - console.log('setDailyTitle() called...'); - console.log('Current title before update:', this.dataDoc.title); + // console.log('setDailyTitle() called...'); + // console.log('Current title before update:', this.dataDoc.title); if (!this.dataDoc.title || this.dataDoc.title !== this.journalDate) { - console.log('Updating title to:', this.journalDate); + // console.log('Updating title to:', this.journalDate); this.dataDoc.title = this.journalDate; } - console.log('New title after update:', this.dataDoc.title); + // console.log('New title after update:', this.dataDoc.title); } /** @@ -68,7 +76,7 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() const placeholderText = 'Start writing here...'; const dateText = `${this.journalDate}\n`; - console.log('Checking if dataDoc has text field...'); + // console.log('Checking if dataDoc has text field...'); this.dataDoc[this.fieldKey] = RichTextField.textToRtfFormat( [ @@ -80,7 +88,63 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() placeholderText.length ); - console.log('Current text field:', this.dataDoc[this.fieldKey]); + // console.log('Current text field:', this.dataDoc[this.fieldKey]); + } + + /** + * Method to show/hide the prompts menu + */ + @action.bound togglePromptMenu() { + this.showPromptMenu = !this.showPromptMenu; + } + + /** + * Method to toggle on/off inline predictive prompts + */ + @action.bound toggleInlinePrompts() { + this.inlinePromptsEnabled = !this.inlinePromptsEnabled; + } + + /** + * Method to toggle on/off inline /ask prompts + */ + @action.bound toggleAskPrompts() { + this.askPromptsEnabled = !this.askPromptsEnabled; + } + + /** + * Method to handle click on document (to close prompt menu) + * @param e - a click on the document + */ + @action.bound + handleDocumentClick(e: MouseEvent) { + const menu = document.getElementById('prompts-menu'); + const button = document.getElementById('prompts-button'); + if (this.showPromptMenu && menu && !menu.contains(e.target as Node) && button && !button.contains(e.target as Node)) { + this.showPromptMenu = false; + } + } + + /** + * Method to set initial date of document in the calendar view + */ + + @action setInitialDateRange() { + if (!this.dataDoc.$task_dateRange && this.journalDate) { + const parsedDate = new Date(this.journalDate); + if (!isNaN(parsedDate.getTime())) { + const localStart = new Date(parsedDate.getFullYear(), parsedDate.getMonth(), parsedDate.getDate()); + const localEnd = new Date(localStart); // same day + + this.dataDoc.$task_dateRange = `${localStart.toISOString()}|${localEnd.toISOString()}`; + this.dataDoc.$task_allDay = true; + this.dataDoc.$task = ''; // needed only to make the keyvalue view look good. + + // console.log('Set task_dateRange and task_allDay on journal (from local date):', this.dataDoc.$task_dateRange); + } else { + // console.log('Could not parse journalDate:', this.journalDate); + } + } } /** @@ -94,8 +158,26 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() if (this.typingTimeout) clearTimeout(this.typingTimeout); - this.typingTimeout = setTimeout(() => { + const { state } = editorView; + const cursorPos = state.selection.from; + + // characters before cursor + const triggerText = state.doc.textBetween(Math.max(0, cursorPos - 4), cursorPos); + + if (triggerText === '/ask' && this.askPromptsEnabled) { + // remove /ask text + const tr = state.tr.delete(cursorPos - 4, cursorPos); + editorView.dispatch(tr); + + // insert predicted question this.insertPredictiveQuestion(); + return; + } + + this.typingTimeout = setTimeout(() => { + if (this.inlinePromptsEnabled) { + this.insertPredictiveQuestion(); + } }, 3500); }; @@ -129,28 +211,38 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() // Only insert if we're at end of node, or there's a newline node after if (!isAtEndOfParent && !hasNewlineAfter) return; - const fontSizeMark = schema.marks.pFontSize.create({ fontSize: '14px' }); + // Save current marks at cursor + const currentMarks = state.storedMarks || resolvedPos.marks(); + this.prePredictiveMarks = [...currentMarks]; + + // color and italics are preset for predictive question, font and size are adaptive const fontColorMark = schema.marks.pFontColor.create({ fontColor: 'lightgray' }); const fontItalicsMark = schema.marks.em.create(); + const fontSizeMark = this.prePredictiveMarks.find(m => m.type.name === 'pFontSize'); + const fontFamilyMark = this.prePredictiveMarks.find(m => m.type.name === 'pFontFamily'); // if applicable - this.predictiveText = ' ...'; // placeholder for now + this.predictiveText = ' ...'; // placeholder const fullTextUpToCursor = state.doc.textBetween(0, state.selection.to, '\n', '\n'); - const gptPrompt = `Given the following incomplete journal entry, generate a single 2-5 word question that continues the user's thought:\n\n"${fullTextUpToCursor}"`; + const gptPrompt = `Given the following incomplete journal entry, generate a single 2-5 word reflective question that continues the user's thought:\n\n"${fullTextUpToCursor}"`; const res = await gptAPICall(gptPrompt, GPTCallType.COMPLETION); if (!res) return; // styled text node const text = ` ... ${res.trim()}`; - const predictedText = schema.text(text, [fontSizeMark, fontColorMark, fontItalicsMark]); + const predictedText = schema.text(text, [fontColorMark, fontItalicsMark, ...(fontSizeMark ? [fontSizeMark] : []), ...(fontFamilyMark ? [fontFamilyMark] : [])]); // Insert styled text at cursor position - const transaction = state.tr.insert(insertPos, predictedText).setStoredMarks([state.schema.marks.pFontColor.create({ fontColor: 'gray' })]); // should probably instead inquire marks before predictive prompt + const transaction = state.tr.insert(insertPos, predictedText).setStoredMarks(this.prePredictiveMarks); dispatch(transaction); this.predictiveText = text; }; + /** + * Method to remove the predictive question upon type/click + * @returns - once predictive text is found, or all text has been checked + */ createPredictiveCleanupPlugin = () => { return new Plugin({ view: () => { @@ -168,15 +260,20 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() if (node.isText && node.text === textToRemove) { const tr = state.tr.delete(pos, pos + node.nodeSize); - // Set the desired default marks for future input + // default marks for input const fontSizeMark = state.schema.marks.pFontSize.create({ fontSize: '14px' }); const fontColorMark = state.schema.marks.pFontColor.create({ fontColor: 'gray' }); tr.setStoredMarks([]); - tr.setStoredMarks([fontSizeMark, fontColorMark]); + if (this.prePredictiveMarks.length > 0) { + tr.setStoredMarks(this.prePredictiveMarks); + } else { + tr.setStoredMarks([fontSizeMark, fontColorMark]); + } dispatch(tr); this.predictiveText = null; + this.prePredictiveMarks = []; return false; } return true; @@ -194,8 +291,9 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() }; componentDidMount(): void { - console.log('componentDidMount() triggered...'); - console.log('Text: ' + RTFCast(this.Document.text)?.Text); + // console.log('componentDidMount() triggered...'); + document.addEventListener('mousedown', this.handleDocumentClick); + // console.log('Text: ' + RTFCast(this.Document.text)?.Text); const editorView = this._ref.current?.EditorView; if (editorView) { @@ -214,15 +312,17 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() const isDefaultTitle = isTitleString && currentTitle.includes('Untitled DailyJournal'); if (isTextEmpty && isDefaultTitle) { - console.log('Journal title and text are default. Initializing...'); + // console.log('Journal title and text are default. Initializing...'); this.setDailyTitle(); this.setDailyText(); + this.setInitialDateRange(); } else { - console.log('Journal already has content. Skipping initialization.'); + // console.log('Journal already has content. Skipping initialization.'); } } componentWillUnmount(): void { + document.removeEventListener('mousedown', this.handleDocumentClick); const editorView = this._ref.current?.EditorView; if (editorView) { editorView.dom.removeEventListener('input', this.onTextInput); @@ -230,10 +330,20 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() if (this.typingTimeout) clearTimeout(this.typingTimeout); } + /** + * Method to generate pormpts via GPT + * @returns - if failed + */ @action handleGeneratePrompts = async () => { + if (this.isLoadingPrompts) { + return; + } + + this.isLoadingPrompts = true; + const rawText = RTFCast(this.Document.text)?.Text ?? ''; - console.log('Extracted Journal Text:', rawText); - console.log('Before Update:', this.Document.text, 'Type:', typeof this.Document.text); + // console.log('Extracted Journal Text:', rawText); + // console.log('Before Update:', this.Document.text, 'Type:', typeof this.Document.text); if (!rawText.trim()) { alert('Journal is empty! Write something first.'); @@ -273,9 +383,15 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() } } catch (err) { console.error('Error calling GPT:', err); + } finally { + this.isLoadingPrompts = false; } }; + /** + * Method to render the styled DailyJournal + * @returns - the HTML component for the journal + */ render() { return ( <div @@ -296,6 +412,7 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() }}> {/* GPT Button */} <button + id="prompts-button" style={{ position: 'absolute', bottom: '5px', @@ -308,9 +425,88 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() cursor: 'pointer', zIndex: 10, }} - onClick={this.handleGeneratePrompts}> + onClick={this.togglePromptMenu}> Prompts </button> + {this.showPromptMenu && ( + <div + id="prompts-menu" + style={{ + position: 'absolute', + bottom: '45px', + right: '5px', + backgroundColor: 'white', + border: '1px solid #ccc', + borderRadius: '4px', + padding: '10px', + boxShadow: '0 2px 6px rgba(0,0,0,0.2)', + zIndex: 20, + minWidth: '170px', + maxWidth: 'fit-content', + overflow: 'auto', + }}> + <div + style={{ + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + marginBottom: '10px', + }}> + <label + style={{ + display: 'flex', + alignItems: 'center', + gap: '6px', + fontSize: '14px', + justifyContent: 'flex-end', + width: '100%', + }}> + /ask + <input type="checkbox" checked={this.askPromptsEnabled} onChange={this.toggleAskPrompts} style={{ margin: 0 }} /> + </label> + </div> + + <div + style={{ + display: 'flex', + justifyContent: 'flex-end', + alignItems: 'center', + marginBottom: '10px', + }}> + <label + style={{ + display: 'flex', + alignItems: 'center', + gap: '6px', + fontSize: '14px', + justifyContent: 'flex-end', + width: '100%', + }}> + Inline Prompting + <input type="checkbox" checked={this.inlinePromptsEnabled} onChange={this.toggleInlinePrompts} style={{ margin: 0 }} /> + </label> + </div> + + <button + onClick={() => { + this.showPromptMenu = false; + this.handleGeneratePrompts(); + }} + disabled={this.isLoadingPrompts} + style={{ + backgroundColor: '#9EAD7C', + color: 'white', + border: 'none', + borderRadius: '4px', + cursor: this.isLoadingPrompts ? 'not-allowed' : 'pointer', + opacity: this.isLoadingPrompts ? 0.6 : 1, + padding: '5px 10px', + float: 'right', + }}> + Generate Prompts + </button> + </div> + )} <FormattedTextBox ref={this._ref} {...this._props} fieldKey={'text'} Document={this.Document} TemplateDataDocument={undefined} /> </div> @@ -318,8 +514,10 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() } } +const ObservedDailyJournal = observer(DailyJournal); + Docs.Prototypes.TemplateMap.set(DocumentType.JOURNAL, { - layout: { view: DailyJournal, dataField: 'text' }, + layout: { view: ObservedDailyJournal, dataField: 'text' }, options: { acl: '', _height: 35, diff --git a/src/client/views/nodes/formattedText/DashFieldView.scss b/src/client/views/nodes/formattedText/DashFieldView.scss index 3734ad9cc..3ef3c2cef 100644 --- a/src/client/views/nodes/formattedText/DashFieldView.scss +++ b/src/client/views/nodes/formattedText/DashFieldView.scss @@ -17,7 +17,7 @@ min-width: 12px; position: relative; display: inline-block; - margin: 0; + margin: 0px; transform: scale(0.7); background-color: rgba(155, 155, 155, 0.24); } @@ -53,7 +53,7 @@ .dashFieldView, .dashFieldView-active { .dashFieldView-select { - height: 10p; + height: 100%; font-size: 12px; background: transparent; opacity: 0; @@ -61,7 +61,7 @@ } } -.dashFieldView { +.dashFieldView-active { &:hover { .dashFieldView-select { opacity: unset; diff --git a/src/client/views/nodes/formattedText/DashFieldView.tsx b/src/client/views/nodes/formattedText/DashFieldView.tsx index 7ea5d1fcf..6e3bdc5e8 100644 --- a/src/client/views/nodes/formattedText/DashFieldView.tsx +++ b/src/client/views/nodes/formattedText/DashFieldView.tsx @@ -262,6 +262,7 @@ export class DashFieldViewInternal extends ObservableReactComponent<IDashFieldVi render() { return ( <div + // eslint-disable-next-line no-use-before-define className={`dashFieldView${this.isRowActive() ? '-active' : ''}`} ref={this._fieldRef} style={{ diff --git a/src/client/views/nodes/formattedText/EquationEditor.scss b/src/client/views/nodes/formattedText/EquationEditor.scss index b0c17e56e..602135a30 100644 --- a/src/client/views/nodes/formattedText/EquationEditor.scss +++ b/src/client/views/nodes/formattedText/EquationEditor.scss @@ -32,7 +32,7 @@ margin-left: -1px; position: relative; z-index: 1; - padding: 0; + padding: 0px; display: -moz-inline-box; display: inline-block; } @@ -128,8 +128,8 @@ .mq-math-mode * { font-size: inherit; line-height: inherit; - margin: 0; - padding: 0; + margin: 0px; + padding: 0px; border-color: black; -webkit-user-select: none; -moz-user-select: none; @@ -178,7 +178,7 @@ margin-left: 0.1em; } .mq-math-mode .mq-roman var.mq-f { - margin: 0; + margin: 0px; } .mq-math-mode big { font-size: 200%; @@ -323,7 +323,7 @@ padding: 0.1em; } .mq-math-mode .mq-sqrt-prefix { - padding-top: 0; + padding-top: 0px; position: relative; top: 0.1em; vertical-align: top; diff --git a/src/client/views/nodes/formattedText/EquationEditor.tsx b/src/client/views/nodes/formattedText/EquationEditor.tsx index 48efa6e63..23d273523 100644 --- a/src/client/views/nodes/formattedText/EquationEditor.tsx +++ b/src/client/views/nodes/formattedText/EquationEditor.tsx @@ -72,6 +72,10 @@ class EquationEditor extends Component<EquationEditorProps> { this.mathField.latex(value || ''); } + componentDidUpdate(prevProps: Readonly<EquationEditorProps>): void { + !prevProps.value && this.mathField.latex(this.props.value || ''); + } + render() { return <span ref={this.element} style={{ border: '0px', boxShadow: 'None' }} />; } diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.scss b/src/client/views/nodes/formattedText/FormattedTextBox.scss index 547a2efa8..d5e566226 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBox.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBox.scss @@ -46,7 +46,7 @@ } audiotag { - left: 0; + left: 0px; position: absolute; cursor: pointer; border-radius: 10px; @@ -62,7 +62,7 @@ audiotag:hover { .formattedTextBox { touch-action: none; background: inherit; - padding: 0; + padding: 0px; border-width: 0px; border-color: global.$medium-gray; box-sizing: border-box; @@ -77,14 +77,14 @@ audiotag:hover { width: 100%; position: relative; transform-origin: left top; - top: 0; - left: 0; + top: 0px; + left: 0px; } .formattedTextBox-cont { touch-action: none; background: inherit; - padding: 0; + padding: 0px; border-width: 0px; border-radius: inherit; border-color: global.$medium-gray; @@ -111,7 +111,7 @@ audiotag:hover { .answer-tooltip { font-size: 15px; padding: 2px; - max-width: 150; + max-width: 150px; line-height: 150%; position: relative; } @@ -122,10 +122,10 @@ audiotag:hover { position: absolute; color: white; background: black; - right: 0; - bottom: 0; - width: 15; - height: 22; + right: 0px; + bottom: 0px; + width: 15px; + height: 22px; cursor: default; } @@ -139,8 +139,8 @@ audiotag:hover { .formattedTextBox-sidebar-handle { position: absolute; - top: 0; - right: 0; + top: 0px; + right: 0px; width: 20px; height: 20px; font-size: 11px; @@ -168,7 +168,7 @@ audiotag:hover { height: 100%; display: inline-block; position: absolute; - right: 0; + right: 0px; overflow: hidden; .collectionfreeformview-container { @@ -302,8 +302,8 @@ footnote::before { position: absolute; top: -0.5em; content: ' '; - height: 0; - width: 0; + height: 0px; + width: 0px; } .formattedTextBox-inlineComment { @@ -346,7 +346,7 @@ footnote::before { .prosemirror-linkBtn { background: unset; color: unset; - padding: 0; + padding: 0px; text-transform: unset; letter-spacing: unset; font-size: unset; @@ -357,7 +357,7 @@ footnote::before { background-color: dimgray; margin-top: 1.5em; z-index: 1; - padding: 5; + padding: 5px; border-radius: 2px; } .prosemirror-hrefoptions { @@ -396,7 +396,7 @@ footnote::before { blockquote { padding: 10px 10px; font-size: smaller; - margin: 0; + margin: 0px; font-style: italic; background: lightgray; border-left: solid 2px dimgray; @@ -415,7 +415,7 @@ footnote::before { p { font-family: inherit; } - margin-left: 0; + margin-left: 0px; } .bullet1 { p { @@ -439,7 +439,7 @@ footnote::before { display: inline-block; font-family: inherit; } - margin-left: 0; + margin-left: 0px; background-color: inherit; } .decimal2-ol { @@ -506,7 +506,7 @@ footnote::before { display: inline-block; font-family: inherit; } - margin-left: 0; + margin-left: 0px; padding-left: 1.2em; background-color: inherit; } @@ -661,7 +661,7 @@ footnote::before { .formattedTextBox-cont { touch-action: none; background: inherit; - padding: 0; + padding: 0px; border-width: 0px; border-radius: inherit; border-color: global.$medium-gray; @@ -706,7 +706,7 @@ footnote::before { height: 100%; display: inline-block; position: absolute; - right: 0; + right: 0px; .collectionfreeformview-container { position: relative; @@ -832,8 +832,8 @@ footnote::before { position: absolute; top: -0.5em; content: ' '; - height: 0; - width: 0; + height: px; + width: 0px; } .formattedTextBox-inlineComment { @@ -892,7 +892,7 @@ footnote::before { display: inline; font-family: inherit; } - margin-left: 0; + margin-left: 0px; } .decimal2-ol { counter-reset: deci2; @@ -952,7 +952,7 @@ footnote::before { display: inline; font-family: inherit; } - margin-left: 0; + margin-left: 0px; padding-left: 1.2em; } .multi2-ol { diff --git a/src/client/views/nodes/formattedText/FormattedTextBox.tsx b/src/client/views/nodes/formattedText/FormattedTextBox.tsx index 57720baae..255ee1afe 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, removeStyleSheet, returnFalse, returnZero, setupMoveUpEvents, simMouseEvent, smoothScroll, StopEvent } from '../../../../ClientUtils'; +import { addStyleSheet, addStyleSheetRule, clearStyleSheetRules, ClientUtils, DivWidth, removeStyleSheet, returnFalse, returnTrue, 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, ForceServerWrite, UpdatingFromServer } from '../../../../fields/DocSymbols'; @@ -98,7 +98,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB public static PasteOnLoad: ClipboardEvent | undefined; public static SelectOnLoadChar = ''; - public static LiveTextUndo: UndoManager.Batch | undefined; // undo batch when typing a new text note into a collection + public static LiveTextUndo: UndoManager.Batch | undefined; // undo batch request when typing a new text note into a collection + private _liveTextUndo: UndoManager.Batch | undefined; // captured undo batch when typing a new text note into a collection private static _nodeViews: (self: FormattedTextBox) => { [key: string]: NodeViewConstructor }; private _curHighlights = new ObservableSet<string>(['Audio Tags']); @@ -270,13 +271,16 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB e.preventDefault(); e.stopPropagation(); const targetCreator = (annotationOn?: Doc) => { - const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn); + const target = DocUtils.GetNewTextDoc('Note linked to ' + this.Document.title, 0, 0, 100, 100, annotationOn, 'yellow'); + target.layout_fitWidth = true; DocumentView.SetSelectOnLoad(target); return target; }; + const sourceAnchorCreator = () => this.getAnchor(true); + const docView = this.DocumentView?.(); - docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, () => this.getAnchor(true), targetCreator), e.pageX, e.pageY); + docView && DragManager.StartAnchorAnnoDrag([ele], new DragManager.AnchorAnnoDragData(docView, sourceAnchorCreator, targetCreator), e.pageX, e.pageY); }); AnchorMenu.Instance.AddDrawingAnnotation = (drawing: Doc) => { @@ -304,6 +308,21 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB } }; + autoTag = () => { + const rawText = RTFCast(this.Document[this.fieldKey])?.Text ?? StrCast(this.Document[this.fieldKey]); + if (rawText && !this.Document.$tags_chat) { + const callType = rawText.includes('[placeholder]') ? GPTCallType.CLASSIFYTEXTMINIMAL : GPTCallType.CLASSIFYTEXTFULL; + + gptAPICall(rawText, callType).then( + action(desc => { + // Split GPT response into tokens and push individually & clear existing tags + this.Document.$tags_chat = new List<string>(desc.trim().split(/\s+/)); + this.Document._layout_showTags = true; + }) + ); + } + }; + leafText = (node: Node) => { if (node.type === this.EditorView?.state.schema.nodes.dashField) { const refDoc = !node.attrs.docId ? this.rootDoc : (DocServer.GetCachedRefField(node.attrs.docId as string) as Doc); @@ -365,6 +384,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB dataDoc[this.fieldKey] = numstring !== undefined ? Number(newText) : newText || (DocCast(dataDoc.proto)?.[this.fieldKey] === undefined && this.layoutDoc[this.fieldKey] === undefined) ? new RichTextField(newJson, newText) : undefined; textChange && ScriptCast(this.layoutDoc.onTextChanged, null)?.script.run({ this: this.Document, text: newText }); + if (textChange) this.dataDoc.$tags_chat = undefined; this.ApplyingChange = ''; // turning this off here allows a Doc to retrieve data from template if noTemplate below is changed to false unchanged = false; } @@ -425,10 +445,10 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB const newAutoLinks = new Set<Doc>(); const oldAutoLinks = Doc.Links(this.Document).filter( link => - ((!Doc.isTemplateForField(this.Document) && - ((DocCast(link.link_anchor_1) && !Doc.isTemplateForField(DocCast(link.link_anchor_1)!)) || !Doc.AreProtosEqual(DocCast(link.link_anchor_1), this.Document)) && - ((DocCast(link.link_anchor_2) && !Doc.isTemplateForField(DocCast(link.link_anchor_2)!)) || !Doc.AreProtosEqual(DocCast(link.link_anchor_2), this.Document))) || - (Doc.isTemplateForField(this.Document) && (link.link_anchor_1 === this.Document || link.link_anchor_2 === this.Document))) && + ((!Doc.IsTemplateForField(this.Document) && + ((DocCast(link.link_anchor_1) && !Doc.IsTemplateForField(DocCast(link.link_anchor_1)!)) || !Doc.AreProtosEqual(DocCast(link.link_anchor_1), this.Document)) && + ((DocCast(link.link_anchor_2) && !Doc.IsTemplateForField(DocCast(link.link_anchor_2)!)) || !Doc.AreProtosEqual(DocCast(link.link_anchor_2), this.Document))) || + (Doc.IsTemplateForField(this.Document) && (link.link_anchor_1 === this.Document || link.link_anchor_2 === this.Document))) && link.link_relationship === LinkManager.AutoKeywords ); // prettier-ignore if (this.EditorView?.state.doc.textContent) { @@ -1068,17 +1088,12 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB return anchorDoc ?? this.Document; } + showBorderRounding = returnTrue; getView = (doc: Doc, options: FocusViewOptions) => { 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; - } - setTimeout(() => this._sidebarRef?.current?.makeDocUnfiltered(doc)); + return SidebarAnnos.getView(this._sidebarRef.current, this.SidebarShown, () => this.toggleSidebar(false), doc, options); } - return new Promise<Opt<DocumentView>>(res => { - DocumentView.addViewRenderedCb(doc, dv => res(dv)); - }); + return new Promise<Opt<DocumentView>>(res => DocumentView.addViewRenderedCb(doc, res)); }; focus = (textAnchor: Doc, options: FocusViewOptions) => { const focusSpeed = options.zoomTime ?? 500; @@ -1145,13 +1160,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB return undefined; }; - // if the scroll height has changed and we're in layout_autoHeight mode, then we need to update the textHeight component of the doc. - // Since we also monitor all component height changes, this will update the document's height. - resetNativeHeight = action((scrollHeight: number) => { - this.layoutDoc['_' + this.fieldKey + '_height'] = scrollHeight; - if (!this.layoutDoc.isTemplateForField && NumCast(this.layoutDoc._nativeHeight)) this.layoutDoc._nativeHeight = scrollHeight; - }); - addPlugin = (plugin: Plugin) => { const editorView = this.EditorView; if (editorView) { @@ -1182,21 +1190,31 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB this._disposers.width = reaction(this._props.PanelWidth, this.tryUpdateScrollHeight); this._disposers.scrollHeight = reaction( () => ({ scrollHeight: this.scrollHeight, layoutAutoHeight: this.layout_autoHeight, width: NumCast(this.layoutDoc._width) }), - ({ width, scrollHeight, layoutAutoHeight }) => width && layoutAutoHeight && this.resetNativeHeight(scrollHeight), + ({ width, scrollHeight, layoutAutoHeight }) => width && layoutAutoHeight && (this.layoutDoc['_' + this.fieldKey + '_height'] = scrollHeight), { fireImmediately: true } ); this._disposers.componentHeights = reaction( // set the document height when one of the component heights changes and layout_autoHeight is on - () => ({ border: this._props.PanelHeight(), sidebarHeight: this.sidebarHeight, textHeight: this.textHeight, layoutAutoHeight: this.layout_autoHeight, marginsHeight: this.layout_autoHeightMargins }), - ({ border, sidebarHeight, textHeight, layoutAutoHeight, marginsHeight }) => { + () => ({ + border: this._props.PanelHeight(), + scrollHeight: NumCast(this.layoutDoc['_' + this.fieldKey + '_height']), + sidebarHeight: this.sidebarHeight, + textHeight: this.textHeight, + layoutAutoHeight: this.layout_autoHeight, + marginsHeight: this.layout_autoHeightMargins, + }), + ({ border, sidebarHeight, scrollHeight, textHeight, layoutAutoHeight, marginsHeight }) => { const newHeight = this.contentScaling * (marginsHeight + Math.max(sidebarHeight, textHeight)); if ( (!Array.from(this._curHighlights).includes('Bold Text') || this._props.isSelected()) && // layoutAutoHeight && newHeight && - (newHeight !== this.layoutDoc.height || border < NumCast(this.layoutDoc.height)) && + (newHeight !== this.layoutDoc.height || border < NumCast(this.layoutDoc.height) || this.layoutDoc._nativeHeight !== scrollHeight) && !this._props.dontRegisterView ) { + if (NumCast(this.layoutDoc.nativeHeight)) { + this.layoutDoc._nativeHeight = scrollHeight; + } this._props.setHeight?.(newHeight); } }, @@ -1219,7 +1237,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB const protoTime = protoData && this.dataDoc[this.fieldKey + '_autoUpdate'] ? (DateCast(DocCast(this.dataDoc.proto)?.[this.fieldKey + '_modificationDate'])?.date.getTime() ?? 0) : 0; const recentData = dataTime >= layoutTime ? (protoTime >= dataTime ? protoData : dataData) : layoutTime >= protoTime ? layoutData : protoData; const whichData = recentData ?? (this.layoutDoc.isTemplateDoc ? layoutData : protoData) ?? protoData; - return !whichData ? undefined : { data: RTFCast(whichData), str: Field.toString(DocCast(whichData) ?? StrCast(whichData)) }; + return !whichData ? undefined : { data: RTFCast(whichData), str: Field.toString(DocCast(whichData) ?? NumCast(whichData)?.toString() ?? StrCast(whichData)) }; }, incomingValue => { if (this.EditorView && this.ApplyingChange !== this.fieldKey) { @@ -1525,7 +1543,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB } if (this.EditorView && selectOnLoad && !this._props.dontRegisterView && !this._props.dontSelectOnLoad && this.isActiveTab(this.ProseRef)) { const { $from } = this.EditorView.state.selection; - const mark = schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) }); + const mark = schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail() }); const curMarks = this.EditorView.state.storedMarks ?? $from?.marksAcross(this.EditorView.state.selection.$head) ?? []; const storedMarks = [...curMarks.filter(m => m.type !== mark.type), mark]; if (selLoadChar === 'Enter') { @@ -1536,6 +1554,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB this.tryUpdateDoc(true); // calling select() above will make isContentActive() true only after a render .. which means the selectAll() above won't write to the Document and the incomingValue will overwrite the selection with the non-updated data } if (selectOnLoad) { + this._liveTextUndo = FormattedTextBox.LiveTextUndo; + FormattedTextBox.LiveTextUndo = undefined; this.EditorView!.focus(); } if (this._props.isContentActive()) this.prepareForTyping(); @@ -1552,7 +1572,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB const { text, paragraph } = schema.nodes; const selNode = this.EditorView.state.selection.$anchor.node(); if (this.EditorView.state.selection.from === 1 && this.EditorView.state.selection.empty && [undefined, text, paragraph].includes(selNode?.type)) { - const docDefaultMarks = [schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) })]; + const docDefaultMarks = [schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail() })]; this.EditorView.state.selection.empty && this.EditorView.state.selection.from === 1 && this.EditorView?.dispatch(this.EditorView?.state.tr.setStoredMarks(docDefaultMarks).removeStoredMark(schema.marks.pFontColor)); } } @@ -1565,8 +1585,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB removeStyleSheet(this._userStyleSheetElement); Object.values(this._disposers).forEach(disposer => disposer?.()); this.endUndoTypingBatch(); - FormattedTextBox.LiveTextUndo?.end(); - FormattedTextBox.LiveTextUndo = undefined; + this._liveTextUndo?.end(); this.unhighlightSearchTerms(); this.EditorView?.destroy(); RichTextMenu.Instance?.TextView === this && RichTextMenu.Instance.updateMenu(undefined, undefined, undefined, undefined); @@ -1688,7 +1707,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB if (e.clientX > boundsRect.left && e.clientX < boundsRect.right && e.clientY > boundsRect.bottom) { // if we clicked below the last prosemirror div, then set the selection to be the end of the document editorView.focus(); - editorView.dispatch(editorView.state.tr.setSelection(TextSelection.create(editorView.state.doc, editorView.state.doc.content.size))); + // editorView.dispatch(editorView.state.tr.setSelection(TextSelection.create(editorView.state.doc, editorView.state.doc.content.size))); } } else if (node && [editorView.state.schema.nodes.ordered_list, editorView.state.schema.nodes.listItem].includes(node.type) && node !== (editorView.state.selection as NodeSelection)?.node && pcords) { editorView.dispatch(editorView.state.tr.setSelection(NodeSelection.create(editorView.state.doc, pcords.pos))); @@ -1770,7 +1789,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB if (this.ProseRef?.children[0] !== e.nativeEvent.target) return; if (!(this.EditorView?.state.selection instanceof NodeSelection) || this.EditorView.state.selection.node.type !== this.EditorView.state.schema.nodes.footnote) { const stordMarks = this.EditorView?.state.storedMarks?.slice(); - if (!(this.EditorView?.state.selection instanceof NodeSelection)) { + if (!(this.EditorView?.state.selection instanceof NodeSelection) && typeof this.dataDoc[this.fieldKey] !== 'number') { this.autoLink(); if (this.EditorView?.state.tr) { const tr = stordMarks?.reduce((tr2, m) => { @@ -1795,8 +1814,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB this.endUndoTypingBatch(); - FormattedTextBox.LiveTextUndo?.end(); - FormattedTextBox.LiveTextUndo = undefined; + this._liveTextUndo?.end(); // if the text box blurs and none of its contents are focused(), then pass the blur along setTimeout(() => !this.ProseRef?.contains(document.activeElement) && this._props.onBlur?.()); @@ -1817,7 +1835,6 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB return; } if (this._enteringStyle && 'tix!'.includes(e.key)) { - const tag = e.key === 't' ? 'todo' : e.key === 'i' ? 'ignore' : e.key === 'x' ? 'disagree' : e.key === '!' ? 'important' : '??'; const node = state.selection.$from.nodeAfter; const start = state.selection.from; const end = state.selection.to; @@ -1826,9 +1843,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB StopEvent(e); _editorView.dispatch( state.tr - .removeMark(start, end, schema.marks.user_mark) - .addMark(start, end, schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified: Math.floor(Date.now() / 1000) })) - .addMark(start, end, schema.marks.user_tag.create({ userid: ClientUtils.CurrentUserEmail(), tag, modified: Math.round(Date.now() / 1000 / 60) })) + .removeMark(start, end, schema.marks.user_mark) // + .addMark(start, end, schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail() })) ); return; } @@ -1861,9 +1877,12 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB break; default: if ([AclEdit, AclAugment, AclAdmin].includes(GetEffectiveAcl(this.Document))) { - const modified = Math.floor(Date.now() / 1000); - const mark = state.selection.$to.marks().find(m => m.type === schema.marks.user_mark && m.attrs.modified === modified); - _editorView.dispatch(state.tr.removeStoredMark(schema.marks.user_mark).addStoredMark(mark ?? schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail(), modified }))); + const mark = state.selection.$to.marks().find(m => m.type === schema.marks.user_mark); + _editorView.dispatch( + state.tr + .removeStoredMark(schema.marks.user_mark) // + .addStoredMark(mark ?? schema.marks.user_mark.create({ userid: ClientUtils.CurrentUserEmail() })) + ); } break; } @@ -1887,14 +1906,14 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB const margins = 2 * NumCast(this.layoutDoc._yMargin, this._props.yMargin || 0); const children = this.ProseRef?.children.length ? Array.from(this.ProseRef.children[0].children) : undefined; if (this.EditorView && children && !SnappingManager.IsDragging) { - const getChildrenHeights = (kids: Element[] | undefined) => kids?.reduce((p, child) => p + toHgt(child), margins) ?? 0; + const getChildrenHeights = (kids: Element[] | undefined) => kids?.reduce((p, child) => p + toHgt(child), 0) ?? 0; const toNum = (val: string) => Number(val.replace('px', '')); const toHgt = (node: Element): number => { const { height, marginTop, marginBottom } = getComputedStyle(node); const childHeight = height === 'auto' ? getChildrenHeights(Array.from(node.children)) : toNum(height); return childHeight + Math.max(0, toNum(marginTop)) + Math.max(0, toNum(marginBottom)); }; - const proseHeight = !this.ProseRef ? 0 : getChildrenHeights(children); + const proseHeight = !this.ProseRef ? 0 : getChildrenHeights(children) + margins; const scrollHeight = this.ProseRef && proseHeight; if (this._props.setHeight && !this._props.suppressSetHeight && scrollHeight && !this._props.dontRegisterView) { // if top === 0, then the text box is growing upward (as the overlay caption) which doesn't contribute to the height computation @@ -2110,6 +2129,8 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB } }; + setRef = (r: HTMLDivElement | null) => this.fixWheelEvents(r, this._props.isContentActive, this.onPassiveWheel); + setScrollRef = (r: HTMLDivElement | null) => (this._scrollRef = r); render() { TraceMobx(); const scale = this.nativeScaling(); @@ -2124,7 +2145,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB ) : styleFromLayout?.height === '0px' ? null : ( <div className="formattedTextBox" - ref={r => this.fixWheelEvents(r, this._props.isContentActive, this.onPassiveWheel)} + ref={this.setRef} style={{ ...(this._props.dontScale ? {} @@ -2162,9 +2183,7 @@ export class FormattedTextBox extends ViewBoxAnnotatableComponent<FormattedTextB onDoubleClick={this.onDoubleClick}> <div className="formattedTextBox-outer" - ref={r => { - this._scrollRef = r; - }} + ref={this.setScrollRef} style={{ width: this.noSidebar ? '100%' : `calc(100% - ${this.sidebarWidthPercent})`, overflow: this.layoutDoc._createDocOnCR || this.layoutDoc._layout_hideScroll ? 'hidden' : this.layout_autoHeight ? 'visible' : undefined, diff --git a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss index bc0810f22..92f3e3290 100644 --- a/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss +++ b/src/client/views/nodes/formattedText/FormattedTextBoxComment.scss @@ -11,8 +11,8 @@ -webkit-transform: translateX(-50%); transform: translateX(-50%); box-shadow: 3px 3px 1.5px grey; - max-width: 400; - max-height: 235; + max-width: 400px; + max-height: 235px; height: max-content; .formattedTextBox-tooltipText { height: max-content; @@ -22,26 +22,26 @@ .formattedTextBox-tooltip:before { content: ''; - height: 0; - width: 0; + height: 0px; + width: 0px; position: absolute; left: 50%; margin-left: -5px; bottom: -6px; border: 5px solid transparent; - border-bottom-width: 0; + border-bottom-width: 0px; border-top-color: silver; } .formattedTextBox-tooltip:after { content: ''; - height: 0; - width: 0; + height: 0px; + width: 0px; position: absolute; left: 50%; margin-left: -5px; bottom: -4.5px; border: 5px solid transparent; - border-bottom-width: 0; + border-bottom-width: 0px; border-top-color: white; } diff --git a/src/client/views/nodes/formattedText/RichTextMenu.scss b/src/client/views/nodes/formattedText/RichTextMenu.scss index fcc816447..7c747de1e 100644 --- a/src/client/views/nodes/formattedText/RichTextMenu.scss +++ b/src/client/views/nodes/formattedText/RichTextMenu.scss @@ -23,7 +23,7 @@ .dropdown { position: absolute; top: 35px; - left: 0; + left: 0px; background-color: #323232; color: global.$light-gray; border: 1px solid #4d4d4d; @@ -47,7 +47,7 @@ } &:last-child { - margin-bottom: 0; + margin-bottom: 0px; } } } diff --git a/src/client/views/nodes/formattedText/TooltipTextMenu.scss b/src/client/views/nodes/formattedText/TooltipTextMenu.scss index 87320943d..8980a93a2 100644 --- a/src/client/views/nodes/formattedText/TooltipTextMenu.scss +++ b/src/client/views/nodes/formattedText/TooltipTextMenu.scss @@ -195,7 +195,7 @@ left: 1px; width: 24px; height: 4px; - margin-top: 0; + margin-top: 0px; } } @@ -221,7 +221,7 @@ display: inline-block; width: 1em; height: 1em; - stroke-width: 0; + stroke-width: 0px; stroke: currentColor; fill: currentColor; margin-right: 15px; @@ -231,7 +231,7 @@ display: inline-block; width: 1em; height: 1em; - stroke-width: 3; + stroke-width: 3px; fill: greenyellow; margin-right: 15px; } @@ -270,7 +270,7 @@ &.ProseMirror-menu-dropdown { width: 10px; height: 25px; - margin: 0; + margin: 0px; padding: 0 2px; background-color: #323232; text-align: center; diff --git a/src/client/views/nodes/formattedText/marks_rts.ts b/src/client/views/nodes/formattedText/marks_rts.ts index b7dae1ca3..333ee6be8 100644 --- a/src/client/views/nodes/formattedText/marks_rts.ts +++ b/src/client/views/nodes/formattedText/marks_rts.ts @@ -330,20 +330,6 @@ export const marks: { [index: string]: MarkSpec } = { return ['span', { class: 'UM-' + uid + remote + ' UM-min-' + min + ' UM-hr-' + hr + ' UM-day-' + day }, 0]; }, }, - // the id of the user who entered the text - user_tag: { - attrs: { - userid: { default: '' }, - modified: { default: 'when?' }, // 1 second intervals since 1970 - tag: { default: '' }, - }, - group: 'inline', - inclusive: false, - toDOM: node => { - const uid = node.attrs.userid.replace('.', '').replace('@', ''); - return ['span', { class: 'UT-' + uid + ' UT-' + node.attrs.tag }, 0]; - }, - }, // :: MarkSpec Code font mark. Represented as a `<code>` element. code: { diff --git a/src/client/views/nodes/imageEditor/ImageEditor.scss b/src/client/views/nodes/imageEditor/ImageEditor.scss index c691e6a18..942a7d4c6 100644 --- a/src/client/views/nodes/imageEditor/ImageEditor.scss +++ b/src/client/views/nodes/imageEditor/ImageEditor.scss @@ -4,8 +4,8 @@ $scale: 0.5; .imageEditorContainer { position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; z-index: 9999; height: 100vh; width: 100vw; @@ -78,7 +78,7 @@ $scale: 0.5; .sideControlsContainer { width: 160px; position: absolute; - left: 0; + left: 0px; height: 100%; .sideControls { @@ -129,8 +129,8 @@ $scale: 0.5; .originalImageLabel { position: absolute; - bottom: 10; - left: 10; + bottom: 10px; + left: 10px; color: #ffffff; font-size: 0.8rem; letter-spacing: 1px; diff --git a/src/client/views/nodes/scrapbook/AIPresetGenerator.ts b/src/client/views/nodes/scrapbook/AIPresetGenerator.ts new file mode 100644 index 000000000..1f159222b --- /dev/null +++ b/src/client/views/nodes/scrapbook/AIPresetGenerator.ts @@ -0,0 +1,31 @@ +import { ScrapbookItemConfig } from './ScrapbookPreset'; +import { GPTCallType, gptAPICall } from '../../../apis/gpt/GPT'; + +// Represents the descriptor for each document +export interface DocumentDescriptor { + type: string; + tags: string[]; +} + +// Main function to request AI-generated presets +export async function requestAiGeneratedPreset(descriptors: DocumentDescriptor[]): Promise<ScrapbookItemConfig[]> { + const prompt = createPrompt(descriptors); + let aiResponse = await gptAPICall(prompt, GPTCallType.GENERATESCRAPBOOK); + // Strip out ```json and ``` if the model wrapped its answer in fences + aiResponse = aiResponse + .trim() + .replace(/^```(?:json)?\s*/, "") // remove leading ``` or ```json + .replace(/\s*```$/, ""); // remove trailing ``` + const parsedPreset = JSON.parse(aiResponse) as ScrapbookItemConfig[]; + return parsedPreset; +} + +// Helper to generate prompt text for AI +function createPrompt(descriptors: DocumentDescriptor[]): string { + let prompt = ""; + descriptors.forEach((desc, index) => { + prompt += `${index + 1}. Type: ${desc.type}, Tags: ${desc.tags.join(', ')}\n`; + }); + + return prompt; +} diff --git a/src/client/views/nodes/scrapbook/EmbeddedDocView.tsx b/src/client/views/nodes/scrapbook/EmbeddedDocView.tsx deleted file mode 100644 index e99bf67c7..000000000 --- a/src/client/views/nodes/scrapbook/EmbeddedDocView.tsx +++ /dev/null @@ -1,52 +0,0 @@ -//IGNORE FOR NOW, CURRENTLY NOT USED IN SCRAPBOOK IMPLEMENTATION -import * as React from "react"; -import { observer } from "mobx-react"; -import { Doc } from "../../../../fields/Doc"; -import { DocumentView } from "../DocumentView"; -import { Transform } from "../../../util/Transform"; - -interface EmbeddedDocViewProps { - doc: Doc; - width?: number; - height?: number; - slotId?: string; -} - -@observer -export class EmbeddedDocView extends React.Component<EmbeddedDocViewProps> { - render() { - const { doc, width = 300, height = 200, slotId } = this.props; - - // Use either an existing embedding or create one - let docToDisplay = doc; - - // If we need an embedding, create or use one - if (!docToDisplay.isEmbedding) { - docToDisplay = Doc.BestEmbedding(doc) || Doc.MakeEmbedding(doc); - // Set the container to the slot's ID so we can track it - if (slotId) { - docToDisplay.embedContainer = `scrapbook-slot-${slotId}`; - } - } - - return ( - <DocumentView - Document={docToDisplay} - renderDepth={0} - // Required sizing functions - NativeWidth={() => width} - NativeHeight={() => height} - PanelWidth={() => width} - PanelHeight={() => height} - // Required state functions - isContentActive={() => true} - childFilters={() => []} - ScreenToLocalTransform={() => new Transform()} - // Display options - hideDeleteButton={true} - hideDecorations={true} - hideResizeHandles={true} - /> - ); - } -}
\ No newline at end of file diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.scss b/src/client/views/nodes/scrapbook/ScrapbookBox.scss new file mode 100644 index 000000000..6ac2220f9 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookBox.scss @@ -0,0 +1,66 @@ +.scrapbook-box { + /* Make sure the container fills its parent, and set a base background */ + position: relative; /* so that absolute children (loading overlay, etc.) are positioned relative to this */ + width: 100%; + height: 100%; + background: beige; + overflow: hidden; /* prevent scrollbars if children overflow */ +} + +/* Loading overlay that covers the entire scrapbook while AI-generation is in progress */ +.scrapbook-box-loading-overlay { + position: absolute; + top: 0; + left: 0; + width: 100%; + height: 100%; + display: flex; + justify-content: center; + align-items: center; + background: rgba(255, 255, 255, 0.8); + z-index: 10; /* sits above the ImageBox and other content */ +} + +/* The <select> dropdown for choosing presets */ +.scrapbook-box-preset-select { + position: relative; + top: 8px; + left: 8px; + z-index: 20; + padding: 4px 8px; + font-size: 14px; + border: 1px solid #ccc; + border-radius: 4px; + background: white; +} + +/* Container for the “Regenerate Background” button */ +.scrapbook-box-ui { + position: relative; + top: 8px; + right: 8px; + z-index: 20; + background: white; + width: 40px; + display: flex; + justify-content: center; +} + +/* The button itself */ +.scrapbook-box-ui-button { + display: flex; + align-items: center; + gap: 6px; + padding: 4px 8px; + font-size: 14px; + color: black; + background: white; + border: 1px solid #ccc; + border-radius: 4px; + cursor: pointer; + white-space: nowrap; +} + +.scrapbook-box-ui-button:hover { + background: #f5f5f5; +} diff --git a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx index 6cfe9a62c..d0ae6194f 100644 --- a/src/client/views/nodes/scrapbook/ScrapbookBox.tsx +++ b/src/client/views/nodes/scrapbook/ScrapbookBox.tsx @@ -1,130 +1,260 @@ -import { action, makeObservable, observable } from 'mobx'; +import { IconButton, Size } from '@dash/components'; +import { action, computed, IReactionDisposer, makeObservable, observable, reaction } from 'mobx'; +import { observer } from 'mobx-react'; import * as React from 'react'; -import { Doc, DocListCast } from '../../../../fields/Doc'; +import ReactLoading from 'react-loading'; +import { Doc, DocListCast, Opt, StrListCast } from '../../../../fields/Doc'; import { List } from '../../../../fields/List'; +import { DateCast, DocCast, NumCast, toList } from '../../../../fields/Types'; import { emptyFunction } from '../../../../Utils'; import { Docs } from '../../../documents/Documents'; import { DocumentType } from '../../../documents/DocumentTypes'; +import { DragManager } from '../../../util/DragManager'; +import { SnappingManager } from '../../../util/SnappingManager'; +import { undoable } from '../../../util/UndoManager'; import { CollectionView } from '../../collections/CollectionView'; import { ViewBoxAnnotatableComponent } from '../../DocComponent'; +import { AspectRatioLimits, FireflyImageDimensions } from '../../smartdraw/FireflyConstants'; +import { SmartDrawHandler } from '../../smartdraw/SmartDrawHandler'; import { DocumentView } from '../DocumentView'; import { FieldView, FieldViewProps } from '../FieldView'; -import { DragManager } from '../../../util/DragManager'; -import { RTFCast, StrCast, toList } from '../../../../fields/Types'; -import { undoable } from '../../../util/UndoManager'; -// Scrapbook view: a container that lays out its child items in a grid/template -export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { - @observable createdDate: string; +import { ImageBox } from '../ImageBox'; +import './ScrapbookBox.scss'; +import { ScrapbookItemConfig } from './ScrapbookPreset'; +import { createPreset, getPresetNames } from './ScrapbookPresetRegistry'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { DocUtils } from '../../../documents/DocUtils'; +import { returnTrue } from '../../../../ClientUtils'; - constructor(props: FieldViewProps) { - super(props); - makeObservable(this); - this.createdDate = this.getFormattedDate(); +function createPlaceholder(cfg: ScrapbookItemConfig, doc: Doc) { + const placeholder = new Doc(); + placeholder.proto = doc; + placeholder.original = doc; + placeholder.x = cfg.x; + placeholder.y = cfg.y; + if (cfg.width !== null) placeholder._width = cfg.width; + if (cfg.height !== null) placeholder._height = cfg.height; + return placeholder; +} - // ensure we always have a List<Doc> in dataDoc['items'] - if (!this.dataDoc[this.fieldKey]) { - this.dataDoc[this.fieldKey] = new List<Doc>(); +function createMessagePlaceholder(cfg: ScrapbookItemConfig) { + return createPlaceholder(cfg, + Docs.Create.TextDocument(cfg.message ?? ('[placeholder] ' + cfg.acceptTags?.[0]), { placeholder: "", placeholder_docType: cfg.type, placeholder_acceptTags: new List<string>(cfg.acceptTags) }) + ); // prettier-ignore +} +export function buildPlaceholdersFromConfigs(configs: ScrapbookItemConfig[]) { + return configs.map(cfg => { + if (cfg.children?.length) { + const childDocs = cfg.children.map(createMessagePlaceholder); + const protoW = cfg.containerWidth ?? cfg.width; + const protoH = cfg.containerHeight ?? cfg.height; + // Create a stacking document with the child placeholders + const containerProto = Docs.Create.StackingDocument(childDocs, { + ...(protoW !== null ? { _width: protoW } : {}), + ...(protoH !== null ? { _height: protoH } : {}), + title: cfg.message, + }); + return createPlaceholder(cfg, containerProto); } - this.createdDate = this.getFormattedDate(); - this.setTitle(); + return createMessagePlaceholder(cfg); + }); +} +export async function slotRealDocIntoPlaceholders(realDoc: Doc, placeholders: Doc[]) { + if (!realDoc.$tags_chart) { + await DocumentView.getFirstDocumentView(realDoc)?.ComponentView?.autoTag?.(); } + const realTags = new Set<string>(StrListCast(realDoc.$tags_chat).map(t => t.toLowerCase?.() ?? '')); + // Find placeholder with most matching tags + let bestMatch: Doc | null = null; + let maxMatches = 0; + + // match fields based on type, or by analyzing content .. simple example of matching text in placeholder to dropped doc's type + placeholders + .filter(ph => ph.placeholder_docType === realDoc.$type) // Skip this placeholder entirely if types do not match. + .forEach(ph => { + const matches = StrListCast(ph.placeholder_acceptTags) + .map(t => t.toLowerCase?.()) + .filter(tag => realTags.has(tag)); + + if (matches.length > maxMatches) { + maxMatches = matches.length; + bestMatch = ph; + } + }); + + if (bestMatch && maxMatches > 0) { + setTimeout(undoable(() => (bestMatch!.proto = realDoc), 'Scrapbook add')); + return true; + } + + return false; +} + +// Scrapbook view: a container that lays out its child items in a template +@observer +export class ScrapbookBox extends ViewBoxAnnotatableComponent<FieldViewProps>() { public static LayoutString(fieldStr: string) { return FieldView.LayoutString(ScrapbookBox, fieldStr); } + private _disposers: { [name: string]: IReactionDisposer } = {}; + private _imageBoxRef = React.createRef<ImageBox>(); + + constructor(props: FieldViewProps) { + super(props); + makeObservable(this); + } + + @observable _selectedPreset = getPresetNames()[0]; + @observable _loading = false; - getFormattedDate(): string { - return new Date().toLocaleDateString(undefined, { + @computed get createdDate() { + return DateCast(this.dataDoc.$author_date)?.date.toLocaleDateString(undefined, { year: 'numeric', month: 'short', day: 'numeric', }); } + @computed get ScrapbookLayoutDocs() { return DocListCast(this.dataDoc[this.fieldKey]); } // prettier-ignore + @computed get BackgroundDoc() { return DocCast(this.dataDoc[this.fieldKey + '_background']); } // prettier-ignore + set ScrapbookLayoutDocs(doc: Doc[]) { this.dataDoc[this.fieldKey] = new List(doc); } // prettier-ignore + set BackgroundDoc(doc: Opt<Doc>) { this.dataDoc[this.fieldKey + '_background'] = doc; } // prettier-ignore @action - setTitle() { - const title = `Scrapbook - ${this.createdDate}`; - if (this.dataDoc.title !== title) { - this.dataDoc.title = title; - - const image = Docs.Create.TextDocument('image'); - image.accepts_docType = DocumentType.IMG; - const placeholder = new Doc(); - placeholder.proto = image; - placeholder.original = image; - placeholder._width = 250; - placeholder._height = 200; - placeholder.x = 0; - placeholder.y = -100; - //placeholder.overrideFields = new List<string>(['x', 'y']); // shouldn't need to do this for layout fields since the placeholder already overrides its protos - - const summary = Docs.Create.TextDocument('summary'); - summary.accepts_docType = DocumentType.RTF; - summary.accepts_textType = 'one line'; - const placeholder2 = new Doc(); - placeholder2.proto = summary; - placeholder2.original = summary; - placeholder2.x = 0; - placeholder2.y = 200; - placeholder2._width = 250; - //placeholder2.overrideFields = new List<string>(['x', 'y', '_width']); // shouldn't need to do this for layout fields since the placeholder already overrides its protos - this.dataDoc[this.fieldKey] = new List<Doc>([placeholder, placeholder2]); - } - } + setDefaultPlaceholder = () => { + this.ScrapbookLayoutDocs = [ + createMessagePlaceholder({ + message: 'To create a scrapbook from existing documents, marquee select. For existing scrapbook arrangements, select a preset from the dropdown.', + type: DocumentType.RTF, + width: 250, + height: 200, + x: 0, + y: 0, + }), + ]; + + const placeholder1 = createMessagePlaceholder({ acceptTags: ['PERSON'], type: DocumentType.IMG, width: 250, height: 200, x: 0, y: -100 }); + const placeholder2 = createMessagePlaceholder({ acceptTags: ['lengthy description'], type: DocumentType.RTF, width: 250, height: undefined, x: 0, y: 200 }); + const placeholder3 = createMessagePlaceholder({ acceptTags: ['title'], type: DocumentType.RTF, width: 50, height: 200, x: 280, y: -50 }); + const placeholder4 = createPlaceholder( { width: 100, height: 200, x: -200, y: -100 }, Docs.Create.StackingDocument([ + createMessagePlaceholder({ acceptTags: ['LANDSCAPE'], type: DocumentType.IMG, width: 50, height: 100, x: 0, y: -100 }) + ], { _width: 300, _height: 300, title: 'internal coll' })); // prettier-ignore + console.log('UNUSED', placeholder4, placeholder3, placeholder2, placeholder1); + /* note-to-self + would doing: + const collection = Docs.Create.ScrapbookDocument([placeholder, placeholder2, placeholder3]); + create issues with references to the same object? */ + + /*note-to-self + Should we consider that there are more collections than just COL type collections? + when spreading */ + + /*note-to-self + difference between passing a new List<Doc> versus just the raw array? */ + }; + + selectPreset = action((presetName: string) => (this.ScrapbookLayoutDocs = buildPlaceholdersFromConfigs(createPreset(presetName)))); componentDidMount() { - this.setTitle(); + const title = `Scrapbook - ${this.createdDate}`; + if (!this.ScrapbookLayoutDocs.length) this.setDefaultPlaceholder(); + if (!this.BackgroundDoc) this.generateAiImage(this.regenPrompt); + if (this.dataDoc.title !== title) this.dataDoc.title = title; // ensure title is set + + this._disposers.propagateResize = reaction( + () => ({ w: this.layoutDoc._width, h: this.layoutDoc._height }), + (dims, prev) => { + const imageBox = this._imageBoxRef.current; + // prev is undefined on the first run + if (prev && SnappingManager.ShiftKey && this.BackgroundDoc && imageBox) { + this.BackgroundDoc[imageBox.fieldKey + '_outpaintOriginalWidth'] = prev.w; + this.BackgroundDoc[imageBox.fieldKey + '_outpaintOriginalHeight'] = prev.h; + imageBox.layoutDoc._width = dims.w; + imageBox.layoutDoc._height = dims.h; + } + } + ); } - childRejectDrop = (de: DragManager.DropEvent, subView?: DocumentView) => { - return true; // disable dropping documents onto any child of the scrapbook. - }; - rejectDrop = (de: DragManager.DropEvent, subView?: DocumentView) => { - // Test to see if the dropped doc is dropped on an acceptable location (anywerhe? on a specific box). - // const draggedDocs = de.complete.docDragData?.draggedDocuments; - return false; // allow all Docs to be dropped onto scrapbook -- let filterAddDocument make the final decision. + isOutpaintable = returnTrue; + showBorderRounding = returnTrue; + + @action + generateAiImage = (prompt: string) => { + this._loading = true; + + const ratio = NumCast(this.layoutDoc._width, 1) / NumCast(this.layoutDoc._height, 1); // Measure the scrapbook’s current aspect + const choosePresetForDimensions = (() => { // Pick the Firefly preset that best matches the aspect ratio + if (ratio > AspectRatioLimits[FireflyImageDimensions.Widescreen]) return FireflyImageDimensions.Widescreen; + if (ratio > AspectRatioLimits[FireflyImageDimensions.Landscape]) return FireflyImageDimensions.Landscape; + if (ratio < AspectRatioLimits[FireflyImageDimensions.Portrait]) return FireflyImageDimensions.Portrait; + return FireflyImageDimensions.Square; + })(); // prettier-ignore + + SmartDrawHandler.CreateWithFirefly(prompt, choosePresetForDimensions) // Call exactly the same CreateWithFirefly that ImageBox uses + .then(action(doc => { + if (doc instanceof Doc) { + this.BackgroundDoc = doc; // set the background image directly on the scrapbook + } else { + alert('Failed to generate document.'); + } + })) + .catch(e => alert(`Generation error: ${e}`)) + .finally(action(() => (this._loading = false))); // prettier-ignore }; - filterAddDocument = (docIn: Doc | Doc[]) => { - const docs = toList(docIn); - if (docs?.length === 1) { - const placeholder = DocListCast(this.dataDoc[this.fieldKey]).find(d => - (d.accepts_docType === docs[0].$type || // match fields based on type, or by analyzing content .. simple example of matching text in placeholder to dropped doc's type - RTFCast(d[Doc.LayoutDataKey(d)])?.Text.includes(StrCast(docs[0].$type))) - ); // prettier-ignore - - if (placeholder) { - // ugh. we have to tell the underlying view not to add the Doc so that we can add it where we want it. - // However, returning 'false' triggers an undo. so this settimeout is needed to make the assignment happen after the undo. - setTimeout( - undoable(() => { - //StrListCast(placeholder.overrideFields).map(field => (docs[0][field] = placeholder[field])); // // shouldn't need to do this for layout fields since the placeholder already overrides its protos - placeholder.proto = docs[0]; - }, 'Scrapbook add') - ); - return false; - } - } + // eslint-disable-next-line @typescript-eslint/no-unused-vars + childRejectDrop = (de: DragManager.DropEvent, subView?: DocumentView) => true; // disable dropping documents onto any child of the scrapbook. + + // eslint-disable-next-line @typescript-eslint/no-unused-vars + rejectDrop = (de: DragManager.DropEvent, subView?: DocumentView) => false; // allow all Docs to be dropped onto scrapbook -- let filterAddDocument make the final decision. + + /** + * Filter function to determine if a document can be added to the scrapbook. + * This checks if the document matches any of the placeholder slots in the scrapbook. + * @param docs - The document(s) being added to the scrapbook. + * @returns true if the document can be added, false otherwise. + */ + filterAddDocument = (docs: Doc | Doc[]) => { + toList(docs).forEach(doc => slotRealDocIntoPlaceholders(doc, DocUtils.unwrapPlaceholders(this.ScrapbookLayoutDocs))); return false; }; + @computed get regenPrompt() { + const allDocs = DocUtils.unwrapPlaceholders(this.ScrapbookLayoutDocs); // find all non-collections in scrapbook (e.g., placeholder content docs) + const internalTagsSet = new Set<string>(allDocs.flatMap(doc => StrListCast(doc.$tags_chat).filter(tag => !tag.startsWith?.('ASPECT_')))); + const internalTags = Array.from(internalTagsSet).join(', '); + + return internalTags ? `Create a new scrapbook background featuring: ${internalTags}` : 'A serene mountain landscape at sunrise, ultra-wide, pastel sky, abstract, scrapbook background'; + } + render() { return ( - <div style={{ background: 'beige', width: '100%', height: '100%' }}> - <CollectionView - {...this._props} // - setContentViewBox={emptyFunction} - rejectDrop={this.rejectDrop} - childRejectDrop={this.childRejectDrop} - filterAddDocument={this.filterAddDocument} - /> - {/* <div style={{ border: '1px black', borderStyle: 'dotted', position: 'absolute', top: '50%', width: '100%', textAlign: 'center' }}>Drop an image here</div> */} + <div className="scrapbook-box"> + <div style={{ display: this._loading ? undefined : 'none' }} className="scrapbook-box-loading-overlay"> + <ReactLoading type="spin" width={50} height={50} /> + </div> + + {this.BackgroundDoc && <ImageBox ref={this._imageBoxRef} {...this._props} Document={this.BackgroundDoc} fieldKey="data" />} + <div style={{ display: this._props.isContentActive() ? 'flex' : 'none', alignItems: 'center', justifyContent: 'space-between', padding: '0 10px' }}> + <select className="scrapbook-box-preset-select" value={this._selectedPreset} onChange={action(e => this.selectPreset((this._selectedPreset = e.currentTarget.value)))}> + {getPresetNames().map(name => ( + <option key={name} value={name}> + {name} + </option> + ))} + </select> + <div className="scrapbook-box-ui" style={{ opacity: this._loading ? 0.5 : 1 }}> + <IconButton size={Size.SMALL} tooltip="regenerate a new background" label="back-ground" icon={<FontAwesomeIcon icon="redo-alt" size="sm" />} onClick={() => !this._loading && this.generateAiImage(this.regenPrompt)} /> + </div> + </div> + + <CollectionView {...this._props} setContentViewBox={emptyFunction} rejectDrop={this.rejectDrop} childRejectDrop={this.childRejectDrop} filterAddDocument={this.filterAddDocument} /> </div> ); } } -// Register scrapbook Docs.Prototypes.TemplateMap.set(DocumentType.SCRAPBOOK, { layout: { view: ScrapbookBox, dataField: 'items' }, options: { diff --git a/src/client/views/nodes/scrapbook/ScrapbookContent.tsx b/src/client/views/nodes/scrapbook/ScrapbookContent.tsx deleted file mode 100644 index ad1d308e8..000000000 --- a/src/client/views/nodes/scrapbook/ScrapbookContent.tsx +++ /dev/null @@ -1,23 +0,0 @@ -import React from "react"; -import { observer } from "mobx-react-lite"; -// Import the Doc type from your actual module. -import { Doc } from "../../../../fields/Doc"; - -export interface ScrapbookContentProps { - doc: Doc; -} - -// A simple view that displays a document's title and content. -// Adjust how you extract the text if your Doc fields are objects. -export const ScrapbookContent: React.FC<ScrapbookContentProps> = observer(({ doc }) => { - // If doc.title or doc.content are not plain strings, convert them. - const titleText = doc.title ? doc.title.toString() : "Untitled"; - const contentText = doc.content ? doc.content.toString() : "No content available."; - - return ( - <div className="scrapbook-content"> - <h3>{titleText}</h3> - <p>{contentText}</p> - </div> - ); -}); diff --git a/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx new file mode 100644 index 000000000..a3405083b --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookPreset.tsx @@ -0,0 +1,94 @@ +import { DocumentType } from '../../../documents/DocumentTypes'; + +export enum ScrapbookPresetType { + None = 'None', + Collage = 'Collage', + Spotlight = 'Spotlight', + Gallery = 'Gallery', + Default = 'Default', + Classic = 'Classic', +} + +export interface ScrapbookItemConfig { + x: number; + y: number; + + message?: string; // optional text to display instead of [placeholder] + acceptTags[0] + type?: DocumentType; + /** what this slot actually accepts (defaults to `tag`) */ + acceptTags?: string[]; + /** the frame this placeholder occupies */ + width?: number; + height?: number; + /** if this is a container with children, use these for the proto’s own size */ + containerWidth?: number; + containerHeight?: number; + children?: ScrapbookItemConfig[]; +} + +export class ScrapbookPreset { + static createPreset(presetType: ScrapbookPresetType): ScrapbookItemConfig[] { + switch (presetType) { + case ScrapbookPresetType.None: return ScrapbookPreset.createNonePreset(); + case ScrapbookPresetType.Classic: return ScrapbookPreset.createClassicPreset(); + case ScrapbookPresetType.Collage: return ScrapbookPreset.createCollagePreset(); + case ScrapbookPresetType.Spotlight: return ScrapbookPreset.createSpotlightPreset(); + case ScrapbookPresetType.Default: return ScrapbookPreset.createDefaultPreset(); + case ScrapbookPresetType.Gallery: return ScrapbookPreset.createGalleryPreset(); + default: + throw new Error(`Unknown preset type: ${presetType}`); + } // prettier-ignore + } + + private static createNonePreset(): ScrapbookItemConfig[] { + return [{ message: 'To create a scrapbook from existing documents, marquee select. For existing scrapbook arrangements, select a preset from the dropdown.', type: DocumentType.RTF, acceptTags: [], x: 0, y: 0, width: 250, height: 200 }]; + } + + private static createClassicPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, message: '[placeholder] landscape', acceptTags: ['LANDSCAPE'], x: 0, y: -100, width: 250, height: 200 }, + { type: DocumentType.RTF, message: '[placeholder] lengthy caption', acceptTags: ['paragraphs'], x: 0, y: 138, width: 250, height: 172 }, + { type: DocumentType.RTF, message: '[placeholder] brief description', acceptTags: ['sentence'], x: 280, y: -50, width: 50, height: 200 }, + { type: DocumentType.IMG, message: '[placeholder] person', acceptTags: ['PERSON'], x: -200, y: -100, width: 167, height: 200 }, + ]; + } + + private static createGalleryPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, message: 'Gallery 1 <drop person images into the gallery!>', acceptTags: ['PERSON'], x: -150, y: -150, width: 150, height: 150 }, + { type: DocumentType.IMG, message: 'Gallery 2', acceptTags: ['PERSON'], x: 0, y: -150, width: 150, height: 150 }, + { type: DocumentType.IMG, message: 'Gallery 3', acceptTags: ['PERSON'], x: 150, y: -150, width: 150, height: 150 }, + { type: DocumentType.IMG, message: 'Gallery 4', acceptTags: ['PERSON'], x: -150, y: 0, width: 150, height: 150 }, + { type: DocumentType.IMG, message: 'Gallery 5', acceptTags: ['PERSON'], x: 0, y: 0, width: 150, height: 150 }, + { type: DocumentType.IMG, message: 'Gallery 6', acceptTags: ['PERSON'], x: 150, y: 0, width: 150, height: 150 }, + ]; + } + + private static createDefaultPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, message: 'drop a landscape image', acceptTags: ['LANDSCAPE'], x: 44, y: -50, width: 200, height: 120 }, + { type: DocumentType.PDF, message: 'summary pdf', acceptTags: ['word', 'sentence', 'paragraphs'], x: 45, y: 93, width: 184, height: 273 }, + { type: DocumentType.RTF, message: 'sidebar text', acceptTags: ['paragraphs'], x: 250, y: -50, width: 100, height: 200 }, + { containerWidth: 200, containerHeight: 425, x: -171, y: -54, width: 200, height: 425, + children: [{ type: DocumentType.IMG, message: 'drop a person image', acceptTags: ['PERSON'], x: -350, y: 200, width: 162, height: 137 }], }, + ]; // prettier-ignore + } + + private static createCollagePreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.IMG, message: 'landscape image', acceptTags: ['LANDSCAPE'], x: -174, y: 100, width: 160, height: 150 }, + { type: DocumentType.IMG, message: 'person image', acceptTags: ['PERSON'], x: 0, y: 100, width: 150, height: 150 }, + { type: DocumentType.RTF, message: 'caption', acceptTags: ['sentence'], x: -174, y: 50, width: 150, height: 40 }, + { type: DocumentType.RTF, message: 'caption', acceptTags: ['sentence'], x: 0, y: 50, width: 150, height: 40 }, + { type: DocumentType.RTF, message: 'lengthy description', acceptTags: ['paragraphs'], x: -180, y: -60, width: 350, height: 100 }, + ]; // prettier-ignore + } + + private static createSpotlightPreset(): ScrapbookItemConfig[] { + return [ + { type: DocumentType.RTF, message: 'title text', acceptTags: ['word'], x: 0, y: -30, width: 300, height: 40 }, + { type: DocumentType.IMG, message: 'drop a landscape image', acceptTags: ['LANDSCAPE'], x: 0, y: 20, width: 300, height: 200 }, + { type: DocumentType.RTF, message: 'caption text', acceptTags: ['sentence'], x: 0, y: 230, width: 300, height: 50 }, + ]; + } +} diff --git a/src/client/views/nodes/scrapbook/ScrapbookPresetRegistry.ts b/src/client/views/nodes/scrapbook/ScrapbookPresetRegistry.ts new file mode 100644 index 000000000..3a2189d00 --- /dev/null +++ b/src/client/views/nodes/scrapbook/ScrapbookPresetRegistry.ts @@ -0,0 +1,36 @@ +import { ScrapbookItemConfig } from './ScrapbookPreset'; +import { ScrapbookPresetType } from './ScrapbookPreset'; + +type PresetGenerator = () => ScrapbookItemConfig[]; + +// Internal map of preset name to generator +const presetRegistry = new Map<string, PresetGenerator>(); + +/** + * Register a new scrapbook preset under the given name. + */ +export function registerPreset(name: string, gen: PresetGenerator) { + presetRegistry.set(name, gen); +} + +/** + * List all registered preset names. + */ +export function getPresetNames(): string[] { + return Array.from(presetRegistry.keys()); +} + +/** + * Create the config array for the named preset. + */ +export function createPreset(name: string): ScrapbookItemConfig[] { + const gen = presetRegistry.get(name); + if (!gen) throw new Error(`Unknown scrapbook preset: ${name}`); + return gen(); +} + +// ------------------------ +// Register built-in presets +import { ScrapbookPreset } from './ScrapbookPreset'; + +Object.keys(ScrapbookPresetType).forEach(key => registerPreset(key, () => ScrapbookPreset.createPreset(key as ScrapbookPresetType))); // pretter-ignore diff --git a/src/client/views/nodes/scrapbook/ScrapbookSlot.scss b/src/client/views/nodes/scrapbook/ScrapbookSlot.scss deleted file mode 100644 index ae647ad36..000000000 --- a/src/client/views/nodes/scrapbook/ScrapbookSlot.scss +++ /dev/null @@ -1,85 +0,0 @@ -//IGNORE FOR NOW, CURRENTLY NOT USED IN SCRAPBOOK IMPLEMENTATION -.scrapbook-slot { - position: absolute; - background-color: rgba(245, 245, 245, 0.7); - border: 2px dashed #ccc; - border-radius: 5px; - box-sizing: border-box; - transition: all 0.2s ease; - overflow: hidden; - - &.scrapbook-slot-over { - border-color: #4a90e2; - background-color: rgba(74, 144, 226, 0.1); - } - - &.scrapbook-slot-filled { - border-style: solid; - border-color: rgba(0, 0, 0, 0.1); - background-color: transparent; - - &.scrapbook-slot-over { - border-color: #4a90e2; - background-color: rgba(74, 144, 226, 0.1); - } - } - - .scrapbook-slot-empty { - display: flex; - align-items: center; - justify-content: center; - width: 100%; - height: 100%; - } - - .scrapbook-slot-placeholder { - text-align: center; - color: #888; - } - - .scrapbook-slot-title { - font-weight: bold; - margin-bottom: 5px; - } - - .scrapbook-slot-instruction { - font-size: 0.9em; - font-style: italic; - } - - .scrapbook-slot-content { - width: 100%; - height: 100%; - position: relative; - } - - .scrapbook-slot-controls { - position: absolute; - top: 5px; - right: 5px; - z-index: 10; - opacity: 0; - transition: opacity 0.2s ease; - - .scrapbook-slot-remove-btn { - background-color: rgba(255, 255, 255, 0.8); - border: 1px solid #ccc; - border-radius: 50%; - width: 20px; - height: 20px; - display: flex; - align-items: center; - justify-content: center; - cursor: pointer; - font-size: 10px; - - &:hover { - background-color: rgba(255, 0, 0, 0.1); - } - } - } - - &:hover .scrapbook-slot-controls { - opacity: 1; - } -}
\ No newline at end of file diff --git a/src/client/views/nodes/scrapbook/ScrapbookSlot.tsx b/src/client/views/nodes/scrapbook/ScrapbookSlot.tsx deleted file mode 100644 index 2c8f93778..000000000 --- a/src/client/views/nodes/scrapbook/ScrapbookSlot.tsx +++ /dev/null @@ -1,28 +0,0 @@ - -//IGNORE FOR NOW, CURRENTLY NOT USED IN SCRAPBOOK IMPLEMENTATION -export interface SlotDefinition { - id: string; - x: number; y: number; - defaultWidth: number; - defaultHeight: number; - } - - export interface SlotContentMap { - slotId: string; - docId?: string; - } - - export interface ScrapbookConfig { - slots: SlotDefinition[]; - contents?: SlotContentMap[]; - } - - export const DEFAULT_SCRAPBOOK_CONFIG: ScrapbookConfig = { - slots: [ - { id: "slot1", x: 10, y: 10, defaultWidth: 180, defaultHeight: 120 }, - { id: "slot2", x: 200, y: 10, defaultWidth: 180, defaultHeight: 120 }, - // …etc - ], - contents: [] - }; -
\ No newline at end of file diff --git a/src/client/views/nodes/scrapbook/ScrapbookSlotTypes.ts b/src/client/views/nodes/scrapbook/ScrapbookSlotTypes.ts deleted file mode 100644 index 686917d9a..000000000 --- a/src/client/views/nodes/scrapbook/ScrapbookSlotTypes.ts +++ /dev/null @@ -1,25 +0,0 @@ -// ScrapbookSlotTypes.ts -export interface SlotDefinition { - id: string; - title: string; - x: number; - y: number; - defaultWidth: number; - defaultHeight: number; - } - - export interface ScrapbookConfig { - slots: SlotDefinition[]; - contents?: { slotId: string; docId: string }[]; - } - - // give it three slots by default: - export const DEFAULT_SCRAPBOOK_CONFIG: ScrapbookConfig = { - slots: [ - { id: "main", title: "Main Content", x: 20, y: 20, defaultWidth: 360, defaultHeight: 200 }, - { id: "notes", title: "Notes", x: 20, y: 240, defaultWidth: 360, defaultHeight: 160 }, - { id: "resources", title: "Resources", x: 400, y: 20, defaultWidth: 320, defaultHeight: 380 }, - ], - contents: [], - }; -
\ No newline at end of file diff --git a/src/client/views/nodes/trails/PresBox.scss b/src/client/views/nodes/trails/PresBox.scss index e24b47bd1..8875f7012 100644 --- a/src/client/views/nodes/trails/PresBox.scss +++ b/src/client/views/nodes/trails/PresBox.scss @@ -229,7 +229,7 @@ .toolbar-transition { display: flex; font-size: 10; - width: 100; + width: 100px; background-color: rgba(0, 0, 0, 0); min-width: max-content; @@ -358,7 +358,7 @@ font-weight: 200; padding: 8px; border-radius: 4px; - // height: 20; + // height: 20px; // display: flex; // margin-left: 5px; // margin-top: 5px; @@ -371,8 +371,8 @@ } .ribbon-propertyUpDown { - height: 20; - width: 20; + height: 20px; + width: 20px; margin-top: 5px; display: grid; grid-template-rows: 10px 10px; @@ -486,7 +486,7 @@ -moz-user-select: none; -ms-user-select: none; user-select: none; - margin: 0; + margin: 0px; margin-right: 3px; border-radius: 100%; height: 15px; @@ -658,9 +658,9 @@ height: 25px; color: white; width: 100%; - max-width: 120; - padding-left: 10; - padding-right: 10; + max-width: 120px; + padding-left: 10px; + padding-right: 10px; border-radius: 10px; background-color: global.$medium-gray; } @@ -683,9 +683,9 @@ height: 25px; color: global.$light-gray; width: 100%; - max-width: 120; - padding-left: 10; - padding-right: 10; + max-width: 120px; + padding-left: 10px; + padding-right: 10px; border-radius: 10px; background-color: global.$black; } @@ -745,9 +745,9 @@ .selectedList { display: block; - min-width: 50; - max-width: 120; - height: 70; + min-width: 50px; + max-width: 120px; + height: 70px; overflow-y: scroll; .selectedList-items { @@ -760,7 +760,7 @@ cursor: pointer; font-size: 10.5; font-weight: 300; - height: 20; + height: 20px; background-color: global.$medium-gray; color: white; display: flex; @@ -788,7 +788,7 @@ cursor: pointer; font-size: 10.5; font-weight: 200; - height: 20; + height: 20px; background-color: global.$white; display: inline-flex; color: global.$black; @@ -813,7 +813,7 @@ } svg.svg-inline--fa.fa-thumbtack.fa-w-12.toolbar-thumbtack { - right: 40; + right: 40px; position: absolute; transform: rotate(45deg); } @@ -850,7 +850,7 @@ background-color: global.$light-gray; border-radius: 5px; font-size: 10; - height: 25; + height: 25px; color: global.$black; padding-left: 5px; align-items: center; @@ -868,8 +868,8 @@ display: block; padding-left: 10px; padding-right: 5px; - padding-top: 3; - padding-bottom: 3; + padding-top: 3px; + padding-bottom: 3px; opacity: 0.8; } @@ -959,8 +959,8 @@ border-radius: 4px; padding-left: 7px; padding-right: 7px; - border-bottom-right-radius: 0; - border-top-right-radius: 0; + border-bottom-right-radius: 0px; + border-top-right-radius: 0px; } .presBox-button-right { @@ -976,8 +976,8 @@ border-radius: 4px; padding-left: 7px; padding-right: 7px; - border-bottom-left-radius: 0; - border-top-left-radius: 0; + border-bottom-left-radius: 0px; + border-top-left-radius: 0px; } .presBox-button-right.active { @@ -1043,8 +1043,8 @@ cursor: pointer; align-self: center; justify-self: center; - margin-top: 5; - margin-bottom: 5; + margin-top: 5px; + margin-bottom: 5px; position: relative; height: 55px; min-width: 90px; @@ -1063,7 +1063,7 @@ padding-left: 3px; margin-left: 3px; margin-right: 3px; - height: 13; + height: 13px; font-size: 12; display: flex; background-color: global.$white; @@ -1076,7 +1076,7 @@ margin-left: 3px; margin-right: 3px; font-weight: 400; - height: 13; + height: 13px; font-size: 9; display: flex; background-color: global.$white; @@ -1089,11 +1089,11 @@ padding-left: 3px; margin-left: 3px; margin-right: 3px; - height: 13; + height: 13px; font-size: 10; display: flex; background-color: global.$white; - height: 33; + height: 33px; text-align: left; font-size: 8px; } @@ -1112,7 +1112,7 @@ .presBox-viewPicker { cursor: pointer; - height: 25; + height: 25px; position: relative; display: inline-block; grid-column: 1; @@ -1184,9 +1184,9 @@ } .collectionViewBaseChrome-viewPicker { - min-width: 50; + min-width: 50px; width: 5%; - height: 25; + height: 25px; position: relative; display: inline-block; left: 8px; @@ -1210,11 +1210,11 @@ } .presBox-backward { - left: 5; + left: 5px; } .presBox-forward { - right: 5; + right: 5px; } // CSS adjusted for mobile devices @@ -1233,8 +1233,8 @@ .presBox-button { margin-top: 5%; - height: 250; - width: 300; + height: 250px; + width: 300px; font-size: 100; display: flex; align-items: center; @@ -1256,7 +1256,7 @@ } .presBox-cont .presBox-listCont { - top: 50; + top: 50px; height: calc(100% - 80px); } @@ -1269,8 +1269,8 @@ .miniPres { cursor: grab; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; opacity: 0.5; transition: all 0.4s; color: global.$white; @@ -1298,7 +1298,7 @@ .presPanel-button-text { cursor: pointer; display: flex; - height: 20; + height: 20px; width: max-content; font-family: Roboto; font-weight: 400; @@ -1327,8 +1327,8 @@ grid-template-columns: auto auto auto; justify-content: space-around; font-size: 11; - margin-left: 7; - width: 30; + margin-left: 7px; + width: 30px; height: 85%; background-color: rgba(91, 157, 221, 0.4); border-radius: 5px; @@ -1337,8 +1337,8 @@ .presPanel-button { cursor: pointer; display: flex; - height: 20; - min-width: 20; + height: 20px; + min-width: 20px; margin-left: 3px; margin-right: 3px; border-radius: 100%; @@ -1361,8 +1361,8 @@ // cursor: grab; // position: absolute; // overflow: hidden; -// right: 10; -// top: 10; +// right: 10px; +// top: 10px; // opacity: 0.1; // transition: all 0.4s; // /* border: solid 1px; */ @@ -1380,7 +1380,7 @@ // .miniPres-button-text { // cursor: pointer; // display: flex; -// height: 20; +// height: 20px; // font-weight: 400; // min-width: 100%; // border-radius: 5px; @@ -1397,8 +1397,8 @@ // grid-template-columns: auto auto auto; // justify-content: space-around; // font-size: 11; -// margin-left: 7; -// width: 30; +// margin-left: 7px; +// width: 30px; // height: 85%; // background-color: rgba(91, 157, 221, 0.4); // border-radius: 5px; @@ -1413,8 +1413,8 @@ // .miniPres-button { // cursor: pointer; // display: flex; -// height: 20; -// min-width: 20; +// height: 20px; +// min-width: 20px; // border-radius: 100%; // align-items: center; // justify-content: center; diff --git a/src/client/views/nodes/trails/PresBox.tsx b/src/client/views/nodes/trails/PresBox.tsx index 11f35b8ef..04b312ca5 100644 --- a/src/client/views/nodes/trails/PresBox.tsx +++ b/src/client/views/nodes/trails/PresBox.tsx @@ -702,7 +702,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { transTime + 10 ); } - if ((pinDataTypes?.pannable || (!pinDataTypes && (activeItem.config_viewBounds !== undefined || activeItem.config_panX !== undefined || activeItem.config_viewScale !== undefined))) && !bestTarget.isGroup) { + if ((pinDataTypes?.pannable || (!pinDataTypes && (activeItem.config_viewBounds !== undefined || activeItem.config_panX !== undefined || activeItem.config_viewScale !== undefined))) && !Doc.IsFreeformGroup(bestTarget)) { const contentBounds = Cast(activeItem.config_viewBounds, listSpec('number')); if (contentBounds) { const viewport = { panX: (contentBounds[0] + contentBounds[2]) / 2, panY: (contentBounds[1] + contentBounds[3]) / 2, width: contentBounds[2] - contentBounds[0], height: contentBounds[3] - contentBounds[1] }; @@ -1743,6 +1743,15 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { return <div />; } + setAiEffectsRef = (r: HTMLTextAreaElement | null) => + setTimeout(() => { + if (r && !r.textContent) { + r.style.height = ''; + r.style.height = r.scrollHeight + 'px'; + } + }); + + setAnimDictationRef = (r: DictationButton | null) => (this._animationDictation = r); /** * This chatbox is for getting slide effect transition suggestions from gpt and visualizing them */ @@ -1755,14 +1764,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { <ReactTextareaAutosize placeholder="Use AI to suggest effects. Leave blank for random results." className="pres-chatbox" - ref={r => { - setTimeout(() => { - if (r && !r.textContent) { - r.style.height = ''; - r.style.height = r.scrollHeight + 'px'; - } - }); - }} + ref={this.setAiEffectsRef} value={this._animationChat} onChange={e => { e.currentTarget.style.height = ''; @@ -1784,12 +1786,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { color={SnappingManager.userVariantColor} onClick={this.customizeAnimations} /> - <DictationButton - ref={r => { - this._animationDictation = r; - }} - setInput={this.setAnimationChat} - /> + <DictationButton ref={this.setAnimDictationRef} setInput={this.setAnimationChat} /> </div> <div style={{ alignItems: 'center' }}> Click a box to use the effect. @@ -1821,6 +1818,16 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { ); } + setPropertiesRef = (r: HTMLTextAreaElement | null) => + setTimeout(() => { + if (r && !r.textContent) { + r.style.height = ''; + r.style.height = r.scrollHeight + 'px'; + } + }); + + setSlideDictationRef = (r: DictationButton | null) => (this._slideDictation = r); + @computed get transitionDropdown() { const { activeItem } = this; // Retrieving spring timing properties @@ -1855,14 +1862,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { <ReactTextareaAutosize placeholder="Describe how to modify the slide properties." className="pres-chatbox" - ref={r => { - setTimeout(() => { - if (r && !r.textContent) { - r.style.height = ''; - r.style.height = r.scrollHeight + 'px'; - } - }); - }} + ref={this.setPropertiesRef} value={this._chatInput} onChange={e => { e.currentTarget.style.height = ''; @@ -1874,12 +1874,7 @@ export class PresBox extends ViewBoxBaseComponent<FieldViewProps>() { e.stopPropagation(); }} /> - <DictationButton - ref={r => { - this._slideDictation = r; - }} - setInput={this.setChatInput} - /> + <DictationButton ref={this.setSlideDictationRef} setInput={this.setChatInput} /> </div> <Button style={{ alignSelf: 'flex-end' }} diff --git a/src/client/views/nodes/trails/PresSlideBox.scss b/src/client/views/nodes/trails/PresSlideBox.scss index 9ac2b5a94..740bcae5f 100644 --- a/src/client/views/nodes/trails/PresSlideBox.scss +++ b/src/client/views/nodes/trails/PresSlideBox.scss @@ -117,8 +117,8 @@ $slide-active: #5b9fdd; height: 100%; position: absolute; border-radius: 3px; - top: 0; - left: 0; + top: 0px; + left: 0px; z-index: 1; overflow: hidden; } @@ -209,7 +209,7 @@ $slide-active: #5b9fdd; position: absolute; /* grid-row: 3; */ /* grid-column: 1/8; */ - top: 28; + top: 28px; display: block; background: #92adb9; width: 100%; @@ -276,7 +276,7 @@ $slide-active: #5b9fdd; cursor: pointer; position: absolute; border-radius: 100px; - bottom: 0; + bottom: 0px; left: -18; z-index: 300; width: 15px; @@ -302,7 +302,7 @@ $slide-active: #5b9fdd; color: #d5dce2; position: absolute; left: -15px; - top: 1; + top: 1px; font-weight: 600; font-size: 12; } diff --git a/src/client/views/pdf/AnchorMenu.scss b/src/client/views/pdf/AnchorMenu.scss index 6990bdcf1..98eeaf80e 100644 --- a/src/client/views/pdf/AnchorMenu.scss +++ b/src/client/views/pdf/AnchorMenu.scss @@ -6,19 +6,19 @@ } .anchorMenu-highlighter { padding-right: 5px; - .antimodeMenu-button { - padding: 0; - padding: 0; + .antimodeMenu-button { + padding: 0px; + padding: 0px; padding-right: 0px; padding-left: 0px; width: 5px; } } -.anchor-color-preview-button { - width: 25px !important; +.anchor-color-preview-button { + width: 25px !important; .anchor-color-preview { display: flex; - flex-direction: column; + flex-direction: column; padding-right: 3px; width: unset !important; .color-preview { @@ -51,4 +51,4 @@ border: 2px solid white; } } -}
\ No newline at end of file +} diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.scss b/src/client/views/pdf/GPTPopup/GPTPopup.scss index f6fa45221..85c1f6759 100644 --- a/src/client/views/pdf/GPTPopup/GPTPopup.scss +++ b/src/client/views/pdf/GPTPopup/GPTPopup.scss @@ -20,8 +20,8 @@ $headingHeight: 32px; left: 75px; width: 100%; height: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; border-top: solid gray 20px; border-radius: 16px; z-index: 999; @@ -58,7 +58,7 @@ $headingHeight: 32px; font-size: 12px; font-weight: 400; letter-spacing: 1px; - margin: 0; + margin: 0px; padding-right: 5px; } @@ -214,8 +214,8 @@ $headingHeight: 32px; .img-container::after { content: ''; position: absolute; - top: 0; - left: 0; + top: 0px; + left: 0px; width: 100%; height: 100%; background-color: rgba(0, 0, 0, 0.5); diff --git a/src/client/views/pdf/GPTPopup/GPTPopup.tsx b/src/client/views/pdf/GPTPopup/GPTPopup.tsx index 568e48edf..9c37428ee 100644 --- a/src/client/views/pdf/GPTPopup/GPTPopup.tsx +++ b/src/client/views/pdf/GPTPopup/GPTPopup.tsx @@ -11,7 +11,7 @@ import { ClientUtils } from '../../../../ClientUtils'; import { Doc } from '../../../../fields/Doc'; import { NumCast, StrCast } from '../../../../fields/Types'; import { Networking } from '../../../Network'; -import { DescriptionSeperator, DocSeperator, GPTCallType, GPTDocCommand, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT'; +import { DataSeperator, DescriptionSeperator, DocSeperator, GPTCallType, GPTDocCommand, gptAPICall, gptImageCall } from '../../../apis/gpt/GPT'; import { DocUtils } from '../../../documents/DocUtils'; import { Docs } from '../../../documents/Documents'; import { SettingsManager } from '../../../util/SettingsManager'; @@ -90,7 +90,7 @@ export class GPTPopup extends ObservableReactComponent<object> { if (hasChildDocs) { this._textToDocMap.clear(); this.setCollectionContext(selDoc.Document); - this.onGptResponse = (sortResult: string, questionType: GPTDocCommand, args?: string) => this.processGptResponse(selDoc, this._textToDocMap, sortResult, questionType, args); + this.onGptResponse = (sortResult: string, questionType: GPTDocCommand) => this.processGptResponse(selDoc, this._textToDocMap, sortResult, questionType); this.onQuizRandom = () => this.randomlyChooseDoc(selDoc.Document, hasChildDocs()); this._documentDescriptions = Promise.all(hasChildDocs().map(doc => Doc.getDescription(doc).then(text => this._textToDocMap.set(text.replace(/\n/g, ' ').trim(), doc) && `${DescriptionSeperator}${text}${DescriptionSeperator}`) @@ -134,7 +134,7 @@ export class GPTPopup extends ObservableReactComponent<object> { * @param questionType * @param tag */ - processGptResponse = (docView: DocumentView, textToDocMap: Map<string, Doc>, gptOutput: string, questionType: GPTDocCommand, args?: string) => + processGptResponse = (docView: DocumentView, textToDocMap: Map<string, Doc>, gptOutput: string, questionType: GPTDocCommand) => undoable(() => { switch (questionType) { // reset collection based on question typefc case GPTDocCommand.Sort: @@ -145,20 +145,17 @@ export class GPTPopup extends ObservableReactComponent<object> { break; } // prettier-ignore - gptOutput.split('======').filter(item => item.trim() !== '') // Split output into individual document contents - .map(docContentRaw => textToDocMap.get(docContentRaw.replace(/\n/g, ' ').trim())) // the find the corresponding Doc using textToDoc map - .filter(doc => doc).map(doc => doc!) // filter out undefined values - .forEach((doc, index) => { + gptOutput.split(DescriptionSeperator).filter(item => item.trim() !== '') // Split output into individual document contents + .map(docContentRaw => docContentRaw.replace(/\n/g, ' ').trim()) + .map(docContentRaw => ({doc: textToDocMap.get(docContentRaw.split(DataSeperator)[0]), data: docContentRaw.split(DataSeperator)[1] })) // the find the corresponding Doc using textToDoc map + .filter(({doc}) => doc).map(({doc, data}) => ({doc:doc!, data})) // filter out undefined values + .forEach(({doc, data}, index) => { switch (questionType) { case GPTDocCommand.Sort: doc[ChatSortField] = index; break; case GPTDocCommand.AssignTags: - if (args) { - const hashTag = args.startsWith('#') ? args : '#' + args[0].toLowerCase() + args.slice(1); - const filterTag = Doc.MyFilterHotKeys.map(key => StrCast(key.toolType)).find(key => key.includes(args)) ?? hashTag; - TagItem.addTagToDoc(doc, filterTag); - } + data && TagItem.addTagToDoc(doc, data.startsWith('#') ? data : '#'+data[0].toLowerCase()+data.slice(1) ); break; case GPTDocCommand.Filter: TagItem.addTagToDoc(doc, GPTPopup.ChatTag); @@ -238,10 +235,10 @@ export class GPTPopup extends ObservableReactComponent<object> { * @param userPrompt the user's input that chat will respond to */ generateUserPromptResponse = (userPrompt: string) => - gptAPICall(userPrompt, GPTCallType.COMMANDTYPE, undefined, true).then((commandType, args = commandType.split(' ').slice(1).join(' ')) => + gptAPICall(userPrompt, GPTCallType.COMMANDTYPE, undefined, true).then(commandType => (async () => { switch (this.NumberToCommandType(commandType)) { - case GPTDocCommand.AssignTags: + case GPTDocCommand.AssignTags:return this._documentDescriptions?.then(descs => gptAPICall(userPrompt, GPTCallType.TAGDOCS, descs)) ?? ""; case GPTDocCommand.Filter: return this._documentDescriptions?.then(descs => gptAPICall(userPrompt, GPTCallType.SUBSETDOCS, descs)) ?? ""; case GPTDocCommand.Sort: return this._documentDescriptions?.then(descs => gptAPICall(userPrompt, GPTCallType.SORTDOCS, descs)) ?? ""; default: return Doc.getDescription(DocumentView.SelectedDocs().lastElement()).then(desc => gptAPICall(userPrompt, GPTCallType.DOCINFO, desc)); @@ -249,7 +246,7 @@ export class GPTPopup extends ObservableReactComponent<object> { })().then( action(res => { // Trigger the callback with the result - this.onGptResponse?.(res || 'Something went wrong :(', this.NumberToCommandType(commandType), args); + this.onGptResponse?.(res || 'Something went wrong :(', this.NumberToCommandType(commandType)); this._conversationArray.push( this.NumberToCommandType(commandType) === GPTDocCommand.GetInfo ? res: // Extract explanation surrounded by the DocSeperator string (defined in GPT.ts) at the top or both at the top and bottom @@ -515,6 +512,7 @@ export class GPTPopup extends ObservableReactComponent<object> { </div> ); + setDictationRef = (r: DictationButton | null) => (this._askDictation = r); promptBox = (heading: string, value: string, onChange: (e: string) => string, placeholder: string) => ( <> <div className="gptPopup-sortBox"> @@ -541,7 +539,7 @@ export class GPTPopup extends ObservableReactComponent<object> { background={SettingsManager.userVariantColor} onClick={() => this.callGpt(this._mode)} /> - <DictationButton ref={r => (this._askDictation = r)} setInput={onChange} /> + <DictationButton ref={this.setDictationRef} setInput={onChange} /> </div> </> ); diff --git a/src/client/views/pdf/PDFViewer.scss b/src/client/views/pdf/PDFViewer.scss index 1aab2b853..dafa908a2 100644 --- a/src/client/views/pdf/PDFViewer.scss +++ b/src/client/views/pdf/PDFViewer.scss @@ -3,8 +3,8 @@ width: 100%; position: absolute; display: inline-block; - top: 0; - left: 0; + top: 0px; + left: 0px; } :root { @@ -16,8 +16,8 @@ position: absolute; width: 100%; height: 100%; - top: 0; - left: 0; + top: 0px; + left: 0px; position: absolute; overflow-y: auto; overflow-x: hidden; @@ -99,7 +99,7 @@ .pdfViewerDash-annotationLayer { position: absolute; transform-origin: left top; - top: 0; + top: 0px; width: 100%; pointer-events: none; mix-blend-mode: multiply; // bcz: makes text fuzzy! @@ -118,6 +118,9 @@ .pdfViewerDash-interactive { pointer-events: all; + &::-webkit-scrollbar { + width: 40px; + } } .loading-spinner { diff --git a/src/client/views/pdf/PDFViewer.tsx b/src/client/views/pdf/PDFViewer.tsx index a88d8b282..616e2119f 100644 --- a/src/client/views/pdf/PDFViewer.tsx +++ b/src/client/views/pdf/PDFViewer.tsx @@ -901,12 +901,6 @@ export class PDFViewer extends ObservableReactComponent<IViewerProps> { @action onPointerDown = (e: React.PointerEvent): void => { - // const hit = document.elementFromPoint(e.clientX, e.clientY); - // bcz: Change. drag selecting requires that preventDefault is NOT called. This used to happen in DocumentView, - // but that's changed, so this shouldn't be needed. - // if (hit && hit.localName === "span" && this.annotationsActive(true)) { // drag selecting text stops propagation - // e.button === 0 && e.stopPropagation(); - // } // if alt+left click, drag and annotate this._downX = e.clientX; this._downY = e.clientY; diff --git a/src/client/views/search/FaceRecognitionHandler.tsx b/src/client/views/search/FaceRecognitionHandler.tsx index 3ad5bc844..84404d65a 100644 --- a/src/client/views/search/FaceRecognitionHandler.tsx +++ b/src/client/views/search/FaceRecognitionHandler.tsx @@ -9,6 +9,7 @@ import { ImageField } from '../../../fields/URLField'; import { DocumentType } from '../../documents/DocumentTypes'; import { Docs } from '../../documents/Documents'; import { DocumentManager } from '../../util/DocumentManager'; +import { DocumentView } from '../nodes/DocumentView'; /** * A singleton class that handles face recognition and manages face Doc collections for each face found. @@ -33,7 +34,7 @@ export class FaceRecognitionHandler { // eslint-disable-next-line no-use-before-define static _instance: FaceRecognitionHandler; private _apiModelReady = false; - private _pendingAPIModelReadyDocs: Doc[] = []; + private _pendingAPIModelReadyDocs: DocumentView[] = []; public static get Instance() { return FaceRecognitionHandler._instance ?? new FaceRecognitionHandler(); @@ -126,7 +127,7 @@ export class FaceRecognitionHandler { constructor() { FaceRecognitionHandler._instance = this; this.loadAPIModels().then(() => this._pendingAPIModelReadyDocs.forEach(this.classifyFacesInImage)); - DocumentManager.Instance.AddAnyViewRenderedCB(dv => FaceRecognitionHandler.Instance.classifyFacesInImage(dv.Document)); + DocumentManager.Instance.AddAnyViewRenderedCB(dv => FaceRecognitionHandler.Instance.classifyFacesInImage(dv)); } /** @@ -199,16 +200,18 @@ export class FaceRecognitionHandler { * match them to existing unique faces, otherwise new unique face(s) are created. * @param imgDoc The document being analyzed. */ - private classifyFacesInImage = async (imgDoc: Doc) => { + private classifyFacesInImage = async (imgDocView: DocumentView) => { + const imgDoc = imgDocView.Document; if (!Doc.UserDoc().recognizeFaceImages) return; const activeDashboard = Doc.ActiveDashboard; if (!this._apiModelReady || !activeDashboard) { - this._pendingAPIModelReadyDocs.push(imgDoc); + this._pendingAPIModelReadyDocs.push(imgDocView); } else if (imgDoc.type === DocumentType.LOADING && !imgDoc.loadingError) { - setTimeout(() => this.classifyFacesInImage(imgDoc), 1000); + setTimeout(() => this.classifyFacesInImage(imgDocView), 1000); } else { const imgUrl = ImageCast(imgDoc[Doc.LayoutDataKey(imgDoc)]); if (imgUrl && !DocListCast(Doc.MyFaceCollection?.examinedFaceDocs).includes(imgDoc[DocData])) { + imgDocView.ComponentView?.autoTag?.(); // only examine Docs that have an image and that haven't already been examined. Doc.MyFaceCollection && Doc.AddDocToList(Doc.MyFaceCollection, 'examinedFaceDocs', imgDoc[DocData]); FaceRecognitionHandler.loadImage(imgUrl).then( diff --git a/src/client/views/smartdraw/SmartDrawHandler.tsx b/src/client/views/smartdraw/SmartDrawHandler.tsx index 3976ec39e..b7ff5fff7 100644 --- a/src/client/views/smartdraw/SmartDrawHandler.tsx +++ b/src/client/views/smartdraw/SmartDrawHandler.tsx @@ -330,8 +330,9 @@ export class SmartDrawHandler extends ObservableReactComponent<object> { switch (doc.type) { case DocumentType.IMG: { const func = changeInPlace ? this.recreateImageWithFirefly : this.createImageWithFirefly; - const newPrompt = doc.ai_prompt && doc.ai_prompt !== this._regenInput ? `${doc.ai_prompt} ~~~ ${this._regenInput}` : this._regenInput; - return this._regenInput ? func(newPrompt, NumCast(doc?.ai_prompt_seed)) : func(this._lastInput.text || StrCast(doc.ai_prompt)); + const promptChange = doc.ai_prompt && doc.ai_prompt !== this._regenInput; + const newPrompt = promptChange ? `${doc.ai_prompt} ~~~ ${this._regenInput}` : this._regenInput; + return this._regenInput ? func(newPrompt, promptChange ? NumCast(doc?.ai_prompt_seed) : undefined) : func(this._lastInput.text || StrCast(doc.ai_prompt)); } case DocumentType.COL: { try { diff --git a/src/client/views/topbar/TopBar.scss b/src/client/views/topbar/TopBar.scss index 35a3da312..ca177c746 100644 --- a/src/client/views/topbar/TopBar.scss +++ b/src/client/views/topbar/TopBar.scss @@ -125,7 +125,7 @@ .topbar-lozenge-user, .topbar-lozenge { - height: 23; + height: 23px; font-size: 12; color: white; font-family: 'Roboto'; diff --git a/src/debug/Viewer.tsx b/src/debug/Viewer.tsx index f3295a6c6..354ed11d6 100644 --- a/src/debug/Viewer.tsx +++ b/src/debug/Viewer.tsx @@ -1,8 +1,5 @@ /* eslint-disable react/no-unescaped-entities */ -/* eslint-disable react/button-has-type */ /* eslint-disable no-use-before-define */ -/* eslint-disable react/no-array-index-key */ -/* eslint-disable no-redeclare */ import { action, configure, observable } from 'mobx'; import { observer } from 'mobx-react'; import * as React from 'react'; diff --git a/src/fields/Doc.ts b/src/fields/Doc.ts index 0d11b9743..6e0c3d112 100644 --- a/src/fields/Doc.ts +++ b/src/fields/Doc.ts @@ -536,23 +536,35 @@ export namespace Doc { export function GetT<T extends FieldType>(doc: Doc, key: string, ctor: ToConstructor<T>, ignoreProto: boolean = false): FieldResult<T> { return Cast(Get(doc, key, ignoreProto), ctor) as FieldResult<T>; } - export function isTemplateDoc(doc: Doc) { - return GetT(doc, 'isTemplateDoc', 'boolean', true); - } - export function isTemplateForField(doc: Doc) { - return GetT(doc, 'isTemplateForField', 'string', true); - } - export function IsDataProto(doc: Doc) { - return GetT(doc, 'isDataDoc', 'boolean', true); - } - export function IsBaseProto(doc: Doc) { - return GetT(doc, 'isBaseProto', 'boolean', true); - } - export function IsSystem(doc: Doc) { - return GetT(doc, 'isSystem', 'boolean', true); - } - export function IsDelegateField(doc: Doc, fieldKey: string) { - return doc && Get(doc, fieldKey, true) !== undefined; + /** + * Tests whether the Doc is flagged as being a template. + * Templates can be set as a layout for another target Doc. When rendered by the target Doc, the template + * creates an instance of itself that holds rendering data specific to the target + * @param doc + */ + export function IsTemplateDoc(doc: Doc) { return GetT(doc, 'isTemplateDoc', 'boolean', true); } // prettier-ignore + /** + * Tests whether the Doc is flagged as being a template for rendering a specific field of a Doc + * When flagged as field template and rendered, the template will redirect its componentView to write to the + * specified template field. In general, a compound Doc template will contain multiple field templates, one for each of the + * data fields rendered by the compound template. + * @param doc + * @returns + */ + export function IsTemplateForField(doc: Doc) { return GetT(doc, 'isTemplateForField', 'string', true); } // prettier-ignore + export function IsDataProto(doc: Doc) { return GetT(doc, 'isDataDoc', 'boolean', true); } // prettier-ignore + export function IsBaseProto(doc: Doc) { return GetT(doc, 'isBaseProto', 'boolean', true); } // prettier-ignore + export function IsSystem(doc: Doc) { return GetT(doc, 'isSystem', 'boolean', true); } // prettier-ignore + export function IsDelegateField(doc: Doc, fieldKey: string) { return doc && Get(doc, fieldKey, true) !== undefined; } // prettier-ignore + /** + * Tests whether a doc is a freeform collection that renders as a group. + * The group variant of a collection automatically resizes so that none of its contents + * are ever hidden. + * @param doc doc to test for being a freeform group + * @returns boolean + */ + export function IsFreeformGroup(doc: Doc) { + return doc.freeform_isGroup && doc.type_collection === CollectionViewType.Freeform; } // // this will write the value to the key on either the data doc or the embedding doc. The choice @@ -596,9 +608,6 @@ export namespace Doc { const value = (fields as { [key: string]: Opt<FieldType> })[key]; if (!skipUndefineds || value !== undefined) { // Do we want to filter out undefineds? - if (typeof value === 'object' && 'values' in value) { - console.log(value); - } doc[key] = value; } }); @@ -734,7 +743,7 @@ export namespace Doc { const FindDocsInRTF = new RegExp(/(audioId|textId|anchorId|docId)"\s*:\s*"(.*?)"/g); export function makeClone(doc: Doc, cloneMap: Map<string, Doc>, linkMap: Map<string, Doc>, rtfs: { copy: Doc; key: string; field: RichTextField }[], exclusions: string[], pruneDocs: Doc[], cloneLinks: boolean, cloneTemplates: boolean): Doc { - if (Doc.IsBaseProto(doc) || ((Doc.isTemplateDoc(doc) || Doc.isTemplateForField(doc)) && !cloneTemplates)) { + if (Doc.IsBaseProto(doc) || ((Doc.IsTemplateDoc(doc) || Doc.IsTemplateForField(doc)) && !cloneTemplates)) { return doc; } if (cloneMap.get(doc[Id])) return cloneMap.get(doc[Id])!; @@ -808,7 +817,7 @@ export namespace Doc { const docAtKey = DocCast(clone[key]); if (docAtKey && !Doc.IsSystem(docAtKey)) { if (!Array.from(cloneMap.values()).includes(docAtKey)) { - clone[key] = !cloneTemplates && (Doc.isTemplateDoc(docAtKey) || Doc.isTemplateForField(docAtKey)) ? docAtKey : cloneMap.get(docAtKey[Id]); + clone[key] = !cloneTemplates && (Doc.IsTemplateDoc(docAtKey) || Doc.IsTemplateForField(docAtKey)) ? docAtKey : cloneMap.get(docAtKey[Id]); } else { repairClone(docAtKey, cloneMap, cloneTemplates, visited); } @@ -859,7 +868,7 @@ export namespace Doc { */ export function expandTemplateLayout(templateLayoutDoc: Doc, targetDoc?: Doc, layoutFieldKey?: string) { // nothing to do if the layout isn't a template or we don't have a target that's different than the template - if (!targetDoc || templateLayoutDoc === targetDoc || (!Doc.isTemplateForField(templateLayoutDoc) && !Doc.isTemplateDoc(templateLayoutDoc))) { + if (!targetDoc || templateLayoutDoc === targetDoc || (!Doc.IsTemplateForField(templateLayoutDoc) && !Doc.IsTemplateDoc(templateLayoutDoc))) { return templateLayoutDoc; } @@ -922,7 +931,7 @@ export namespace Doc { console.log('Warning: GetLayoutDataDocPair childDoc not defined'); return { layout: childDoc, data: childDoc }; } - const data = Doc.AreProtosEqual(containerDataDoc, containerDoc) || (!Doc.isTemplateDoc(childDoc) && !Doc.isTemplateForField(childDoc)) ? undefined : containerDataDoc; + const data = Doc.AreProtosEqual(containerDataDoc, containerDoc) || (!Doc.IsTemplateDoc(childDoc) && !Doc.IsTemplateForField(childDoc)) ? undefined : containerDataDoc; const templateRoot = DocCast(containerDoc?.rootDocument); return { layout: Doc.expandTemplateLayout(childDoc, templateRoot, layoutFieldKey), data }; } @@ -1190,14 +1199,14 @@ export namespace Doc { } export function NativeWidth(doc?: Doc, dataDoc?: Doc, useWidth?: boolean) { // if this is a field template, then don't use the doc's nativeWidth/height - return !doc ? 0 : NumCast(doc.isTemplateForField ? undefined : doc._nativeWidth, NumCast((dataDoc || doc)[Doc.LayoutDataKey(doc) + '_nativeWidth'], !doc.isTemplateForField && useWidth ? NumCast(doc._width) : 0)); + return !doc ? 0 : NumCast(doc._nativeWidth, doc[DocLayout].isTemplateDoc ? 0 : NumCast((dataDoc || doc)[Doc.LayoutDataKey(doc) + '_nativeWidth'], useWidth ? NumCast(doc._width) : 0)); } export function NativeHeight(doc?: Doc, dataDoc?: Doc, useHeight?: boolean) { if (!doc) return 0; const nheight = (Doc.NativeWidth(doc, dataDoc, useHeight) / NumCast(doc._width)) * NumCast(doc._height); // divide before multiply to avoid floating point errrorin case nativewidth = width - const dheight = NumCast((dataDoc || doc)[Doc.LayoutDataKey(doc) + '_nativeHeight'], useHeight ? NumCast(doc._height) : 0); + const dheight = doc[DocLayout].isTemplateDoc ? 0 : NumCast((dataDoc || doc)[Doc.LayoutDataKey(doc) + '_nativeHeight'], useHeight ? NumCast(doc._height) : 0); // if this is a field template, then don't use the doc's nativeWidth/height - return NumCast(doc.isTemplateForField ? undefined : doc._nativeHeight, nheight || dheight); + return NumCast(doc._nativeHeight, nheight || dheight); } export function OutpaintingWidth(doc?: Doc, dataDoc?: Doc, useWidth?: boolean) { @@ -1467,7 +1476,6 @@ export namespace Doc { layoutDoc._nativeWidth = undefined; layoutDoc._nativeHeight = undefined; } else { - layoutDoc._layout_autoHeight = false; if (!Doc.NativeWidth(layoutDoc)) { layoutDoc._nativeWidth = NumCast(layoutDoc._width, panelWidth); layoutDoc._nativeHeight = NumCast(layoutDoc._height, panelHeight); diff --git a/src/fields/FieldLoader.scss b/src/fields/FieldLoader.scss index 9a23c3e4f..62d85a763 100644 --- a/src/fields/FieldLoader.scss +++ b/src/fields/FieldLoader.scss @@ -1,7 +1,7 @@ .fieldLoader { z-index: 10000; width: 200px; - height: 30; + height: 30px; background: lightblue; position: absolute; left: calc(50% - 99px); diff --git a/src/fields/InkField.ts b/src/fields/InkField.ts index d1dda106a..f2c09b6b7 100644 --- a/src/fields/InkField.ts +++ b/src/fields/InkField.ts @@ -18,6 +18,7 @@ export enum InkInkTool { Pen = 'Pen', Highlight = 'Highlight', Write = 'Write', + Math = 'Math', } export enum InkEraserTool { diff --git a/src/server/ApiManagers/FireflyManager.ts b/src/server/ApiManagers/FireflyManager.ts index e934e635b..6393a1f74 100644 --- a/src/server/ApiManagers/FireflyManager.ts +++ b/src/server/ApiManagers/FireflyManager.ts @@ -23,7 +23,16 @@ export default class FireflyManager extends ApiManager { return undefined; }); - generateImageFromStructure = (prompt: string = 'a realistic illustration of a cat coding', width: number = 2048, height: number = 2048, structureUrl: string, strength: number = 50, styles: string[], styleUrl: string | undefined) => + generateImageFromStructure = ( + prompt: string = 'a realistic illustration of a cat coding', + width: number = 2048, + height: number = 2048, + structureUrl: string, + strength: number = 50, + styles: string[], + styleUrl: string | undefined, + variations: number = 4 + ) => this.getBearerToken().then(response => response?.json().then((data: { access_token: string }) => //prettier-ignore @@ -37,7 +46,7 @@ export default class FireflyManager extends ApiManager { ], body: JSON.stringify({ prompt, - numVariations: 4, + numVariations: variations, detailLevel: 'preview', modelVersion: 'image3_fast', size: { width, height }, @@ -107,10 +116,7 @@ export default class FireflyManager extends ApiManager { }); generateImage = (prompt: string = 'a realistic illustration of a cat coding', width: number = 2048, height: number = 2048, seed?: number) => { - let body = `{ "prompt": "${prompt}", "size": { "width": ${width}, "height": ${height}} }`; - if (seed) { - body = `{ "prompt": "${prompt}", "size": { "width": ${width}, "height": ${height}}, "seeds": [${seed}]}`; - } + const body = `{ "prompt": "${prompt}", "size": { "width": ${width}, "height": ${height}} ${seed ? ', "seeds": [' + seed + ']' : ''}}`; const fetched = this.getBearerToken().then(response => response?.json().then((data: { access_token: string }) => fetch('https://firefly-api.adobe.io/v3/images/generate', { diff --git a/src/server/ApiManagers/GeneralGoogleManager.ts b/src/server/ApiManagers/GeneralGoogleManager.ts index 12913b1ef..110701418 100644 --- a/src/server/ApiManagers/GeneralGoogleManager.ts +++ b/src/server/ApiManagers/GeneralGoogleManager.ts @@ -3,6 +3,7 @@ import { Method } from '../RouteManager'; import { GoogleApiServerUtils } from '../apis/google/GoogleApiServerUtils'; import RouteSubscriber from '../RouteSubscriber'; import { Database } from '../database'; +import { google } from 'googleapis'; const EndpointHandlerMap = new Map<GoogleApiServerUtils.Action, GoogleApiServerUtils.ApiRouter>([ ['create', (api, params) => api.create(params)], @@ -61,5 +62,139 @@ export default class GeneralGoogleManager extends ApiManager { res.send(undefined); }, }); + + // Task Creation + register({ + method: Method.POST, + subscription: new RouteSubscriber('googleTasks').add('create'), + secureHandler: async ({ req, res, user }) => { + try { + const auth = await GoogleApiServerUtils.retrieveOAuthClient(user.id); + + if (!auth) { + return res.status(401).send('Google credentials missing or invalid.'); + } + + const tasks = google.tasks({ version: 'v1', auth }); + + const { title, notes, due, status, completed, deleted } = req.body; + const result = await tasks.tasks.insert({ + tasklist: '@default', + requestBody: { title, notes, due, status, completed, deleted }, + }); + + res.status(200).send(result.data); + } catch (err) { + console.error('Google Tasks error:', err); + res.status(500).send('Failed to create task.'); + } + }, + }); + + // Task Update + register({ + method: Method.PATCH, + subscription: new RouteSubscriber('googleTasks').add('taskId'), + // any way to add static params? like /update (this is not very descriptive) + secureHandler: async ({ req, res, user }) => { + try { + const auth = await GoogleApiServerUtils.retrieveOAuthClient(user.id); + + if (!auth) { + return res.status(401).send('Google credentials missing or invalid.'); + } + + const tasks = google.tasks({ version: 'v1', auth }); + + const { taskId } = req.params; + const { title, notes, due, status, completed, deleted } = req.body; + + const result = await tasks.tasks.patch({ + tasklist: '@default', + task: taskId, + requestBody: { title, notes, due, status, completed, deleted}, + }); + + res.status(200).send(result.data); + } catch (err) { + console.error('Google Tasks update error:', err); + res.status(500).send('Failed to update task.'); + } + }, + }); + + // Task Deletion + register({ + method: Method.DELETE, + subscription: new RouteSubscriber('googleTasks').add('taskId'), + secureHandler: async ({ req, res, user }) => { + try { + const auth = await GoogleApiServerUtils.retrieveOAuthClient(user.id); + + if (!auth) { + return res.status(401).send('Google credentials missing or invalid.'); + } + + const tasks = google.tasks({ version: 'v1', auth }); + const { taskId } = req.params; + + await tasks.tasks.delete({ + tasklist: '@default', + task: taskId, + }); + + res.status(200).send({ success: true }); + } catch (err) { + console.error('Google Tasks delete error:', err); + res.status(500).send('Failed to delete task.'); + } + }, + }); + + // Google Account Linking + + register({ + method: Method.GET, + subscription: '/refreshGoogle', + secureHandler: async ({ user, req, res }) => + new Promise<void>(resolve => + GoogleApiServerUtils.processNewUser(user.id, req.query.code as string) + .then(() => res.status(200).send('Google account linked successfully!')) + .catch(err => { + console.error('Failed to process Google code:', err); + res.status(500).send('Error linking Google account'); + }) + .finally(resolve) + ), + }); + + // Task Retrieval + register({ + method: Method.GET, + subscription: new RouteSubscriber('googleTasks').add('taskId'), + secureHandler: async ({ req, res, user }) => { + try { + const auth = await GoogleApiServerUtils.retrieveOAuthClient(user.id); + + if (!auth) { + return res.status(401).send('Google credentials missing or invalid.'); + } + + const tasks = google.tasks({ version: 'v1', auth }); + const { taskId } = req.params; + + const result = await tasks.tasks.get({ + tasklist: '@default', + task: taskId, + }); + + res.status(200).send(result.data); + } catch (err) { + console.error('Google Tasks retrieval error:', err); + res.status(500).send('Failed to retrieve task.'); + } + }, + }); } + } diff --git a/src/server/ApiManagers/UploadManager.ts b/src/server/ApiManagers/UploadManager.ts index 7c55e4a42..1e68a4e30 100644 --- a/src/server/ApiManagers/UploadManager.ts +++ b/src/server/ApiManagers/UploadManager.ts @@ -18,7 +18,7 @@ export default class UploadManager extends ApiManager { protected initialize(register: Registration): void { register({ method: Method.POST, - subscription: '/ping', + subscription: '/DashPing', secureHandler: async ({ /* req, */ res }) => { _success(res, { message: DashVersion, date: new Date() }); }, diff --git a/src/server/PdfTypes.ts b/src/server/PdfTypes.ts index fb435a677..458df7b26 100644 --- a/src/server/PdfTypes.ts +++ b/src/server/PdfTypes.ts @@ -2,7 +2,7 @@ export interface PDFInfo { PDFFormatVersion: string; IsAcroFormPresent: boolean; IsXFAPresent: boolean; - [key: string]: any; + [key: string]: unknown; } export interface PDFMetadata { parse(): void; diff --git a/src/server/RouteManager.ts b/src/server/RouteManager.ts index 2f6cf80b5..c5d70da3d 100644 --- a/src/server/RouteManager.ts +++ b/src/server/RouteManager.ts @@ -8,6 +8,8 @@ import { DashUserModel } from './authentication/DashUserModel'; export enum Method { GET, POST, + PATCH, + DELETE, } export interface CoreArguments { @@ -207,6 +209,12 @@ export default class RouteManager { case Method.POST: this.server.post(route, supervised); break; + case Method.PATCH: + this.server.patch(route, supervised); + break; + case Method.DELETE: + this.server.delete(route, supervised); + break; default: } } diff --git a/src/server/apis/google/GoogleApiServerUtils.ts b/src/server/apis/google/GoogleApiServerUtils.ts index 7373df473..ad0f0e580 100644 --- a/src/server/apis/google/GoogleApiServerUtils.ts +++ b/src/server/apis/google/GoogleApiServerUtils.ts @@ -13,7 +13,7 @@ import { GoogleCredentialsLoader } from './CredentialsLoader'; * This is the somewhat overkill list of what Dash requests * from the user. */ -const scope = ['documents.readonly', 'documents', 'presentations', 'presentations.readonly', 'drive', 'drive.file', 'photoslibrary', 'photoslibrary.appendonly', 'photoslibrary.sharing', 'userinfo.profile'].map( +const scope = ['tasks', 'documents.readonly', 'documents', 'presentations', 'presentations.readonly', 'drive', 'drive.file', 'photoslibrary', 'photoslibrary.appendonly', 'photoslibrary.sharing', 'userinfo.profile'].map( relative => `https://www.googleapis.com/auth/${relative}` ); @@ -181,7 +181,7 @@ export namespace GoogleApiServerUtils { * @returns the newly generated url to the authentication landing page */ export function generateAuthenticationUrl(): string { - return worker.generateAuthUrl({ scope, access_type: 'offline' }); + return worker.generateAuthUrl({ scope, access_type: 'offline', prompt: 'consent' }); } /** @@ -203,12 +203,12 @@ export namespace GoogleApiServerUtils { */ export async function processNewUser(userId: string, authenticationCode: string): Promise<EnrichedCredentials> { const credentials = await new Promise<Credentials>((resolve, reject) => { - worker.getToken(authenticationCode, (err, credentials) => { - if (err || !credentials) { + worker.getToken(authenticationCode, (err, creds) => { + if (err || !creds) { reject(err); return; } - resolve(credentials); + resolve(creds); }); }); const enriched = injectUserInfo(credentials); @@ -304,6 +304,7 @@ export namespace GoogleApiServerUtils { const { access_token, expires_in } = await new Promise<{ access_token: string; expires_in: number }>(resolve => { request.post(url, headerParameters).then(response => resolve(JSON.parse(response))); }); + // expires_in is in seconds, but we're building the new expiry date in milliseconds const expiry_date = new Date().getTime() + expires_in * 1000; await Database.Auxiliary.GoogleAccessToken.Update(userId, access_token, expiry_date); diff --git a/src/server/apis/google/google_project_credentials.json b/src/server/apis/google/google_project_credentials.json index 955c5a3c1..438e5157e 100644 --- a/src/server/apis/google/google_project_credentials.json +++ b/src/server/apis/google/google_project_credentials.json @@ -1,11 +1,11 @@ { "installed": { - "client_id": "343179513178-ud6tvmh275r2fq93u9eesrnc66t6akh9.apps.googleusercontent.com", - "project_id": "quickstart-1565056383187", + "client_id": "740987818053-dtflji3hfkn5r9t8ad6jb8740pls8moh.apps.googleusercontent.com", + "project_id": "dash-web-461920", "auth_uri": "https://accounts.google.com/o/oauth2/auth", "token_uri": "https://oauth2.googleapis.com/token", "auth_provider_x509_cert_url": "https://www.googleapis.com/oauth2/v1/certs", - "client_secret": "w8KIFSc0MQpmUYHed4qEzn8b", - "redirect_uris": ["urn:ietf:wg:oauth:2.0:oob", "http://localhost"] + "client_secret": "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", + "redirect_uris": ["http://localhost:1050/refreshGoogle"] } }
\ No newline at end of file diff --git a/src/server/authentication/DashUserModel.ts b/src/server/authentication/DashUserModel.ts index debeef60c..6fd8dd593 100644 --- a/src/server/authentication/DashUserModel.ts +++ b/src/server/authentication/DashUserModel.ts @@ -2,9 +2,10 @@ import * as bcrypt from 'bcrypt-nodejs'; import * as mongoose from 'mongoose'; import { Utils } from '../../Utils'; -type comparePasswordFunction = (candidatePassword: string, cb: (err: any, isMatch: any) => void) => void; -export type DashUserModel = mongoose.Document & { - email: String; +type comparePasswordFunction = (candidatePassword: string, cb: (err: Error, isMatch: boolean) => void) => void; +type mongooseDocument = { id: string }; // & mongoose.Document; +export type DashUserModel = mongooseDocument & { + email: string; password: string; passwordResetToken?: string; passwordResetExpires?: Date; @@ -65,12 +66,13 @@ const userSchema = new mongoose.Schema( /** * Password hash middleware. */ -userSchema.pre('save', function save(next) { +// eslint-disable-next-line @typescript-eslint/no-explicit-any +userSchema.pre('save', function save(next: any) { const user = this; if (!user.isModified('password')) { return next(); } - bcrypt.genSalt(10, (err: any, salt: string) => { + bcrypt.genSalt(10, (err: Error, salt: string) => { if (err) { return next(err); } @@ -102,7 +104,7 @@ const comparePassword: comparePasswordFunction = function (this: DashUserModel, userSchema.methods.comparePassword = comparePassword; -const User: any = mongoose.model('User', userSchema); +const User = mongoose.model('User', userSchema); export function initializeGuest() { new User({ email: 'guest', diff --git a/src/server/authentication/Passport.ts b/src/server/authentication/Passport.ts index ca9e3058e..a62d38e3e 100644 --- a/src/server/authentication/Passport.ts +++ b/src/server/authentication/Passport.ts @@ -5,13 +5,13 @@ import User, { DashUserModel } from './DashUserModel'; const LocalStrategy = passportLocal.Strategy; passport.serializeUser<any, any>((req, user, done) => { - done(undefined, (user as any)?.id); + done(undefined, (user as DashUserModel)?.id); }); passport.deserializeUser<any, any>((id, done) => { User.findById(id) .exec() - .then((user: any) => done(undefined, user)); + .then((user: DashUserModel) => done(undefined, user)); }); // AUTHENTICATE JUST WITH EMAIL AND PASSWORD @@ -30,6 +30,6 @@ passport.use( }); } }) - .catch((error: any) => done(error)); + .catch((error: Error) => done(error)); }) ); diff --git a/src/server/server_Initialization.ts b/src/server/server_Initialization.ts index 80cf977ee..5deb66caf 100644 --- a/src/server/server_Initialization.ts +++ b/src/server/server_Initialization.ts @@ -126,10 +126,13 @@ function registerCorsProxy(server: express.Express) { //res.status(400).json({ error: 'Invalid URL format' }); return; } + + const isBinary = /\.(gif|png|jpe?g|bmp|webp|ico|pdf|zip|mp3|mp4|wav|ogg)$/i.test(targetUrl as string); + const responseType = isBinary ? 'arraybuffer' : 'text'; const response = await axios.get(targetUrl as string, { headers: { 'User-Agent': req.headers['user-agent'] || 'Mozilla/5.0' }, - responseType: 'text', + responseType: responseType }); const baseUrl = new URL(targetUrl as string); |