import { makeObservable, action, observable } from 'mobx'; import * as React from 'react'; 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'; import { Mark } from 'prosemirror-model'; import { observer } from 'mobx-react'; export class DailyJournal extends ViewBoxAnnotatableComponent() { @observable journalDate: string; @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(); // 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); } constructor(props: FormattedTextBoxProps) { super(props); makeObservable(this); this.journalDate = this.getFormattedDate(); } /** * 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', year: 'numeric', month: 'long', day: 'numeric', }); // console.log('getFormattedDate():', date); return date; } /** * Method to set the title of the node to the date */ @action setDailyTitle() { // 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); this.dataDoc.title = this.journalDate; } // console.log('New title after update:', this.dataDoc.title); } /** * Method to set the standard text of the node (to the current date) */ @action setDailyText() { const placeholderText = 'Start writing here...'; const dateText = `${this.journalDate}\n`; // console.log('Checking if dataDoc has text field...'); 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]); } /** * 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); } } } /** * 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); 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); }; /** * 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; // 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 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 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, [fontColorMark, fontItalicsMark, ...(fontSizeMark ? [fontSizeMark] : []), ...(fontFamilyMark ? [fontFamilyMark] : [])]); // Insert styled text at cursor position 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: () => { 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); // default marks for input const fontSizeMark = state.schema.marks.pFontSize.create({ fontSize: '14px' }); const fontColorMark = state.schema.marks.pFontColor.create({ fontColor: 'gray' }); tr.setStoredMarks([]); 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; }); if (!found) { // fallback cleanup this.predictiveText = null; } } }, }; }, }); }; componentDidMount(): void { // 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) { 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(); this.setInitialDateRange(); } else { // 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); } 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); 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); } finally { this.isLoadingPrompts = false; } }; /** * Method to render the styled DailyJournal * @returns - the HTML component for the journal */ render() { return (
{/* GPT Button */} {this.showPromptMenu && (
)}
); } } const ObservedDailyJournal = observer(DailyJournal); Docs.Prototypes.TemplateMap.set(DocumentType.JOURNAL, { layout: { view: ObservedDailyJournal, dataField: 'text' }, options: { acl: '', _height: 35, _xMargin: 10, _yMargin: 10, _layout_autoHeight: true, _layout_nativeDimEditable: true, _layout_reflowVertical: true, _layout_reflowHorizontal: true, defaultDoubleClick: 'ignore', systemIcon: 'BsFileEarmarkTextFill', }, });