diff options
Diffstat (limited to 'src/client/views/nodes/formattedText/DailyJournal.tsx')
-rw-r--r-- | src/client/views/nodes/formattedText/DailyJournal.tsx | 280 |
1 files changed, 254 insertions, 26 deletions
diff --git a/src/client/views/nodes/formattedText/DailyJournal.tsx b/src/client/views/nodes/formattedText/DailyJournal.tsx index ec1f7a023..871c556e6 100644 --- a/src/client/views/nodes/formattedText/DailyJournal.tsx +++ b/src/client/views/nodes/formattedText/DailyJournal.tsx @@ -1,14 +1,22 @@ -import { action, makeObservable, observable } from 'mobx'; +import { makeObservable, action, observable } from 'mobx'; import * as React from 'react'; -import { RichTextField } from '../../../../fields/RichTextField'; import { Docs } from '../../../documents/Documents'; import { DocumentType } from '../../../documents/DocumentTypes'; import { ViewBoxAnnotatableComponent } from '../../DocComponent'; import { FieldView, FieldViewProps } from '../FieldView'; import { FormattedTextBox, FormattedTextBoxProps } from './FormattedTextBox'; +import { gptAPICall, GPTCallType } from '../../../apis/gpt/GPT'; +import { RichTextField } from '../../../../fields/RichTextField'; +import { Plugin } from 'prosemirror-state'; +import { RTFCast } from '../../../../fields/Types'; 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 + _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?'; public static LayoutString(fieldStr: string) { return FieldView.LayoutString(DailyJournal, fieldStr); @@ -18,12 +26,13 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() super(props); makeObservable(this); this.journalDate = this.getFormattedDate(); - - console.log('Constructor: Setting initial title and text...'); - this.setDailyTitle(); - this.setDailyText(); } + /** + * Method to get the current date in standard format + * @returns - date in standard long format + */ + getFormattedDate(): string { const date = new Date().toLocaleDateString(undefined, { weekday: 'long', @@ -35,6 +44,9 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() return date; } + /** + * Method to set the title of the node to the date + */ @action setDailyTitle() { console.log('setDailyTitle() called...'); @@ -48,43 +60,259 @@ export class DailyJournal extends ViewBoxAnnotatableComponent<FieldViewProps>() console.log('New title after update:', this.dataDoc.title); } + /** + * Method to set the standard text of the node (to the current date) + */ @action setDailyText() { - console.log('setDailyText() called...'); const placeholderText = 'Start writing here...'; - const initialText = `Journal Entry - ${this.journalDate}\n${placeholderText}`; + const dateText = `${this.journalDate}\n`; console.log('Checking if dataDoc has text field...'); - const styles = { - bold: true, // Make the journal date bold - color: 'blue', // Set the journal date color to blue - fontSize: 18, // Set the font size to 18px for the whole text - }; - - console.log('Setting new text field with:', initialText); - this.dataDoc[this.fieldKey] = RichTextField.textToRtf( - initialText, - undefined, // No image DocId - styles, // Pass the styles object here - placeholderText.length // The position for text selection + this.dataDoc[this.fieldKey] = 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 ); console.log('Current text field:', this.dataDoc[this.fieldKey]); } + /** + * Tracks user typing text inout into the node, to call the insert predicted + * text function when appropriate (i.e. when the user stops typing) + */ + + @action onTextInput = () => { + const editorView = this._ref.current?.EditorView; + if (!editorView) return; + + if (this.typingTimeout) clearTimeout(this.typingTimeout); + + this.typingTimeout = setTimeout(() => { + this.insertPredictiveQuestion(); + }, 3500); + }; + + /** + * Inserts predictive text at the end of what the user is typing + */ + + @action insertPredictiveQuestion = async () => { + const editorView = this._ref.current?.EditorView; + if (!editorView) return; + + const { state, dispatch } = editorView; + const { schema } = state; + const { to } = state.selection; + const insertPos = to; // cursor position + + const resolvedPos = state.doc.resolve(insertPos); + const parentNode = resolvedPos.parent; + const indexInParent = resolvedPos.index(); + const isAtEndOfParent = indexInParent >= parentNode.childCount; + + // Check if there's a line break or paragraph node after the current position + let hasNewlineAfter = false; + try { + const nextNode = parentNode.child(indexInParent); + hasNewlineAfter = nextNode.type.name === schema.nodes.hard_break.name || nextNode.type.name === schema.nodes.paragraph.name; + } catch { + hasNewlineAfter = false; + } + + // 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' }); + const fontColorMark = schema.marks.pFontColor.create({ fontColor: 'lightgray' }); + const fontItalicsMark = schema.marks.em.create(); + + this.predictiveText = ' ...'; // placeholder for now + + 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 res = await gptAPICall(gptPrompt, GPTCallType.COMPLETION); + if (!res) return; + + // styled text node + const text = ` ... ${res.trim()}`; + const predictedText = schema.text(text, [fontSizeMark, fontColorMark, fontItalicsMark]); + + // 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 + dispatch(transaction); + + this.predictiveText = text; + }; + + createPredictiveCleanupPlugin = () => { + return new Plugin({ + view: () => { + return { + update: (view, prevState) => { + const { state, dispatch } = view; + if (!this.predictiveText) return; + + // Check if doc or selection changed + if (!prevState.doc.eq(state.doc) || !prevState.selection.eq(state.selection)) { + const found = false; + const textToRemove = this.predictiveText; + + state.doc.descendants((node, pos) => { + if (node.isText && node.text === textToRemove) { + const tr = state.tr.delete(pos, pos + node.nodeSize); + + // Set the desired default marks for future 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]); + + dispatch(tr); + + this.predictiveText = null; + return false; + } + return true; + }); + + if (!found) { + // fallback cleanup + this.predictiveText = null; + } + } + }, + }; + }, + }); + }; + componentDidMount(): void { console.log('componentDidMount() triggered...'); - // bcz: This should be moved into Docs.Create.DailyJournalDocument() - // otherwise, it will override all the text whenever the note is reloaded - this.setDailyTitle(); - this.setDailyText(); + console.log('Text: ' + RTFCast(this.Document.text)?.Text); + + const editorView = this._ref.current?.EditorView; + if (editorView) { + editorView.dom.addEventListener('input', this.onTextInput); + + // Add plugin to state if not already added + const cleanupPlugin = this.createPredictiveCleanupPlugin(); + this._ref.current?.addPlugin(cleanupPlugin); + } + + const rawText = RTFCast(this.Document.text)?.Text ?? ''; + const isTextEmpty = !rawText || rawText === ''; + + const currentTitle = this.dataDoc.title || ''; + const isTitleString = typeof currentTitle === 'string'; + const isDefaultTitle = isTitleString && currentTitle.includes('Untitled DailyJournal'); + + if (isTextEmpty && isDefaultTitle) { + console.log('Journal title and text are default. Initializing...'); + this.setDailyTitle(); + this.setDailyText(); + } else { + console.log('Journal already has content. Skipping initialization.'); + } } + componentWillUnmount(): void { + const editorView = this._ref.current?.EditorView; + if (editorView) { + editorView.dom.removeEventListener('input', this.onTextInput); + } + if (this.typingTimeout) clearTimeout(this.typingTimeout); + } + + @action handleGeneratePrompts = async () => { + 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); + + if (!rawText.trim()) { + alert('Journal is empty! Write something first.'); + return; + } + + try { + // Call GPT API to generate prompts + const res = await gptAPICall('Generate 1-2 short journal prompts for the following journal entry: ' + rawText, GPTCallType.COMPLETION); + + if (!res) { + console.error('GPT call failed.'); + return; + } + + const editorView = this._ref.current?.EditorView; + if (!editorView) { + console.error('EditorView is not available.'); + return; + } else { + const { state, dispatch } = editorView; + const { schema } = state; + + // Use available marks + const boldMark = schema.marks.strong.create(); + const italicMark = schema.marks.em.create(); + const fontSizeMark = schema.marks.pFontSize.create({ fontSize: '14px' }); + const fontColorMark = schema.marks.pFontColor.create({ fontColor: 'gray' }); + + // Create text nodes with formatting + const headerText = schema.text('\n\n# Suggested Prompts:\n', [boldMark, italicMark, fontSizeMark, fontColorMark]); + const responseText = schema.text(res, [fontSizeMark, fontColorMark]); + + // Insert formatted text + const transaction = state.tr.insert(state.selection.from, headerText).insert(state.selection.from + headerText.nodeSize, responseText); + dispatch(transaction); + } + } catch (err) { + console.error('Error calling GPT:', err); + } + }; + render() { return ( - <div style={{ background: 'beige', width: '100%', height: '100%' }}> - <FormattedTextBox {...this._props} fieldKey={'text'} Document={this.Document} TemplateDataDocument={undefined} /> + <div + style={{ + // background: 'beige', + width: '100%', + height: '100%', + backgroundColor: 'beige', + backgroundImage: ` + repeating-linear-gradient( + to bottom, + rgba(255, 26, 26, 0.2) 0px, rgba(255, 26, 26, 0.2) 1px, /* Thin red stripes */ + transparent 1px, transparent 20px + ) + `, + backgroundSize: '100% 20px', + backgroundRepeat: 'repeat', + }}> + {/* GPT Button */} + <button + style={{ + position: 'absolute', + bottom: '5px', + right: '5px', + padding: '5px 10px', + backgroundColor: '#9EAD7C', + color: 'white', + border: 'none', + borderRadius: '5px', + cursor: 'pointer', + zIndex: 10, + }} + onClick={this.handleGeneratePrompts}> + Prompts + </button> + + <FormattedTextBox ref={this._ref} {...this._props} fieldKey={'text'} Document={this.Document} TemplateDataDocument={undefined} /> </div> ); } |