diff --git a/.vscode/settings.json b/.vscode/settings.json index fc8166b5..41542fc6 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,5 +1,8 @@ { "typescript.tsdk": "node_modules/typescript/lib", + "cursor.ai.language": "en", + "cursor.commitMessage.language": "english", + "locale": "en-US", "editor.rulers": [ 120 ], diff --git a/package.json b/package.json index f9efd604..fc8b4ff9 100644 --- a/package.json +++ b/package.json @@ -41,6 +41,12 @@ "@emotion/react": "^11.10.6", "@emotion/styled": "^11.10.6", "@floating-ui/react": "^0.26.27", + "@fullcalendar/core": "^6.1.19", + "@fullcalendar/daygrid": "^6.1.19", + "@fullcalendar/interaction": "^6.1.19", + "@fullcalendar/multimonth": "^6.1.19", + "@fullcalendar/react": "^6.1.19", + "@fullcalendar/timegrid": "^6.1.19", "@jest/globals": "^29.7.0", "@mui/icons-material": "^5.11.11", "@mui/material": "^6.0.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 0b764498..c296ebc8 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -53,6 +53,24 @@ importers: '@floating-ui/react': specifier: ^0.26.27 version: 0.26.28(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@fullcalendar/core': + specifier: ^6.1.19 + version: 6.1.19 + '@fullcalendar/daygrid': + specifier: ^6.1.19 + version: 6.1.19(@fullcalendar/core@6.1.19) + '@fullcalendar/interaction': + specifier: ^6.1.19 + version: 6.1.19(@fullcalendar/core@6.1.19) + '@fullcalendar/multimonth': + specifier: ^6.1.19 + version: 6.1.19(@fullcalendar/core@6.1.19) + '@fullcalendar/react': + specifier: ^6.1.19 + version: 6.1.19(@fullcalendar/core@6.1.19)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@fullcalendar/timegrid': + specifier: ^6.1.19 + version: 6.1.19(@fullcalendar/core@6.1.19) '@jest/globals': specifier: ^29.7.0 version: 29.7.0 @@ -1786,6 +1804,36 @@ packages: '@floating-ui/utils@0.2.9': resolution: {integrity: sha512-MDWhGtE+eHw5JW7lq4qhc5yRLS11ERl1c7Z6Xd0a58DozHES6EnNNwUWbMiG4J9Cgj053Bhk8zvlhFYKVhULwg==} + '@fullcalendar/core@6.1.19': + resolution: {integrity: sha512-z0aVlO5e4Wah6p6mouM0UEqtRf1MZZPt4mwzEyU6kusaNL+dlWQgAasF2cK23hwT4cmxkEmr4inULXgpyeExdQ==} + + '@fullcalendar/daygrid@6.1.19': + resolution: {integrity: sha512-IAAfnMICnVWPjpT4zi87i3FEw0xxSza0avqY/HedKEz+l5MTBYvCDPOWDATpzXoLut3aACsjktIyw9thvIcRYQ==} + peerDependencies: + '@fullcalendar/core': ~6.1.19 + + '@fullcalendar/interaction@6.1.19': + resolution: {integrity: sha512-GOciy79xe8JMVp+1evAU3ytdwN/7tv35t5i1vFkifiuWcQMLC/JnLg/RA2s4sYmQwoYhTw/p4GLcP0gO5B3X5w==} + peerDependencies: + '@fullcalendar/core': ~6.1.19 + + '@fullcalendar/multimonth@6.1.19': + resolution: {integrity: sha512-YYP8o/tjNLFRKhelwiq5ja3Jm3WDf3bfOUHf32JvAWwfotCvZjD7tYv66Nj02mQ8OWWJINa2EQGJxFHgIs14aA==} + peerDependencies: + '@fullcalendar/core': ~6.1.19 + + '@fullcalendar/react@6.1.19': + resolution: {integrity: sha512-FP78vnyylaL/btZeHig8LQgfHgfwxLaIG6sKbNkzkPkKEACv11UyyBoTSkaavPsHtXvAkcTED1l7TOunAyPEnA==} + peerDependencies: + '@fullcalendar/core': ~6.1.19 + react: ^16.7.0 || ^17 || ^18 || ^19 + react-dom: ^16.7.0 || ^17 || ^18 || ^19 + + '@fullcalendar/timegrid@6.1.19': + resolution: {integrity: sha512-OuzpUueyO9wB5OZ8rs7TWIoqvu4v3yEqdDxZ2VcsMldCpYJRiOe7yHWKr4ap5Tb0fs7Rjbserc/b6Nt7ol6BRg==} + peerDependencies: + '@fullcalendar/core': ~6.1.19 + '@humanwhocodes/config-array@0.13.0': resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} engines: {node: '>=10.10.0'} @@ -7222,6 +7270,9 @@ packages: resolution: {integrity: sha512-3Ybi1tAuwAP9s0r1UQ2J4n5Y0G05bJkpUIO0/bI9MhwmD70S5aTWbXGBwxHrelT+XM1k6dM0pk+SwNkpTRN7Pg==} engines: {node: ^10 || ^12 || >=14} + preact@10.12.1: + resolution: {integrity: sha512-l8386ixSsBdbreOAkqtrwqHwdvR35ID8c3rKPa8lCWuO86dBi32QWHV4vfsZK1utLLFMvw+Z5Ad4XLkZzchscg==} + prefix-style@2.0.1: resolution: {integrity: sha512-gdr1MBNVT0drzTq95CbSNdsrBDoHGlb2aDJP/FoY+1e+jSDPOb1Cv554gH2MGiSr2WTcXi/zu+NaFzfcHQkfBQ==} @@ -10623,6 +10674,34 @@ snapshots: '@floating-ui/utils@0.2.9': {} + '@fullcalendar/core@6.1.19': + dependencies: + preact: 10.12.1 + + '@fullcalendar/daygrid@6.1.19(@fullcalendar/core@6.1.19)': + dependencies: + '@fullcalendar/core': 6.1.19 + + '@fullcalendar/interaction@6.1.19(@fullcalendar/core@6.1.19)': + dependencies: + '@fullcalendar/core': 6.1.19 + + '@fullcalendar/multimonth@6.1.19(@fullcalendar/core@6.1.19)': + dependencies: + '@fullcalendar/core': 6.1.19 + '@fullcalendar/daygrid': 6.1.19(@fullcalendar/core@6.1.19) + + '@fullcalendar/react@6.1.19(@fullcalendar/core@6.1.19)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@fullcalendar/core': 6.1.19 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + '@fullcalendar/timegrid@6.1.19(@fullcalendar/core@6.1.19)': + dependencies: + '@fullcalendar/core': 6.1.19 + '@fullcalendar/daygrid': 6.1.19(@fullcalendar/core@6.1.19) + '@humanwhocodes/config-array@0.13.0': dependencies: '@humanwhocodes/object-schema': 2.0.3 @@ -16988,6 +17067,8 @@ snapshots: picocolors: 1.1.1 source-map-js: 1.2.1 + preact@10.12.1: {} + prefix-style@2.0.1: {} prelude-ls@1.1.2: {} diff --git a/scripts/create-symlink.cjs b/scripts/create-symlink.cjs index 472f511f..8cc58294 100644 --- a/scripts/create-symlink.cjs +++ b/scripts/create-symlink.cjs @@ -28,7 +28,7 @@ if (!fs.existsSync(fullTargetPath)) { if (fs.existsSync(fullTargetPath)) { // unlink existing symlink - console.log(chalk.yellow(`unlinking existing symlink: `) + chalk.blue(`${fullTargetPath}`)); + console.debug(chalk.yellow(`unlinking existing symlink: `) + chalk.blue(`${fullTargetPath}`)); fs.unlinkSync(fullTargetPath); } @@ -38,6 +38,6 @@ fs.symlink(fullSourcePath, fullTargetPath, 'junction', (err) => { console.error(chalk.red(`error creating symlink: ${err.message}`)); process.exit(1); } - console.log(chalk.green(`symlink created: `) + chalk.blue(`${fullSourcePath}`) + ' -> ' + chalk.blue(`${fullTargetPath}`)); + console.debug(chalk.green(`symlink created: `) + chalk.blue(`${fullSourcePath}`) + ' -> ' + chalk.blue(`${fullTargetPath}`)); }); diff --git a/scripts/generateTailwindColors.cjs b/scripts/generateTailwindColors.cjs index 83f5bb25..6591f5e0 100644 --- a/scripts/generateTailwindColors.cjs +++ b/scripts/generateTailwindColors.cjs @@ -58,4 +58,4 @@ fs.writeFileSync(tailwindConfigFilePath, tailwindColorTemplate, 'utf-8'); const tailwindShadowFilePath = path.join(__dirname, '../tailwind/box-shadow.cjs'); fs.writeFileSync(tailwindShadowFilePath, tailwindShadowTemplate, 'utf-8'); -console.log('Tailwind CSS colors configuration generated successfully.'); +console.debug('Tailwind CSS colors configuration generated successfully.'); diff --git a/scripts/generate_af_icons.cjs b/scripts/generate_af_icons.cjs index 9763beba..5cf5df52 100644 --- a/scripts/generate_af_icons.cjs +++ b/scripts/generate_af_icons.cjs @@ -58,7 +58,7 @@ const main = () => { const categories = processSvgFiles(iconsDirPath); const outputFilePath = path.join(iconsDirPath, 'icons.json'); outputJson(categories, outputFilePath); - console.log(`JSON data has been written to ${outputFilePath}`); + console.debug(`JSON data has been written to ${outputFilePath}`); }; main(); diff --git a/scripts/merge-coverage.cjs b/scripts/merge-coverage.cjs index 1939ca4e..95240bc9 100644 --- a/scripts/merge-coverage.cjs +++ b/scripts/merge-coverage.cjs @@ -29,7 +29,7 @@ fs.copyFileSync(path.join(__dirname, '../coverage/merged/coverage-final.json'), // Generate final merged report execSync('nyc report --reporter=html --reporter=text-summary --report-dir=coverage/merged --temp-dir=coverage/.nyc_output', { stdio: 'inherit' }); -console.log(`Merged coverage report written to coverage/merged`); +console.debug(`Merged coverage report written to coverage/merged`); diff --git a/scripts/system-token/convert-tokens.cjs b/scripts/system-token/convert-tokens.cjs index 386c191c..7e2d7712 100644 --- a/scripts/system-token/convert-tokens.cjs +++ b/scripts/system-token/convert-tokens.cjs @@ -5,7 +5,7 @@ const path = require('path'); function ensureDirectoryExists (dirPath) { if (!fs.existsSync(dirPath)) { fs.mkdirSync(dirPath, { recursive: true }); - console.log(`Created directory: ${dirPath}`); + console.debug(`Created directory: ${dirPath}`); } } @@ -219,7 +219,7 @@ function convertDesignTokens (primitiveFilePath, semanticFilePath, outputFilePat // Write file fs.writeFileSync(outputFilePath, css, 'utf8'); - console.log(`CSS variables written to ${outputFilePath}`); + console.debug(`CSS variables written to ${outputFilePath}`); return { variableNames }; } @@ -237,7 +237,7 @@ ensureDirectoryExists(cssOutputDir); ensureDirectoryExists(tailwindOutputDir); // Execute conversion -console.log('Converting design tokens to CSS variables...'); +console.debug('Converting design tokens to CSS variables...'); // Collect all variable names let allVariableNames = []; @@ -261,9 +261,9 @@ const darkResult = convertDesignTokens( // Dark theme variables are the same as light theme, no need to merge // Generate Tailwind color configuration -console.log('Generating Tailwind color configuration with CSS variable references...'); +console.debug('Generating Tailwind color configuration with CSS variable references...'); const tailwindColors = createTailwindColorsFromVariables(allVariableNames); fs.writeFileSync(path.join(tailwindOutputDir, 'new-colors.cjs'), tailwindColors); -console.log(`Tailwind colors written to ${path.join(tailwindOutputDir, 'new-colors.cjs')}`); +console.debug(`Tailwind colors written to ${path.join(tailwindOutputDir, 'new-colors.cjs')}`); -console.log('Conversion completed successfully!'); \ No newline at end of file +console.debug('Conversion completed successfully!'); \ No newline at end of file diff --git a/src/@types/translations/en.json b/src/@types/translations/en.json index 722bbf58..8ebe68d0 100644 --- a/src/@types/translations/en.json +++ b/src/@types/translations/en.json @@ -342,6 +342,7 @@ "lightMode": "Switch to Light mode", "darkMode": "Switch to Dark mode", "openAsPage": "Open as a Page", + "openEvent": "Open event", "addNewRow": "Add a new row", "openMenu": "Click to open menu", "dragRow": "Drag to reorder the row", @@ -1608,7 +1609,8 @@ "action": "Action", "add": "Click add to below", "drag": "Drag to move", - "deleteRowPrompt": "Are you sure you want to delete {{count}} rows? This action cannot be undone.", + "deleteRowPrompt_one": "Are you sure you want to delete {{count}} row? This action cannot be undone.", + "deleteRowPrompt_many": "Are you sure you want to delete {{count}} rows? This action cannot be undone.", "deleteCardPrompt": "Are you sure you want to delete this card? This action cannot be undone.", "dragAndClick": "Drag to move, click to open menu", "insertRecordAbove": "Insert record above", @@ -2201,11 +2203,14 @@ "menuName": "Calendar", "defaultNewCalendarTitle": "Untitled", "newEventButtonTooltip": "Add a new event", + "deleteEvent": "Delete event", + "addEventOn": "Add event on", + "more": "{{num}}+ more", "navigation": { "today": "Today", "jumpToday": "Jump to Today", - "previousMonth": "Previous Month", - "nextMonth": "Next Month", + "previous": "Previous {{view}}", + "next": "Next {{view}}", "views": { "day": "Day", "week": "Week", @@ -2213,6 +2218,9 @@ "year": "Year" } }, + "week": "Week", + "month": "Month", + "multiMonth": "Multi-month", "mobileEventScreen": { "emptyTitle": "No events yet", "emptyBody": "Press the plus button to create an event on this day." @@ -2232,7 +2240,8 @@ "unscheduledEventsTitle": "Unscheduled events", "clickToAdd": "Click to add to the calendar", "name": "Calendar settings", - "clickToOpen": "Click to open the record" + "clickToOpen": "Click to open the record", + "noDatePopoverTitle": "Drag or click to assign a date" }, "referencedCalendarPrefix": "View of", "quickJumpYear": "Jump to", diff --git a/src/application/awareness/selector.ts b/src/application/awareness/selector.ts index 7257d448..acd540f1 100644 --- a/src/application/awareness/selector.ts +++ b/src/application/awareness/selector.ts @@ -92,7 +92,7 @@ export function useRemoteSelectionsSelector(awareness?: Awareness) { timestamp: state.timestamp, }); } else { - console.log(`🎯 No selection found for client ${clientId}`); + console.debug(`🎯 No selection found for client ${clientId}`); } }); @@ -120,7 +120,7 @@ export function useRemoteSelectionsSelector(awareness?: Awareness) { }; }); - console.log('🎯 Final cursors array:', result); + console.debug('🎯 Final cursors array:', result); return result; }, [cursors, editor]); diff --git a/src/application/database-yjs/cell.type.ts b/src/application/database-yjs/cell.type.ts index a0ff414e..c1fdf9e3 100644 --- a/src/application/database-yjs/cell.type.ts +++ b/src/application/database-yjs/cell.type.ts @@ -115,4 +115,5 @@ export interface CellProps { setEditing?: (editing: boolean) => void; isHovering?: boolean; wrap: boolean; + onCellUpdated?: (cell: Cell) => void; } diff --git a/src/application/database-yjs/context.ts b/src/application/database-yjs/context.ts index 24bdc915..1e329693 100644 --- a/src/application/database-yjs/context.ts +++ b/src/application/database-yjs/context.ts @@ -5,6 +5,7 @@ import { CreateFolderViewPayload, CreateRowDoc, DatabaseRelations, + DateFormat, GenerateAISummaryRowPayload, GenerateAITranslateRowPayload, LoadDatabasePrompts, @@ -13,6 +14,7 @@ import { RowId, Subscription, TestDatabasePromptConfig, + TimeFormat, UpdatePagePayload, View, YDatabase, @@ -23,6 +25,8 @@ import { YSharedRoot, } from '@/application/types'; import EventEmitter from 'events'; +import { useCurrentUser } from '@/components/main/app.hooks'; +import { DefaultTimeSetting, MetadataKey } from '@/application/user-metadata'; export interface DatabaseContextState { readOnly: boolean; @@ -58,6 +62,7 @@ export interface DatabaseContextState { checkIfRowDocumentExists?: (documentId: string) => Promise; eventEmitter?: EventEmitter; getSubscriptions?: (() => Promise) | undefined; + getViewIdFromDatabaseId?: (databaseId: string) => string | null; } export const DatabaseContext = createContext(null); @@ -146,3 +151,14 @@ export const useDatabaseSelectedView = (viewId: string) => { return database.get(YjsDatabaseKey.views).get(viewId); }; + +export const useDefaultTimeSetting = (): DefaultTimeSetting => { + const currentUser = useCurrentUser(); + + + return { + dateFormat: currentUser?.metadata?.[MetadataKey.DateFormat] as DateFormat ?? DateFormat.Local, + timeFormat: currentUser?.metadata?.[MetadataKey.TimeFormat] as TimeFormat ?? TimeFormat.TwelveHour, + startWeekOn: currentUser?.metadata?.[MetadataKey.StartWeekOn] as number ?? 0, + } +} \ No newline at end of file diff --git a/src/application/database-yjs/database.type.ts b/src/application/database-yjs/database.type.ts index 69cac31b..f12dbca7 100644 --- a/src/application/database-yjs/database.type.ts +++ b/src/application/database-yjs/database.type.ts @@ -65,6 +65,7 @@ export interface CalendarLayoutSetting { showWeekNumbers: boolean; showWeekends: boolean; layout: CalendarLayout; + numberOfDays: number; } export enum RowMetaKey { diff --git a/src/application/database-yjs/dispatch.ts b/src/application/database-yjs/dispatch.ts index e5f504c0..3bf3a39c 100644 --- a/src/application/database-yjs/dispatch.ts +++ b/src/application/database-yjs/dispatch.ts @@ -13,6 +13,7 @@ import { useDatabaseFields, useDatabaseView, useDatabaseViewId, + useDefaultTimeSetting, useDocGuid, useRowDocMap, useSharedRoot, @@ -20,6 +21,8 @@ import { import { AITranslateLanguage, CalculationType, + CalendarLayout, + CalendarLayoutSetting, DateGroupCondition, FieldType, FieldVisibility, @@ -42,11 +45,11 @@ import { import { createCheckboxCell, getChecked } from '@/application/database-yjs/fields/checkbox/utils'; import { EnhancedBigStats } from '@/application/database-yjs/fields/number/EnhancedBigStats'; import { createSelectOptionCell } from '@/application/database-yjs/fields/select-option/utils'; -import { createTextField } from '@/application/database-yjs/fields/text/utils'; +import { createDateTimeField, createTextField } from '@/application/database-yjs/fields/text/utils'; import { dateFilterFillData, filterFillData, getDefaultFilterCondition } from '@/application/database-yjs/filter'; import { getOptionsFromRow, initialDatabaseRow } from '@/application/database-yjs/row'; import { generateRowMeta, getMetaIdMap, getMetaJSON } from '@/application/database-yjs/row_meta'; -import { useBoardLayoutSettings, useFieldSelector, useFieldType } from '@/application/database-yjs/selector'; +import { useBoardLayoutSettings, useCalendarLayoutSetting, useDatabaseViewLayout, useFieldSelector, useFieldType } from '@/application/database-yjs/selector'; import { executeOperations } from '@/application/slate-yjs/utils/yjs'; import { DatabaseViewLayout, @@ -60,6 +63,7 @@ import { YDatabaseBoardLayoutSetting, YDatabaseCalculation, YDatabaseCalculations, + YDatabaseCalendarLayoutSetting, YDatabaseCell, YDatabaseField, YDatabaseFieldOrders, @@ -82,6 +86,7 @@ import { YMapFieldTypeOption, YSharedRoot, } from '@/application/types'; +import { DefaultTimeSetting } from '@/application/user-metadata'; import { useCurrentUser } from '@/components/main/app.hooks'; export function useResizeColumnWidthDispatch() { @@ -966,6 +971,8 @@ function createField(type: FieldType, fieldId: string) { switch (type) { case FieldType.RichText: return createTextField(fieldId); + case FieldType.DateTime: + return createDateTimeField(fieldId); default: throw new Error(`Field type ${type} not supported`); } @@ -1180,6 +1187,9 @@ export function useNewRowDispatch() { const guid = useDocGuid(); const viewId = useDatabaseViewId(); const currentView = useDatabaseView(); + const layout = useDatabaseViewLayout(); + const isCalendar = layout === DatabaseViewLayout.Calendar; + const calendarSetting = useCalendarLayoutSetting(); const filters = currentView?.get(YjsDatabaseKey.filters); const { navigateToRow } = useDatabaseContext(); @@ -1190,7 +1200,17 @@ export function useNewRowDispatch() { tailing = false, }: { beforeRowId?: string; - cellsData?: Record; + cellsData?: Record< + FieldId, + | string + | { + data: string; + endTimestamp?: string; + isRange?: boolean; + includeTime?: boolean; + reminderId?: string; + } + >; tailing?: boolean; }) => { if (!createRow) { @@ -1200,6 +1220,7 @@ export function useNewRowDispatch() { const rowId = uuidv4(); const rowDoc = await createRow(`${guid}_rows_${rowId}`); + let shouldOpenRowModal = false; rowDoc.transact(() => { initialDatabaseRow(rowId, database.get(YjsDatabaseKey.id), rowDoc); @@ -1208,7 +1229,7 @@ export function useNewRowDispatch() { const cells = row.get(YjsDatabaseKey.cells); - let shouldOpenRowModal = false; + if (filters) { filters.toArray().forEach((filter) => { @@ -1220,6 +1241,10 @@ export function useNewRowDispatch() { return; } + if (isCalendar && calendarSetting.fieldId === fieldId) { + shouldOpenRowModal = true; + } + const type = Number(field.get(YjsDatabaseKey.type)); if (type === FieldType.DateTime) { @@ -1266,7 +1291,15 @@ export function useNewRowDispatch() { cell.set(YjsDatabaseKey.created_at, String(dayjs().unix())); cell.set(YjsDatabaseKey.field_type, type); - cell.set(YjsDatabaseKey.data, data); + if (typeof data === 'object') { + cell.set(YjsDatabaseKey.data, data.data); + cell.set(YjsDatabaseKey.end_timestamp, data.endTimestamp); + cell.set(YjsDatabaseKey.is_range, data.isRange); + cell.set(YjsDatabaseKey.include_time, data.includeTime); + cell.set(YjsDatabaseKey.reminder_id, data.reminderId); + } else { + cell.set(YjsDatabaseKey.data, data); + } cells.set(fieldId, cell); }); @@ -1305,9 +1338,44 @@ export function useNewRowDispatch() { 'newRowDispatch' ); + if (isCalendar && shouldOpenRowModal) { + return null; + } + return rowId; }, - [createRow, database, filters, guid, navigateToRow, sharedRoot, viewId] + [calendarSetting.fieldId, createRow, database, filters, guid, isCalendar, navigateToRow, sharedRoot, viewId] + ); +} + +export function useCreateCalendarEvent(fieldId: string) { + const newRowDispatch = useNewRowDispatch(); + + return useCallback( + async ({ + startTimestamp, + endTimestamp, + includeTime, + }: { + startTimestamp: string; + endTimestamp?: string; + includeTime?: boolean; + }) => { + const rowId = await newRowDispatch({ + tailing: true, + cellsData: { + [fieldId]: { + data: startTimestamp, + endTimestamp, + isRange: !!endTimestamp, + includeTime, + }, + }, + }); + + return rowId; + }, + [fieldId, newRowDispatch] ); } @@ -1423,8 +1491,8 @@ export function useDuplicateRowDispatch() { throw new Error(`Cell not found`); } - const field = database.get(YjsDatabaseKey.fields); - const fieldType = Number(field.get(fieldId)?.get(YjsDatabaseKey.type)); + const fields = database.get(YjsDatabaseKey.fields); + const fieldType = Number(fields.get(fieldId)?.get(YjsDatabaseKey.type)); const cell = cloneCell(fieldType, referenceCell); @@ -1885,7 +1953,7 @@ function updateDateCell( } if (payload.includeTime !== undefined) { - console.log('includeTime', payload.includeTime); + console.debug('includeTime', payload.includeTime); cell.set(YjsDatabaseKey.include_time, payload.includeTime); } @@ -1963,6 +2031,51 @@ export function useUpdateCellDispatch(rowId: string, fieldId: string) { ); } +export function useUpdateStartEndTimeCell() { + const rowDocMap = useRowDocMap(); + + return useCallback( + (rowId: string, fieldId: string, startTimestamp: string, endTimestamp?: string, isAllDay?: boolean) => { + const rowDoc = rowDocMap?.[rowId]; + + if (!rowDoc) { + throw new Error(`Row not found`); + } + + const rowSharedRoot = rowDoc.getMap(YjsEditorKey.data_section) as YSharedRoot; + const row = rowSharedRoot.get(YjsEditorKey.database_row); + + const cells = row.get(YjsDatabaseKey.cells); + + rowDoc.transact(() => { + let cell = cells.get(fieldId); + + if (!cell) { + cell = new Y.Map() as YDatabaseCell; + cell.set(YjsDatabaseKey.field_type, FieldType.DateTime); + + cell.set(YjsDatabaseKey.created_at, String(dayjs().unix())); + cells.set(fieldId, cell); + } + + + cell.set(YjsDatabaseKey.data, startTimestamp); + cell.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + + updateDateCell(cell, { + data: startTimestamp, + endTimestamp, + isRange: !!endTimestamp, + includeTime: !isAllDay, + }); + row.set(YjsDatabaseKey.last_modified, String(dayjs().unix())); + }); + + }, + [rowDocMap] + ); +} + function generateBoardSetting(database: YDatabase): YDatabaseFieldSettings { const fieldSettingsMap = new Y.Map() as YDatabaseFieldSettings; @@ -2032,11 +2145,26 @@ function generateBoardGroup(database: YDatabase, fieldOrders: YDatabaseFieldOrde return groups; } +function generateCalendarLayoutSettings(fieldId: FieldId, defaultTimeSetting: DefaultTimeSetting) { + const layoutSettings = new Y.Map() as YDatabaseLayoutSettings; + const layoutSetting = new Y.Map() as YDatabaseCalendarLayoutSetting; + + layoutSetting.set(YjsDatabaseKey.first_day_of_week, defaultTimeSetting.startWeekOn); + layoutSetting.set(YjsDatabaseKey.field_id, fieldId); + layoutSetting.set(YjsDatabaseKey.layout_ty, CalendarLayout.MonthLayout); + layoutSetting.set(YjsDatabaseKey.show_week_numbers, true); + layoutSetting.set(YjsDatabaseKey.show_weekends, true); + layoutSettings.set('2', layoutSetting); + return layoutSettings; +} + export function useAddDatabaseView() { const { iidIndex, createFolderView } = useDatabaseContext(); const database = useDatabase(); const sharedRoot = useSharedRoot(); + const defaultTimeSetting = useDefaultTimeSetting(); + return useCallback( async (layout: DatabaseViewLayout) => { if (!createFolderView) { @@ -2066,6 +2194,52 @@ export function useAddDatabaseView() { const refView = database.get(YjsDatabaseKey.views)?.get(iidIndex); const refRowOrders = refView.get(YjsDatabaseKey.row_orders); const refFieldOrders = refView.get(YjsDatabaseKey.field_orders); + const fields = database.get(YjsDatabaseKey.fields); + + // find date field in all views + let dateField: YDatabaseField | undefined; + + if (layout === DatabaseViewLayout.Calendar) { + refFieldOrders.forEach((fieldOrder) => { + const field = fields?.get(fieldOrder.id); + + if ( + !dateField && + [FieldType.DateTime].includes( + Number(field?.get(YjsDatabaseKey.type)) + ) + ) { + dateField = field; + } + }); + + // if no date field, create a new one + if (!dateField) { + const fieldId = nanoid(6); + + dateField = createField(FieldType.DateTime, fieldId); + + const typeOptionMap = generateDateTimeFieldTypeOptions(); + + dateField.set(YjsDatabaseKey.type_option, typeOptionMap); + fields.set(fieldId, dateField); + + executeOperationWithAllViews( + sharedRoot, + database, + (view) => { + const fieldOrders = view?.get(YjsDatabaseKey.field_orders); + + fieldOrders.push([ + { + id: fieldId, + }, + ]); + }, + 'newDateTimeField' + ); + } + } executeOperations( sharedRoot, @@ -2103,6 +2277,16 @@ export function useAddDatabaseView() { layoutSettings = generateBoardLayoutSettings(); } + if (layout === DatabaseViewLayout.Calendar) { + const fieldId = dateField?.get(YjsDatabaseKey.id); + + if (!fieldId) { + throw new Error(`Date field not found`); + } + + layoutSettings = generateCalendarLayoutSettings(fieldId, defaultTimeSetting); + } + newView.set(YjsDatabaseKey.database_id, databaseId); newView.set(YjsDatabaseKey.name, name); newView.set(YjsDatabaseKey.layout, layout); @@ -2125,7 +2309,7 @@ export function useAddDatabaseView() { ); return newViewId; }, - [createFolderView, database, iidIndex, sharedRoot] + [createFolderView, database, defaultTimeSetting, iidIndex, sharedRoot] ); } @@ -2230,6 +2414,19 @@ export function useDeleteView() { ); } +function generateDateTimeFieldTypeOptions() { + const typeOptionMap = new Y.Map() as YDatabaseFieldTypeOption; + const typeOption = new Y.Map() as YMapFieldTypeOption; + + typeOptionMap.set(String(FieldType.DateTime), typeOption); + + typeOption.set(YjsDatabaseKey.time_format, TimeFormat.TwentyFourHour); + typeOption.set(YjsDatabaseKey.date_format, DateFormat.Friendly); + typeOption.set(YjsDatabaseKey.include_time, true); + + return typeOptionMap; +} + export function useSwitchPropertyType() { const database = useDatabase(); const sharedRoot = useSharedRoot(); @@ -3491,7 +3688,7 @@ export function useUpdateFileMediaTypeOption(fieldId: string) { ); typeOptionMap.set(String(FieldType.FileMedia), newTypeOption); } else { - console.log('Updating file media type option', typeOption.toJSON()); + console.debug('Updating file media type option', typeOption.toJSON()); typeOption.set( YjsDatabaseKey.content, JSON.stringify({ @@ -3507,3 +3704,63 @@ export function useUpdateFileMediaTypeOption(fieldId: string) { [database, fieldId, sharedRoot] ); } + +export function useUpdateCalendarSetting() { + const view = useDatabaseView(); + const sharedRoot = useSharedRoot(); + + return useCallback( + (settings: Partial) => { + executeOperations( + sharedRoot, + [ + () => { + if (!view) { + throw new Error(`Unable to toggle hide ungrouped column`); + } + + // Get or create the layout settings for the view + let layoutSettings = view.get(YjsDatabaseKey.layout_settings); + + if (!layoutSettings) { + layoutSettings = new Y.Map() as YDatabaseLayoutSettings; + } + + let layoutSetting = layoutSettings.get('2'); + + if (!layoutSetting) { + layoutSetting = new Y.Map() as YDatabaseCalendarLayoutSetting; + layoutSettings.set('2', layoutSetting); + } + + if (settings.fieldId !== undefined) { + layoutSetting.set(YjsDatabaseKey.field_id, settings.fieldId); + } + + if (settings.firstDayOfWeek !== undefined) { + layoutSetting.set(YjsDatabaseKey.first_day_of_week, settings.firstDayOfWeek); + } + + if (settings.showWeekNumbers !== undefined) { + layoutSetting.set(YjsDatabaseKey.show_week_numbers, settings.showWeekNumbers); + } + + if (settings.showWeekends !== undefined) { + layoutSetting.set(YjsDatabaseKey.show_weekends, settings.showWeekends); + } + + if (settings.layout !== undefined) { + layoutSetting.set(YjsDatabaseKey.layout_ty, settings.layout); + } + + if (settings.numberOfDays !== undefined) { + layoutSetting.set(YjsDatabaseKey.number_of_days, settings.numberOfDays); + } + }, + ], + 'updateCalendarSetting' + ); + }, + [sharedRoot, view] + ); +} \ No newline at end of file diff --git a/src/application/database-yjs/fields/text/utils.ts b/src/application/database-yjs/fields/text/utils.ts index cd788ddb..75286877 100644 --- a/src/application/database-yjs/fields/text/utils.ts +++ b/src/application/database-yjs/fields/text/utils.ts @@ -11,3 +11,13 @@ export function createTextField (id: string) { return field; } + +export function createDateTimeField(fieldId: string) { + const field = new Y.Map() as YDatabaseField; + + field.set(YjsDatabaseKey.name, 'Date'); + field.set(YjsDatabaseKey.id, fieldId); + field.set(YjsDatabaseKey.type, FieldType.DateTime); + + return field; +} \ No newline at end of file diff --git a/src/application/database-yjs/index.ts b/src/application/database-yjs/index.ts index 244faddd..4c02b0c5 100644 --- a/src/application/database-yjs/index.ts +++ b/src/application/database-yjs/index.ts @@ -1,7 +1,6 @@ -export * from './context'; -export * from './fields'; -export * from './context'; -export * from './selector'; -export * from './database.type'; export * from './const'; - +export * from './context'; +export * from './database.type'; +export * from './dispatch'; +export * from './fields'; +export * from './selector'; diff --git a/src/application/database-yjs/selector.ts b/src/application/database-yjs/selector.ts index 4b159c80..ec31b241 100644 --- a/src/application/database-yjs/selector.ts +++ b/src/application/database-yjs/selector.ts @@ -35,6 +35,7 @@ import { YjsDatabaseKey, YjsEditorKey, } from '@/application/types'; +import { MetadataKey } from '@/application/user-metadata'; import { useCurrentUser } from '@/components/main/app.hooks'; import { getDateFormat, getTimeFormat, renderDate } from '@/utils/time'; @@ -841,12 +842,17 @@ export interface CalendarEvent { start?: Date; end?: Date; id: string; + title: string; + allDay: boolean; + rowId: string; + isRange?: boolean; } export function useCalendarEventsSelector() { const setting = useCalendarLayoutSetting(); const filedId = setting.fieldId; const { field } = useFieldSelector(filedId); + const primaryFieldId = usePrimaryFieldId(); const rowOrders = useRowOrdersSelector(); const rows = useRowDocMap(); const [events, setEvents] = useState([]); @@ -856,68 +862,134 @@ export function useCalendarEventsSelector() { if (!field || !rowOrders || !rows) return; const fieldType = Number(field?.get(YjsDatabaseKey.type)) as FieldType; - if (fieldType !== FieldType.DateTime) return; - const newEvents: CalendarEvent[] = []; - const emptyEvents: CalendarEvent[] = []; + if (![FieldType.DateTime, FieldType.LastEditedTime, FieldType.CreatedTime].includes(fieldType) || !primaryFieldId) return; - rowOrders?.forEach((row) => { - const cell = getCell(row.id, filedId, rows); + const observerEvent = () => { + const newEvents: CalendarEvent[] = []; + const emptyEvents: CalendarEvent[] = []; - if (!cell) { - emptyEvents.push({ - id: `${row.id}:${filedId}`, - }); - return; - } + rowOrders?.forEach((row) => { + const cell = getCell(row.id, filedId, rows); + const primaryCell = getCell(row.id, primaryFieldId, rows); + const allDay = !cell?.get(YjsDatabaseKey.include_time); - const value = parseYDatabaseCellToCell(cell) as DateTimeCell; + const title = (primaryCell?.get(YjsDatabaseKey.data) as string) || ''; - if (!value || !value.data) { - emptyEvents.push({ - id: `${row.id}:${filedId}`, - }); - return; - } + const doc = rows?.[row.id]; - const getDate = (timestamp: string) => { - const dayjsResult = timestamp.length === 10 ? dayjs.unix(Number(timestamp)) : dayjs(timestamp); + if (!doc) return; + + const rowSharedRoot = doc.getMap(YjsEditorKey.data_section); + const databbaseRow = rowSharedRoot.get(YjsEditorKey.database_row); + + const rowCreatedTime = databbaseRow.get(YjsDatabaseKey.created_at); + const rowLastEditedTime = databbaseRow.get(YjsDatabaseKey.last_modified); + + + const value = cell ? parseYDatabaseCellToCell(cell) as DateTimeCell : undefined; + + if ((!value?.data && fieldType !== FieldType.CreatedTime && fieldType !== FieldType.LastEditedTime) || + (fieldType === FieldType.CreatedTime && !rowCreatedTime) || + (fieldType === FieldType.LastEditedTime && !rowLastEditedTime) + ) { + emptyEvents.push({ + id: `${row.id}`, + title, + allDay, + rowId: row.id, + }); + return; + } + + const getDate = (timestamp: string) => { + const dayjsResult = timestamp.length === 10 ? dayjs.unix(Number(timestamp)) : dayjs(timestamp); + + return dayjsResult.toDate(); + }; + + + if ([FieldType.CreatedTime, FieldType.LastEditedTime].includes(fieldType)) { + newEvents.push({ + id: `${row.id}`, + start: fieldType === FieldType.CreatedTime ? getDate(rowCreatedTime) : getDate(rowLastEditedTime), + title, + allDay, + rowId: row.id, + }); + } else if (value) { + newEvents.push({ + id: `${row.id}`, + start: getDate(value.data), + isRange: value.isRange || false, + end: value.endTimestamp && value.isRange ? getDate(value.endTimestamp) : dayjs(getDate(value.data)).add(30, 'minute').toDate(), + title, + allDay, + rowId: row.id, + }); + } - return dayjsResult.toDate(); - }; - newEvents.push({ - id: `${row.id}:${filedId}`, - start: getDate(value.data), - end: value.endTimestamp && value.isRange ? getDate(value.endTimestamp) : getDate(value.data), }); - }); - setEvents(newEvents); - setEmptyEvents(emptyEvents); - }, [field, rowOrders, rows, filedId]); + setEvents(newEvents); + setEmptyEvents(emptyEvents); + } + + observerEvent(); + + field?.observeDeep(observerEvent); + + const debouncedObserverEvent = debounce(observerEvent, 150); + + // for every row + rowOrders?.forEach((row) => { + const rowDoc = rows?.[row.id]; + + if (!rowDoc) return; + rowDoc.getMap(YjsEditorKey.data_section).observeDeep(debouncedObserverEvent); + }); + return () => { + debouncedObserverEvent.cancel(); + field?.unobserveDeep(observerEvent); + rowOrders?.forEach((row) => { + const rowDoc = rows?.[row.id]; + + if (!rowDoc) return; + rowDoc.getMap(YjsEditorKey.data_section).unobserveDeep(debouncedObserverEvent); + }); + }; + + }, [field, rowOrders, rows, filedId, primaryFieldId]); return { events, emptyEvents }; } export function useCalendarLayoutSetting() { + const currentUser = useCurrentUser(); + const startWeekOn = Number(currentUser?.metadata?.[MetadataKey.StartWeekOn] || 0); + const view = useDatabaseView(); const layoutSetting = view?.get(YjsDatabaseKey.layout_settings)?.get('2'); const [setting, setSetting] = useState({ fieldId: '', - firstDayOfWeek: 0, + firstDayOfWeek: startWeekOn, showWeekNumbers: true, showWeekends: true, layout: 0, + numberOfDays: 7 }); useEffect(() => { const observerHandler = () => { + const firstDayOfWeek = layoutSetting?.get(YjsDatabaseKey.first_day_of_week) === undefined ? startWeekOn : Number(layoutSetting?.get(YjsDatabaseKey.first_day_of_week) || 0); + setSetting({ fieldId: layoutSetting?.get(YjsDatabaseKey.field_id) as string, - firstDayOfWeek: Number(layoutSetting?.get(YjsDatabaseKey.first_day_of_week)), + firstDayOfWeek, showWeekNumbers: Boolean(layoutSetting?.get(YjsDatabaseKey.show_week_numbers)), showWeekends: Boolean(layoutSetting?.get(YjsDatabaseKey.show_weekends)), layout: Number(layoutSetting?.get(YjsDatabaseKey.layout_ty)), + numberOfDays: layoutSetting?.get(YjsDatabaseKey.number_of_days) || 7, }); }; @@ -926,7 +998,7 @@ export function useCalendarLayoutSetting() { return () => { layoutSetting?.unobserve(observerHandler); }; - }, [layoutSetting]); + }, [layoutSetting, startWeekOn]); return setting; } diff --git a/src/application/db/index.ts b/src/application/db/index.ts index 14668021..9df196f5 100644 --- a/src/application/db/index.ts +++ b/src/application/db/index.ts @@ -1,11 +1,11 @@ -import { userSchema, UserTable } from '@/application/db/tables/users'; -import { YDoc } from '@/application/types'; import { databasePrefix } from '@/application/constants'; +import { rowSchema, rowTable } from '@/application/db/tables/rows'; +import { userSchema, UserTable } from '@/application/db/tables/users'; +import { viewMetasSchema, ViewMetasTable } from '@/application/db/tables/view_metas'; +import { YDoc } from '@/application/types'; +import BaseDexie from 'dexie'; import { IndexeddbPersistence } from 'y-indexeddb'; import * as Y from 'yjs'; -import BaseDexie from 'dexie'; -import { viewMetasSchema, ViewMetasTable } from '@/application/db/tables/view_metas'; -import { rowSchema, rowTable } from '@/application/db/tables/rows'; type DexieTables = ViewMetasTable & UserTable & rowTable; @@ -47,7 +47,6 @@ export async function openCollabDB(name: string): Promise { } export async function closeCollabDB(name: string) { - if (openedSet.has(name)) { openedSet.delete(name); } @@ -80,7 +79,7 @@ export async function clearData() { const deleteRequest = indexedDB.deleteDatabase(dbName); deleteRequest.onsuccess = () => { - console.log(`Database ${dbName} deleted successfully`); + console.debug(`Database ${dbName} deleted successfully`); resolve(true); }; diff --git a/src/application/services/js-services/__tests__/sync.test.ts b/src/application/services/js-services/__tests__/sync.test.ts index 5c562f64..8209b443 100644 --- a/src/application/services/js-services/__tests__/sync.test.ts +++ b/src/application/services/js-services/__tests__/sync.test.ts @@ -1,24 +1,24 @@ -import * as Y from 'yjs'; -import * as awarenessProtocol from 'y-protocols/awareness'; -import * as random from 'lib0/random'; -import { expect } from '@jest/globals'; +import { handleMessage, initSync, SyncContext } from '@/application/services/js-services/sync-protocol'; import { Types } from '@/application/types'; import { messages } from '@/proto/messages'; -import { handleMessage, initSync, SyncContext } from '@/application/services/js-services/sync-protocol'; +import { expect } from '@jest/globals'; +import * as random from 'lib0/random'; +import * as awarenessProtocol from 'y-protocols/awareness'; +import * as Y from 'yjs'; /** * Default tracer function for logging messages sent by clients. * This function can be replaced with a custom tracer used to assertions etc. */ const defaultTracer = (message: messages.IMessage, i: number) => { - console.log(`Client ${i} sending message:`, message); -} + console.debug(`Client ${i} sending message:`, message); +}; const mockSync = (clientCount: number, tracer = defaultTracer): SyncContext[] => { const clients: SyncContext[] = []; const guid = random.uuidv4(); for (let i = 0; i < clientCount; i++) { - const doc = new Y.Doc({guid}); + const doc = new Y.Doc({ guid }); const awareness = new awarenessProtocol.Awareness(doc); clients.push({ doc, @@ -37,10 +37,9 @@ const mockSync = (clientCount: number, tracer = defaultTracer): SyncContext[] => } }); }; - } return clients; -} +}; describe('sync protocol', () => { it('should exchange updates between client and server', () => { @@ -59,6 +58,5 @@ describe('sync protocol', () => { // remote -> local txt2.insert(5, ' World'); expect(txt1.toString()).toEqual('Hello World'); - - }) -}) \ No newline at end of file + }); +}); diff --git a/src/application/services/js-services/cache/index.ts b/src/application/services/js-services/cache/index.ts index af982995..99375383 100644 --- a/src/application/services/js-services/cache/index.ts +++ b/src/application/services/js-services/cache/index.ts @@ -14,7 +14,7 @@ import { } from '@/application/types'; import { applyYDoc } from '@/application/ydoc/apply'; -export function collabTypeToDBType (type: Types) { +export function collabTypeToDBType(type: Types) { switch (type) { case Types.Folder: return 'folder'; @@ -43,7 +43,7 @@ const collabSharedRootKeyMap = { [Types.Empty]: YjsEditorKey.empty, }; -export function hasCollabCache (doc: YDoc) { +export function hasCollabCache(doc: YDoc) { const data = doc.getMap(YjsEditorKey.data_section) as YSharedRoot; return Object.values(collabSharedRootKeyMap).some((key) => { @@ -51,13 +51,13 @@ export function hasCollabCache (doc: YDoc) { }); } -export async function hasViewMetaCache (name: string) { +export async function hasViewMetaCache(name: string) { const data = await db.view_metas.get(name); return !!data; } -export async function hasUserCache (userId: string) { +export async function hasUserCache(userId: string) { const data = await db.users.get(userId); return !!data; @@ -69,7 +69,7 @@ export async function getPublishViewMeta< child_views: ViewInfo[]; ancestor_views: ViewInfo[]; } -> ( +>( fetcher: Fetcher, { namespace, @@ -78,7 +78,7 @@ export async function getPublishViewMeta< namespace: string; publishName: string; }, - strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, + strategy: StrategyType = StrategyType.CACHE_AND_NETWORK ) { const name = `${namespace}_${publishName}`; const exist = await hasViewMetaCache(name); @@ -117,12 +117,10 @@ export async function getPublishViewMeta< } } -export async function getUser< - T extends User -> ( +export async function getUser( fetcher: Fetcher, userId?: string, - strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, + strategy: StrategyType = StrategyType.CACHE_AND_NETWORK ) { const exist = userId && (await hasUserCache(userId)); const data = await db.users.get(userId); @@ -173,7 +171,7 @@ export async function getPublishView< ancestor_views: ViewInfo[]; }; } -> ( +>( fetcher: Fetcher, { namespace, @@ -182,7 +180,7 @@ export async function getPublishView< namespace: string; publishName: string; }, - strategy: StrategyType = StrategyType.CACHE_AND_NETWORK, + strategy: StrategyType = StrategyType.CACHE_AND_NETWORK ) { const name = `${namespace}_${publishName}`; @@ -226,11 +224,12 @@ export async function getPublishView< return { doc }; } -export async function getPageDoc; -}> (fetcher: Fetcher, name: string, strategy: StrategyType = StrategyType.CACHE_AND_NETWORK) { - +export async function getPageDoc< + T extends { + data: Uint8Array; + rows?: Record; + } +>(fetcher: Fetcher, name: string, strategy: StrategyType = StrategyType.CACHE_AND_NETWORK) { const doc = await openCollabDB(name); const exist = hasCollabCache(doc); @@ -271,7 +270,7 @@ export async function getPageDoc) { +async function updateRows(collab: YDoc, rows: Record) { const bulkData = []; for (const [key, value] of Object.entries(rows)) { @@ -296,7 +295,8 @@ export async function revalidateView< T extends { data: Uint8Array; rows?: Record; - }> (fetcher: Fetcher, collab: YDoc) { + } +>(fetcher: Fetcher, collab: YDoc) { try { const { data, rows } = await fetcher(); @@ -308,7 +308,6 @@ export async function revalidateView< } catch (e) { return Promise.reject(e); } - } export async function revalidatePublishViewMeta< @@ -317,7 +316,7 @@ export async function revalidatePublishViewMeta< child_views: ViewInfo[]; ancestor_views: ViewInfo[]; } -> (name: string, fetcher: Fetcher) { +>(name: string, fetcher: Fetcher) { const { view, child_views, ancestor_views } = await fetcher(); const dbView = await db.view_metas.get(name); @@ -331,7 +330,7 @@ export async function revalidatePublishViewMeta< visible_view_ids: dbView?.visible_view_ids ?? [], database_relations: dbView?.database_relations ?? {}, }, - name, + name ); return db.view_metas.get(name); @@ -346,7 +345,7 @@ export async function revalidatePublishView< subDocuments?: Record; meta: PublishViewMetaData; } -> (name: string, fetcher: Fetcher, collab: YDoc) { +>(name: string, fetcher: Fetcher, collab: YDoc) { const { data, meta, rows, visibleViewIds = [], relations = {}, subDocuments } = await fetcher(); await db.view_metas.put( @@ -358,7 +357,7 @@ export async function revalidatePublishView< visible_view_ids: visibleViewIds, database_relations: relations, }, - name, + name ); if (rows) { @@ -376,25 +375,23 @@ export async function revalidatePublishView< applyYDoc(collab, data); } -export async function deleteViewMeta (name: string) { +export async function deleteViewMeta(name: string) { try { await db.view_metas.delete(name); - } catch (e) { console.error(e); } } -export async function deleteView (name: string) { - console.log('deleteView', name); +export async function deleteView(name: string) { + console.debug('deleteView', name); await deleteViewMeta(name); await closeCollabDB(name); await closeCollabDB(`${name}_rows`); } -export async function revalidateUser< - T extends User> (fetcher: Fetcher) { +export async function revalidateUser(fetcher: Fetcher) { const data = await fetcher(); await db.users.put(data, data.uuid); @@ -404,7 +401,7 @@ export async function revalidateUser< const rowDocs = new Map(); -export async function createRowDoc (rowKey: string) { +export async function createRowDoc(rowKey: string) { if (rowDocs.has(rowKey)) { return rowDocs.get(rowKey) as YDoc; } @@ -416,6 +413,6 @@ export async function createRowDoc (rowKey: string) { return doc; } -export function deleteRowDoc (rowKey: string) { +export function deleteRowDoc(rowKey: string) { rowDocs.delete(rowKey); } diff --git a/src/application/services/js-services/http/http_api.ts b/src/application/services/js-services/http/http_api.ts index c8e1bb3d..99060bae 100644 --- a/src/application/services/js-services/http/http_api.ts +++ b/src/application/services/js-services/http/http_api.ts @@ -234,14 +234,13 @@ export async function getCurrentUser(): Promise { return Promise.reject(data); } - export async function updateUserProfile(metadata: Record): Promise { const url = 'api/user/update'; const response = await axiosInstance?.post<{ code: number; message: string; }>(url, { - metadata + metadata, }); const data = response?.data; @@ -1471,7 +1470,7 @@ export async function uploadImportFile(presignedUrl: string, file: File, onProgr onUploadProgress: (progressEvent) => { const { progress = 0 } = progressEvent; - console.log(`Upload progress: ${progress * 100}%`); + console.debug(`Upload progress: ${progress * 100}%`); onProgress(progress); }, headers: { @@ -1549,10 +1548,14 @@ export async function updatePage(workspaceId: string, viewId: string, data: Upda return Promise.reject(res?.data); } -export async function updatePageIcon(workspaceId: string, viewId: string, icon: { - ty: ViewIconType; - value: string; -}): Promise { +export async function updatePageIcon( + workspaceId: string, + viewId: string, + icon: { + ty: ViewIconType; + value: string; + } +): Promise { const url = `/api/workspace/${workspaceId}/page-view/${viewId}/update-icon`; const response = await axiosInstance?.post<{ code: number; diff --git a/src/application/services/js-services/sync-protocol.ts b/src/application/services/js-services/sync-protocol.ts index 9515b047..b8a09910 100644 --- a/src/application/services/js-services/sync-protocol.ts +++ b/src/application/services/js-services/sync-protocol.ts @@ -1,10 +1,9 @@ import { debounce } from 'lodash-es'; -import * as awarenessProtocol from "y-protocols/awareness"; -import * as Y from "yjs"; - -import { Types } from "@/application/types"; -import { collab, messages } from "@/proto/messages"; +import * as awarenessProtocol from 'y-protocols/awareness'; +import * as Y from 'yjs'; +import { Types } from '@/application/types'; +import { collab, messages } from '@/proto/messages'; /** * SyncContext is the context object passed to the sync protocol handlers. @@ -12,95 +11,94 @@ import { collab, messages } from "@/proto/messages"; * and an emit function to send messages back to the server. */ export interface SyncContext { - doc: Y.Doc, - awareness?: awarenessProtocol.Awareness, - collabType: Types, - lastMessageId?: collab.IRid, - /** - * Emit function to send messages back to the server. - */ - emit: (reply: messages.IMessage) => void, + doc: Y.Doc; + awareness?: awarenessProtocol.Awareness; + collabType: Types; + lastMessageId?: collab.IRid; + /** + * Emit function to send messages back to the server. + */ + emit: (reply: messages.IMessage) => void; } interface AwarenessEvent { - added: number[]; - updated: number[]; - removed: number[]; + added: number[]; + updated: number[]; + removed: number[]; } export enum UpdateFlags { - /** Payload encoded using lib0 v1 encoding */ - Lib0v1 = 0, - /** Payload encoded using lib0 v2 encoding */ - Lib0v2 = 1, + /** Payload encoded using lib0 v1 encoding */ + Lib0v1 = 0, + /** Payload encoded using lib0 v2 encoding */ + Lib0v2 = 1, } const handleSyncRequest = (ctx: SyncContext, message: collab.ISyncRequest): void => { - const { doc, emit } = ctx; - const stateVector = message.stateVector && message.stateVector.length > 0 ? message.stateVector : undefined; - const update = Y.encodeStateAsUpdate(doc, stateVector); + const { doc, emit } = ctx; + const stateVector = message.stateVector && message.stateVector.length > 0 ? message.stateVector : undefined; + const update = Y.encodeStateAsUpdate(doc, stateVector); - // send the update containing new data back to the server - emit({ - collabMessage: { - objectId: doc.guid, - collabType: ctx.collabType, - update: { - flags: UpdateFlags.Lib0v1, - payload: update, - } - } - }); -} + // send the update containing new data back to the server + emit({ + collabMessage: { + objectId: doc.guid, + collabType: ctx.collabType, + update: { + flags: UpdateFlags.Lib0v1, + payload: update, + }, + }, + }); +}; const handleAccessChanged = (ctx: SyncContext, message: collab.IAccessChanged): void => { - if (message.canRead === false) { - //FIXME: we should not only destroy the doc, but also remove it from the persistent storage. - ctx.doc.destroy(); - } -} + if (message.canRead === false) { + //FIXME: we should not only destroy the doc, but also remove it from the persistent storage. + ctx.doc.destroy(); + } +}; const handleAwarenessUpdate = (ctx: SyncContext, message: collab.IAwarenessUpdate): void => { - if (!ctx.awareness) { - console.log(`No awareness instance found in SyncContext for objectId ${ctx.doc.guid}`); - } else { - awarenessProtocol.applyAwarenessUpdate(ctx.awareness, message.payload!, 'remote'); - } - -} + if (!ctx.awareness) { + console.debug(`No awareness instance found in SyncContext for objectId ${ctx.doc.guid}`); + } else { + awarenessProtocol.applyAwarenessUpdate(ctx.awareness, message.payload!, 'remote'); + } +}; const handleUpdate = (ctx: SyncContext, message: collab.IUpdate): void => { - const { doc, emit } = ctx; + const { doc, emit } = ctx; - switch (message.flags) { - case UpdateFlags.Lib0v1: - Y.applyUpdate(doc, message.payload!, 'remote'); - break; - case UpdateFlags.Lib0v2: - Y.applyUpdateV2(doc, message.payload!, 'remote'); - break; - default: - throw new Error(`Unknown update flags: ${message.flags} at ${message.messageId?.timestamp}`); - } + switch (message.flags) { + case UpdateFlags.Lib0v1: + Y.applyUpdate(doc, message.payload!, 'remote'); + break; + case UpdateFlags.Lib0v2: + Y.applyUpdateV2(doc, message.payload!, 'remote'); + break; + default: + throw new Error(`Unknown update flags: ${message.flags} at ${message.messageId?.timestamp}`); + } - console.log(`applied update to doc ${doc.guid}`); - ctx.lastMessageId = message.messageId || ctx.lastMessageId; + console.debug(`applied update to doc ${doc.guid}`); + ctx.lastMessageId = message.messageId || ctx.lastMessageId; - // check if there are any missing update data - if (doc.store.pendingStructs || doc.store.pendingDs) { - console.log(`Doc ${doc.guid} has missing dependencies. Sending sync request...`); - emit({ - collabMessage: { - objectId: doc.guid, - collabType: ctx.collabType, - syncRequest: { - stateVector: Y.encodeStateVector(doc), - lastMessageId: ctx.lastMessageId || { timestamp: 0, counter: 0 }, - } - } - }); - } -} + // check if there are any missing update data + if (doc.store.pendingStructs || doc.store.pendingDs) { + console.debug(`Doc ${doc.guid} has missing dependencies. Sending sync request...`); + emit({ + collabMessage: { + objectId: doc.guid, + collabType: ctx.collabType, + syncRequest: { + stateVector: Y.encodeStateVector(doc), + lastMessageId: ctx.lastMessageId || { timestamp: 0, counter: 0 }, + }, + }, + }); + } +}; /** * Initializes the sync protocol for a given SyncContext. It will register @@ -112,90 +110,90 @@ const handleUpdate = (ctx: SyncContext, message: collab.IUpdate): void => { * @returns An object containing cleanup functions used to deregister the observers. */ export const initSync = (ctx: SyncContext) => { - ctx.doc = ctx.doc || ctx.awareness?.doc; - const { doc, awareness, emit, collabType, lastMessageId } = ctx; + ctx.doc = ctx.doc || ctx.awareness?.doc; + const { doc, awareness, emit, collabType, lastMessageId } = ctx; - if (!doc) { - throw new Error("SyncContext must have a Y.Doc instance."); + if (!doc) { + throw new Error('SyncContext must have a Y.Doc instance.'); + } + + console.debug(`Initializing sync for objectId ${doc.guid} with collabType ${collabType}`); + + let onAwarenessChange; + const updates: Uint8Array[] = []; + const debounced = debounce(() => { + const mergedUpdates = Y.mergeUpdates(updates); + + updates.length = 0; // Clear the updates array without GC overhead + emit({ + collabMessage: { + objectId: doc.guid, + collabType, + update: { + flags: UpdateFlags.Lib0v1, + payload: mergedUpdates, + }, + }, + }); + }, 250); + const onUpdate = (update: Uint8Array, origin: string) => { + if (origin === 'remote') { + return; // Ignore remote updates } - console.log(`Initializing sync for objectId ${doc.guid} with collabType ${collabType}`); + updates.push(update); + debounced(); + }; - let onAwarenessChange - const updates: Uint8Array[] = []; - const debounced = debounce(() => { - const mergedUpdates = Y.mergeUpdates(updates); + doc.on('update', onUpdate); - updates.length = 0; // Clear the updates array without GC overhead + // emit initial sync request to the server + emit({ + collabMessage: { + objectId: ctx.doc.guid, + collabType: ctx.collabType, + syncRequest: { + stateVector: Y.encodeStateVector(ctx.doc), + lastMessageId: lastMessageId || { timestamp: 0, counter: 0 }, + }, + }, + }); + + if (awareness) { + onAwarenessChange = ({ added, updated, removed }: AwarenessEvent, _: string) => { + const changedClients = added.concat(updated).concat(removed); + + // emit awareness update to the server containing clients that changed emit({ collabMessage: { - objectId: doc.guid, - collabType, - update: { - flags: UpdateFlags.Lib0v1, - payload: mergedUpdates, - } - } + objectId: ctx.doc.guid, + collabType: ctx.collabType, + awarenessUpdate: { + payload: awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients), + }, + }, }); - }, 250); - const onUpdate = (update: Uint8Array, origin: string) => { - if (origin === 'remote') { - return; // Ignore remote updates - } - - updates.push(update); - debounced(); }; - doc.on('update', onUpdate); + awareness.on('change', onAwarenessChange); - // emit initial sync request to the server + const allClients = Array.from(awareness.getStates().keys()); + + // emit initial awareness update with all the clients emit({ - collabMessage: { - objectId: ctx.doc.guid, - collabType: ctx.collabType, - syncRequest: { - stateVector: Y.encodeStateVector(ctx.doc), - lastMessageId: lastMessageId || { timestamp: 0, counter: 0 }, - } - } + collabMessage: { + objectId: ctx.doc.guid, + collabType: ctx.collabType, + awarenessUpdate: { + payload: awarenessProtocol.encodeAwarenessUpdate(awareness, allClients), + }, + }, }); + } - if (awareness) { - onAwarenessChange = ({ added, updated, removed }: AwarenessEvent, _: string) => { - const changedClients = added.concat(updated).concat(removed); - - // emit awareness update to the server containing clients that changed - emit({ - collabMessage: { - objectId: ctx.doc.guid, - collabType: ctx.collabType, - awarenessUpdate: { - payload: awarenessProtocol.encodeAwarenessUpdate(awareness, changedClients) - } - } - }); - }; - - awareness.on('change', onAwarenessChange) - - const allClients = Array.from(awareness.getStates().keys()); - - // emit initial awareness update with all the clients - emit({ - collabMessage: { - objectId: ctx.doc.guid, - collabType: ctx.collabType, - awarenessUpdate: { - payload: awarenessProtocol.encodeAwarenessUpdate(awareness, allClients) - } - } - }); - } - - // return cleanup function to remove listeners - return { onUpdate, onAwarenessChange }; -} + // return cleanup function to remove listeners + return { onUpdate, onAwarenessChange }; +}; /** * Handles incoming collab messages by dispatching them to the appropriate handler. @@ -204,19 +202,19 @@ export const initSync = (ctx: SyncContext) => { * @param message */ export const handleMessage = (ctx: SyncContext, message: collab.ICollabMessage): void => { - const doc = ctx.doc || ctx.awareness?.doc; + const doc = ctx.doc || ctx.awareness?.doc; - if (message.objectId !== doc.guid) { - throw new Error(`collab message mismatch - expected objectId ${message.objectId}, got ${doc.guid}`); - } + if (message.objectId !== doc.guid) { + throw new Error(`collab message mismatch - expected objectId ${message.objectId}, got ${doc.guid}`); + } - if (message.update) { - handleUpdate(ctx, message.update); - } else if (message.syncRequest) { - handleSyncRequest(ctx, message.syncRequest); - } else if (message.accessChanged) { - handleAccessChanged(ctx, message.accessChanged); - } else if (message.awarenessUpdate) { - handleAwarenessUpdate(ctx, message.awarenessUpdate); - } -} \ No newline at end of file + if (message.update) { + handleUpdate(ctx, message.update); + } else if (message.syncRequest) { + handleSyncRequest(ctx, message.syncRequest); + } else if (message.accessChanged) { + handleAccessChanged(ctx, message.accessChanged); + } else if (message.awarenessUpdate) { + handleAwarenessUpdate(ctx, message.awarenessUpdate); + } +}; diff --git a/src/application/slate-yjs/command/index.ts b/src/application/slate-yjs/command/index.ts index cf132119..9fe2bb71 100644 --- a/src/application/slate-yjs/command/index.ts +++ b/src/application/slate-yjs/command/index.ts @@ -386,7 +386,7 @@ export const CustomEditor = { // Try to apply the new selection, with multiple fallback strategies if (newSelection && isValidSelection(editor, newSelection)) { - console.log('✅ Using calculated selection:', newSelection); + console.debug('✅ Using calculated selection:', newSelection); Transforms.select(editor, newSelection); } else { console.warn('⚠️ Calculated selection invalid, trying fallback strategies'); @@ -396,7 +396,7 @@ export const CustomEditor = { const nearestFromCalculated = findNearestValidSelection(editor, newSelection); if (nearestFromCalculated) { - console.log('✅ Using nearest from calculated:', nearestFromCalculated); + console.debug('✅ Using nearest from calculated:', nearestFromCalculated); Transforms.select(editor, nearestFromCalculated); return; } @@ -406,7 +406,7 @@ export const CustomEditor = { const nearestFromOriginal = findNearestValidSelection(editor, originalSelection); if (nearestFromOriginal) { - console.log('✅ Using nearest from original:', nearestFromOriginal); + console.debug('✅ Using nearest from original:', nearestFromOriginal); Transforms.select(editor, nearestFromOriginal); return; } @@ -417,7 +417,7 @@ export const CustomEditor = { const startOfBlock = Editor.start(editor, newStartBlockPath); if (isValidSelection(editor, { anchor: startOfBlock, focus: startOfBlock })) { - console.log('✅ Using start of block:', startOfBlock); + console.debug('✅ Using start of block:', startOfBlock); Transforms.select(editor, startOfBlock); return; } @@ -430,7 +430,7 @@ export const CustomEditor = { const documentSelection = findNearestValidSelection(editor, null); if (documentSelection) { - console.log('✅ Using document fallback:', documentSelection); + console.debug('✅ Using document fallback:', documentSelection); Transforms.select(editor, documentSelection); } else { console.warn('❌ Could not establish any valid selection after tab operation'); diff --git a/src/application/slate-yjs/plugins/withYjs.ts b/src/application/slate-yjs/plugins/withYjs.ts index b40a3f25..b220c5e9 100644 --- a/src/application/slate-yjs/plugins/withYjs.ts +++ b/src/application/slate-yjs/plugins/withYjs.ts @@ -181,7 +181,7 @@ export function withYjs( throw new Error('Already connected'); } - console.log('===connect', id); + console.debug('===connect', id); initializeDocumentContent(); e.sharedRoot.observeDeep(handleYEvents); connectSet.add(e); diff --git a/src/application/slate-yjs/utils/applyTextToSlate.ts b/src/application/slate-yjs/utils/applyTextToSlate.ts index 41bc756f..e2538bab 100644 --- a/src/application/slate-yjs/utils/applyTextToSlate.ts +++ b/src/application/slate-yjs/utils/applyTextToSlate.ts @@ -30,7 +30,7 @@ function applyTextYEvent(editor: YjsEditor, textId: string, event: Y.YTextEvent) const [targetElement, textPath] = entry as [Element, number[]]; const delta = event.delta as Delta[]; - console.log('📝 Applying YText event', { + console.debug('📝 Applying YText event', { textId, delta, targetPath: textPath, @@ -39,10 +39,10 @@ function applyTextYEvent(editor: YjsEditor, textId: string, event: Y.YTextEvent) Editor.withoutNormalizing(editor, () => { const operations = applyDelta(targetElement, textPath, delta); - console.log(`🔄 Generated ${operations.length} operations from delta:`, operations); + console.debug(`🔄 Generated ${operations.length} operations from delta:`, operations); operations.forEach((op, index) => { - console.log(`Applying operation ${index + 1}/${operations.length}:`, op); + console.debug(`Applying operation ${index + 1}/${operations.length}:`, op); editor.apply(op); }); }); @@ -121,7 +121,7 @@ function handleAttributeChange( ): Operation[] { const ops: Operation[] = []; - console.log(`🎨 Applying attributes from offset ${startOffset} to ${endOffset}:`, attributes); + console.debug(`🎨 Applying attributes from offset ${startOffset} to ${endOffset}:`, attributes); // Convert Y offsets to Slate path/text offsets const [startPathOffset, startTextOffset] = yOffsetToSlateOffsets(node, startOffset); @@ -210,7 +210,7 @@ function handleAttributeChange( function handleDelete(node: Element, slatePath: Path, startOffset: number, endOffset: number): Operation[] { const ops: Operation[] = []; - console.log(`➖ Deleting from offset ${startOffset} to ${endOffset}`); + console.debug(`➖ Deleting from offset ${startOffset} to ${endOffset}`); const [startPathOffset, startTextOffset] = yOffsetToSlateOffsets(node, startOffset); const [endPathOffset, endTextOffset] = yOffsetToSlateOffsets(node, endOffset, { assoc: -1 }); @@ -282,7 +282,7 @@ function handleInsert( ): Operation[] { const ops: Operation[] = []; - console.log(`➕ Inserting at offset ${offset}:`, insert, attributes); + console.debug(`➕ Inserting at offset ${offset}:`, insert, attributes); const [pathOffset, textOffset] = yOffsetToSlateOffsets(node, offset, { insert: true }); diff --git a/src/application/slate-yjs/utils/applyToSlate.ts b/src/application/slate-yjs/utils/applyToSlate.ts index ea7fa44e..d41aca87 100644 --- a/src/application/slate-yjs/utils/applyToSlate.ts +++ b/src/application/slate-yjs/utils/applyToSlate.ts @@ -21,21 +21,21 @@ type BlockMapEvent = YMapEvent; * @param events - Array of Yjs events to process */ export function translateYEvents(editor: YjsEditor, events: Array) { - console.log('=== Translating Yjs events to Slate operations ===', { + console.debug('=== Translating Yjs events to Slate operations ===', { eventCount: events.length, eventTypes: events.map((e) => e.path.join('.')), timestamp: new Date().toISOString(), }); events.forEach((event, index) => { - console.log(`Processing event ${index + 1}/${events.length}:`, { + console.debug(`Processing event ${index + 1}/${events.length}:`, { path: event.path, type: event.constructor.name, }); // Handle block-level changes (document.blocks) if (isEqual(event.path, ['document', 'blocks'])) { - console.log('→ Applying block map changes'); + console.debug('→ Applying block map changes'); applyBlocksYEvent(editor, event as BlockMapEvent); } @@ -43,7 +43,7 @@ export function translateYEvents(editor: YjsEditor, events: Array) { if (isEqual(event.path, ['document', 'blocks', event.path[2]])) { const blockId = event.path[2] as string; - console.log(`→ Applying block update for blockId: ${blockId}`); + console.debug(`→ Applying block update for blockId: ${blockId}`); applyUpdateBlockYEvent(editor, blockId, event as YMapEvent); } @@ -51,12 +51,12 @@ export function translateYEvents(editor: YjsEditor, events: Array) { if (isEqual(event.path, ['document', 'meta', 'text_map', event.path[3]])) { const textId = event.path[3] as string; - console.log(`→ Applying text content changes for textId: ${textId}`); + console.debug(`→ Applying text content changes for textId: ${textId}`); applyTextYEvent(editor, textId, event as YTextEvent); } }); - console.log('=== Yjs events translation completed ==='); + console.debug('=== Yjs events translation completed ==='); } /** @@ -85,7 +85,7 @@ function applyUpdateBlockYEvent(editor: YjsEditor, blockId: string, event: YMapE const [node, path] = entry; const oldData = node.data as Record; - console.log(`✅ Updating block data for blockId: ${blockId}`, { + console.debug(`✅ Updating block data for blockId: ${blockId}`, { path, oldDataKeys: Object.keys(oldData), newDataKeys: Object.keys(newData), @@ -115,7 +115,7 @@ function applyBlocksYEvent(editor: YjsEditor, event: BlockMapEvent) { const { changes, keysChanged } = event; const { keys } = changes; - console.log('🔄 Processing block map changes:', { + console.debug('🔄 Processing block map changes:', { keysChangedCount: keysChanged?.size ?? 0, keysChanged: Array.from(keysChanged ?? []), changes: Array.from(keys.entries()).map(([key, value]) => ({ @@ -135,20 +135,20 @@ function applyBlocksYEvent(editor: YjsEditor, event: BlockMapEvent) { return; } - console.log(`📋 Processing block change ${index + 1}/${keysChanged.size}:`, { + console.debug(`📋 Processing block change ${index + 1}/${keysChanged.size}:`, { key, action: value.action, oldValue: value.oldValue, }); if (value.action === 'add') { - console.log(`➕ Adding new block: ${key}`); + console.debug(`➕ Adding new block: ${key}`); handleNewBlock(editor, key, keyPath); } else if (value.action === 'delete') { - console.log(`🗑️ Deleting block: ${key}`); + console.debug(`🗑️ Deleting block: ${key}`); handleDeleteNode(editor, key); } else if (value.action === 'update') { - console.log(`🔄 Updating block: ${key}`); + console.debug(`🔄 Updating block: ${key}`); // TODO: Implement block update logic } }); @@ -168,7 +168,7 @@ function handleNewBlock(editor: YjsEditor, key: string, keyPath: Record leafKeys.includes(prop)) || (Object.keys(newProperties).length === 0 && Object.keys(properties).some((prop: string) => leafKeys.includes(prop))); + const isLeaf = + Object.keys(newProperties).some((prop: string) => leafKeys.includes(prop)) || + (Object.keys(newProperties).length === 0 && Object.keys(properties).some((prop: string) => leafKeys.includes(prop))); const isData = Object.keys(newProperties).some((prop: string) => prop === 'data'); const sharedRoot = ydoc.getMap(YjsEditorKey.data_section) as YSharedRoot; - console.log('applySetNode isLeaf', isLeaf, op); + console.debug('applySetNode isLeaf', isLeaf, op); if (isLeaf) { const node = getNodeAtPath(slateContent, path.slice(0, -1)) as Element; const textId = node.textId; @@ -196,13 +218,11 @@ function applySetNode(ydoc: Y.Doc, editor: Editor, op: SetNodeOperation, slateCo const block = getBlock(blockId, sharedRoot); - if ( - 'data' in newProperties - ) { + if ('data' in newProperties) { block.set(YjsEditorKey.block_data, JSON.stringify(newProperties.data)); return; } } console.error('set_node operation is not supported', op); -} \ No newline at end of file +} diff --git a/src/application/slate-yjs/utils/transformSelection.ts b/src/application/slate-yjs/utils/transformSelection.ts index 186cd6e9..f3d44d88 100644 --- a/src/application/slate-yjs/utils/transformSelection.ts +++ b/src/application/slate-yjs/utils/transformSelection.ts @@ -97,14 +97,14 @@ export function isValidSelection(editor: Editor, selection: Range): boolean { */ export function findNearestValidSelection(editor: Editor, originalSelection: Range | null): Range | null { try { - console.log('🎯 Finding nearest valid selection for:', originalSelection); + console.debug('🎯 Finding nearest valid selection for:', originalSelection); // Strategy 1: Try to fix the original selection if it exists if (originalSelection) { const fixedSelection = tryFixSelection(editor, originalSelection); if (fixedSelection) { - console.log('✅ Fixed original selection:', fixedSelection); + console.debug('✅ Fixed original selection:', fixedSelection); return fixedSelection; } } @@ -114,7 +114,7 @@ export function findNearestValidSelection(editor: Editor, originalSelection: Ran const nearestSelection = findNearestTextNode(editor, originalSelection.anchor.path); if (nearestSelection) { - console.log('✅ Found nearest text node selection:', nearestSelection); + console.debug('✅ Found nearest text node selection:', nearestSelection); return nearestSelection; } } @@ -123,7 +123,7 @@ export function findNearestValidSelection(editor: Editor, originalSelection: Ran const startSelection = findDocumentStart(editor); if (startSelection) { - console.log('✅ Using document start selection:', startSelection); + console.debug('✅ Using document start selection:', startSelection); return startSelection; } @@ -131,7 +131,7 @@ export function findNearestValidSelection(editor: Editor, originalSelection: Ran const endSelection = findDocumentEnd(editor); if (endSelection) { - console.log('✅ Using document end selection:', endSelection); + console.debug('✅ Using document end selection:', endSelection); return endSelection; } diff --git a/src/application/slate-yjs/utils/yjs.ts b/src/application/slate-yjs/utils/yjs.ts index edc5296e..40641495 100644 --- a/src/application/slate-yjs/utils/yjs.ts +++ b/src/application/slate-yjs/utils/yjs.ts @@ -29,7 +29,6 @@ export function getTextMap(sharedRoot: YSharedRoot) { } export function getText(textId: string, sharedRoot: YSharedRoot) { - const textMap = getTextMap(sharedRoot); return textMap.get(textId); @@ -64,13 +63,16 @@ export function generateBlockId() { return nanoid(8); } -export function createBlock(sharedRoot: YSharedRoot, { - ty, - data, -}: { - ty: BlockType; - data: object; -}): YBlock { +export function createBlock( + sharedRoot: YSharedRoot, + { + ty, + data, + }: { + ty: BlockType; + data: object; + } +): YBlock { const block = new Y.Map(); const id = generateBlockId(); @@ -89,7 +91,7 @@ export function createBlock(sharedRoot: YSharedRoot, { childrenMap.set(id, new Y.Array()); - if(!isEmbedBlockTypes(ty)) { + if (!isEmbedBlockTypes(ty)) { block.set(YjsEditorKey.block_external_id, id); block.set(YjsEditorKey.block_external_type, 'text'); const textMap = meta.get(YjsEditorKey.text_map) as YTextMap; @@ -103,7 +105,7 @@ export function createBlock(sharedRoot: YSharedRoot, { export function assertDocExists(sharedRoot: YSharedRoot): YDoc { const doc = sharedRoot.doc; - if(!doc) { + if (!doc) { throw new Error('Document not found'); } @@ -125,7 +127,7 @@ export function updateBlockParent(sharedRoot: YSharedRoot, block: YBlock, parent block.set(YjsEditorKey.block_parent, parent.get(YjsEditorKey.block_id)); const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); - if(index >= parentChildren.length) { + if (index >= parentChildren.length) { parentChildren.push([block.get(YjsEditorKey.block_id)]); return; } @@ -136,7 +138,7 @@ export function updateBlockParent(sharedRoot: YSharedRoot, block: YBlock, parent export function getPageId(sharedRoot: YSharedRoot) { const document = getDocument(sharedRoot); - if(!document) { + if (!document) { throw new Error('Document not found'); } @@ -149,18 +151,24 @@ export function appendFirstEmptyParagraph(sharedRoot: YSharedRoot, defaultText: const pageId = getPageId(sharedRoot); const page = getBlock(pageId, sharedRoot); - executeOperations(sharedRoot, [() => { - const newBlock = createBlock(sharedRoot, { - ty: BlockType.Paragraph, - data: {}, - }); + executeOperations( + sharedRoot, + [ + () => { + const newBlock = createBlock(sharedRoot, { + ty: BlockType.Paragraph, + data: {}, + }); - const newBlockText = getText(newBlock.get(YjsEditorKey.block_external_id), sharedRoot); + const newBlockText = getText(newBlock.get(YjsEditorKey.block_external_id), sharedRoot); - newBlockText.insert(0, defaultText); + newBlockText.insert(0, defaultText); - updateBlockParent(sharedRoot, newBlock, page, 0); - }], 'appendFirstEmptyParagraph'); + updateBlockParent(sharedRoot, newBlock, page, 0); + }, + ], + 'appendFirstEmptyParagraph' + ); } export function createEmptyDocument() { @@ -205,26 +213,30 @@ export function getBlockIndex(blockId: string, sharedRoot: YSharedRoot) { export function compatibleDataDeltaToYText(sharedRoot: YSharedRoot, ops: Op[], blockId: string) { const yText = new Y.Text(); - executeOperations(sharedRoot, [() => { + executeOperations( + sharedRoot, + [ + () => { + yText.applyDelta(ops); - yText.applyDelta(ops); + const block = getBlock(blockId, sharedRoot); - const block = getBlock(blockId, sharedRoot); + block.set(YjsEditorKey.block_external_id, blockId); + block.set(YjsEditorKey.block_external_type, YjsEditorKey.text); + const textMap = getTextMap(sharedRoot); - block.set(YjsEditorKey.block_external_id, blockId); - block.set(YjsEditorKey.block_external_type, YjsEditorKey.text); - const textMap = getTextMap(sharedRoot); - - textMap.set(blockId, yText); - - }], 'compatibleDataDeltaToYText'); + textMap.set(blockId, yText); + }, + ], + 'compatibleDataDeltaToYText' + ); return yText; } export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) { const block = getBlock(blockId, sharedRoot); - if(!block) return; + if (!block) return; const document = getDocument(sharedRoot); const blocks = document.get(YjsEditorKey.blocks) as YBlocks; @@ -242,17 +254,17 @@ export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) { const parent = getBlock(parentId, sharedRoot); - if(!parent) return; + if (!parent) return; const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); const afterDeletedLength = parentChildren.length - 1; const parentType = parent.get(YjsEditorKey.block_type); const index = parentChildren.toArray().findIndex((id) => id === blockId); - if(index !== -1) { + if (index !== -1) { parentChildren.delete(index, 1); } else { - console.info('Block not found in parent\'s children'); + console.info("Block not found in parent's children"); } blocks.delete(blockId); @@ -260,12 +272,12 @@ export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) { textMap.delete(blockId); // delete parent if it's empty column block - if(parentType === BlockType.ColumnBlock && afterDeletedLength === 0) { + if (parentType === BlockType.ColumnBlock && afterDeletedLength === 0) { deleteBlock(sharedRoot, parentId); } // delete parent and move children to grandparent if it's one column block - if(parentType === BlockType.ColumnsBlock && afterDeletedLength === 1) { + if (parentType === BlockType.ColumnsBlock && afterDeletedLength === 1) { const targetParent = getBlock(parent.get(YjsEditorKey.block_parent), sharedRoot); const targetIndex = getBlockIndex(parentId, sharedRoot); @@ -282,12 +294,18 @@ export function liftChildren(sharedRoot: YSharedRoot, sourceBlock: YBlock, targe const targetParent = getBlock(targetBlock.get(YjsEditorKey.block_parent), sharedRoot); const targetChildrenArray = getChildrenArray(targetParent.get(YjsEditorKey.block_children), sharedRoot); - if(!sourceChildrenArray || !targetChildrenArray) return; + if (!sourceChildrenArray || !targetChildrenArray) return; const index = targetChildrenArray.toArray().findIndex((id) => id === targetBlock.get(YjsEditorKey.block_id)); const targetIndex = index !== -1 ? index + 1 : targetChildrenArray.length; - if(sourceChildrenArray.length > 0) { - deepCopyChildren(sharedRoot, sourceChildrenArray, targetChildrenArray, targetParent.get(YjsEditorKey.block_id), targetIndex); + if (sourceChildrenArray.length > 0) { + deepCopyChildren( + sharedRoot, + sourceChildrenArray, + targetChildrenArray, + targetParent.get(YjsEditorKey.block_id), + targetIndex + ); sourceChildrenArray.toArray().forEach((id) => { deleteBlock(sharedRoot, id); }); @@ -299,14 +317,14 @@ export function copyBlockText(sharedRoot: YSharedRoot, sourceBlock: YBlock, targ const sourceTextId = sourceBlock.get(YjsEditorKey.block_external_id); const targetTextId = targetBlock.get(YjsEditorKey.block_external_id); - if(!sourceTextId || !targetTextId) { + if (!sourceTextId || !targetTextId) { return; } const sourceText = getText(sourceTextId, sharedRoot); const targetText = getText(targetTextId, sharedRoot); - if(!sourceText || !targetText) { + if (!sourceText || !targetText) { return; } @@ -322,7 +340,7 @@ export function prepareBreakOperation(sharedRoot: YSharedRoot, block: YBlock, of const parentId = block.get(YjsEditorKey.block_parent); const parent = getBlock(parentId, sharedRoot); - if(!parent) { + if (!parent) { throw new Error('Parent block not found'); } @@ -333,11 +351,11 @@ export function prepareBreakOperation(sharedRoot: YSharedRoot, block: YBlock, of } export function getSplitBlockType(block: YBlock) { - switch(block.get(YjsEditorKey.block_type)) { + switch (block.get(YjsEditorKey.block_type)) { case BlockType.ToggleListBlock: { const data = dataStringTOJson(block.get(YjsEditorKey.block_data)) as ToggleListBlockData; - if(!data.collapsed) { + if (!data.collapsed) { return BlockType.Paragraph; } else { return block.get(YjsEditorKey.block_type); @@ -354,11 +372,17 @@ export function getSplitBlockType(block: YBlock) { } } -export function splitBlock(sharedRoot: YSharedRoot, block: YBlock, offset: number, nextLineDelta: Delta, parentInfo: { - parent: YBlock, - targetIndex: number, - parentChildren: Y.Array -}) { +export function splitBlock( + sharedRoot: YSharedRoot, + block: YBlock, + offset: number, + nextLineDelta: Delta, + parentInfo: { + parent: YBlock; + targetIndex: number; + parentChildren: Y.Array; + } +) { const { parent, targetIndex, parentChildren } = parentInfo; const yText = getText(block.get(YjsEditorKey.block_external_id), sharedRoot); @@ -375,13 +399,13 @@ export function splitBlock(sharedRoot: YSharedRoot, block: YBlock, offset: numbe const blockType = block.get(YjsEditorKey.block_type); - if(TOGGLE_BLOCK_TYPES.includes(blockType)) { + if (TOGGLE_BLOCK_TYPES.includes(blockType)) { const data = dataStringTOJson(block.get(YjsEditorKey.block_data)) as ToggleListBlockData; - if(!data.collapsed) { + if (!data.collapsed) { const blockChildrenArray = getChildrenArray(block.get(YjsEditorKey.block_children), sharedRoot); - if(blockChildrenArray) { + if (blockChildrenArray) { updateBlockParent(sharedRoot, newBlock, block, 0); } @@ -399,7 +423,7 @@ export function splitBlock(sharedRoot: YSharedRoot, block: YBlock, offset: numbe export function ensureBlockHasChildren(sharedRoot: YSharedRoot, block: YBlock) { const childrenArray = getChildrenArray(block.get(YjsEditorKey.block_children), sharedRoot); - if(!childrenArray) { + if (!childrenArray) { const newArray = new Y.Array(); const childrenMap = getChildrenMap(sharedRoot); @@ -414,9 +438,15 @@ export function transferChildren(sharedRoot: YSharedRoot, sourceBlock: YBlock, t const targetChildrenArray = ensureBlockHasChildren(sharedRoot, targetBlock); - if(!sourceChildrenArray || !targetChildrenArray) return; - if(sourceChildrenArray.length > 0) { - deepCopyChildren(sharedRoot, sourceChildrenArray, targetChildrenArray, targetBlock.get(YjsEditorKey.block_id), index); + if (!sourceChildrenArray || !targetChildrenArray) return; + if (sourceChildrenArray.length > 0) { + deepCopyChildren( + sharedRoot, + sourceChildrenArray, + targetChildrenArray, + targetBlock.get(YjsEditorKey.block_id), + index + ); sourceChildrenArray.toArray().forEach((id) => { deleteBlock(sharedRoot, id); }); @@ -424,20 +454,25 @@ export function transferChildren(sharedRoot: YSharedRoot, sourceBlock: YBlock, t } } -export function turnToBlock(sharedRoot: YSharedRoot, sourceBlock: YBlock, type: BlockType, data: T) { +export function turnToBlock( + sharedRoot: YSharedRoot, + sourceBlock: YBlock, + type: BlockType, + data: T +) { const newBlock = createBlock(sharedRoot, { ty: type, data, }); const newBlockId = newBlock.get(YjsEditorKey.block_id); - if(!isEmbedBlockTypes(type)) { + if (!isEmbedBlockTypes(type)) { copyBlockText(sharedRoot, sourceBlock, newBlock); } const parent = getBlock(sourceBlock.get(YjsEditorKey.block_parent), sharedRoot); - if(!parent) { + if (!parent) { return newBlockId; } @@ -446,7 +481,7 @@ export function turnToBlock(sharedRoot: YSharedRoot, source updateBlockParent(sharedRoot, newBlock, parent, index); - if(CONTAINER_BLOCK_TYPES.includes(type)) { + if (CONTAINER_BLOCK_TYPES.includes(type)) { transferChildren(sharedRoot, sourceBlock, newBlock); } else { liftChildren(sharedRoot, sourceBlock, newBlock); @@ -463,24 +498,31 @@ export function turnToBlock(sharedRoot: YSharedRoot, source export function dataStringTOJson(data: string): object { try { return JSON.parse(data); - } catch(e) { + } catch (e) { return {}; } } export function moveNode(sharedRoot: YSharedRoot, sourceBlock: YBlock, targetParent: YBlock, targetIndex: number) { - console.log('moveNode:', sourceBlock.get(YjsEditorKey.block_id), 'to', targetParent.get(YjsEditorKey.block_id), 'at index', targetIndex); + console.debug( + 'moveNode:', + sourceBlock.get(YjsEditorKey.block_id), + 'to', + targetParent.get(YjsEditorKey.block_id), + 'at index', + targetIndex + ); const copiedBlockId = deepCopyBlock(sharedRoot, sourceBlock); - if(!copiedBlockId) { + if (!copiedBlockId) { console.warn('Failed to copy block'); return; } const copiedBlock = getBlock(copiedBlockId, sharedRoot); - if(!copiedBlock) { + if (!copiedBlock) { console.warn('Copied block not found'); return; } @@ -504,43 +546,41 @@ export function deepCopyBlock(sharedRoot: YSharedRoot, sourceBlock: YBlock): str const sourceChildrenArray = getChildrenArray(sourceBlock.get(YjsEditorKey.block_children), sharedRoot); const targetChildrenArray = getChildrenArray(newBlock.get(YjsEditorKey.block_children), sharedRoot); - if(sourceChildrenArray && targetChildrenArray) { - + if (sourceChildrenArray && targetChildrenArray) { deepCopyChildren(sharedRoot, sourceChildrenArray, targetChildrenArray, newBlock.get(YjsEditorKey.block_id)); } return newBlock.get(YjsEditorKey.block_id); - } catch(error) { + } catch (error) { console.error('Error in deepCopyBlock:', error); return null; } } export function indentBlock(sharedRoot: YSharedRoot, block: YBlock) { - const parentId = block.get(YjsEditorKey.block_parent); const parent = getBlock(parentId, sharedRoot); - if(!parent) { + if (!parent) { console.warn('Cannot indent block: parent not found'); return; } const parentChildrenArray = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); - if(!parentChildrenArray) { + if (!parentChildrenArray) { console.warn('Cannot indent block: parent children array not found'); return; } const blockIndex = parentChildrenArray.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id)); - if(blockIndex === -1) { - console.warn('Cannot indent block: block not found in parent\'s children'); + if (blockIndex === -1) { + console.warn("Cannot indent block: block not found in parent's children"); return; } - if(blockIndex === 0) { + if (blockIndex === 0) { console.warn('Cannot indent block: block is the first child'); return; } @@ -548,14 +588,14 @@ export function indentBlock(sharedRoot: YSharedRoot, block: YBlock) { const previousSiblingId = parentChildrenArray.get(blockIndex - 1); const previousSibling = getBlock(previousSiblingId, sharedRoot); - if(!previousSibling) { + if (!previousSibling) { console.warn('Cannot indent block: previous sibling not found'); return; } const previousSiblingChildrenArray = getChildrenArray(previousSibling.get(YjsEditorKey.block_children), sharedRoot); - if(!previousSiblingChildrenArray) { + if (!previousSiblingChildrenArray) { console.warn('Cannot indent block: previous sibling children array not found'); return; } @@ -567,19 +607,24 @@ export function extendNextSiblingsToToggleHeading(sharedRoot: YSharedRoot, block const type = block.get(YjsEditorKey.block_type); const data = dataStringTOJson(block.get(YjsEditorKey.block_data)) as ToggleListBlockData; - if(type !== BlockType.ToggleListBlock || !data.level) return; + if (type !== BlockType.ToggleListBlock || !data.level) return; const nextSiblings = getNextSiblings(sharedRoot, block); - if(!nextSiblings || nextSiblings.length === 0) return; + if (!nextSiblings || nextSiblings.length === 0) return; // find the next sibling with the same or higher level const index = nextSiblings.findIndex((id) => { const block = getBlock(id, sharedRoot); const blockData = dataStringTOJson(block.get(YjsEditorKey.block_data)); - if('level' in blockData && (blockData as { - level: number - }).level <= ((data as unknown as ToggleListBlockData).level as number)) { + if ( + 'level' in blockData && + ( + blockData as { + level: number; + } + ).level <= ((data as unknown as ToggleListBlockData).level as number) + ) { return true; } @@ -599,19 +644,19 @@ export function extendNextSiblingsToToggleHeading(sharedRoot: YSharedRoot, block export function getPreviousSiblingBlock(sharedRoot: YSharedRoot, block: YBlock) { const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot); - if(!parent) return; + if (!parent) return; const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id)); - if(index === 0) return null; + if (index === 0) return null; return parentChildren.get(index - 1); } export function getNextSiblings(sharedRoot: YSharedRoot, block: YBlock) { const parent = getBlock(block.get(YjsEditorKey.block_parent), sharedRoot); - if(!parent) return; + if (!parent) return; const parentChildren = getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot); const index = parentChildren.toArray().findIndex((id) => id === block.get(YjsEditorKey.block_id)); @@ -619,13 +664,17 @@ export function getNextSiblings(sharedRoot: YSharedRoot, block: YBlock) { return parentChildren.toArray().slice(index + 1); } -export function getSplitBlockOperations(sharedRoot: YSharedRoot, block: YBlock, offset: number): { +export function getSplitBlockOperations( + sharedRoot: YSharedRoot, + block: YBlock, + offset: number +): { select: boolean; operations: (() => void)[]; } { const operations: (() => void)[] = []; - if(offset === 0) { + if (offset === 0) { operations.push(() => { const type = block.get(YjsEditorKey.block_type); const data = dataStringTOJson(block.get(YjsEditorKey.block_data)); @@ -652,14 +701,19 @@ export function getSplitBlockOperations(sharedRoot: YSharedRoot, block: YBlock, return { operations, select: true }; } -export function deepCopyChildren(sharedRoot: YSharedRoot, sourceArray: Y.Array, targetArray: Y.Array, targetBlockId: string, index?: number) { - +export function deepCopyChildren( + sharedRoot: YSharedRoot, + sourceArray: Y.Array, + targetArray: Y.Array, + targetBlockId: string, + index?: number +) { const sourceArraySorted = index === undefined ? sourceArray.toArray() : sourceArray.toArray().reverse(); sourceArraySorted.forEach((childId) => { const sourceChild = getBlock(childId, sharedRoot); - if(sourceChild) { + if (sourceChild) { const oldData = dataStringTOJson(sourceChild.get(YjsEditorKey.block_data)); const newChild = createBlock(sharedRoot, { ty: sourceChild.get(YjsEditorKey.block_type), @@ -669,16 +723,16 @@ export function deepCopyChildren(sharedRoot: YSharedRoot, sourceArray: Y.Array 0) { + if (sourceChildrenArray && sourceChildrenArray.length > 0) { const newChildrenArray = getChildrenArray(newChild.get(YjsEditorKey.block_children), sharedRoot); - if(newChildrenArray) { + if (newChildrenArray) { deepCopyChildren(sharedRoot, sourceChildrenArray, newChildrenArray, newChild.get(YjsEditorKey.block_id)); } } @@ -693,7 +747,7 @@ export function deepCopyChildren(sharedRoot: YSharedRoot, sourceArray: Y.Array id === parentId); - if(parentIndex === -1) { - console.warn('Cannot lift block: parent not found in grandparent\'s children'); + if (parentIndex === -1) { + console.warn("Cannot lift block: parent not found in grandparent's children"); return; } @@ -756,11 +810,11 @@ export function appendEmptyParagraph(sharedRoot: YSharedRoot): string { export function getParent(blockId: string, sharedRoot: YSharedRoot) { const block = getBlock(blockId, sharedRoot); - if(!block) { + if (!block) { return; } const parentId = block.get(YjsEditorKey.block_parent); return getBlock(parentId, sharedRoot); -} \ No newline at end of file +} diff --git a/src/application/types.ts b/src/application/types.ts index a3193710..d989a6bf 100644 --- a/src/application/types.ts +++ b/src/application/types.ts @@ -363,6 +363,7 @@ export enum YjsDatabaseKey { is_inline = 'is_inline', auto_fill = 'auto_fill', language = 'language', + number_of_days = 'number_of_days', } export interface YDoc extends Y.Doc { @@ -589,6 +590,7 @@ export interface YDatabaseBoardLayoutSetting extends Y.Map { export interface YDatabaseCalendarLayoutSetting extends Y.Map { get(key: YjsDatabaseKey.first_day_of_week | YjsDatabaseKey.field_id | YjsDatabaseKey.layout_ty): string; + get(key: YjsDatabaseKey.number_of_days): number; get(key: YjsDatabaseKey.show_week_numbers | YjsDatabaseKey.show_weekends): boolean; } diff --git a/src/application/user-metadata.ts b/src/application/user-metadata.ts index 7cf4f71f..c980189e 100644 --- a/src/application/user-metadata.ts +++ b/src/application/user-metadata.ts @@ -3,6 +3,11 @@ import { toZonedTime } from 'date-fns-tz'; import { DateFormat, TimeFormat } from './types'; import { UserTimezone } from './user-timezone.types'; +export interface DefaultTimeSetting { + dateFormat: DateFormat; + timeFormat: TimeFormat; + startWeekOn: number; +} /** * Predefined metadata keys to ensure consistency * This matches the Rust implementation on the backend diff --git a/src/components/_shared/cutsom-icon/CustomIconPopover.tsx b/src/components/_shared/cutsom-icon/CustomIconPopover.tsx index 27bc0e33..254877eb 100644 --- a/src/components/_shared/cutsom-icon/CustomIconPopover.tsx +++ b/src/components/_shared/cutsom-icon/CustomIconPopover.tsx @@ -72,21 +72,15 @@ export function CustomIconPopover ({ if (!enable) return <>{children}; return ( - - - {children} - + + {children} e.preventDefault()} - onOpenAutoFocus={e => e.preventDefault()} - onClick={e => { + className='w-[402px]' + onCloseAutoFocus={(e) => e.preventDefault()} + onOpenAutoFocus={(e) => e.preventDefault()} + onClick={(e) => { e.stopPropagation(); }} {...popoverContentProps} @@ -95,21 +89,15 @@ export function CustomIconPopover ({ value={tabValue} onValueChange={setTabValue} defaultValue={defaultActiveTab} - className="flex flex-col gap-3" + className='flex flex-col gap-3' > -
- +
+ {tabs.map((tab) => ( - - - {tab.charAt(0).toUpperCase() + tab.slice(1)} - + + {tab.charAt(0).toUpperCase() + tab.slice(1)} ))} - {!hideRemove && ( + + + {t('calendar.addEventOn')} {date.toLocaleDateString('en-US', { month: 'short', day: 'numeric' })} + + , + container || document.body + ); + } +); diff --git a/src/components/database/fullcalendar/CalendarContent.tsx b/src/components/database/fullcalendar/CalendarContent.tsx new file mode 100644 index 00000000..fe8e29a7 --- /dev/null +++ b/src/components/database/fullcalendar/CalendarContent.tsx @@ -0,0 +1,577 @@ +// Remove Atlaskit drag and drop - using FullCalendar's native external dragging instead +import dayGridPlugin from '@fullcalendar/daygrid'; +import interactionPlugin, { EventReceiveArg } from '@fullcalendar/interaction'; +import FullCalendar from '@fullcalendar/react'; +import timeGridPlugin from '@fullcalendar/timegrid'; +import { debounce } from 'lodash-es'; +import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; + +import './FullCalendar.styles.scss'; + +import { useDatabaseContext } from '@/application/database-yjs'; +import { useConditionsContext } from '@/components/database/components/conditions/context'; +import { AddButton } from '@/components/database/fullcalendar/AddButton'; +import { MoreLinkContent } from '@/components/database/fullcalendar/event/MoreLinkContent'; +import { useFullCalendarSetup } from '@/components/database/fullcalendar/FullCalendar.hooks'; +import { + useAddButton, + useCalendarHandlers, + useCalendarPermissions, + useCalendarResize, + useCalendarStickyHeader, + useCalendarStickyWeekHeader, + useCurrentTimeIndicator, + useDynamicDayMaxEventRows, + useScrollDetection, + useScrollNavigation, +} from '@/components/database/fullcalendar/hooks'; +import { dayCellContent } from '@/components/database/fullcalendar/utils/dayCellContent'; +import { dateToUnixTimestamp } from '@/utils/time'; + +// CustomToolbar will be handled by parent component +import EventWithPopover from './event/EventWithPopover'; +import { CalendarViewType } from './types'; + +import type { CalendarApi, EventContentArg, MoreLinkContentArg } from '@fullcalendar/core'; + +import { EventDef } from '@fullcalendar/core/internal'; + +// Context to provide the clearNewEvent function to EventWithPopover components +const EventContext = createContext<{ + clearNewEvent: (rowId: string) => void; + setOpenEventRowId: (rowId: string | null) => void; + markEventAsNew: (rowId: string) => void; + markEventAsUpdate: (rowId: string) => void; + clearUpdateEvent: (rowId: string) => void; +} | null>(null); + +export const useEventContext = () => { + const context = useContext(EventContext); + + if (!context) { + throw new Error('useEventContext must be used within EventContext.Provider'); + } + + return context; +}; + +/** + * Calendar data and handlers interface for parent components + */ +interface CalendarContentData { + calendarApi: CalendarApi | null; + currentView: CalendarViewType; + showStickyToolbar: boolean; + shouldShowWeekHeader: boolean; + weekHeaderCells: Array<{ + date: Date; + dayName: string; + dayNumber: number; + isToday: boolean; + isWeekend: boolean; + }>; + weekHeaderScrollLeft: number; + handleViewChange: (view: CalendarViewType) => void; + emptyEvents: import('@/application/database-yjs').CalendarEvent[]; +} + +/** + * Props for CalendarContent component + */ +interface CalendarContentProps { + onDataChange?: (data: CalendarContentData) => void; + normalToolbarRef?: React.RefObject; +} + +/** + * Inner calendar component that uses the popover context + */ +export function CalendarContent({ onDataChange, normalToolbarRef }: CalendarContentProps) { + // State to track newly created events + const [newEventRowIds, setNewEventRowIds] = useState>(new Set()); + const [openEventRowId, setOpenEventRowId] = useState(null); + const [updateEventRowIds, setUpdateEventRowIds] = useState>(new Set()); + + // Get calendar data and setup + const { events, emptyEvents, firstDayOfWeek } = useFullCalendarSetup( + newEventRowIds, + openEventRowId, + updateEventRowIds + ); + const { paddingStart, paddingEnd, isDocumentBlock, onRendered } = useDatabaseContext(); + const conditionsContext = useConditionsContext(); + const expanded = conditionsContext?.expanded ?? false; + + // Calendar permissions and behavior based on field type + const { permissions, isAddButtonEnabled, createEvent } = useCalendarPermissions(); + // Calendar reference for API access + const calendarRef = useRef(null); + + const [calendarElement, setCalendarElement] = useState(null); + // Get calendar API instance + const calendarApi = calendarRef.current?.getApi() || null; + + // Resize handling + const resizeRef = useCalendarResize(onRendered, expanded, isDocumentBlock, calendarApi || undefined); + + // Function to clear new event status + const clearNewEvent = useCallback((rowId: string) => { + setNewEventRowIds((prev) => { + const newSet = new Set(prev); + + newSet.delete(rowId); + return newSet; + }); + }, []); + + // Function to mark event as new (for duplicate functionality) + const markEventAsNew = useCallback((rowId: string) => { + console.debug('[CalendarContent] Marking event as new:', rowId); + setNewEventRowIds((prev) => new Set(prev).add(rowId)); + }, []); + + const markEventAsUpdate = useCallback((rowId: string) => { + console.debug('[CalendarContent] Marking event as updated:', rowId); + setUpdateEventRowIds((prev) => new Set(prev).add(rowId)); + }, []); + + const clearUpdateEvent = useCallback((rowId: string) => { + setUpdateEventRowIds((prev) => { + const newSet = new Set(prev); + + newSet.delete(rowId); + return newSet; + }); + }, []); + + // Calendar handlers and state + const { + currentView, + calendarTitle, + handleViewChange, + handleDatesSet, + handleMoreLinkClick, + handleEventDrop, + handleEventResize, + handleSelect: originalHandleSelect, + handleAdd: originalHandleAdd, + updateEventTime, + morelinkInfo, + closeMorePopover, + } = useCalendarHandlers(); + + // Wrap handleAdd to mark event as new after creating + const handleAdd = useCallback( + async (date: Date) => { + let rowId: string | null = null; + + if (createEvent) { + // For created/modified time fields, use custom dispatch + rowId = await createEvent(); + } else { + // For regular date fields, use the original handler + rowId = await originalHandleAdd(date); + } + + if (rowId) { + // Mark this event as newly created + setNewEventRowIds((prev) => new Set(prev).add(rowId!)); + } + + return rowId; + }, + [originalHandleAdd, createEvent] + ); + + // Create debounced version of the select handler to prevent double-click issues + const debouncedSelectHandler = useMemo( + () => + debounce( + async (selectInfo: Parameters[0]) => { + let rowId: string | null = null; + + if (createEvent) { + // For created/modified time fields, use custom dispatch + rowId = await createEvent(); + } else { + // For regular date fields, use the original handler + rowId = await originalHandleSelect(selectInfo); + } + + if (rowId) { + // Mark this event as newly created + setNewEventRowIds((prev) => new Set(prev).add(rowId!)); + } + + return rowId; + }, + 300, + { leading: true, trailing: false } + ), // Leading edge trigger to prevent double-click + [originalHandleSelect, createEvent] + ); + + // Wrap handleSelect to use debounced version + const handleSelect = useCallback( + (selectInfo: Parameters[0]) => { + return debouncedSelectHandler(selectInfo); + }, + [debouncedSelectHandler] + ); + + // Handle external event creation (FullCalendar eventReceive callback) + const handleEventReceive = useCallback( + (receiveInfo: EventReceiveArg) => { + console.debug('📅 FullCalendar eventReceive:', receiveInfo); + + try { + const event = receiveInfo.event; + const rowId = event.extendedProps?.rowId; + + if (!rowId) { + console.error('❌ No rowId found in dropped event'); + receiveInfo.revert(); + return; + } + + // Get the date information from the dropped event + const startDate = event.start; + const endDate = event.end; + const allDay = event.allDay; + + if (!startDate) { + console.error('❌ No start date found in dropped event'); + receiveInfo.revert(); + return; + } + + // Convert to timestamps + const startTimestamp = dateToUnixTimestamp(startDate); + let endTimestamp = undefined; + + if (endDate) { + endTimestamp = dateToUnixTimestamp(endDate); + } else if (!allDay) { + // Default 1 hour duration for timed events without end time + const defaultEndDate = new Date(startDate); + + defaultEndDate.setHours(startDate.getHours() + 1); + endTimestamp = dateToUnixTimestamp(defaultEndDate); + } + + // Update the row's date field to move it from NoDate to calendar + updateEventTime(rowId, startTimestamp, endTimestamp, allDay); + + // Mark the event as new for visual feedback + setNewEventRowIds((prev) => new Set(prev).add(rowId)); + + // Remove the external event since we'll show our own calendar event + receiveInfo.revert(); + + console.debug('📅 NoDateRow successfully converted to calendar event'); + } catch (error) { + console.error('❌ Failed to handle external event receive:', error); + receiveInfo.revert(); + } + }, + [updateEventTime] + ); + + // No need for manual drop target setup - FullCalendar handles this with droppable: true + + // Scroll navigation with threshold detection + const { containerRef: scrollRef } = useScrollNavigation(currentView, calendarApi); + + // Dynamic day max event rows + const { dayMaxEventRows, updateDayMaxEventRows } = useDynamicDayMaxEventRows(currentView); + + // Sticky header handling + const { parentRef: stickyHeaderRef, showStickyToolbar } = useCalendarStickyHeader(calendarApi, normalToolbarRef); + + // Sticky week header handling + const { + parentRef: stickyWeekHeaderRef, + headerCells, + scrollLeft, + shouldShowWeekHeader, + } = useCalendarStickyWeekHeader(calendarApi, { currentView, firstDayOfWeek }); + + // Add button functionality + const { + ref: addButtonRef, + addButtonState, + handleAddButtonClick, + handleAddButtonMouseLeave, + } = useAddButton({ + readOnly: false, // We handle enabled state via isAddButtonEnabled function + currentView, + calendarElement, + onAddEvent: handleAdd, + isAddButtonEnabled, + }); + + // Scroll detection (handles add button visibility via CSS) + useScrollDetection(scrollRef, addButtonRef); + + // Enhanced current time indicator with time label + useCurrentTimeIndicator(calendarApi, currentView); + + // Combine refs for container element + const setContainerRef = useCallback( + (node: HTMLDivElement | null) => { + resizeRef.current = node; + scrollRef.current = node; + stickyHeaderRef.current = node; + stickyWeekHeaderRef.current = node; + setCalendarElement(node); + }, + [resizeRef, scrollRef, stickyHeaderRef, stickyWeekHeaderRef] + ); + + // Memoized handlers with proper dependencies + const memoizedHandleViewChange = useCallback( + (view: CalendarViewType) => handleViewChange(view, calendarApi), + [handleViewChange, calendarApi] + ); + + const memoizedHandleDatesSet = useCallback( + (dateInfo: Parameters[0]) => { + handleDatesSet(dateInfo, calendarApi); + }, + [handleDatesSet, calendarApi] + ); + + // Memoized style object + const containerStyle = useMemo( + () => ({ + marginLeft: paddingStart === undefined ? undefined : paddingStart, + marginRight: paddingEnd === undefined ? undefined : paddingEnd, + }), + [paddingStart, paddingEnd] + ); + + // Memoized className + const containerClassName = useMemo(() => { + let viewClass = 'week-view'; // default + + if (currentView === CalendarViewType.DAY_GRID_MONTH) { + viewClass = 'month-view'; + } + + return `database-calendar relative z-[1] mx-24 min-h-full h-auto text-sm max-sm:!mx-6 ${viewClass}`; + }, [currentView]); + + // Memoized calendar plugins array + const calendarPlugins = useMemo(() => [dayGridPlugin, timeGridPlugin, interactionPlugin], []); + + // Memoized slot label format + const slotLabelFormat = useMemo( + () => ({ + hour: 'numeric' as const, + meridiem: 'short' as const, + }), + [] + ); + + // Memoized day header format for week view + const dayHeaderFormat = useMemo( + () => + currentView === CalendarViewType.DAY_GRID_MONTH + ? undefined + : { + weekday: 'short' as const, + day: 'numeric' as const, + }, + [currentView] + ); + + // Memoized slot label content component + const slotLabelContent = useCallback((args: { date: Date }) => { + const hour = args.date.getHours(); + const period = hour >= 12 ? 'PM' : 'AM'; + const displayHour = hour === 0 ? 12 : hour > 12 ? hour - 12 : hour; + + return ( + + {displayHour} + {period} + + ); + }, []); + + // Memoized day header content for week view + const dayHeaderContent = useCallback( + (args: { date: Date; isToday: boolean }) => { + if (currentView === CalendarViewType.DAY_GRID_MONTH) return undefined; + + const dayName = args.date.toLocaleDateString('en-US', { weekday: 'short' }); + const dayNumber = args.date.getDate(); + + if (args.isToday) { + return ( + <> + {dayName} {dayNumber} + + ); + } + + return `${dayName} ${dayNumber}`; + }, + [currentView] + ); + + // Memoized event content component + const eventContent = useCallback( + (eventInfo: EventContentArg) => ( + + ), + [currentView] + ); + + // Memoized day cell content for month view + const dayCellContentCallback = useCallback( + (args: { date: Date; dayNumberText: string; isToday: boolean }) => { + if (currentView !== CalendarViewType.DAY_GRID_MONTH) return undefined; + return dayCellContent(args); + }, + [currentView] + ); + + // Memoized day popover format for title + const dayPopoverFormat = useMemo( + () => ({ + month: 'short' as const, + day: 'numeric' as const, + }), + [] + ); + + const eventOrder = useMemo(() => { + return [ + (a: EventDef) => { + if (a.extendedProps.isNew) { + return -1; + } + + if (a.extendedProps.isUpdate) { + return -1; + } + + if (!a.extendedProps.includeTime) { + return -1; + } + + if (a.extendedProps.isMultipleDayEvent) { + return -1; + } + + return 1; + }, + + 'start', + ]; + }, []); + + // Sync calendar data to parent component + useEffect(() => { + if (onDataChange) { + onDataChange({ + calendarApi, + currentView, + showStickyToolbar, + shouldShowWeekHeader, + weekHeaderCells: headerCells, + weekHeaderScrollLeft: scrollLeft, + handleViewChange: memoizedHandleViewChange, + emptyEvents, + }); + } + }, [ + onDataChange, + calendarApi, + currentView, + calendarTitle, + showStickyToolbar, + shouldShowWeekHeader, + headerCells, + scrollLeft, + memoizedHandleViewChange, + emptyEvents, + ]); + + const renderMoreLinkContent = useCallback( + (props: MoreLinkContentArg) => { + if (!calendarApi) return null; + return ( + + ); + }, + [closeMorePopover, calendarApi, morelinkInfo] + ); + + return ( + +
+ +
+ +
+ ); +} diff --git a/src/components/database/fullcalendar/CalendarUnsupportedPage.tsx b/src/components/database/fullcalendar/CalendarUnsupportedPage.tsx new file mode 100644 index 00000000..8460d830 --- /dev/null +++ b/src/components/database/fullcalendar/CalendarUnsupportedPage.tsx @@ -0,0 +1,42 @@ +import { useTranslation } from 'react-i18next'; + +import { ReactComponent as CalendarLogo } from '@/assets/icons/warning_logo.svg'; +import { Button } from '@/components/ui/button'; + +export function CalendarUnsupportedPage() { + const { t } = useTranslation(); + + return ( +
+ {/* Icon */} +
+ +
+ + {/* Title */} +

+ Calendar Not Supported +

+ + {/* Description */} +

+ Calendar view is not supported on this device. For the best calendar experience, please download the AppFlowy mobile app. +

+ + {/* Buttons */} +
+ + +
+
+ ); +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/CustomToolbar.tsx b/src/components/database/fullcalendar/CustomToolbar.tsx new file mode 100644 index 00000000..8c5b8e1f --- /dev/null +++ b/src/components/database/fullcalendar/CustomToolbar.tsx @@ -0,0 +1,186 @@ +import { CalendarApi } from '@fullcalendar/core'; +import dayjs from 'dayjs'; +import { AnimatePresence, motion } from 'framer-motion'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CalendarEvent } from '@/application/database-yjs'; +import { ReactComponent as ChevronLeft } from '@/assets/icons/alt_arrow_left.svg'; +import { ReactComponent as ChevronRight } from '@/assets/icons/alt_arrow_right.svg'; +import { ReactComponent as CalendarIcon } from '@/assets/icons/calendar.svg'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +import { NoDateButton } from './NoDateButton'; +import { CalendarViewType } from './types'; + +interface CustomToolbarProps { + calendar: CalendarApi | null; + onViewChange?: (view: CalendarViewType) => void; + currentView?: CalendarViewType; + slideDirection?: 'up' | 'down' | null; + emptyEvents?: CalendarEvent[]; +} + +export const CustomToolbar = memo( + ({ + calendar, + onViewChange, + currentView = CalendarViewType.DAY_GRID_MONTH, + slideDirection, + emptyEvents = [], + }: CustomToolbarProps) => { + const { t } = useTranslation(); + + const [currentMonth, setCurrentMonth] = useState(''); + const [animationKey, setAnimationKey] = useState(0); + + const getCurrentMonth = useCallback(() => { + if (!calendar) return ''; + const currentDate = dayjs(calendar.getDate()); + + if (currentView === CalendarViewType.TIME_GRID_WEEK) { + const view = calendar.view; + const startDate = dayjs(view.activeStart); + const endDate = dayjs(view.activeEnd).subtract(1, 'day'); + + if (startDate.month() === endDate.month()) { + return `${startDate.format('MMMM YYYY')}`; + } else { + return `${startDate.format('MMM')} - ${endDate.format('MMM YYYY')}`; + } + } + + return currentDate.format('MMMM YYYY'); + }, [calendar, currentView]); + + useEffect(() => { + if (!calendar) return; + + const handleDateChange = () => { + const newMonth = getCurrentMonth(); + + setCurrentMonth(newMonth); + setAnimationKey((prev) => prev + 1); + }; + + calendar.on('datesSet', handleDateChange); + + const initialMonth = getCurrentMonth(); + + setCurrentMonth(initialMonth); + + return () => { + calendar.off('datesSet', handleDateChange); + }; + }, [calendar, getCurrentMonth]); + + const handlePrev = useCallback(() => { + calendar?.prev(); + }, [calendar]); + + const handleNext = useCallback(() => { + calendar?.next(); + }, [calendar]); + + const handleToday = useCallback(() => { + calendar?.today(); + }, [calendar]); + + const handleViewChange = useCallback( + (view: CalendarViewType) => { + calendar?.changeView(view); + onViewChange?.(view); + }, + [calendar, onViewChange] + ); + + const views = useMemo( + () => [ + { key: CalendarViewType.TIME_GRID_WEEK, label: t('calendar.week'), icon: CalendarIcon }, + { key: CalendarViewType.DAY_GRID_MONTH, label: t('calendar.month'), icon: CalendarIcon }, + ], + [t] + ); + + const label = useMemo(() => { + return views.find((view) => view.key === currentView)?.label; + }, [currentView, views]); + + return ( +
+
+ + + {currentMonth} + + +
+ +
+
+ {views.map((view) => ( + + ))} +
+
+ +
+ + + + + + + {t('calendar.navigation.previous', { view: label })} + + + + + + + + + {t('calendar.navigation.next', { view: label })} + +
+
+ ); + } +); \ No newline at end of file diff --git a/src/components/database/fullcalendar/FullCalendar.hooks.ts b/src/components/database/fullcalendar/FullCalendar.hooks.ts new file mode 100644 index 00000000..af533fb5 --- /dev/null +++ b/src/components/database/fullcalendar/FullCalendar.hooks.ts @@ -0,0 +1,70 @@ +import { sortBy } from 'lodash-es'; +import { useMemo } from 'react'; + +import { useCalendarEventsSelector, useCalendarLayoutSetting } from '@/application/database-yjs'; +import { correctAllDayEndForDisplay } from '@/utils/time'; + +export function useFullCalendarSetup(newEventRowIds: Set, openEventRowId: string | null, updateEventRowIds: Set) { + const layoutSetting = useCalendarLayoutSetting(); + const { events, emptyEvents } = useCalendarEventsSelector(); + + // Convert events to FullCalendar format + const fullCalendarEvents = useMemo(() => { + const today = new Date(); + + today.setHours(0, 0, 0, 0); + + const processedEvents = events.map((event) => { + const eventEndTime = event.end ? new Date(event.end) : new Date(event.start!); + const isPastEvent = eventEndTime < today; + const isNewEvent = newEventRowIds.has(event.rowId); + const isUpdateEvent = updateEventRowIds.has(event.rowId); + const isOpenEvent = isNewEvent || isUpdateEvent || openEventRowId === event.rowId; + const classNames = isPastEvent ? ['fc-event-past'] : []; + + const isMultipleDayEvent = event.start && event.end && event.start.toDateString() !== event.end.toDateString(); + + // Correct all-day event end time for FullCalendar display + const end = event.allDay && event.end ? + correctAllDayEndForDisplay(event.end) : + event.end; + + if (isOpenEvent) { + classNames.push('fc-event-open'); + } + + if (isNewEvent) { + classNames.push('fc-event-new'); + } + + return { + id: event.id, + title: event.title, + start: event.start, + end: end, + allDay: event.allDay, + classNames, + isMultipleDayEvent, + extendedProps: { + isActiveRow: isOpenEvent, + isNew: isNewEvent, + isUpdate: isUpdateEvent, + rowId: event.rowId, + includeTime: !event.allDay, + start: event.start, + end: end, + isMultipleDayEvent, + isRange: event.isRange + }, + }; + }); + + return sortBy(processedEvents, ['allDay', 'isMultipleDayEvent', 'start', 'title']); + }, [events, newEventRowIds, openEventRowId, updateEventRowIds]); + + return { + events: fullCalendarEvents, + emptyEvents, + firstDayOfWeek: layoutSetting.firstDayOfWeek, + }; +} diff --git a/src/components/database/fullcalendar/FullCalendar.styles.scss b/src/components/database/fullcalendar/FullCalendar.styles.scss new file mode 100644 index 00000000..c5ef0368 --- /dev/null +++ b/src/components/database/fullcalendar/FullCalendar.styles.scss @@ -0,0 +1,1027 @@ +/* ============================================= + FullCalendar Custom Styles + ============================================= */ + +/* CSS Variables */ +:root { + --fc-border-color: var(--border-primary); + --fc-timegrid-axis-width: 63px; /* Width of the all-day/time axis area in week view */ + --fc-today-bg-color: transparent; + --fc-event-bg-color: var(--other-colors-filled-event); + --fc-highlight-color: var(--fill-theme-select); +} + +.database-calendar { + /* Event height configuration */ + --fc-daygrid-event-height: 22px; + + /* Disable momentum scrolling and overscroll behavior */ + -webkit-overflow-scrolling: auto; /* Disable momentum scrolling on iOS */ + overscroll-behavior: none; /* Prevent overscroll bounce */ + scroll-behavior: auto; /* Disable smooth scrolling */ + + /* ============================================= + 1. Base Calendar Styles + ============================================= */ + + + .fc { + font-family: inherit; + font-size: 0.875rem; + + &.fc-theme-standard { + height: 100%; + + .fc-view-harness { + height: 100%; + } + } + + .fc-timegrid-slots, .fc-timegrid-cols, .fc-daygrid-body { + > table > colgroup > col { + width: var(--fc-timegrid-axis-width) !important; + } + } + } + + /* Remove all outer borders */ + .fc-scrollgrid { + border: none !important; + } + + .fc-scrollgrid-section-header th { + border: none !important; + } + + .fc-scrollgrid-section-body { + border: none !important; + + > td { + border: 1px solid theme('colors.border.primary') !important; + } + } + + .fc-day-other .fc-daygrid-day-top { + opacity: 1 !important; + } + + /* ============================================= + 2. Month View Styles + ============================================= */ + + &.month-view { + .fc { + height: 100%; + /* Remove overflow hidden to allow container expansion */ + + /* Hide scrollbars in month view */ + .fc-scroller { + overflow: hidden !important; + scrollbar-width: none !important; + -ms-overflow-style: none !important; + position: static !important; /* Remove absolute positioning that causes inset:0 */ + inset: auto !important; /* Override inset: 0 that prevents expansion */ + + &::-webkit-scrollbar { + display: none !important; + } + } + + + .fc-daygrid-day-top { + height: 28px; + } + + /* Month view cell minimum height settings */ + .fc-daygrid-day { + min-height: 114px; /* Set minimum height for each date cell */ + } + + .fc-daygrid-day-frame { + min-height: 114px; /* Ensure the entire date frame has minimum height */ + display: flex; + flex-direction: column; + padding: 0; + + } + + .fc-daygrid-day-events { + min-height: 60px; /* Minimum height for event area */ + flex-grow: 1; + } + + .fc-daygrid-day-top { + flex-shrink: 0; /* Prevent date number from shrinking */ + height: 28px; + } + } + } + + + /* ============================================= + 3. Week View Styles + ============================================= */ + + &.week-view { + padding-bottom: 2rem; + + .fc { + height: calc(100% - 2rem) !important; + + .fc-view-harness { + height: 100% !important; + } + + .fc-daygrid-day, .fc-daygrid-day-frame { + min-height: 26px !important; + height: auto; + max-height: 72px; + + } + /* Hide and remove borders for divider sections */ + .fc-scrollgrid-section:has(.fc-timegrid-divider.fc-cell-shaded) { + display: none !important; + } + + .fc-timegrid-divider, + .fc-cell-shaded { + border: none !important; + } + + .fc-theme-standard .fc-scrollgrid { + border: none !important; + } + + .fc-daygrid-day:nth-child(2) { + border-right: none !important; + border-left: none !important; + } + + .fc-timegrid-cols tbody td:nth-child(1) { + border-right: none !important; + } + + .fc-scrollgrid-section { + border-left: none !important; + border-right: none !important; + } + + .fc-timegrid-axis { + border: none !important; + width: var(--fc-timegrid-axis-width) !important; + } + + .fc-timegrid-slot-label-cushion { + padding: 0; + } + + .fc-scrollgrid-section-body > td { + border-left: none !important; + border-right: none !important; + } + + .fc-timegrid-slots td, + .fc-timegrid-slots th { + border-left: none !important; + cursor: default; + } + + .fc-timegrid-col:first-child, + .fc-timegrid-axis + .fc-timegrid-col { + border-left: none !important; + } + + .fc-timegrid-body tr td:first-child { + border-left: none !important; + border-right: none !important; + } + + .fc-timegrid-slots td:first-child { + border-left: none !important; + } + + .fc-timegrid-axis-cushion::after { + border-right: none !important; + } + + .fc-timegrid-body { + border-left: none !important; + } + + .fc-timegrid-slots { + border-right: none !important; + } + + .fc-timegrid-col:last-child, + .fc-col-header-cell:last-child { + border-right: none !important; + } + + /* Weekend background colors */ + .fc-timegrid-col.fc-day-sat, + .fc-timegrid-col.fc-day-sun, + .fc-daygrid-day.fc-day-sat, + .fc-daygrid-day.fc-day-sun { + @apply bg-surface-container-layer-00; + } + + /* Today background (transparent) */ + .fc-timegrid-body .fc-timegrid-col.fc-day-today, + .fc-daygrid-day.fc-day-today { + @apply bg-transparent !important; + } + } + + .fc-daygrid-day-bottom { + @apply ml-0 mr-[1px] py-0.5; + } + + .fc-event { + overflow: hidden; + } + + .fc-daygrid-day-events .fc-event { + overflow: visible; + } + + .fc-daygrid-day-events .fc-daygrid-block-event:not(.fc-h-event) { + margin-top: 4px !important; + + .fc-event-main { + margin-top: 4px !important; + } + } + } + + /* ============================================= + 4. Header and Column Styles + ============================================= */ + + /* Hide native FullCalendar week header since we use custom sticky header */ + .fc-col-header-row { + height: 0 !important; + min-height: 0 !important; + max-height: 0 !important; + overflow: hidden !important; + } + + .fc-col-header-cell { + height: 0 !important; + min-height: 0 !important; + max-height: 0 !important; + padding: 0 !important; + font-size: 14px !important; + vertical-align: middle !important; + @apply bg-fill-content py-2; + border-left: none !important; + border-right: none !important; + border-top: none !important; + + .fc-theme-standard td, + .fc-theme-standard th { + border: none !important; + } + + .fc-col-header-cell-cushion { + @apply text-text-primary font-medium text-sm tracking-wide; + text-transform: capitalize; + } + } + + /* Week view column header specific styles */ + &.week-view .fc .fc-col-header-cell .fc-col-header-cell-cushion { + height: 32px !important; + line-height: 32px !important; + padding: 0 8px !important; + display: flex !important; + align-items: center !important; + justify-content: center !important; + } + + /* Today's date styling in week view */ + .fc-col-header-cell.fc-day-today .fc-col-header-cell-cushion { + @apply flex items-center gap-1 justify-center; + + .today-date { + @apply bg-other-colors-filled-today text-text-inverse rounded-300; + @apply min-w-6 w-fit h-6 flex items-center justify-center font-medium text-sm; + @apply inline-flex; + } + } + + /* ============================================= + 5. Date Grid Styles + ============================================= */ + + .fc-daygrid { + .fc-daygrid-day { + @apply relative; + border: none !important; + overflow: visible !important; + cursor: default; + border-bottom: 1px solid theme('colors.border.primary') !important; + border-right: none !important; + + &:not(:nth-child(1)) { + border-left: 1px solid theme('colors.border.primary') !important; + } + + /* Weekend background */ + &.fc-day-sat, + &.fc-day-sun { + @apply bg-surface-container-layer-00; + } + + /* Today's styles */ + &.fc-day-today { + @apply bg-transparent; + + .fc-daygrid-day-number { + @apply flex items-center justify-center font-medium; + } + } + + /* Non-current month dates */ + &.fc-day-other .fc-daygrid-day-number { + @apply text-text-tertiary; + } + } + + .fc-daygrid-day-number { + @apply text-text-primary font-normal text-sm; + padding: 4px 8px; + } + } + + /* ============================================= + 6. Time Grid Styles (Week View) + ============================================= */ + + &.week-view .fc { + /* Time slot configuration */ + .fc-timegrid-slot { + height: 28px !important; + min-height: 28px !important; + } + + /* Hide specific time labels */ + .fc-timegrid-slot[data-time="00:00:00"] .fc-timegrid-slot-label, + .fc-timegrid-slot[data-time="12:00:00"] .fc-timegrid-slot-label, + .fc-timegrid-axis .fc-timegrid-slot-label:first-child, + .fc-timegrid-slot:first-child .fc-timegrid-slot-label, + .fc-timegrid-slot-minor:first-child .fc-timegrid-slot-label, + .fc-timegrid-slot-label[aria-label*="12:00 AM"], + .fc-timegrid-slot-label[aria-label*="12:00 PM"] { + display: none !important; + } + + /* Hide half-hour slots */ + .fc-timegrid-slot[data-time$="30:00"] { + border-top: none !important; + + .fc-timegrid-slot-label { + display: none !important; + } + } + + /* Hide full hour time lines */ + .fc-timegrid-slot[data-time$="00:00:00"] { + visibility: hidden !important; + } + + /* Time label styling */ + .fc-timegrid-slot-label { + @apply text-xs font-normal bg-background-primary; + padding: 0 4px !important; + text-align: left !important; + transform: translateY(-50%) !important; + position: relative !important; + z-index: 2 !important; + border-radius: 2px !important; + + .text-number { + @apply text-text-primary; + } + + .text-slot { + @apply text-text-secondary; + } + + &.hidden-text { + .text-number, .text-slot { + @apply text-transparent; + } + } + } + + /* Current time indicator */ + .fc-timegrid-now-indicator-line { + @apply bg-other-colors-filled-today !important; + height: 2px !important; + z-index: 10; + position: relative !important; + + &::before { + content: ''; + position: absolute; + left: 0; + top: 50%; + transform: translateY(-50%); + width: 8px; + height: 8px; + @apply bg-other-colors-filled-today; + border-radius: 50%; + z-index: 11; + } + } + + + /* Current time label using FullCalendar's native arrow element */ + .fc-timegrid-axis .fc-timegrid-now-indicator-arrow { + display: block !important; /* Override the previous display: none */ + position: relative !important; + @apply bg-other-colors-filled-today rounded-200 py-0 px-0.5 text-text-inverse text-xs; + white-space: nowrap; + z-index: 20; + pointer-events: none; + left: 4px !important; + line-height: 18px; + transform: translateY(-3px) !important; + border: none !important; + width: 58px !important; + height: auto !important; + text-align: right; + } + + /* Custom horizontal time indicator line */ + .custom-now-indicator-line { + position: absolute !important; + height: 1px !important; + @apply bg-other-colors-filled-today !important; + z-index: 10 !important; + opacity: 25%; + pointer-events: none !important; + } + + /* Scrollbar */ + .fc-scroller { + height: 100% !important; + overflow-y: auto !important; + scrollbar-width: thin; + scrollbar-color: theme('colors.border.primary') transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + + &::-webkit-scrollbar-thumb { + @apply bg-border-primary rounded-full; + + &:hover { + @apply bg-border-primary-hover; + } + } + } + + .fc-timegrid { + min-height: 100%; + } + + .fc-scrollgrid { + height: 100% !important; + } + } + + /* ============================================= + 7. Event Styles + ============================================= */ + + .fc-event { + @apply border-0 bg-transparent !important; + border-color: transparent !important; + + .fc-event-main { + @apply bg-transparent px-0 !important; + } + + .fc-event-title { + @apply bg-transparent truncate !important; + } + + + + } + + .fc-event-past { + &:not(:hover):not(.fc-event-open) { + .event-inner { + opacity: 0.50 !important; + } + .event-line { + opacity: 0.30 !important; + } + } + } + + // .fc-daygrid-day, .fc-daygrid-day.fc-day-today { + // &:hover { + // @apply bg-fill-info-light; + // } + // } + + /* Week view event background */ + &.week-view .fc-event { + @apply bg-other-colors-filled-event !important; + @apply hover:bg-other-colors-filled-event-hover !important; + } + + + /* Time grid events */ + .fc-timegrid-event { + @apply rounded-200; + + .fc-event-main { + padding: 2px 4px !important; + height: 100% !important; + display: flex !important; + flex-direction: column !important; + justify-content: flex-start !important; + } + + /* Override FullCalendar default heights */ + &[style] { + height: auto !important; + min-height: auto !important; + top: var(--event-top, initial) !important; + bottom: auto !important; + } + + height: auto !important; + min-height: auto !important; + max-height: none !important; + + /* Events without end time */ + &.event-no-end, + &.event-no-end .fc-event-main { + height: 22px !important; + min-height: 22px !important; + max-height: 22px !important; + bottom: auto !important; + } + + &.event-no-end[style*="height"] { + height: 22px !important; + } + + /* Events with duration */ + &.event-with-duration { + height: auto !important; + min-height: 22px !important; + } + } + + .fc-timegrid-event-harness { + height: auto !important; + min-height: auto !important; + max-height: none !important; + + &:has(.event-no-end) { + height: 22px !important; + min-height: 22px !important; + max-height: 22px !important; + bottom: auto !important; + } + + &:has(.event-no-end)[style*="height"] { + height: 22px !important; + } + } + + /* All-day events */ + .fc-daygrid-event { + height: var(--fc-daygrid-event-height) !important; + min-height: var(--fc-daygrid-event-height) !important; + max-height: var(--fc-daygrid-event-height) !important; + margin-bottom: 0px !important; + margin-left: 0 !important; + + .fc-event-main { + height: 100% !important; + min-height: auto !important; + max-height: none !important; + padding: 0px !important; + } + } + + /* Event frames */ + .fc-h-event { + border-color: transparent !important; + border: none !important; + } + + .fc-event-main-frame { + border: none !important; + } + + .fc-daygrid-day-events { + min-height: 20px; + overflow: visible !important; + + .fc-event { + @apply bg-transparent !important; + } + } + + .fc-daygrid-body-natural .fc-daygrid-day-events { + margin-bottom: 2px !important; + } + + /* Week view event indicators */ + .fc-timegrid-col-events .fc-timegrid-event .fc-event-main { + position: relative; + + &::before { + content: ''; + position: absolute; + left: 3px; + top: 4px; + bottom: 4px; + width: 3px; + @apply bg-fill-theme-thick rounded-200; + z-index: 2; + } + + > div { + @apply ml-0; + } + } + + /* ============================================= + 8. More Link and Popover Styles + ============================================= */ + + .fc-daygrid-more-link { + @apply w-full text-text-secondary border-none bg-transparent hover:bg-transparent p-0; + } + + .fc-daygrid-day-bottom { + @apply px-0 py-0.5 ml-0 mx-[2px]; + } + + .custom-more-link { + @apply h-[20px] w-full flex items-center px-1 text-xs text-text-primary; + @apply bg-transparent hover:bg-fill-content-hover; + @apply border-none rounded-200 cursor-pointer; + } + + .fc-popover { + @apply bg-background-primary shadow-popover rounded-400 border-none; + min-width: 240px !important; + z-index: 60 !important; + + .fc-event { + @apply bg-transparent !important; + } + + + .fc-popover-header { + @apply bg-transparent border-none px-2 py-2; + + .fc-popover-title { + @apply text-sm font-medium text-text-primary; + } + + .fc-popover-close { + @apply text-text-secondary hover:text-text-primary bg-transparent border-none; + @apply h-5 w-5 p-0 flex items-center justify-center rounded-200; + @apply hover:bg-fill-content; + + &::before { + content: '×'; + @apply font-normal; + line-height: 0.7; + font-size: 18px; + height: 20px; + width: 20px; + } + } + } + + &.fc-day-today { + .fc-popover-header { + .fc-popover-title { + @apply relative pl-4; + &:before { + @apply absolute left-0 top-1/2 -translate-y-1/2 bg-other-colors-filled-today rounded-full; + content: ' '; + height: 10px; + width: 10px; + } + } + } + } + + .fc-popover-body { + @apply p-2 pt-0; + max-height: 240px; + overflow-y: auto; + scrollbar-width: thin; + scrollbar-color: theme('colors.border.primary') transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + + &::-webkit-scrollbar-thumb { + @apply bg-border-primary rounded-full; + + &:hover { + @apply bg-border-primary-hover; + } + } + + + } + } + + /* ============================================= + 9. Drag and Drop Styles + ============================================= */ + + .fc-event-draggable { + cursor: grab; + transition: transform 0.2s ease, box-shadow 0.2s ease !important; + border-radius: 6px; + &:active { + cursor: grabbing; + transform: translateY(0) scale(0.98) !important; + } + } + + + .fc-event-dragging { + transform: scale(1.001) !important; + transition: opacity 0.2s ease, transform 0.2s ease !important; + animation: dragPulse 2s infinite ease-in-out !important; + } + + .fc-event-resizing { + transform: scale(1.001) !important; + } + + /* ============================================= + 10. State and Utility Styles + ============================================= */ + + /* Loading state */ + &.loading .fc-daygrid { + @apply opacity-50 pointer-events-none; + } + + .fc-highlight { + @apply opacity-30; + } + + + /* Global scrollbar styles */ + .fc-scroller { + scrollbar-width: thin; + scrollbar-color: theme('colors.border.primary') transparent; + + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + @apply bg-transparent; + } + + &::-webkit-scrollbar-thumb { + @apply bg-border-primary rounded-full; + + &:hover { + @apply bg-border-primary-hover; + } + } + } + + /* ============================================= + 11. Responsive Styles + ============================================= */ + + @media (max-width: 640px) { + .fc-header-toolbar { + @apply flex-col gap-2; + + .fc-toolbar-chunk { + @apply justify-center; + } + } + + .fc-daygrid-day { + @apply min-h-[80px]; + } + + .fc-col-header-cell-cushion { + @apply text-xs; + } + } +} + +/* ============================================= + Global Styles + ============================================= */ + +/* Drag mirror styles */ +body > .fc-event[data-is-mirror="true"], +body > .fc-event[style*="position: fixed"], +body > .fc-event[style*="z-index"] { + border-radius: 6px !important; + background-color: transparent !important; + border: none !important; +} + +.fc-event { + &.fc-event-open, &.fc-event-dragging-start, &.fc-event-resizing { + border: none !important; + transform: scale(1.001) !important; + &:not(.fc-timegrid-event) .event-content { + @apply border !bg-fill-theme-thick !border-border-theme-thick !text-text-inverse; + + .event-inner { + @apply text-text-inverse !important; + } + + .time-slot { + @apply text-text-inverse !important; + } + } + + &.fc-timegrid-event { + @apply border !bg-fill-theme-thick !border-border-theme-thick !text-text-inverse; + + .event-content { + @apply bg-fill-theme-thick text-text-inverse !important; + + .event-inner { + @apply text-text-inverse !important; + } + + .time-slot { + @apply text-text-inverse !important; + } + } + } + } +} + +/* ============================================= + Animations + ============================================= */ + +@keyframes dragPulse { + 0%, 100% { + opacity: 0.4; + } + 50% { + opacity: 0.6; + } +} + + +/* ============================================= + External Drag and Drop Styles + ============================================= */ + +/* Style for external draggable elements */ +.fc-event.external-draggable { + cursor: grab !important; + + @apply bg-other-colors-filled-event; + + &.fc-dragging { + // opacity: 0.5 !important; + cursor: grabbing !important; + } +} + +body { + .fc-event.fc-event-dragging { + border: none !important; + transform: scale(1.001) !important; + opacity: 1 !important; + &.fc-nodate-event { + box-shadow: 0 2px 16px 0 var(--shadow-color); + } + + &:not(.fc-timegrid-event) .event-content { + @apply border !bg-fill-theme-thick !border-border-theme-thick !text-text-inverse; + + .event-inner { + @apply text-text-inverse !important; + } + + .time-slot { + @apply text-text-inverse !important; + } + } + + &.fc-timegrid-event { + @apply border !bg-fill-theme-thick !border-border-theme-thick !text-text-inverse; + + .event-content { + @apply bg-fill-theme-thick text-text-inverse !important; + + .event-inner { + @apply text-text-inverse !important; + } + + .time-slot { + @apply text-text-inverse !important; + } + } + } + } +} +/* Calendar drop zone highlighting */ +.fc-daygrid-day { + transition: background-color 0.2s ease !important; + + &.fc-day-hover { + @apply bg-other-colors-filled-event !important; + } +} + +.fc-timegrid-slot { + transition: background-color 0.2s ease !important; + + &.fc-slot-hover { + @apply bg-other-colors-filled-event !important; + @apply hover:bg-other-colors-filled-event-hover; + } +} + +/* Drag over states for calendar cells */ +.fc .fc-daygrid-day.fc-drag-over, +.fc .fc-timegrid-slot.fc-drag-over { + @apply bg-other-colors-filled-event !important; + animation: dragPulse 1s ease-in-out infinite; +} + +/* Enhanced drag feedback for external dragging */ +.fc .fc-daygrid-day:hover.fc-external-drag-target, +.fc .fc-timegrid-slot:hover.fc-external-drag-target { + @apply bg-fill-info-light !important; + position: relative; + z-index: 1; +} + +/* Global drag state class when external dragging is active */ +.calendar-external-dragging { + .fc-daygrid-day:hover, + .fc-timegrid-slot:hover { + @apply bg-fill-info-light !important; + transition: all 0.1s ease !important; + } +} + +/* Alternative approach - always show hover feedback during any external drag */ +body.fc-dragging { + .database-calendar { + .fc-daygrid-day:hover, + .fc-timegrid-slot:hover { + @apply bg-fill-info-light !important; + transition: all 0.1s ease !important; + } + } +} + +/* Mirror/ghost element styling during drag */ +.fc-external-ghost { + @apply bg-fill-theme-thick !important; + @apply border-border-theme-thick !important; + @apply text-text-inverse !important; + border-radius: 4px !important; + padding: 2px 6px !important; + font-size: 0.75rem !important; + opacity: 0.8 !important; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15) !important; +} + +@keyframes event-bling { + 0%, + 100% { + opacity: 1; + } + 50% { + opacity: 0.5; + } +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/FullCalendar.tsx b/src/components/database/fullcalendar/FullCalendar.tsx new file mode 100644 index 00000000..c32d18f7 --- /dev/null +++ b/src/components/database/fullcalendar/FullCalendar.tsx @@ -0,0 +1,145 @@ +import dayjs from 'dayjs'; +import { memo, useCallback, useEffect, useRef, useState } from 'react'; + +import { CalendarEvent } from '@/application/database-yjs'; +import DatabaseStickyTopOverlay from '@/components/database/components/sticky-overlay/DatabaseStickyTopOverlay'; +import { getPlatform } from '@/utils/platform'; + +import { CalendarContent } from './CalendarContent'; +import { CalendarUnsupportedPage } from './CalendarUnsupportedPage'; +import { StickyCalendarToolbar } from './StickyCalendarToolbar'; +import { StickyWeekHeader } from './StickyWeekHeader'; +import { CalendarViewType } from './types'; + +import type { CalendarApi } from '@fullcalendar/core'; + +/** + * Calendar data interface from CalendarContent + */ +interface CalendarData { + calendarApi: CalendarApi | null; + currentView: CalendarViewType; + showStickyToolbar: boolean; + shouldShowWeekHeader: boolean; + weekHeaderCells: Array<{ + date: Date; + dayName: string; + dayNumber: number; + isToday: boolean; + isWeekend: boolean; + }>; + weekHeaderScrollLeft: number; + handleViewChange: (view: CalendarViewType) => void; + emptyEvents: CalendarEvent[]; +} + +/** + * Main Calendar component with separated toolbar and sticky header support + */ +function Calendar() { + const [calendarData, setCalendarData] = useState(null); + const normalToolbarRef = useRef(null); + + const [slideDirection, setSlideDirection] = useState<'up' | 'down' | null>(null); + const prevMonthRef = useRef(''); + + // Mobile detection + const [isMobile, setIsMobile] = useState(false); + + // Check for mobile device on component mount + useEffect(() => { + const { isMobile } = getPlatform(); + + setIsMobile(isMobile); + }, []); + + // Handle calendar data changes from CalendarContent + const handleCalendarDataChange = useCallback((data: CalendarData) => { + // 当calendar数据变化时,检查月份变化并设置动画方向 + if (data.calendarApi) { + const currentDate = dayjs(data.calendarApi.getDate()); + const currentMonth = currentDate.format('MMMM YYYY'); + const prevMonth = prevMonthRef.current; + + if (prevMonth && currentMonth !== prevMonth) { + const current = dayjs(currentMonth, 'MMMM YYYY'); + const previous = dayjs(prevMonth, 'MMMM YYYY'); + + if (current.isAfter(previous)) { + setSlideDirection('up'); + } else { + setSlideDirection('down'); + } + + setTimeout(() => { + setSlideDirection(null); + }, 300); + } + + prevMonthRef.current = currentMonth; + } + + setCalendarData(data); + }, []); + + // Return unsupported page for mobile devices + if (isMobile) { + return ; + } + + return ( +
+ {/* Normal toolbar - always visible */} + {calendarData && ( +
+ +
+ )} + + {/* Normal week header - always visible for comparison */} + {calendarData && calendarData.shouldShowWeekHeader && ( + + )} + + {/* Calendar content without toolbar */} + + + {/* Sticky toolbar and week header via DatabaseStickyTopOverlay */} + {calendarData?.showStickyToolbar && ( + + + + + )} +
+ ); +} + +// Export the memoized component +export default memo(Calendar); + +// Named export for backward compatibility +export { Calendar }; diff --git a/src/components/database/fullcalendar/NoDateButton.tsx b/src/components/database/fullcalendar/NoDateButton.tsx new file mode 100644 index 00000000..2dd38939 --- /dev/null +++ b/src/components/database/fullcalendar/NoDateButton.tsx @@ -0,0 +1,73 @@ +import { memo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { CalendarEvent, usePrimaryFieldId } from '@/application/database-yjs'; +import { ReactComponent as DropdownIcon } from '@/assets/icons/alt_arrow_down.svg'; +import { Button } from '@/components/ui/button'; +import { DropdownMenuLabel } from '@/components/ui/dropdown-menu'; +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +import { NoDateRow } from './NoDateRow'; + +interface NoDateButtonProps { + emptyEvents: CalendarEvent[]; + isWeekView: boolean; +} + +export const NoDateButton = memo(({ emptyEvents, isWeekView }: NoDateButtonProps) => { + const [open, setOpen] = useState(false); + const { t } = useTranslation(); + const primaryFieldId = usePrimaryFieldId(); + + if (emptyEvents.length === 0 || !primaryFieldId) { + return null; + } + + return ( + + + + + { + e.preventDefault(); + }} + onPointerDownOutside={(e) => { + const target = e.target as HTMLElement; + + if (target.closest('.MuiDialogContent-root')) return; + setOpen(false); + }} + className='appflowy-scroller max-h-[360px] w-[260px] overflow-y-auto p-2' + > + {t('calendar.settings.noDatePopoverTitle')} +
+ {emptyEvents.map((event) => { + const rowId = event.id; + + return ; + })} +
+
+
+ ); +}); + +NoDateButton.displayName = 'NoDateButton'; + +export default NoDateButton; \ No newline at end of file diff --git a/src/components/database/fullcalendar/NoDateRow.tsx b/src/components/database/fullcalendar/NoDateRow.tsx new file mode 100644 index 00000000..067f365f --- /dev/null +++ b/src/components/database/fullcalendar/NoDateRow.tsx @@ -0,0 +1,94 @@ +import { Draggable } from '@fullcalendar/interaction'; +import { useEffect, useRef } from 'react'; + +import { useCellSelector, useDatabaseContext } from '@/application/database-yjs'; +import { ReactComponent as DragIcon } from '@/assets/icons/drag.svg'; +import { Cell } from '@/components/database/components/cell'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; +import { cn } from '@/lib/utils'; + +interface NoDateRowProps { + rowId: string; + primaryFieldId: string; + isWeekView: boolean; +} + +export function NoDateRow({ rowId, primaryFieldId, isWeekView }: NoDateRowProps) { + const toRow = useDatabaseContext()?.navigateToRow; + const cell = useCellSelector({ + rowId, + fieldId: primaryFieldId || '', + }); + + const dragRef = useRef(null); + + useEffect(() => { + const element = dragRef.current; + + if (!element) return; + + console.debug('🎯 Creating optimized Draggable for rowId:', rowId); + + // Create individual Draggable for this row with performance optimizations + const draggable = new Draggable(element, { + eventData: { + title: cell?.data?.toString() || '', + duration: isWeekView ? '01:00' : undefined, + extendedProps: { + rowId: rowId, + }, + }, + }); + + console.debug('✅ Optimized Draggable created for rowId:', rowId); + + return () => { + console.debug('🎯 Destroying optimized Draggable for rowId:', rowId); + draggable.destroy(); + }; + }, [rowId, cell?.data, isWeekView]); + + return ( +
+
+ +
+ + +
{ + e.stopPropagation(); + e.preventDefault(); + toRow?.(rowId); + }} + className='flex h-[28px] flex-1 cursor-pointer items-center truncate rounded-300 bg-surface-container-layer-01 px-2 text-sm hover:bg-surface-container-layer-02' + > + +
+
+ {`Click to open ${cell?.data?.toString() || 'event'}`} +
+
+ ); +} + +export default NoDateRow; diff --git a/src/components/database/fullcalendar/StickyCalendarToolbar.tsx b/src/components/database/fullcalendar/StickyCalendarToolbar.tsx new file mode 100644 index 00000000..2f57a3df --- /dev/null +++ b/src/components/database/fullcalendar/StickyCalendarToolbar.tsx @@ -0,0 +1,53 @@ +import { CalendarApi } from '@fullcalendar/core'; +import { useMemo } from 'react'; + +import { CalendarEvent, useDatabaseContext } from '@/application/database-yjs'; + +import { CustomToolbar } from './CustomToolbar'; +import { CalendarViewType } from './types'; + +/** + * Props for StickyCalendarToolbar component + */ +interface StickyCalendarToolbarProps { + calendar: CalendarApi | null; + currentView: CalendarViewType; + onViewChange: (view: CalendarViewType) => void; + slideDirection?: 'up' | 'down' | null; + emptyEvents?: CalendarEvent[]; +} + +/** + * Sticky calendar toolbar component that wraps CustomToolbar + * Used for both normal and sticky positioning with proper spacing + */ +export function StickyCalendarToolbar({ calendar, currentView, onViewChange, slideDirection, emptyEvents = [] }: StickyCalendarToolbarProps) { + const { paddingStart, paddingEnd } = useDatabaseContext(); + + // Memoized style object matching calendar spacing + const toolbarStyle = useMemo( + () => ({ + marginLeft: paddingStart === undefined ? undefined : paddingStart, + marginRight: paddingEnd === undefined ? undefined : paddingEnd, + }), + [paddingStart, paddingEnd] + ); + + // Memoized className matching calendar spacing + const toolbarClassName = useMemo( + () => 'mx-24 max-sm:!mx-6', // Same as calendar container + [] + ); + + return ( +
+ +
+ ); +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/StickyWeekHeader.tsx b/src/components/database/fullcalendar/StickyWeekHeader.tsx new file mode 100644 index 00000000..25117a1e --- /dev/null +++ b/src/components/database/fullcalendar/StickyWeekHeader.tsx @@ -0,0 +1,186 @@ +import { useMemo } from 'react'; + +import { useDatabaseContext } from '@/application/database-yjs'; + +import { CalendarViewType } from './types'; + +/** + * Interface for header cell data + */ +interface HeaderCellData { + date: Date; + dayName: string; + dayNumber: number; + isToday: boolean; + isWeekend: boolean; +} + +/** + * Props for StickyWeekHeader component + */ +interface StickyWeekHeaderProps { + headerCells: HeaderCellData[]; + visible: boolean; + scrollLeft?: number; + currentView?: CalendarViewType; + isSticky?: boolean; +} + +/** + * Sticky week header component that manually renders header cells + * Replicates FullCalendar's header structure with custom data + */ +export function StickyWeekHeader({ + headerCells, + visible, + scrollLeft = 0, + currentView, + isSticky = false, +}: StickyWeekHeaderProps) { + const { paddingStart, paddingEnd } = useDatabaseContext(); + + // Memoized style object matching calendar spacing + const containerStyle = useMemo( + () => ({ + marginLeft: paddingStart === undefined ? undefined : paddingStart, + marginRight: paddingEnd === undefined ? undefined : paddingEnd, + }), + [paddingStart, paddingEnd] + ); + + // Memoized className matching calendar spacing + const containerClassName = useMemo( + () => `mx-24 bg-background-primary max-sm:!mx-6 overflow-hidden ${isSticky ? 'border-b border-border-primary' : ''}`, // Same as calendar container + hide overflow + [isSticky] + ); + + // Check if we should show the time slot column + const showTimeSlotColumn = useMemo(() => { + return currentView === CalendarViewType.TIME_GRID_WEEK; + }, [currentView]); + + if (!visible || headerCells.length === 0) { + return null; + } + + return ( +
+
+
+ + + + {/* Time slot column for week view */} + {showTimeSlotColumn && ( + + )} + + {/* Date columns */} + {headerCells.map((cell, index) => ( + + ))} + + +
+
+ {/* Empty or could show "all-day" label */} +
+
+
+ {cell.isToday && cell.dayNumber > 0 ? ( + + {cell.dayName}{' '} + + {cell.dayNumber} + + + ) : cell.dayNumber > 0 ? ( + `${cell.dayName} ${cell.dayNumber}` + ) : ( + cell.dayName + )} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/EventDisplay.tsx b/src/components/database/fullcalendar/event/EventDisplay.tsx new file mode 100644 index 00000000..04ff58bf --- /dev/null +++ b/src/components/database/fullcalendar/event/EventDisplay.tsx @@ -0,0 +1,83 @@ +import { EventApi, EventContentArg } from '@fullcalendar/core'; +import { useEffect, useState } from 'react'; + +import { + MonthAllDayEvent, + MonthMultiDayTimedEvent, + MonthTimedEvent, + WeekAllDayEvent, + WeekTimedEvent, +} from './components'; + +interface EventDisplayProps { + event: EventApi; + eventInfo: EventContentArg; + onClick?: (event: EventApi) => void; + isWeekView?: boolean; + showLeftIndicator?: boolean; + className?: string; + isHiddenFirst?: boolean; +} + +export function EventDisplay({ + event, + eventInfo, + onClick, + isWeekView = false, + showLeftIndicator = true, + className, + isHiddenFirst = false, +}: EventDisplayProps) { + const rowId = event.extendedProps?.rowId; + const [showBling, setShowBling] = useState(isHiddenFirst); + + useEffect(() => { + if (isHiddenFirst) { + setShowBling(true); + const timer = setTimeout(() => { + setShowBling(false); + }, 1000); + + return () => clearTimeout(timer); + } + }, [isHiddenFirst]); + + if (!rowId) return null; + + const isMultiDay = event.start && event.end && event.start.toDateString() !== event.end.toDateString(); + + const getEventComponent = () => { + if (isWeekView) { + return event.allDay ? WeekAllDayEvent : WeekTimedEvent; + } else { + if (event.allDay) { + return MonthAllDayEvent; + } else { + return isMultiDay ? MonthMultiDayTimedEvent : MonthTimedEvent; + } + } + }; + + const EventComponent = getEventComponent(); + + return ( +
+ +
+ ); +} + +// Export alias for backward compatibility +export { EventDisplay as EventContent }; diff --git a/src/components/database/fullcalendar/event/EventPopoverContent.tsx b/src/components/database/fullcalendar/event/EventPopoverContent.tsx new file mode 100644 index 00000000..7cd75201 --- /dev/null +++ b/src/components/database/fullcalendar/event/EventPopoverContent.tsx @@ -0,0 +1,170 @@ +import dayjs from 'dayjs'; +import { uniqBy } from 'lodash-es'; +import { useCallback, useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { FieldType, useFieldsSelector, useNavigateToRow, usePrimaryFieldId } from '@/application/database-yjs'; +import { Cell } from '@/application/database-yjs/cell.type'; +import { useDuplicateRowDispatch } from '@/application/database-yjs/dispatch'; +import { ReactComponent as CloseIcon } from '@/assets/icons/close.svg'; +import { ReactComponent as DeleteIcon } from '@/assets/icons/delete.svg'; +import { ReactComponent as DuplicateIcon } from '@/assets/icons/duplicate.svg'; +import { ReactComponent as ExpandMoreIcon } from '@/assets/icons/expand.svg'; +import DeleteRowConfirm from '@/components/database/components/database-row/DeleteRowConfirm'; +import RowPropertyPrimitive from '@/components/database/components/database-row/RowPropertyPrimitive'; +import { EventTitle } from '@/components/database/fullcalendar/event/EventTitle'; +import { Button } from '@/components/ui/button'; +import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; + +import { useEventContext } from '../CalendarContent'; + +function EventPopoverContent({ + rowId, + onCloseEvent, + onGotoDate, +}: { + rowId: string; + onCloseEvent: () => void; + onGotoDate: (date: Date) => void; +}) { + const primaryFieldId = usePrimaryFieldId(); + const { setOpenEventRowId, markEventAsNew, markEventAsUpdate } = useEventContext(); + const duplicateRowDispatch = useDuplicateRowDispatch(); + const navigateToRow = useNavigateToRow(); + const { t } = useTranslation(); + const [activePropertyId, setActivePropertyId] = useState(null); + + // State for delete confirmation dialog + const [showDeleteConfirm, setShowDeleteConfirm] = useState(false); + + const fields = useFieldsSelector(); + const filteredFields = useMemo(() => { + return uniqBy( + fields.filter((column) => column.fieldId !== primaryFieldId), + 'fieldId' + ); + }, [fields, primaryFieldId]); + + // Handle delete action + const handleDelete = useCallback(() => { + console.debug('[EventPopoverContent] Delete button clicked for row:', rowId); + setShowDeleteConfirm(true); + }, [rowId]); + + // Handle delete confirmation + const handleDeleteConfirm = useCallback(() => { + console.debug('[EventPopoverContent] Delete confirmed for row:', rowId); + // Close the current popover after deletion + onCloseEvent(); + }, [onCloseEvent, rowId]); + + // Handle duplicate action + const handleDuplicate = useCallback(async () => { + console.debug('[EventPopoverContent] Duplicate button clicked for row:', rowId); + try { + const newRowId = await duplicateRowDispatch(rowId); + + console.debug('[EventPopoverContent] Row duplicated successfully. New row ID:', newRowId); + + // Mark the new event as new to trigger auto-open popover + markEventAsNew(newRowId); + + // Close current popover + setOpenEventRowId(null); + + console.debug('[EventPopoverContent] New row marked as new and will auto-open popover'); + } catch (error) { + console.error('[EventPopoverContent] Failed to duplicate row:', error); + } + }, [rowId, duplicateRowDispatch, setOpenEventRowId, markEventAsNew]); + + const handleCellUpdated = useCallback( + (cell: Cell) => { + if (cell.fieldType === FieldType.DateTime) { + markEventAsUpdate(rowId); + if (cell.data) { + onGotoDate(dayjs.unix(Number(cell.data)).toDate()); + } + } + }, + [markEventAsUpdate, onGotoDate, rowId] + ); + + return ( +
+
+ {/* Duplicate button */} + + + + + {t('calendar.duplicateEvent')} + + {/* Delete button */} + + + + + {t('calendar.deleteEvent')} + + + {/* Open page button */} + + + + + {t('tooltip.openEvent')} + + + {/* Close button */} + + + + + {t('button.close')} + +
+
+ {primaryFieldId && } + {filteredFields.map((field) => { + return ( + + ); + })} +
+ {/* Delete confirmation dialog */} + setShowDeleteConfirm(false)} + rowIds={[rowId]} + onDeleted={handleDeleteConfirm} + /> +
+ ); +} + +export default EventPopoverContent; diff --git a/src/components/database/fullcalendar/event/EventTitle.tsx b/src/components/database/fullcalendar/event/EventTitle.tsx new file mode 100644 index 00000000..20689052 --- /dev/null +++ b/src/components/database/fullcalendar/event/EventTitle.tsx @@ -0,0 +1,41 @@ +import { memo, useRef } from "react"; + +import { useCellSelector, useReadOnly, useUpdateCellDispatch } from "@/application/database-yjs"; +import { TextCell } from "@/application/database-yjs/cell.type"; +import { Input } from "@/components/ui/input"; + + +export const EventTitle = memo( + ({ rowId, fieldId, onCloseEvent }: { rowId: string; fieldId: string; onCloseEvent?: () => void }) => { + const readOnly = useReadOnly(); + const cell = useCellSelector({ rowId, fieldId }) as TextCell; + const value = cell?.data; + const inputRef = useRef(null); + const updateCell = useUpdateCellDispatch(rowId, fieldId); + + return ( +
+ { + if (e.key === 'Enter') { + e.stopPropagation(); + e.preventDefault(); + updateCell((e.target as HTMLInputElement).value); + onCloseEvent?.(); + } + }} + value={value} + onChange={(e) => { + updateCell(e.target.value); + }} + placeholder='Untitled' + variant={'ghost'} + className={'!h-9 flex-1 text-base font-semibold text-text-primary'} + /> +
+ ); + } +); \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/EventWithPopover.tsx b/src/components/database/fullcalendar/event/EventWithPopover.tsx new file mode 100644 index 00000000..b6c1c34c --- /dev/null +++ b/src/components/database/fullcalendar/event/EventWithPopover.tsx @@ -0,0 +1,85 @@ +import { EventApi, EventContentArg } from '@fullcalendar/core'; +import { memo, useCallback, useEffect, useState } from 'react'; + +import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover'; + +import { useEventContext } from '../CalendarContent'; + +import { EventDisplay } from './EventDisplay'; +import EventPopoverContent from './EventPopoverContent'; + +interface EventWithPopoverProps { + event: EventApi; + eventInfo: EventContentArg; + isWeekView?: boolean; + isHiddenFirst?: boolean; +} + +export const EventWithPopover = memo(({ event, eventInfo, isWeekView = false, isHiddenFirst = false }: EventWithPopoverProps) => { + const [isOpen, setIsOpen] = useState(false); + const { clearNewEvent, setOpenEventRowId, clearUpdateEvent } = useEventContext(); + const rowId = event.id; + + // Check if this is a newly created event and should auto-open + // For newly created events, only open the start segment (isStart=true) + const isNewEvent = event.extendedProps?.isNew; + const isUpdateEvent = event.extendedProps?.isUpdate; + const isStart = eventInfo.isStart; + + useEffect(() => { + // Auto-open newly created events at their start segment + if ((isNewEvent || isUpdateEvent) && isStart) { + setIsOpen(true); + } + }, [isNewEvent, isUpdateEvent, isStart, eventInfo]); + + const handleOpenChange = useCallback( + (open: boolean) => { + setIsOpen(open); + + // When closing the popover for a new event, clear its new status + if (!open && isNewEvent) { + clearNewEvent(rowId); + } + + if (!open && isUpdateEvent) { + clearUpdateEvent(rowId); + } + + if (open) { + setOpenEventRowId(rowId); + } else { + setOpenEventRowId(null); + } + }, + [isNewEvent, isUpdateEvent, rowId, clearNewEvent, clearUpdateEvent, setOpenEventRowId] + ); + + const handleCloseEvent = useCallback(() => { + handleOpenChange(false); + }, [handleOpenChange]); + + const handleGotoDate = useCallback( + (date: Date) => { + const calendar = eventInfo.view.calendar; + + return calendar.gotoDate(date); + }, + [eventInfo] + ); + + return ( + + +
+ +
+
+ + + +
+ ); +}); + +export default EventWithPopover; diff --git a/src/components/database/fullcalendar/event/MoreLinkContent.tsx b/src/components/database/fullcalendar/event/MoreLinkContent.tsx new file mode 100644 index 00000000..e61396d4 --- /dev/null +++ b/src/components/database/fullcalendar/event/MoreLinkContent.tsx @@ -0,0 +1,38 @@ +import { CalendarApi, MoreLinkArg, MoreLinkContentArg } from "@fullcalendar/core"; +import { useState } from "react"; +import { useTranslation } from "react-i18next"; + +import { + Popover, + PopoverContent, + PopoverTrigger +} from '@/components/ui/popover'; + +import { MoreLinkPopoverContent } from "./MoreLinkPopoverContent"; + +export function MoreLinkContent({ data, moreLinkInfo, calendar, onClose }: { + moreLinkInfo?: MoreLinkArg; + data: MoreLinkContentArg; + calendar: CalendarApi; + onClose: () => void; +}) { + const { t } = useTranslation(); + const { num } = data; + + const [open, setOpen] = useState(false); + + return { + if (!open) { + onClose() + } + + setOpen(open) + }}> + {t('calendar.more', { num })} + + {moreLinkInfo && { + setOpen(false) + }} />} + + +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/MoreLinkPopoverContent.tsx b/src/components/database/fullcalendar/event/MoreLinkPopoverContent.tsx new file mode 100644 index 00000000..1ae0f533 --- /dev/null +++ b/src/components/database/fullcalendar/event/MoreLinkPopoverContent.tsx @@ -0,0 +1,104 @@ +import { CalendarApi, EventContentArg, MoreLinkArg } from "@fullcalendar/core"; +import { Draggable } from "@fullcalendar/interaction"; +import { useEffect, useRef } from "react"; + +import { ReactComponent as CloseIcon } from "@/assets/icons/close.svg"; +import { dayCellContent } from "@/components/database/fullcalendar/utils/dayCellContent"; +import { Button } from "@/components/ui/button"; +import { cn } from "@/lib/utils"; + +import EventWithPopover from "./EventWithPopover"; + + +export function MoreLinkPopoverContent({moreLinkInfo, calendar, onClose}: { + moreLinkInfo: MoreLinkArg; + calendar: CalendarApi; + onClose: () => void; +}) { + const { allSegs, date, view, hiddenSegs } = moreLinkInfo; + + const dragContainerRef = useRef(null); + // Use dayCellContent for consistent date formatting + const dateDisplay = dayCellContent({ + date, + dayNumberText: date.getDate().toString(), + isToday: new Date().toDateString() === date.toDateString(), + isPopover: true, + }); + + useEffect(() => { + const element = dragContainerRef.current; + + if (!element) return; + + // Create individual Draggable for this row with performance optimizations + const draggable = new Draggable(element, { + itemSelector: '.fc-event-draggable', + eventData: function (eventEl) { + return { + title: eventEl.innerText, + extendedProps: { + rowId: eventEl.dataset.rowId, + }, + }; + }, + }); + + return () => { + draggable.destroy(); + }; + }, []); + + return ( + <> +
+ {dateDisplay} +
+ +
+
+
+ {allSegs.map((seg) => { + // Check if this segment is the first hidden segment + const isHiddenFirst = hiddenSegs.length > 0 && hiddenSegs[0].event.id === seg.event.id; + + // Construct EventContentArg-like object for EventWithPopover + const eventInfo: EventContentArg = { + event: seg.event, + timeText: seg.event.allDay + ? '' + : seg.event.start + ? seg.event.start.toLocaleTimeString([], { hour: 'numeric', minute: '2-digit' }) + : '', + borderColor: seg.event.borderColor || '', + backgroundColor: seg.event.backgroundColor || '', + textColor: seg.event.textColor || '', + isStart: seg.isStart, + isEnd: seg.isEnd, + isPast: seg.event.end ? seg.event.end < new Date() : false, + isFuture: seg.event.start ? seg.event.start > new Date() : false, + isToday: seg.event.start ? seg.event.start.toDateString() === new Date().toDateString() : false, + view, + } as EventContentArg; + + const event = calendar.getEventById(seg.event.id) || seg.event; + + return ( +
+ +
+ ); + })} +
+ + ); +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/components/EventIconButton.tsx b/src/components/database/fullcalendar/event/components/EventIconButton.tsx new file mode 100644 index 00000000..0d3029ee --- /dev/null +++ b/src/components/database/fullcalendar/event/components/EventIconButton.tsx @@ -0,0 +1,41 @@ +import { CustomIconPopover } from '@/components/_shared/cutsom-icon'; +import { Button } from '@/components/ui/button'; +import { cn } from '@/lib/utils'; + +import { useEventIcon } from '../hooks/useEventIcon'; + +interface EventIconButtonProps { + rowId: string; + readOnly?: boolean; + className?: string; + iconSize?: number; +} + +export function EventIconButton({ rowId, readOnly = false, iconSize, className }: EventIconButtonProps) { + const { showIcon, isFlag, onSelectIcon, removeIcon, renderIcon } = useEventIcon(rowId); + + if (!showIcon) return null; + + return ( + { + onSelectIcon(icon.value); + }} + removeIcon={removeIcon} + enable={Boolean(!readOnly && showIcon)} + > + + + ); +} diff --git a/src/components/database/fullcalendar/event/components/MonthAllDayEvent.tsx b/src/components/database/fullcalendar/event/components/MonthAllDayEvent.tsx new file mode 100644 index 00000000..e786d553 --- /dev/null +++ b/src/components/database/fullcalendar/event/components/MonthAllDayEvent.tsx @@ -0,0 +1,122 @@ +import { EventApi, EventContentArg } from '@fullcalendar/core'; +import { useCallback } from 'react'; + +import { cn } from '@/lib/utils'; + +import { EventIconButton } from './EventIconButton'; + +interface MonthAllDayEventProps { + event: EventApi; + eventInfo: EventContentArg; + onClick?: (event: EventApi) => void; + showLeftIndicator?: boolean; + className?: string; + rowId: string; +} + +export function MonthAllDayEvent({ + event, + eventInfo, + onClick, + showLeftIndicator = true, + className, + rowId, +}: MonthAllDayEventProps) { + const isEventStart = eventInfo.isStart; + const isEventEnd = eventInfo.isEnd; + + const handleClick = () => { + onClick?.(event); + }; + + const isMultiDay = event.start && event.end && event.start.toDateString() !== event.end.toDateString(); + + const getDisplayContent = useCallback(() => { + if (!isMultiDay || isEventStart) { + return event.title || 'Untitled'; + } + + if (isEventEnd) { + return `${event.title || 'Untitled'}`; + } else { + return `${event.title || 'Untitled'}`; + } + }, [isMultiDay, isEventStart, isEventEnd, event.title]); + + const getSegmentConfig = useCallback(() => { + if (!isMultiDay) { + return { + className: 'rounded-200', + leftArrow: false, + rightArrow: false, + }; + } + + if (isEventStart && !isEventEnd) { + return { + className: 'rounded-l-200', + leftArrow: false, + rightArrow: true, + }; + } else if (isEventEnd && !isEventStart) { + return { + className: 'rounded-r-200', + leftArrow: true, + rightArrow: false, + }; + } else if (!isEventStart && !isEventEnd) { + return { + className: '', + leftArrow: true, + rightArrow: true, + }; + } else { + return { + className: 'rounded-200', + leftArrow: false, + rightArrow: false, + }; + } + }, [isMultiDay, isEventStart, isEventEnd]); + + const segmentConfig = getSegmentConfig(); + + return ( +
+
+ {showLeftIndicator && !segmentConfig.leftArrow && ( +
+ )} +
+
+ + {getDisplayContent()} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/components/MonthMultiDayTimedEvent.tsx b/src/components/database/fullcalendar/event/components/MonthMultiDayTimedEvent.tsx new file mode 100644 index 00000000..d4cb149a --- /dev/null +++ b/src/components/database/fullcalendar/event/components/MonthMultiDayTimedEvent.tsx @@ -0,0 +1,136 @@ +import { EventApi, EventContentArg } from '@fullcalendar/core'; +import dayjs from 'dayjs'; +import { useCallback } from 'react'; + +import { cn } from '@/lib/utils'; + +import { EventIconButton } from './EventIconButton'; + +interface MonthMultiDayTimedEventProps { + event: EventApi; + eventInfo: EventContentArg; + onClick?: (event: EventApi) => void; + showLeftIndicator?: boolean; + className?: string; + rowId: string; +} + +const formatTimeDisplay = (date: Date): string => { + const time = dayjs(date); + const minutes = time.minute(); + + if (minutes === 0) { + return time.format('h A').toLowerCase(); + } else { + return time.format('h:mm A').toLowerCase(); + } +}; + +export function MonthMultiDayTimedEvent({ + event, + eventInfo, + onClick, + showLeftIndicator = true, + className, + rowId, +}: MonthMultiDayTimedEventProps) { + const isEventStart = eventInfo.isStart; + const isEventEnd = eventInfo.isEnd; + + // Check if event is in the past - use end time if available, otherwise start time + const eventTime = event.end || event.start; + const isPastEvent = eventTime && dayjs(eventTime).isBefore(dayjs(), 'minute'); + + const handleClick = () => { + onClick?.(event); + }; + + const getDisplayContent = useCallback(() => { + if (isEventStart) { + return event.title || 'Untitled'; + } + + if (isEventEnd) { + return `${event.title || 'Untitled'}`; + } else { + return `${event.title || 'Untitled'}`; + } + }, [isEventStart, isEventEnd, event.title]); + + const getSegmentConfig = useCallback(() => { + if (isEventStart && !isEventEnd) { + return { + className: 'rounded-l-200', + leftArrow: false, + rightArrow: true, + }; + } else if (isEventEnd && !isEventStart) { + return { + className: 'rounded-r-200', + leftArrow: true, + rightArrow: false, + }; + } else if (!isEventStart && !isEventEnd) { + return { + className: '', + leftArrow: true, + rightArrow: true, + }; + } else { + return { + className: 'rounded-200', + leftArrow: false, + rightArrow: false, + }; + } + }, [isEventStart, isEventEnd]); + + const segmentConfig = getSegmentConfig(); + + return ( +
+
+ {showLeftIndicator && !segmentConfig.leftArrow && ( +
+ )} +
+
+ {isEventStart && event.start && ( + {formatTimeDisplay(event.start)} + )} + {isEventEnd && event.end && !isEventStart && ( + {formatTimeDisplay(event.end)} + )} + + {getDisplayContent()} +
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/components/MonthTimedEvent.tsx b/src/components/database/fullcalendar/event/components/MonthTimedEvent.tsx new file mode 100644 index 00000000..8440d56c --- /dev/null +++ b/src/components/database/fullcalendar/event/components/MonthTimedEvent.tsx @@ -0,0 +1,67 @@ +import { EventApi, EventContentArg } from '@fullcalendar/core'; +import dayjs from 'dayjs'; + +import { cn } from '@/lib/utils'; + +import { EventIconButton } from './EventIconButton'; + +interface MonthTimedEventProps { + event: EventApi; + eventInfo: EventContentArg; + onClick?: (event: EventApi) => void; + showLeftIndicator?: boolean; + className?: string; + rowId: string; +} + +const formatTimeDisplay = (date: Date): string => { + const time = dayjs(date); + const minutes = time.minute(); + + if (minutes === 0) { + return time.format('h A').toLowerCase(); + } else { + return time.format('h:mm A').toLowerCase(); + } +}; + +export function MonthTimedEvent({ event, onClick, showLeftIndicator = true, className, rowId }: MonthTimedEventProps) { + const handleClick = () => { + onClick?.(event); + }; + + + // Check if event is in the past + const isPastEvent = event.start && dayjs(event.start).isBefore(dayjs(), 'minute'); + + return ( +
+
+ {showLeftIndicator &&
} +
+
+ {event.start && ( + + {formatTimeDisplay(event.start)} + + )} +
+ + {event.title || 'Untitled'} +
+
+
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/components/WeekAllDayEvent.tsx b/src/components/database/fullcalendar/event/components/WeekAllDayEvent.tsx new file mode 100644 index 00000000..6d682ca6 --- /dev/null +++ b/src/components/database/fullcalendar/event/components/WeekAllDayEvent.tsx @@ -0,0 +1,129 @@ +import { EventApi, EventContentArg } from '@fullcalendar/core'; +import { useCallback, useMemo } from 'react'; + +import { cn } from '@/lib/utils'; + +import { EventIconButton } from './EventIconButton'; + +interface WeekAllDayEventProps { + event: EventApi; + eventInfo: EventContentArg; + onClick?: (event: EventApi) => void; + showLeftIndicator?: boolean; + className?: string; + rowId: string; +} + +export function WeekAllDayEvent({ + event, + eventInfo, + onClick, + showLeftIndicator = true, + className, + rowId, +}: WeekAllDayEventProps) { + const isEventStart = eventInfo.isStart; + const isEventEnd = eventInfo.isEnd; + + const handleClick = () => { + onClick?.(event); + }; + + const isMultiDay = event.start && event.end && event.start.toDateString() !== event.end.toDateString(); + + const getDisplayContent = useCallback(() => { + if (!isMultiDay || isEventStart) { + return event.title || 'Untitled'; + } + + if (isEventEnd) { + return `${event.title || 'Untitled'}`; + } else { + return `${event.title || 'Untitled'}`; + } + }, [isMultiDay, isEventStart, isEventEnd, event.title]); + + const getSegmentConfig = useCallback(() => { + if (!isMultiDay) { + return { + className: 'rounded-200', + leftArrow: false, + rightArrow: false, + }; + } + + if (isEventStart && !isEventEnd) { + return { + className: 'rounded-l-200', + leftArrow: false, + rightArrow: true, + }; + } else if (isEventEnd && !isEventStart) { + return { + className: 'rounded-r-200', + leftArrow: true, + rightArrow: false, + }; + } else if (!isEventStart && !isEventEnd) { + return { + className: '', + leftArrow: true, + rightArrow: true, + }; + } else { + return { + className: 'rounded-200', + leftArrow: false, + rightArrow: false, + }; + } + }, [isMultiDay, isEventStart, isEventEnd]); + + const segmentConfig = getSegmentConfig(); + + const renderAllDayEvent = useMemo(() => { + return ( +
+ + {getDisplayContent()} +
+ ); + }, [getDisplayContent, rowId]); + + const hideLine = segmentConfig.leftArrow; + + return ( +
+
+ {showLeftIndicator && !hideLine &&
} +
+ {renderAllDayEvent} +
+
+
+ ); +} diff --git a/src/components/database/fullcalendar/event/components/WeekTimedEvent.tsx b/src/components/database/fullcalendar/event/components/WeekTimedEvent.tsx new file mode 100644 index 00000000..7c61bc93 --- /dev/null +++ b/src/components/database/fullcalendar/event/components/WeekTimedEvent.tsx @@ -0,0 +1,148 @@ +import { EventApi, EventContentArg } from '@fullcalendar/core'; +import dayjs from 'dayjs'; +import { useCallback, useMemo } from 'react'; + +import { cn } from '@/lib/utils'; + +import { EventIconButton } from './EventIconButton'; + +interface WeekTimedEventProps { + event: EventApi; + eventInfo: EventContentArg; + onClick?: (event: EventApi) => void; + showLeftIndicator?: boolean; + className?: string; + rowId: string; +} + +const formatTimeDisplay = (date: Date): string => { + const time = dayjs(date); + const minutes = time.minute(); + + if (minutes === 0) { + return time.format('h A').toLowerCase(); + } else { + return time.format('h:mm A').toLowerCase(); + } +}; + +export function WeekTimedEvent({ + event, + eventInfo, + onClick, + className, + rowId, +}: WeekTimedEventProps) { + const isEventStart = eventInfo.isStart; + const isEventEnd = eventInfo.isEnd; + const isRange = event.extendedProps.isRange; + + const handleClick = () => { + onClick?.(event); + }; + + const getDisplayContent = useCallback(() => { + if (isEventStart) { + return event.title || 'Untitled'; + } + + if (isEventEnd) { + return `${event.title || 'Untitled'}`; + } else { + return `${event.title || 'Untitled'}`; + } + }, [isEventStart, isEventEnd, event.title]); + + const renderTimeEvent = useMemo(() => { + const moreThanHalfHour = dayjs(event.end).diff(dayjs(event.start), 'minute') > 30; + const isShortEvent = event.end && dayjs(event.end).diff(dayjs(event.start), 'minute') < 30; + + if (isShortEvent) { + // For short events (< 30 minutes), use single line layout with minimum height + return ( +
+ + {getDisplayContent()} + +
+ {isEventStart && event.start && {formatTimeDisplay(event.start)}} + + {isEventStart && -} + {event.end && formatTimeDisplay(event.end)} + +
+
+ ); + } + + return ( +
+
+ + + {getDisplayContent()} + {moreThanHalfHour ? '' : ','} + +
+
+ {isEventStart && event.start && {formatTimeDisplay(event.start)}} + {isRange && ( + + {isEventStart && -} + {event.end && formatTimeDisplay(event.end)} + + )} +
+
+ ); + }, [event.end, event.start, getDisplayContent, isEventStart, rowId, isRange]); + + const isShortEvent = event.end && dayjs(event.end).diff(dayjs(event.start), 'minute') < 30; + const isCompactLayout = !event.end || isShortEvent; + + return ( +
+
+
+ {renderTimeEvent} +
+
+
+ ); +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/components/index.ts b/src/components/database/fullcalendar/event/components/index.ts new file mode 100644 index 00000000..eb7c8008 --- /dev/null +++ b/src/components/database/fullcalendar/event/components/index.ts @@ -0,0 +1,6 @@ +export { MonthAllDayEvent } from './MonthAllDayEvent'; +export { MonthTimedEvent } from './MonthTimedEvent'; +export { MonthMultiDayTimedEvent } from './MonthMultiDayTimedEvent'; +export { WeekAllDayEvent } from './WeekAllDayEvent'; +export { WeekTimedEvent } from './WeekTimedEvent'; +export { EventIconButton } from './EventIconButton'; \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/hooks/useEventIcon.tsx b/src/components/database/fullcalendar/event/hooks/useEventIcon.tsx new file mode 100644 index 00000000..4286aa16 --- /dev/null +++ b/src/components/database/fullcalendar/event/hooks/useEventIcon.tsx @@ -0,0 +1,58 @@ +import { useMemo } from 'react'; + +import { RowMetaKey, useRowMetaSelector } from '@/application/database-yjs'; +import { useUpdateRowMetaDispatch } from '@/application/database-yjs/dispatch'; +import { ReactComponent as DocumentSvg } from '@/assets/icons/doc.svg'; +import { isFlagEmoji } from '@/utils/emoji'; + +export function useEventIcon(rowId: string) { + const meta = useRowMetaSelector(rowId); + const onUpdateMeta = useUpdateRowMetaDispatch(rowId); + + const hasDocument = meta?.isEmptyDocument === false; + const icon = meta?.icon; + const showIcon = icon || hasDocument; + + const isFlag = useMemo(() => { + if (!icon) return false; + return isFlagEmoji(icon); + }, [icon]); + + const onSelectIcon = (iconValue: string) => { + onUpdateMeta(RowMetaKey.IconId, iconValue); + }; + + const removeIcon = () => { + onUpdateMeta(RowMetaKey.IconId, undefined); + }; + + const renderIcon = (iconSize?: number) => { + if (icon) { + return icon; + } + + if (hasDocument) { + return ( + + ); + } + + return null; + }; + + return { + icon, + showIcon, + isFlag, + onSelectIcon, + removeIcon, + renderIcon, + hasDocument, + }; +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/event/index.ts b/src/components/database/fullcalendar/event/index.ts new file mode 100644 index 00000000..11657c08 --- /dev/null +++ b/src/components/database/fullcalendar/event/index.ts @@ -0,0 +1,3 @@ +export { EventDisplay } from './EventDisplay'; +export { default as EventPopoverContent } from './EventPopoverContent'; +export { EventWithPopover } from './EventWithPopover'; \ No newline at end of file diff --git a/src/components/database/fullcalendar/hooks/index.ts b/src/components/database/fullcalendar/hooks/index.ts new file mode 100644 index 00000000..c3e6f78f --- /dev/null +++ b/src/components/database/fullcalendar/hooks/index.ts @@ -0,0 +1,11 @@ +export { useCalendarHandlers } from './useCalendarHandlers'; +export { useCalendarPermissions } from './useCalendarPermissions'; +export { useCalendarResize } from './useCalendarResize'; +export { useCalendarStickyHeader } from './useCalendarStickyHeader'; +export { useCalendarStickyWeekHeader } from './useCalendarStickyWeekHeader'; +export { useScrollNavigation } from './useScrollNavigation'; +export { useCalendarEvents } from './useCalendarEvents'; +export { useAddButton } from './useAddButton'; +export { useDynamicDayMaxEventRows } from './useDynamicDayMaxEventRows'; +export { useScrollDetection } from './useScrollDetection'; +export { useCurrentTimeIndicator } from './useCurrentTimeIndicator'; \ No newline at end of file diff --git a/src/components/database/fullcalendar/hooks/useAddButton.ts b/src/components/database/fullcalendar/hooks/useAddButton.ts new file mode 100644 index 00000000..b7df8084 --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useAddButton.ts @@ -0,0 +1,168 @@ +import { debounce } from 'lodash-es'; +import { useCallback, useEffect, useMemo, useRef, useState } from 'react'; + +import { CalendarViewType } from '../types'; + +interface AddButtonState { + visible: boolean; + position: { top: number; left: number }; + date: Date | null; +} + +interface UseAddButtonOptions { + readOnly: boolean; + currentView: CalendarViewType; + calendarElement: HTMLDivElement | null; + onAddEvent: (date: Date) => Promise; + isAddButtonEnabled?: (date: Date) => boolean; +} + +export function useAddButton({ + readOnly, + currentView, + calendarElement, + onAddEvent, + isAddButtonEnabled, +}: UseAddButtonOptions) { + const ref = useRef(null); + const [addButtonState, setAddButtonState] = useState({ + visible: false, + position: { top: 0, left: 0 }, + date: null, + }); + + // Note: Scrolling state is now handled via CSS opacity in AddButton component + + // Memoized debounced show function + const debouncedShowAddButton = useMemo( + () => + debounce((dayCell: Element) => { + const rect = dayCell.getBoundingClientRect(); + const dateAttr = dayCell.getAttribute('data-date'); + + if (dateAttr) { + const date = new Date(dateAttr); + + // Check if add button should be enabled for this date + const enabled = isAddButtonEnabled ? isAddButtonEnabled(date) : true; + + if (!enabled) return; + + let top = rect.top + 4; + let left = rect.left + 4; + + // If containerRef is provided, calculate position relative to container + if (calendarElement) { + const containerRect = calendarElement.getBoundingClientRect(); + + top = rect.top - containerRect.top + 4; + left = rect.left - containerRect.left + 4; + } + + setAddButtonState({ + visible: true, + position: { + top, + left, + }, + date, + }); + } + }, 150), + [calendarElement, isAddButtonEnabled] + ); + + // Handle day cell hover for add button + useEffect(() => { + if (readOnly || currentView !== CalendarViewType.DAY_GRID_MONTH) return; + + const cellListeners = new Map void; leave: (e: Event) => void }>(); + + const setupCellListeners = () => { + if (!calendarElement) { + return; + } + + const dayCells = calendarElement.querySelectorAll('.fc-daygrid-day'); + + dayCells.forEach((dayCell) => { + if (cellListeners.has(dayCell as HTMLElement)) return; + + const handleCellEnter = (e: Event) => { + e.stopPropagation(); + debouncedShowAddButton(dayCell); + }; + + const handleCellLeave = (e: Event) => { + const mouseEvent = e as MouseEvent; + const relatedTarget = mouseEvent.relatedTarget as HTMLElement; + + if (relatedTarget?.closest('[data-add-button]')) { + return; + } + + debouncedShowAddButton.cancel(); + setAddButtonState((prev) => ({ ...prev, visible: false })); + }; + + dayCell.addEventListener('mouseenter', handleCellEnter); + dayCell.addEventListener('mouseleave', handleCellLeave); + + cellListeners.set(dayCell as HTMLElement, { + enter: handleCellEnter, + leave: handleCellLeave, + }); + }); + }; + + setupCellListeners(); + + const observer = new MutationObserver(() => { + setupCellListeners(); + }); + + if (calendarElement) { + observer.observe(calendarElement, { childList: true, subtree: true }); + } + + return () => { + debouncedShowAddButton.cancel(); + + cellListeners.forEach((listeners, cell) => { + cell.removeEventListener('mouseenter', listeners.enter); + cell.removeEventListener('mouseleave', listeners.leave); + }); + cellListeners.clear(); + + observer.disconnect(); + }; + }, [readOnly, currentView, calendarElement, debouncedShowAddButton]); + + // Handle add button click + const handleAddButtonClick = useCallback(async () => { + if (addButtonState.date) { + + + await onAddEvent(addButtonState.date); + setAddButtonState((prev) => ({ ...prev, visible: false })); + } + }, [addButtonState.date, onAddEvent]); + + // Handle add button mouse leave + const handleAddButtonMouseLeave = useCallback((e: React.MouseEvent) => { + const relatedTarget = e.relatedTarget as HTMLElement; + + if (relatedTarget?.closest('.fc-daygrid-day')) { + return; + } + + setAddButtonState((prev) => ({ ...prev, visible: false })); + }, []); + + return { + addButtonState, + handleAddButtonClick, + handleAddButtonMouseLeave, + ref, + }; +} diff --git a/src/components/database/fullcalendar/hooks/useCalendarEvents.ts b/src/components/database/fullcalendar/hooks/useCalendarEvents.ts new file mode 100644 index 00000000..6b09db69 --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useCalendarEvents.ts @@ -0,0 +1,187 @@ +import { DateSelectArg, EventDropArg } from '@fullcalendar/core'; +import { EventResizeDoneArg } from '@fullcalendar/interaction'; +import { useCallback } from 'react'; + + +import { useCalendarLayoutSetting, useCreateCalendarEvent, useUpdateStartEndTimeCell } from '@/application/database-yjs'; +import { dateToUnixTimestamp, correctAllDayEndForStorage } from '@/utils/time'; + +import { CalendarViewType } from '../types'; + +/** + * Custom hook to handle calendar event interactions (drag, resize, create) + * Provides functions that can update the database when events are modified + */ +export function useCalendarEvents() { + const calendarSetting = useCalendarLayoutSetting(); + const fieldId = calendarSetting.fieldId; + const createCalendarEvent = useCreateCalendarEvent(fieldId); + const updateCell = useUpdateStartEndTimeCell(); + + // Create a function that can update any event's time directly + const updateEventTime = useCallback( + (rowId: string, startTimestamp: string, endTimestamp?: string, isAllDay?: boolean) => { + console.debug('📅 Updating event time:', { rowId, fieldId, startTimestamp, endTimestamp }); + + updateCell(rowId, fieldId, startTimestamp, endTimestamp, isAllDay); + }, + [fieldId, updateCell] + ); + + // Handle event drop (move event to different time) + const handleEventDrop = useCallback( + (dropInfo: EventDropArg) => { + console.debug('📅 Event dropped:', dropInfo.event); + + try { + // Parse event ID to get rowId + const rowId = dropInfo.event.id; + + if (!rowId) { + throw new Error('Invalid event ID format'); + } + + // Convert dates to Unix timestamps + const startTimestamp = dateToUnixTimestamp(dropInfo.event.start!); + const isAllDay = dropInfo.event.allDay; + + // For all-day events, correct end time for storage if needed + const endDate = dropInfo.event.end; + const correctedEndDate = isAllDay && endDate ? correctAllDayEndForStorage(endDate) : endDate; + const endTimestamp = correctedEndDate ? dateToUnixTimestamp(correctedEndDate) : undefined; + + // Update the event time + updateEventTime(rowId, startTimestamp, endTimestamp, isAllDay); + + console.debug('📅 Event time updated successfully'); + } catch (error) { + console.error('❌ Failed to update event time:', error); + dropInfo.revert(); + } + }, + [updateEventTime] + ); + + // Handle event resize (change event duration) + const handleEventResize = useCallback( + (resizeInfo: EventResizeDoneArg) => { + console.debug('📅 Event resized:', resizeInfo.event); + + try { + // Parse event ID to get rowId + const rowId = resizeInfo.event.id; + + if (!rowId) { + throw new Error('Invalid event ID format'); + } + + // Convert dates to Unix timestamps + const startTimestamp = dateToUnixTimestamp(resizeInfo.event.start!); + const isAllDay = resizeInfo.event.allDay; + + // For all-day events, correct end time for storage if needed + const endDate = resizeInfo.event.end; + const correctedEndDate = isAllDay && endDate ? correctAllDayEndForStorage(endDate) : endDate; + const endTimestamp = correctedEndDate ? dateToUnixTimestamp(correctedEndDate) : undefined; + + // Update the event time + updateEventTime(rowId, startTimestamp, endTimestamp, isAllDay); + + console.debug('📅 Event duration updated successfully'); + } catch (error) { + console.error('❌ Failed to update event duration:', error); + resizeInfo.revert(); + } + }, + [updateEventTime] + ); + + // Handle date selection (create new event) + const handleSelect = useCallback( + async (selectInfo: DateSelectArg): Promise => { + console.debug('📅 Date range selected:', selectInfo); + + + try { + // Convert dates to Unix timestamps + const startTimestamp = dateToUnixTimestamp(selectInfo.start); + + // For all-day events, correct end time for storage if needed + const correctedEndDate = selectInfo.allDay ? correctAllDayEndForStorage(selectInfo.end) : selectInfo.end; + let endTimestamp = dateToUnixTimestamp(correctedEndDate); + + // For week view time grid selections, default to 1-hour events only for small selections (clicks or short drags) + if (selectInfo.view.type === CalendarViewType.TIME_GRID_WEEK && !selectInfo.allDay) { + const selectionDuration = selectInfo.end.getTime() - selectInfo.start.getTime(); + const thirtyMinutesInMs = 30 * 60 * 1000; // 30 minutes in milliseconds + + // Only adjust to 1-hour if selection is 30 minutes or less (typically clicks) + if (selectionDuration <= thirtyMinutesInMs) { + const startDate = new Date(selectInfo.start); + const endDate = new Date(startDate); + + endDate.setHours(startDate.getHours() + 1); // Add 1 hour + + endTimestamp = dateToUnixTimestamp(endDate); + console.debug('📅 Week view: Adjusted to 1-hour event for short selection', { + originalDuration: `${selectionDuration / 1000 / 60} minutes`, + original: selectInfo.end, + adjusted: endDate + }); + } else { + console.debug('📅 Week view: Keeping original selection duration', { + duration: `${selectionDuration / 1000 / 60} minutes` + }); + } + } + + + // Create new calendar event + const rowId = await createCalendarEvent({ startTimestamp, endTimestamp, includeTime: !selectInfo.allDay }); + + console.debug('📅 New event created successfully with rowId:', rowId); + + // Clear the selection + selectInfo.view.calendar.unselect(); + + return rowId; + } catch (error) { + console.error('❌ Failed to create new event:', error); + // Clear the selection even if creation failed + selectInfo.view.calendar.unselect(); + return null; + } + }, + [createCalendarEvent] + ); + + // Handle add button click for specific date + const handleAdd = useCallback( + async (date: Date): Promise => { + console.debug('📅 Add button clicked for date:', date); + + try { + // Create event for the selected date at current time or start of day + const startTimestamp = dateToUnixTimestamp(date); + + // Create new calendar event for the specific date + const rowId = await createCalendarEvent({ startTimestamp }); + + console.debug('📅 New event created successfully with rowId:', rowId); + return rowId; + } catch (error) { + console.error('❌ Failed to create new event:', error); + return null; + } + }, + [createCalendarEvent] + ); + + return { + handleEventDrop, + handleEventResize, + handleSelect, + handleAdd, + updateEventTime, + }; +} diff --git a/src/components/database/fullcalendar/hooks/useCalendarHandlers.ts b/src/components/database/fullcalendar/hooks/useCalendarHandlers.ts new file mode 100644 index 00000000..698989ea --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useCalendarHandlers.ts @@ -0,0 +1,71 @@ +import { useCallback, useState } from 'react'; + +import { CalendarViewType } from '../types'; + +import { useCalendarEvents } from './useCalendarEvents'; + +import type { CalendarApi, DatesSetArg, MoreLinkArg } from '@fullcalendar/core'; + +/** + * Custom hook to manage calendar event handlers and state + * Centralizes all calendar interaction logic + */ +export function useCalendarHandlers() { + const [currentView, setCurrentView] = useState(CalendarViewType.DAY_GRID_MONTH); + const [calendarTitle, setCalendarTitle] = useState(''); + const [morelinkInfo, setMorelinkInfo] = useState(undefined); + const [, setCurrentDateRange] = useState<{ start: Date; end: Date } | null>(null); + + // Get calendar event handlers + const { handleEventDrop, handleEventResize, handleSelect, handleAdd, updateEventTime } = useCalendarEvents(); + + // Handle view changes (month/week toggle) + const handleViewChange = useCallback((view: CalendarViewType, calendarApi: CalendarApi | null) => { + if (calendarApi) { + // Switch view and adjust to today's date range + calendarApi.changeView(view); + + // Navigate to today + calendarApi.today(); + + setCurrentView(view); + } + }, []); + + // Handle calendar date range changes + const handleDatesSet = useCallback((dateInfo: DatesSetArg, _calendarApi: CalendarApi | null) => { + setCalendarTitle(dateInfo.view.title); + setCurrentView(dateInfo.view.type as CalendarViewType); + setCurrentDateRange({ + start: dateInfo.start, + end: dateInfo.end, + }); + }, []); + + // Handle more link clicks (when there are too many events in a day) + const handleMoreLinkClick = useCallback((moreLinkInfo: MoreLinkArg) => { + console.debug('📅 More link clicked:', moreLinkInfo); + setMorelinkInfo(moreLinkInfo); + + return 'null'; // Prevent FullCalendar's native popover + }, []); + + const closeMorePopover = useCallback(() => { + setMorelinkInfo(undefined); + }, []); + + return { + currentView, + calendarTitle, + morelinkInfo, + handleViewChange, + handleDatesSet, + handleMoreLinkClick, + handleEventDrop, + handleEventResize, + handleSelect, + handleAdd, + updateEventTime, + closeMorePopover + }; +} diff --git a/src/components/database/fullcalendar/hooks/useCalendarPermissions.ts b/src/components/database/fullcalendar/hooks/useCalendarPermissions.ts new file mode 100644 index 00000000..7feff3ef --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useCalendarPermissions.ts @@ -0,0 +1,70 @@ +import { useMemo, useCallback } from 'react'; +import dayjs from 'dayjs'; + +import { FieldType, useCalendarLayoutSetting, useFieldSelector, useReadOnly } from '@/application/database-yjs'; +import { useNewRowDispatch } from '@/application/database-yjs/dispatch'; +import { YjsDatabaseKey } from '@/application/types'; + +/** + * Hook to manage calendar permissions and behavior based on field type + * When the layout field is CreatedTime or LastEditedTime, certain features are disabled + */ +export function useCalendarPermissions() { + const readOnly = useReadOnly(); + const calendarSetting = useCalendarLayoutSetting(); + const { field: layoutField } = useFieldSelector(calendarSetting.fieldId); + const newRowDispatch = useNewRowDispatch(); + + // Get field type + const fieldType = layoutField ? (Number(layoutField.get(YjsDatabaseKey.type)) as FieldType) : null; + + // Check if field type is created time or modified time + const isTimeSystemField = useMemo(() => { + return fieldType === FieldType.CreatedTime || fieldType === FieldType.LastEditedTime; + }, [fieldType]); + + // Calendar interaction permissions + const permissions = useMemo(() => ({ + editable: !readOnly && !isTimeSystemField, + selectable: !readOnly && !isTimeSystemField, + droppable: !readOnly && !isTimeSystemField, + eventResizable: !readOnly && !isTimeSystemField, + }), [readOnly, isTimeSystemField]); + + // Add button enabled function that checks hover date + const isAddButtonEnabled = useCallback((hoverDate: Date) => { + // If readonly, always disabled + if (readOnly) return false; + + // If not a time system field, always enabled + if (!isTimeSystemField) return true; + + // For time system fields, enable only if hover date matches today + const today = dayjs().startOf('day'); + const hoverDateFormatted = dayjs(hoverDate).startOf('day'); + + if (fieldType === FieldType.CreatedTime || fieldType === FieldType.LastEditedTime) { + return today.isSame(hoverDateFormatted); + } + + return false; + }, [readOnly, isTimeSystemField, fieldType]); + + // Event creation function that uses appropriate dispatch based on field type + const createEvent = useMemo(() => { + if (isTimeSystemField) { + // For created/modified time fields, use newRowDispatch with tailing=true + return () => newRowDispatch({ tailing: true }); + } + + return null; // Return null to indicate should use original handlers + }, [isTimeSystemField, newRowDispatch]); + + return { + isTimeSystemField, + permissions, + isAddButtonEnabled, + createEvent, + fieldType, + }; +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/hooks/useCalendarResize.ts b/src/components/database/fullcalendar/hooks/useCalendarResize.ts new file mode 100644 index 00000000..bd687fb5 --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useCalendarResize.ts @@ -0,0 +1,51 @@ +import { CalendarApi } from '@fullcalendar/core'; +import { debounce } from 'lodash-es'; +import { useEffect, useRef } from 'react'; + +/** + * Custom hook to handle calendar resize events + * Manages both window resize and element resize observers + */ +export function useCalendarResize( + onRendered?: () => void, + expanded?: boolean, + isDocumentBlock?: boolean, + calendar?: CalendarApi +) { + const containerRef = useRef(null); + + useEffect(() => { + const el = containerRef.current; + + if (!el) return; + + // Debounced resize handler to prevent excessive re-renders + const onResize = debounce(() => { + onRendered?.(); + calendar?.updateSize(); + // FullCalendar React component handles resize automatically + }, 200); + + onResize(); + + // Add window resize listener for general resize events + window.addEventListener('resize', onResize); + + // Add element resize listener if available (requires ResizeObserver) + let resizeObserver: ResizeObserver | null = null; + + if (window.ResizeObserver) { + resizeObserver = new ResizeObserver(onResize); + resizeObserver.observe(el); + } + + return () => { + window.removeEventListener('resize', onResize); + if (resizeObserver) { + resizeObserver.disconnect(); + } + }; + }, [calendar, onRendered, expanded, isDocumentBlock]); + + return containerRef; +} diff --git a/src/components/database/fullcalendar/hooks/useCalendarStickyHeader.ts b/src/components/database/fullcalendar/hooks/useCalendarStickyHeader.ts new file mode 100644 index 00000000..ae85ab5a --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useCalendarStickyHeader.ts @@ -0,0 +1,103 @@ +import { useCallback, useEffect, useLayoutEffect, useRef, useState } from 'react'; + +import { getScrollParent } from '@/components/global-comment/utils'; + +import type { CalendarApi } from '@fullcalendar/core'; + +/** + * Custom hook to handle sticky header for calendar toolbar + * Similar to useGridVirtualizer pattern for scroll-based sticky behavior + */ +export function useCalendarStickyHeader(calendarApi: CalendarApi | null, toolbarRef?: React.RefObject) { + const parentRef = useRef(null); + const toolbarOffsetRef = useRef(0); + const [showStickyToolbar, setShowStickyToolbar] = useState(false); + + /** + * Update normal toolbar offset position + * Monitor when the normal toolbar reaches the top of viewport + */ + const updateToolbarOffset = useCallback(() => { + const targetElement = toolbarRef?.current || parentRef.current; + + if (targetElement) { + const rect = targetElement.getBoundingClientRect(); + + toolbarOffsetRef.current = rect.top; + } + }, [toolbarRef]); + + /** + * Get scroll element for the calendar container + * Reuses the same logic as useGridVirtualizer + */ + const getScrollElement = useCallback(() => { + if (!parentRef.current) return null; + return parentRef.current.closest('.appflowy-scroll-container') || getScrollParent(parentRef.current); + }, []); + + /** + * Handle scroll events to show/hide sticky toolbar + */ + useEffect(() => { + const scrollElement = getScrollElement(); + + if (!scrollElement) return; + + const handleScroll = () => { + updateToolbarOffset(); + // Show sticky toolbar when normal toolbar reaches the app header area + // App header is approximately 48px, so show sticky when toolbar goes above 48px from top + const APP_HEADER_HEIGHT = 48; + + const shouldShow = toolbarOffsetRef.current <= APP_HEADER_HEIGHT; + + setShowStickyToolbar(shouldShow); + }; + + // Initial check + handleScroll(); + + scrollElement.addEventListener('scroll', handleScroll); + + return () => { + scrollElement.removeEventListener('scroll', handleScroll); + }; + }, [getScrollElement, updateToolbarOffset]); + + /** + * Update offset on layout changes + */ + useLayoutEffect(() => { + updateToolbarOffset(); + }, [updateToolbarOffset]); + + /** + * Monitor scroll element changes to recalculate offset + * Similar to useGridVirtualizer resize monitoring + */ + useLayoutEffect(() => { + const scrollElement = getScrollElement(); + + if (!scrollElement) return; + + const handleResize = () => { + updateToolbarOffset(); + }; + + scrollElement.addEventListener('resize', handleResize); + window.addEventListener('resize', handleResize); + + return () => { + scrollElement.removeEventListener('resize', handleResize); + window.removeEventListener('resize', handleResize); + }; + }, [getScrollElement, updateToolbarOffset]); + + return { + parentRef, + showStickyToolbar, + updateToolbarOffset, + getScrollElement, + }; +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/hooks/useCalendarStickyWeekHeader.ts b/src/components/database/fullcalendar/hooks/useCalendarStickyWeekHeader.ts new file mode 100644 index 00000000..14c262ba --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useCalendarStickyWeekHeader.ts @@ -0,0 +1,189 @@ +import { useCallback, useEffect, useRef, useState } from 'react'; + +import { getScrollParent } from '@/components/global-comment/utils'; + +import { CalendarViewType } from '../types'; + +import type { CalendarApi } from '@fullcalendar/core'; + +/** + * Interface for header cell data + */ +interface HeaderCellData { + date: Date; + dayName: string; + dayNumber: number; + isToday: boolean; + isWeekend: boolean; +} + +/** + * Custom hook to handle sticky week header using Calendar API data + * Generates header cells based on current calendar view and date range + */ +export function useCalendarStickyWeekHeader( + calendarApi: CalendarApi | null, + { + currentView, + firstDayOfWeek, + numberOfDays = 7, + }: { + currentView: CalendarViewType, + firstDayOfWeek: number, + numberOfDays?: number, + } +) { + const parentRef = useRef(null); + const [headerCells, setHeaderCells] = useState([]); + const [scrollLeft, setScrollLeft] = useState(0); + + /** + * Generate header cells from Calendar API data + */ + const generateHeaderCells = useCallback(() => { + if (!calendarApi) return []; + + const view = calendarApi.view; + const currentDate = view.currentStart; + + const today = new Date(); + const cellsData: HeaderCellData[] = []; + + // Generate cells based on view type + if (currentView === CalendarViewType.TIME_GRID_WEEK) { + // Week view: show numberOfDays days starting from firstDayOfWeek + const startOfWeek = new Date(currentDate); + + for (let i = 0; i < numberOfDays; i++) { + const date = new Date(startOfWeek); + + date.setDate(startOfWeek.getDate() + i); + + const dayName = date.toLocaleDateString('en-US', { weekday: 'short' }); + const dayNumber = date.getDate(); + const isToday = + date.getDate() === today.getDate() && + date.getMonth() === today.getMonth() && + date.getFullYear() === today.getFullYear(); + const isWeekend = date.getDay() === 0 || date.getDay() === 6; // Sunday or Saturday + + cellsData.push({ + date: new Date(date), + dayName, + dayNumber, + isToday, + isWeekend, + }); + } + } else if (currentView === CalendarViewType.DAY_GRID_MONTH) { + // Month view: show days of week (just day names) starting from firstDayOfWeek + const startDay = firstDayOfWeek || 0; // 0 = Sunday, 1 = Monday + + for (let i = 0; i < 7; i++) { + const dayIndex = (startDay + i) % 7; + // Create a date for the specific day to get localized day name + const tempDate = new Date(); + + tempDate.setDate(tempDate.getDate() - tempDate.getDay() + dayIndex); + const dayName = tempDate.toLocaleDateString('en-US', { weekday: 'short' }); + const isWeekend = dayIndex === 0 || dayIndex === 6; + + cellsData.push({ + date: new Date(), // Not specific date for month view + dayName, + dayNumber: 0, // Not applicable for month view + isToday: false, // Not applicable for month view header + isWeekend, + }); + } + } + + return cellsData; + }, [calendarApi, currentView, firstDayOfWeek, numberOfDays]); + + /** + * Update header cells data + */ + const updateHeaderCells = useCallback(() => { + const cells = generateHeaderCells(); + + setHeaderCells(cells); + }, [generateHeaderCells]); + + /** + * Get scroll element for the calendar container + */ + const getScrollElement = useCallback(() => { + if (!parentRef.current) return null; + return parentRef.current.closest('.appflowy-scroll-container') || getScrollParent(parentRef.current); + }, []); + + // Week header sticky state will be managed by parent component + // since it should match the toolbar sticky state + + /** + * Check if current view should show sticky week header + */ + const shouldShowForCurrentView = useCallback(() => { + if (!currentView) return true; + + // Show for all views + return true; + }, [currentView]); + + /** + * Handle horizontal scroll synchronization + */ + const handleHorizontalScroll = useCallback(() => { + const scrollElement = getScrollElement(); + + if (scrollElement) { + setScrollLeft(scrollElement.scrollLeft); + } + }, [getScrollElement]); + + /** + * Setup horizontal scroll monitoring only + */ + useEffect(() => { + const scrollElement = getScrollElement(); + + if (!scrollElement || !shouldShowForCurrentView()) return; + + scrollElement.addEventListener('scroll', handleHorizontalScroll); + + // Initial check + handleHorizontalScroll(); + + return () => { + scrollElement.removeEventListener('scroll', handleHorizontalScroll); + }; + }, [getScrollElement, handleHorizontalScroll, shouldShowForCurrentView]); + + /** + * Initialize header generation when calendar API is available + * and when calendar dates change + */ + useEffect(() => { + if (!calendarApi || !shouldShowForCurrentView()) return; + + // Generate header cells when calendar is ready + updateHeaderCells(); + + calendarApi.on('datesSet', updateHeaderCells); + + return () => { + calendarApi.off('datesSet', updateHeaderCells); + }; + }, [calendarApi, updateHeaderCells, shouldShowForCurrentView]); + + + + return { + parentRef, + headerCells, + scrollLeft, + shouldShowWeekHeader: shouldShowForCurrentView() && headerCells.length > 0, + updateHeaderCells, + }; +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/hooks/useCurrentTimeIndicator.ts b/src/components/database/fullcalendar/hooks/useCurrentTimeIndicator.ts new file mode 100644 index 00000000..e9673e19 --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useCurrentTimeIndicator.ts @@ -0,0 +1,197 @@ +import { useEffect, useRef } from 'react'; + +import { CalendarViewType } from '../types'; + +import type { CalendarApi } from '@fullcalendar/core'; + +/** + * Custom hook to enhance the current time indicator with time label + * Adds a time label (e.g., "9:07 AM") next to the current time line in week view + */ +export function useCurrentTimeIndicator(calendarApi: CalendarApi | null, currentView: CalendarViewType) { + const intervalRef = useRef(null); + + useEffect(() => { + if (!calendarApi || currentView !== CalendarViewType.TIME_GRID_WEEK) { + // Clear interval if not in week view + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + + // Remove existing custom line when switching away from week view + const existingLine = document.querySelector('.custom-now-indicator-line'); + + if (existingLine) { + existingLine.remove(); + } + + // Reset time slot visibility when leaving week view + resetTimeSlotVisibility(); + + return; + } + + const updateTimeLabel = () => { + // Find FullCalendar's native now indicator arrow element + const nowIndicatorArrow = document.querySelector('.fc-timegrid-now-indicator-arrow') as HTMLElement; + + if (nowIndicatorArrow) { + // Get current time + const now = new Date(); + const timeString = now.toLocaleTimeString('en-US', { + hour: 'numeric', + minute: '2-digit', + hour12: true + }); + const [time, period] = timeString.split(' '); + + + // Set the content directly to the arrow element + nowIndicatorArrow.innerHTML = `${time}${period}`; + + // Handle dynamic time slot visibility + handleTimeSlotVisibility(now); + + // Create or update the horizontal line across the week view + createHorizontalTimeLine(nowIndicatorArrow); + } + }; + + const handleTimeSlotVisibility = (currentTime: Date) => { + const currentHour = currentTime.getHours(); + const currentMinute = currentTime.getMinutes(); + + // Hide/show hourly time slots based on proximity to current time + for (let hour = 0; hour < 24; hour++) { + const timeString = String(hour).padStart(2, '0') + ':00:00'; + const timeSlot = document.querySelector(`[data-time="${timeString}"]`) as HTMLElement; + + if (timeSlot) { + const shouldHide = shouldHideTimeSlot(currentHour, currentMinute, hour); + + if (shouldHide) { + timeSlot.classList.add('hidden-text'); + } else { + timeSlot.classList.remove('hidden-text'); + } + } + } + }; + + const shouldHideTimeSlot = (currentHour: number, currentMinute: number, slotHour: number): boolean => { + // If current time is between X:46 and (X+1):16, hide the (X+1):00 slot + + // Case 1: Current time is X:46-X:59, hide next hour slot + if (currentMinute >= 46 && slotHour === (currentHour + 1) % 24) { + return true; + } + + // Case 2: Current time is X:00-X:16, hide current hour slot + if (currentMinute <= 16 && slotHour === currentHour) { + return true; + } + + return false; + }; + + + const createHorizontalTimeLine = (arrowElement: HTMLElement) => { + // Remove existing line if it exists + const existingLine = document.querySelector('.custom-now-indicator-line'); + + if (existingLine) { + existingLine.remove(); + } + + // Find the FullCalendar's native now indicator line to align with + const nowIndicatorLine = document.querySelector('.fc-timegrid-now-indicator-line') as HTMLElement; + + if (!nowIndicatorLine) return; + + // Find the week view container + const weekViewContainer = document.querySelector('.database-calendar.week-view .fc') as HTMLElement; + + if (!weekViewContainer) return; + + // Get the positions + const lineRect = nowIndicatorLine.getBoundingClientRect(); + const containerRect = weekViewContainer.getBoundingClientRect(); + const arrowRect = arrowElement.getBoundingClientRect(); + + // Debug: Log positions to console + console.debug('Position Debug:', { + nowIndicatorLine: { top: lineRect.top, height: lineRect.height }, + arrow: { top: arrowRect.top, height: arrowRect.height }, + container: { top: containerRect.top } + }); + + // Create the horizontal line element + const horizontalLine = document.createElement('div'); + + horizontalLine.className = 'custom-now-indicator-line'; + + // Position the line to align with fc-timegrid-now-indicator-line + const lineTop = lineRect.top - containerRect.top + 0.5; + const lineLeft = arrowRect.right - containerRect.left; // Start from arrow's right edge + gap + const lineWidth = containerRect.width - lineLeft; + + horizontalLine.style.top = `${lineTop}px`; + horizontalLine.style.left = `${lineLeft}px`; + horizontalLine.style.width = `${lineWidth}px`; + + + // Append the line to the week view container + weekViewContainer.appendChild(horizontalLine); + }; + + // Initial update + updateTimeLabel(); + + // Update 15s to keep the time accurate + intervalRef.current = setInterval(updateTimeLabel, 15000); + + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + intervalRef.current = null; + } + }; + }, [calendarApi, currentView]); + + // Cleanup on unmount + useEffect(() => { + return () => { + if (intervalRef.current) { + clearInterval(intervalRef.current); + } + + // Remove custom line on unmount + const existingLine = document.querySelector('.custom-now-indicator-line'); + + if (existingLine) { + existingLine.remove(); + } + + // Reset time slot visibility on unmount + resetTimeSlotVisibility(); + }; + }, []); + + // Define resetTimeSlotVisibility outside the main effect so it can be accessed in cleanup + const resetTimeSlotVisibility = () => { + // Reset all time slot labels to fully opaque + for (let hour = 0; hour < 24; hour++) { + const timeString = String(hour).padStart(2, '0') + ':00:00'; + const timeSlot = document.querySelector(`[data-time="${timeString}"]`) as HTMLElement; + + if (timeSlot) { + const timeLabel = timeSlot.querySelector('.fc-timegrid-slot-label') as HTMLElement; + + if (timeLabel) { + timeLabel.style.opacity = '1'; + } + } + } + }; +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/hooks/useDynamicDayMaxEventRows.ts b/src/components/database/fullcalendar/hooks/useDynamicDayMaxEventRows.ts new file mode 100644 index 00000000..fcac3fc7 --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useDynamicDayMaxEventRows.ts @@ -0,0 +1,67 @@ +import { useCallback, useEffect, useState } from 'react'; + +import { CalendarViewType } from '@/components/database/fullcalendar/types'; + +export const useDynamicDayMaxEventRows = (currentView: CalendarViewType) => { + const [dayMaxEventRows, setDayMaxEventRows] = useState(4); + + const calculateDayMaxEventRows = useCallback(() => { + + // 148: 148px is the height of the week header + // 20: 20px is the padding-bottom of the calendar content + // 6: 6 is the number of rows in the calendar + const weekHeight = (window.innerHeight - 148 - 20) / 6; + + + if (weekHeight < 114) return 2; + if (weekHeight < 140) return 3; + if (weekHeight < 166) return 4; + if (weekHeight < 192) return 5; + return 6; + }, []); + + const updateCalendarCellStyles = useCallback((weekHeight: number) => { + if (currentView === CalendarViewType.TIME_GRID_WEEK) return; + + const minHeight = Math.max(weekHeight, 80); + + const styleId = 'dynamic-calendar-styles'; + let styleElement = document.getElementById(styleId) as HTMLStyleElement; + + if (!styleElement) { + styleElement = document.createElement('style'); + styleElement.id = styleId; + document.head.appendChild(styleElement); + } + + styleElement.textContent = ` + .fc-daygrid-day, + .fc-daygrid-day-frame { + min-height: ${minHeight}px !important; + } + `; + }, [currentView]); + + const updateDayMaxEventRows = useCallback(() => { + const newRows = calculateDayMaxEventRows(); + const weekHeight = (window.innerHeight - 148 - 26) / 6; + + setDayMaxEventRows(newRows); + updateCalendarCellStyles(weekHeight); + }, [calculateDayMaxEventRows, updateCalendarCellStyles]); + + useEffect(() => { + updateDayMaxEventRows(); + + window.addEventListener('resize', updateDayMaxEventRows); + + return () => { + window.removeEventListener('resize', updateDayMaxEventRows); + }; + }, [updateDayMaxEventRows]); + + return { + dayMaxEventRows, + updateDayMaxEventRows + }; +}; \ No newline at end of file diff --git a/src/components/database/fullcalendar/hooks/useScrollDetection.ts b/src/components/database/fullcalendar/hooks/useScrollDetection.ts new file mode 100644 index 00000000..293cf524 --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useScrollDetection.ts @@ -0,0 +1,37 @@ +import { useCallback, useEffect } from 'react'; + +import { getScrollParent } from '@/components/global-comment/utils'; + +export const useScrollDetection = (containerRef: React.RefObject, buttonRef: React.RefObject) => { + + const getScrollElement = useCallback(() => { + if (!containerRef.current) return null; + return containerRef.current.closest('.appflowy-scroll-container') || getScrollParent(containerRef.current); + }, [containerRef]); + + + useEffect(() => { + const scrollElement = getScrollElement(); + + if (scrollElement) { + const handleScroll = () => { + buttonRef.current?.style.setProperty('opacity', '0'); + buttonRef.current?.style.setProperty('pointer-events', 'none'); + + setTimeout(() => { + buttonRef.current?.style.setProperty('opacity', '1'); + buttonRef.current?.style.setProperty('pointer-events', 'auto'); + }, 1000); + }; + + scrollElement.addEventListener('scroll', handleScroll); + + return () => { + scrollElement.removeEventListener('scroll', handleScroll); + }; + } + }, [getScrollElement, buttonRef]); + + + +}; \ No newline at end of file diff --git a/src/components/database/fullcalendar/hooks/useScrollNavigation.ts b/src/components/database/fullcalendar/hooks/useScrollNavigation.ts new file mode 100644 index 00000000..5e792709 --- /dev/null +++ b/src/components/database/fullcalendar/hooks/useScrollNavigation.ts @@ -0,0 +1,173 @@ +import { useCallback, useEffect, useRef } from 'react'; + +import { getScrollParent } from '@/components/global-comment/utils'; + +import { CalendarViewType } from '../types'; + +import type { CalendarApi } from '@fullcalendar/core'; + +/** + * Custom hook to handle elastic scroll-based navigation for calendar + * Pull beyond 150px threshold to trigger month navigation, otherwise bounce back + */ +export function useScrollNavigation(currentView: CalendarViewType, calendarApi: CalendarApi | null) { + const containerRef = useRef(null); + const lastNavigationTime = useRef(0); + const throttleDelay = 300; // 300ms throttle between navigations + + // Scroll accumulation state for threshold detection + const scrollAccumulator = useRef(0); + const isAccumulating = useRef(false); + const lastTriggerTime = useRef(0); + + const SCROLL_THRESHOLD = 500; // Total scroll amount needed to trigger navigation (further increased) + const TRIGGER_COOLDOWN = 800; // Cooldown period after triggering to prevent rapid consecutive triggers + + /** + * Get the scroll element for the calendar container + */ + const getScrollElement = useCallback(() => { + if (!containerRef.current) return null; + return containerRef.current.closest('.appflowy-scroll-container') || getScrollParent(containerRef.current); + }, []); + + /** + * Check if we're at scroll boundary for the given direction + */ + const isAtScrollBoundary = useCallback((direction: 'up' | 'down'): boolean => { + const scrollElement = getScrollElement(); + + if (!scrollElement) return false; + + const { scrollTop, scrollHeight, clientHeight } = scrollElement; + const tolerance = 1; // Stricter boundary detection (was 5px) + + if (direction === 'up') { + const isAtTop = scrollTop <= tolerance; + + console.debug(`📅 Boundary check UP: scrollTop=${scrollTop}, tolerance=${tolerance}, isAtTop=${isAtTop}`); + return isAtTop; + } else { + const isAtBottom = scrollTop + clientHeight >= scrollHeight - tolerance; + + console.debug(`📅 Boundary check DOWN: scrollTop=${scrollTop}, clientHeight=${clientHeight}, scrollHeight=${scrollHeight}, isAtBottom=${isAtBottom}`); + return isAtBottom; + } + }, [getScrollElement]); + + /** + * Reset scroll accumulator + */ + const resetScrollAccumulator = useCallback(() => { + scrollAccumulator.current = 0; + isAccumulating.current = false; + }, []); + + /** + * Check if we're in cooldown period after last trigger + */ + const isInCooldown = useCallback(() => { + const now = Date.now(); + + return now - lastTriggerTime.current < TRIGGER_COOLDOWN; + }, [TRIGGER_COOLDOWN]); + + /** + * Navigate to next/prev month and reset accumulator + */ + const navigateMonth = useCallback((direction: 'up' | 'down') => { + const now = Date.now(); + + if (now - lastNavigationTime.current < throttleDelay) { + return false; + } + + lastNavigationTime.current = now; + + if (direction === 'down') { + console.debug('📅 Scroll Navigation: Moving to next month'); + calendarApi?.next(); + } else { + console.debug('📅 Scroll Navigation: Moving to previous month'); + calendarApi?.prev(); + } + + // Reset accumulator after navigation + resetScrollAccumulator(); + return true; + }, [calendarApi, throttleDelay, resetScrollAccumulator]); + + /** + * Handle wheel events with scroll threshold detection + */ + const handleWheel = useCallback((e: WheelEvent) => { + const target = e.target as HTMLElement; + + // Skip if scrolling within popover + if (target.closest('.fc-popover-body')) return; + + // Only handle vertical scrolling + if (Math.abs(e.deltaY) <= Math.abs(e.deltaX)) return; + + const direction = e.deltaY > 0 ? 'down' : 'up'; + + // Check if we're at the scroll boundary for this direction + if (isAtScrollBoundary(direction)) { + e.preventDefault(); + + // Check if we're in cooldown period + if (isInCooldown()) { + console.debug(`📅 In cooldown period, ignoring scroll`); + return; + } + + // Start or continue accumulating scroll + if (!isAccumulating.current) { + isAccumulating.current = true; + scrollAccumulator.current = 0; + console.debug(`📅 Started accumulating scroll at ${direction} boundary`); + } + + // Only accumulate significant scroll amounts to reduce sensitivity + const deltaY = Math.abs(e.deltaY); + + if (deltaY >= 8) { // Only count scrolls >= 8px to reduce sensitivity + scrollAccumulator.current += deltaY; + console.debug(`📅 Scroll accumulated: ${Math.round(scrollAccumulator.current)}px (threshold: ${SCROLL_THRESHOLD}px, delta: ${deltaY}px)`); + + // Check if threshold is reached + if (scrollAccumulator.current >= SCROLL_THRESHOLD) { + console.debug(`📅 Threshold reached! Triggering navigation ${direction}`); + // Set cooldown time and reset accumulator + lastTriggerTime.current = Date.now(); + resetScrollAccumulator(); + navigateMonth(direction); + } + } else { + console.debug(`📅 Ignoring small scroll: ${deltaY}px`); + } + } else if (isAccumulating.current) { + // Not at boundary anymore - reset accumulator + resetScrollAccumulator(); + } + }, [isAtScrollBoundary, navigateMonth, resetScrollAccumulator, isInCooldown, SCROLL_THRESHOLD]); + + useEffect(() => { + const calendarContainer = containerRef.current; + + // Only enable scroll navigation in month view + if (currentView !== CalendarViewType.DAY_GRID_MONTH || !calendarContainer || !calendarApi) { + return; + } + + calendarContainer.addEventListener('wheel', handleWheel, { passive: false }); + + return () => { + calendarContainer.removeEventListener('wheel', handleWheel); + }; + }, [currentView, calendarApi, handleWheel]); + + return { + containerRef, + }; +} diff --git a/src/components/database/fullcalendar/index.ts b/src/components/database/fullcalendar/index.ts new file mode 100644 index 00000000..0df73c49 --- /dev/null +++ b/src/components/database/fullcalendar/index.ts @@ -0,0 +1,4 @@ +import { lazy } from 'react'; + +export const Calendar = lazy(() => import('./FullCalendar')); +export * from './event'; \ No newline at end of file diff --git a/src/components/database/fullcalendar/types.ts b/src/components/database/fullcalendar/types.ts new file mode 100644 index 00000000..21867bc3 --- /dev/null +++ b/src/components/database/fullcalendar/types.ts @@ -0,0 +1,7 @@ +/** + * FullCalendar view types enumeration + */ +export enum CalendarViewType { + DAY_GRID_MONTH = 'dayGridMonth', + TIME_GRID_WEEK = 'timeGridWeek', +} \ No newline at end of file diff --git a/src/components/database/fullcalendar/utils/dayCellContent.tsx b/src/components/database/fullcalendar/utils/dayCellContent.tsx new file mode 100644 index 00000000..5ccacb07 --- /dev/null +++ b/src/components/database/fullcalendar/utils/dayCellContent.tsx @@ -0,0 +1,33 @@ +import { cn } from '@/lib/utils'; + +interface DayCellContentArgs { + date: Date; + dayNumberText: string; + isToday: boolean; + isPopover?: boolean; +} + +export const dayCellContent = (args: DayCellContentArgs) => { + const isToday = args.isToday; + const date = args.date; + const dayNumber = date.getDate(); + const monthName = date.toLocaleDateString('en-US', { month: 'short' }); + const { isPopover = false } = args; + + if (!args.dayNumberText) return null; + + return ( +
+ {(dayNumber === 1 || isPopover) ? monthName : null} + + {args.dayNumberText} + +
+ ); +}; \ No newline at end of file diff --git a/src/components/editor/CollaborativeEditor.tsx b/src/components/editor/CollaborativeEditor.tsx index c5913931..b3928f92 100644 --- a/src/components/editor/CollaborativeEditor.tsx +++ b/src/components/editor/CollaborativeEditor.tsx @@ -92,7 +92,7 @@ function CollaborativeEditor({ onEditorConnected?.(editor); return () => { - console.log('disconnect'); + console.debug('disconnect'); editor.disconnect(); }; // eslint-disable-next-line react-hooks/exhaustive-deps diff --git a/src/components/editor/__tests__/blocks/Code.cy.tsx b/src/components/editor/__tests__/blocks/Code.cy.tsx index 6855d236..77bf6ca6 100644 --- a/src/components/editor/__tests__/blocks/Code.cy.tsx +++ b/src/components/editor/__tests__/blocks/Code.cy.tsx @@ -1,12 +1,14 @@ import { initialEditorTest, moveCursor } from '@/components/editor/__tests__/mount'; import { FromBlockJSON } from 'cypress/support/document'; -const initialData: FromBlockJSON[] = [{ - type: 'paragraph', - data: {}, - text: [{ insert: '' }], - children: [], -}]; +const initialData: FromBlockJSON[] = [ + { + type: 'paragraph', + data: {}, + text: [{ insert: '' }], + children: [], + }, +]; const { assertJSON, initializeEditor } = initialEditorTest(); @@ -27,12 +29,12 @@ describe('CodeBlock', () => { it('should turn to code block when typing ```', () => { moveCursor(0, 0); cy.get('@editor').type('```'); - cy.get('@editor').type(`function main() {\n console.log('Hello, World!');\n}`); + cy.get('@editor').type(`function main() {\n console.debug('Hello, World!');\n}`); assertJSON([ { type: 'code', data: {}, - text: [{ insert: 'function main() {\n console.log(\'Hello, World!\');\n}' }], + text: [{ insert: "function main() {\n console.debug('Hello, World!');\n}" }], children: [], }, ]); @@ -41,7 +43,7 @@ describe('CodeBlock', () => { it('should add a paragraph below the code block when pressing Shift+Enter', () => { moveCursor(0, 0); cy.get('@editor').type('```'); - cy.get('@editor').type(`function main() {\n console.log('Hello, World!');\n}`); + cy.get('@editor').type(`function main() {\n console.debug('Hello, World!');\n}`); cy.get('@editor').get('[data-block-type="code"]').as('code'); cy.get('@code').should('exist'); cy.get('@editor').realPress(['Shift', 'Enter']); @@ -49,7 +51,7 @@ describe('CodeBlock', () => { { type: 'code', data: {}, - text: [{ insert: 'function main() {\n console.log(\'Hello, World!\');\n}' }], + text: [{ insert: "function main() {\n console.debug('Hello, World!');\n}" }], children: [], }, { @@ -64,14 +66,14 @@ describe('CodeBlock', () => { it('should insert soft break when pressing Enter', () => { moveCursor(0, 0); cy.get('@editor').type('```'); - cy.get('@editor').type(`function main() {\n console.log('Hello, World!');\n}`); + cy.get('@editor').type(`function main() {\n console.debug('Hello, World!');\n}`); cy.get('@editor').realPress('Enter'); assertJSON([ { type: 'code', data: {}, - text: [{ insert: 'function main() {\n console.log(\'Hello, World!\');\n}\n' }], + text: [{ insert: "function main() {\n console.debug('Hello, World!');\n}\n" }], children: [], }, ]); @@ -80,7 +82,7 @@ describe('CodeBlock', () => { it('should remove the code block when pressing Backspace at the beginning', () => { moveCursor(0, 0); cy.get('@editor').type('```'); - cy.get('@editor').type(`function main() {\n console.log('Hello, World!');\n}`); + cy.get('@editor').type(`function main() {\n console.debug('Hello, World!');\n}`); cy.get('@editor').get('[data-block-type="code"]').as('code'); cy.get('@code').should('exist'); @@ -91,10 +93,9 @@ describe('CodeBlock', () => { { type: 'paragraph', data: {}, - text: [{ insert: 'function main() {\n console.log(\'Hello, World!\');\n}' }], + text: [{ insert: "function main() {\n console.debug('Hello, World!');\n}" }], children: [], }, ]); }); - -}); \ No newline at end of file +}); diff --git a/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx b/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx index 24a6ac22..ef60e640 100644 --- a/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx +++ b/src/components/editor/components/block-popover/FileBlockPopoverContent.tsx @@ -1,14 +1,16 @@ +import React, { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSlateStatic } from 'slate-react'; + import { YjsEditor } from '@/application/slate-yjs'; import { CustomEditor } from '@/application/slate-yjs/command'; import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor'; import { BlockType, FieldURLType, FileBlockData } from '@/application/types'; -import { useEditorContext } from '@/components/editor/EditorContext'; import FileDropzone from '@/components/_shared/file-dropzone/FileDropzone'; import { TabPanel, ViewTab, ViewTabs } from '@/components/_shared/tabs/ViewTabs'; +import { useEditorContext } from '@/components/editor/EditorContext'; import { FileHandler } from '@/utils/file'; -import React, { useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { useSlateStatic } from 'slate-react'; + import EmbedLink from 'src/components/_shared/image-upload/EmbedLink'; export function getFileName(url: string) { @@ -32,6 +34,7 @@ function FileBlockPopoverContent({ blockId, onClose }: { blockId: string; onClos const { t } = useTranslation(); const [tabValue, setTabValue] = React.useState('upload'); + const [uploading, setUploading] = React.useState(false); const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: string) => { setTabValue(newValue); @@ -96,17 +99,22 @@ function FileBlockPopoverContent({ blockId, onClose }: { blockId: string; onClos async (files: File[]) => { if (!files.length) return; - const [file, ...otherFiles] = files; - const url = await uploadFileRemote(file); - const data = await getData(file, url); + setUploading(true); + try { + const [file, ...otherFiles] = files; + const url = await uploadFileRemote(file); + const data = await getData(file, url); - CustomEditor.setBlockData(editor, blockId, data); + CustomEditor.setBlockData(editor, blockId, data); - for (const file of otherFiles.reverse()) { - await insertFileBlock(file); + for (const file of otherFiles.reverse()) { + await insertFileBlock(file); + } + + onClose(); + } finally { + setUploading(false); } - - onClose(); }, [blockId, editor, getData, insertFileBlock, onClose, uploadFileRemote] ); @@ -126,6 +134,7 @@ function FileBlockPopoverContent({ blockId, onClose }: { blockId: string; onClos } onChange={handleChangeUploadFiles} + loading={uploading} /> ), }, @@ -141,7 +150,7 @@ function FileBlockPopoverContent({ blockId, onClose }: { blockId: string; onClos ), }, ]; - }, [entry, handleChangeUploadFiles, handleInsertEmbedLink, t]); + }, [entry, handleChangeUploadFiles, handleInsertEmbedLink, t, uploading]); const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue); diff --git a/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx b/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx index 5ba59232..04eec7c2 100644 --- a/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx +++ b/src/components/editor/components/block-popover/ImageBlockPopoverContent.tsx @@ -28,6 +28,7 @@ function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClo const { t } = useTranslation(); const [tabValue, setTabValue] = React.useState('upload'); + const [uploading, setUploading] = React.useState(false); const handleTabChange = useCallback((_event: React.SyntheticEvent, newValue: string) => { setTabValue(newValue); @@ -89,42 +90,47 @@ function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClo async (files: File[]) => { if (!files.length) return; - const [file, ...otherFiles] = files; - const url = await uploadFileRemote(file); - const data = await getData(file, url); + setUploading(true); + try { + const [file, ...otherFiles] = files; + const url = await uploadFileRemote(file); + const data = await getData(file, url); - CustomEditor.setBlockData(editor, blockId, data); + CustomEditor.setBlockData(editor, blockId, data); - let belowBlockId: string | undefined = blockId; + let belowBlockId: string | undefined = blockId; - for (const file of otherFiles) { - const newId = await insertImageBlock(file); + for (const file of otherFiles) { + const newId = await insertImageBlock(file); - if (newId) { - belowBlockId = newId; + if (newId) { + belowBlockId = newId; + } } + + belowBlockId = CustomEditor.addBelowBlock(editor, belowBlockId, BlockType.Paragraph, {}); + + const entry = belowBlockId ? findSlateEntryByBlockId(editor, belowBlockId) : null; + + if (!entry) return; + + const [node, path] = entry; + + onClose(); + + if (path) { + editor.select(editor.start(path)); + } + + setTimeout(() => { + if (!node) return; + const el = ReactEditor.toDOMNode(editor, node); + + el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); + }, 250); + } finally { + setUploading(false); } - - belowBlockId = CustomEditor.addBelowBlock(editor, belowBlockId, BlockType.Paragraph, {}); - - const entry = belowBlockId ? findSlateEntryByBlockId(editor, belowBlockId) : null; - - if (!entry) return; - - const [node, path] = entry; - - onClose(); - - if (path) { - editor.select(editor.start(path)); - } - - setTimeout(() => { - if (!node) return; - const el = ReactEditor.toDOMNode(editor, node); - - el?.scrollIntoView({ behavior: 'smooth', block: 'center' }); - }, 250); }, [blockId, editor, getData, insertImageBlock, onClose, uploadFileRemote] ); @@ -135,7 +141,7 @@ function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClo key: 'upload', label: t('button.upload'), panel: ( - + ), }, { @@ -155,7 +161,7 @@ function ImageBlockPopoverContent({ blockId, onClose }: { blockId: string; onClo panel: , }, ]; - }, [entry, handleChangeUploadFiles, handleUpdateLink, t]); + }, [entry, handleChangeUploadFiles, handleUpdateLink, t, uploading]); const selectedIndex = tabOptions.findIndex((tab) => tab.key === tabValue); const ref = useRef(null); diff --git a/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts b/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts index 50f321a6..3af7be2c 100644 --- a/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts +++ b/src/components/editor/components/toolbar/selection-toolbar/SelectionToolbar.hooks.ts @@ -1,14 +1,15 @@ -import { CustomEditor } from '@/application/slate-yjs/command'; -import { EditorMarkFormat } from '@/application/slate-yjs/types'; -import { getSelectionPosition } from '@/components/editor/components/toolbar/selection-toolbar/utils'; -import { Decorate, useEditorContext } from '@/components/editor/EditorContext'; -import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; import { useAIWriter } from '@appflowyinc/ai-chat'; import { debounce } from 'lodash-es'; import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from 'react'; import { Range } from 'slate'; import { ReactEditor, useFocused, useReadOnly, useSlate, useSlateStatic } from 'slate-react'; +import { CustomEditor } from '@/application/slate-yjs/command'; +import { EditorMarkFormat } from '@/application/slate-yjs/types'; +import { getSelectionPosition } from '@/components/editor/components/toolbar/selection-toolbar/utils'; +import { Decorate, useEditorContext } from '@/components/editor/EditorContext'; +import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; + export function useVisible() { const editor = useSlate(); const selection = editor.selection; diff --git a/src/components/editor/plugins/withInsertText.ts b/src/components/editor/plugins/withInsertText.ts index e0659f32..a1ad6211 100644 --- a/src/components/editor/plugins/withInsertText.ts +++ b/src/components/editor/plugins/withInsertText.ts @@ -47,7 +47,7 @@ export const withInsertText = (editor: ReactEditor) => { // If the text node is a formula or mention, split the node and insert the text if (textNode.formula || textNode.mention) { - console.log('Inserting text into formula or mention', newAt); + console.debug('Inserting text into formula or mention', newAt); Transforms.insertNodes(editor, { text }, { at: point, select: true, voids: false }); return; diff --git a/src/components/editor/plugins/withPasted.ts b/src/components/editor/plugins/withPasted.ts index 5c8f77f6..45f16f5f 100644 --- a/src/components/editor/plugins/withPasted.ts +++ b/src/components/editor/plugins/withPasted.ts @@ -150,7 +150,7 @@ export function insertHtmlData(editor: ReactEditor, data: DataTransfer) { const html = data.getData('text/html'); if (html) { - console.log('insert HTML Data', html); + console.debug('insert HTML Data', html); const fragment = deserializeHTML(html) as Node[]; insertFragment(editor, fragment); @@ -162,7 +162,7 @@ export function insertHtmlData(editor: ReactEditor, data: DataTransfer) { } function insertFragment(editor: ReactEditor, fragment: Node[], options = {}) { - console.log('insertFragment', fragment, options); + console.debug('insertFragment', fragment, options); if (!beforePasted(editor)) return; const point = editor.selection?.anchor as BasePoint; @@ -218,7 +218,6 @@ function insertFragment(editor: ReactEditor, fragment: Node[], options = {}) { if (isEmptyNode) { deleteBlock(sharedRoot, blockId); } - }); setTimeout(() => { diff --git a/src/components/editor/utils/__tests__/fragment.test.ts b/src/components/editor/utils/__tests__/fragment.test.ts index 02ce01b0..24332d7d 100644 --- a/src/components/editor/utils/__tests__/fragment.test.ts +++ b/src/components/editor/utils/__tests__/fragment.test.ts @@ -1,7 +1,7 @@ -import { deserializeHTML } from '../fragment'; -import { BlockType, ImageType, AlignType } from '@/application/types'; +import { AlignType, BlockType, ImageType } from '@/application/types'; import { expect } from '@jest/globals'; import { Element, Node } from 'slate'; +import { deserializeHTML } from '../fragment'; jest.mock('nanoid'); describe('deserializeHTML', () => { @@ -24,51 +24,65 @@ describe('deserializeHTML', () => { // Check paragraph let blockId = (result[0] as Element).blockId as string; - expect(result[0]).toEqual(expect.objectContaining({ - blockId, - type: BlockType.Paragraph, - data: {}, - children: [{ type: 'text', children: [{ text: 'Hello, world!' }], textId: blockId }], - })); + expect(result[0]).toEqual( + expect.objectContaining({ + blockId, + type: BlockType.Paragraph, + data: {}, + children: [{ type: 'text', children: [{ text: 'Hello, world!' }], textId: blockId }], + }) + ); // Check headings for (let i = 1; i <= 3; i++) { const blockId = (result[i] as Element).blockId as string; - expect(result[i]).toEqual(expect.objectContaining({ - type: BlockType.HeadingBlock, - children: [{ - type: 'text', - textId: blockId, - children: [{ text: `Title ${i}` }], - }], - data: { level: i }, - blockId, - })); + expect(result[i]).toEqual( + expect.objectContaining({ + type: BlockType.HeadingBlock, + children: [ + { + type: 'text', + textId: blockId, + children: [{ text: `Title ${i}` }], + }, + ], + data: { level: i }, + blockId, + }) + ); } // Check image blockId = (result[4] as Element).blockId as string; - expect(result[4]).toEqual(expect.objectContaining({ - blockId, - type: BlockType.ImageBlock, - children: [{ - text: '', - }], - data: { url: 'https://example.com/image.jpg', image_type: ImageType.External }, - })); + expect(result[4]).toEqual( + expect.objectContaining({ + blockId, + type: BlockType.ImageBlock, + children: [ + { + text: '', + }, + ], + data: { url: 'https://example.com/image.jpg', image_type: ImageType.External }, + }) + ); // Check todo list blockId = (result[5] as Element).blockId as string; - expect(result[5]).toEqual(expect.objectContaining({ - type: BlockType.TodoListBlock, - blockId, - children: [{ - type: 'text', - textId: blockId, - children: [{ text: 'Task' }], - }], - data: { checked: true }, - })); + expect(result[5]).toEqual( + expect.objectContaining({ + type: BlockType.TodoListBlock, + blockId, + children: [ + { + type: 'text', + textId: blockId, + children: [{ text: 'Task' }], + }, + ], + data: { checked: true }, + }) + ); }); // Test list elements @@ -90,34 +104,40 @@ describe('deserializeHTML', () => { // Check unordered list let blockId = (result[0] as Element).blockId as string; - expect(result[0]).toEqual(expect.objectContaining({ - blockId, - type: BlockType.BulletedListBlock, - children: [ - { type: 'text', textId: blockId, children: [{ text: 'Bullet 1' }] }, - ], - })); + expect(result[0]).toEqual( + expect.objectContaining({ + blockId, + type: BlockType.BulletedListBlock, + children: [{ type: 'text', textId: blockId, children: [{ text: 'Bullet 1' }] }], + }) + ); blockId = (result[1] as Element).blockId as string; - expect(result[1]).toEqual(expect.objectContaining({ - blockId, - type: BlockType.BulletedListBlock, - children: [{ type: 'text', textId: blockId, children: [{ text: 'Bullet 2' }] }], - })); + expect(result[1]).toEqual( + expect.objectContaining({ + blockId, + type: BlockType.BulletedListBlock, + children: [{ type: 'text', textId: blockId, children: [{ text: 'Bullet 2' }] }], + }) + ); // Check ordered list blockId = (result[2] as Element).blockId as string; - expect(result[2]).toEqual(expect.objectContaining({ - blockId, - type: BlockType.NumberedListBlock, - children: [{ type: 'text', textId: blockId, children: [{ text: 'Number 1' }] }], - })); + expect(result[2]).toEqual( + expect.objectContaining({ + blockId, + type: BlockType.NumberedListBlock, + children: [{ type: 'text', textId: blockId, children: [{ text: 'Number 1' }] }], + }) + ); blockId = (result[3] as Element).blockId as string; - expect(result[3]).toEqual(expect.objectContaining({ - type: BlockType.NumberedListBlock, - children: [{ type: 'text', textId: blockId, children: [{ text: 'Number 2' }] }], - blockId, - })); + expect(result[3]).toEqual( + expect.objectContaining({ + type: BlockType.NumberedListBlock, + children: [{ type: 'text', textId: blockId, children: [{ text: 'Number 2' }] }], + blockId, + }) + ); }); // Test blockquote and code block @@ -133,19 +153,23 @@ describe('deserializeHTML', () => { // Check blockquote let blockId = (result[0] as Element).blockId as string; - expect(result[0]).toEqual(expect.objectContaining({ - type: BlockType.QuoteBlock, - children: [{ type: 'text', textId: blockId, children: [{ text: 'This is a quote' }] }], - blockId, - })); + expect(result[0]).toEqual( + expect.objectContaining({ + type: BlockType.QuoteBlock, + children: [{ type: 'text', textId: blockId, children: [{ text: 'This is a quote' }] }], + blockId, + }) + ); // Check code block blockId = (result[1] as Element).blockId as string; - expect(result[1]).toEqual(expect.objectContaining({ - type: BlockType.CodeBlock, - children: [{ type: 'text', textId: blockId, children: [{ text: 'const x = 5;' }] }], - blockId, - })); + expect(result[1]).toEqual( + expect.objectContaining({ + type: BlockType.CodeBlock, + children: [{ type: 'text', textId: blockId, children: [{ text: 'const x = 5;' }] }], + blockId, + }) + ); }); // Test inline styles @@ -176,17 +200,19 @@ describe('deserializeHTML', () => { expect(result.length).toBe(1); let blockId = (result[0] as Element).blockId as string; - expect(result[0]).toEqual(expect.objectContaining({ - blockId, - type: BlockType.Paragraph, - children: [{ type: 'text', textId: blockId, children: [{ text: 'Centered text with background and color' }] }], + expect(result[0]).toEqual( + expect.objectContaining({ + blockId, + type: BlockType.Paragraph, + children: [{ type: 'text', textId: blockId, children: [{ text: 'Centered text with background and color' }] }], - data: { - align: AlignType.Center, - bgColor: '#f0f0f0', - font_color: '#333', - }, - })); + data: { + align: AlignType.Center, + bgColor: '#f0f0f0', + font_color: '#333', + }, + }) + ); }); // Test empty HTML @@ -213,7 +239,7 @@ describe('deserializeHTML', () => { expect(result.length).toBe(1); const block = result[0] as Element; - console.log('===', result); + console.debug('===', result); expect(block.type).toEqual(BlockType.BulletedListBlock); const blockChildren = block.children; expect(blockChildren.length).toEqual(3); @@ -221,11 +247,13 @@ describe('deserializeHTML', () => { blockId: (blockChildren[1] as Element).blockId, type: BlockType.HeadingBlock, relationId: (blockChildren[1] as Element).blockId, - children: [{ - type: 'text', - textId: (blockChildren[1] as Element).blockId, - children: [{ text: 'Nested Heading' }], - }], + children: [ + { + type: 'text', + textId: (blockChildren[1] as Element).blockId, + children: [{ text: 'Nested Heading' }], + }, + ], data: { level: 3 }, }); expect(blockChildren[2]).toEqual({ @@ -233,12 +261,13 @@ describe('deserializeHTML', () => { type: BlockType.Paragraph, relationId: (blockChildren[2] as Element).blockId, data: {}, - children: [{ - type: 'text', - textId: (blockChildren[2] as Element).blockId, - children: [{ text: 'Nested paragraph' }], - }], + children: [ + { + type: 'text', + textId: (blockChildren[2] as Element).blockId, + children: [{ text: 'Nested paragraph' }], + }, + ], }); - }); -}); \ No newline at end of file +}); diff --git a/src/components/editor/utils/fragment.ts b/src/components/editor/utils/fragment.ts index e71e268a..4c4ee4cb 100644 --- a/src/components/editor/utils/fragment.ts +++ b/src/components/editor/utils/fragment.ts @@ -1,26 +1,31 @@ import { TEXT_BLOCK_TYPES } from '@/application/slate-yjs/command/const'; import { yDocToSlateContent } from '@/application/slate-yjs/utils/convert'; import { - AlignType, - BlockData, - BlockType, HeadingBlockData, - ImageBlockData, - ImageType, NumberedListBlockData, TodoListBlockData, - YBlock, - YjsEditorKey, - YSharedRoot, -} from '@/application/types'; -import { filter } from 'lodash-es'; -import { - createBlock, createEmptyDocument, generateBlockId, + createBlock, + createEmptyDocument, + generateBlockId, getBlock, getChildrenArray, getPageId, getText, updateBlockParent, } from '@/application/slate-yjs/utils/yjs'; +import { + AlignType, + BlockData, + BlockType, + HeadingBlockData, + ImageBlockData, + ImageType, + NumberedListBlockData, + TodoListBlockData, + YBlock, + YjsEditorKey, + YSharedRoot, +} from '@/application/types'; +import { filter } from 'lodash-es'; import { Op } from 'quill-delta'; -import { Text as SlateText, Element as SlateElement, Node as SlateNode } from 'slate'; +import { Element as SlateElement, Node as SlateNode, Text as SlateText } from 'slate'; export function deserialize(body: HTMLElement, sharedRoot: YSharedRoot) { const pageId = getPageId(sharedRoot); @@ -105,7 +110,12 @@ function deserializeNode(node: Node, parentBlock: YBlock, sharedRoot: YSharedRoo } currentBlock = createBlock(sharedRoot, { ty: blockType, data: blockData }); - updateBlockParent(sharedRoot, currentBlock, parentBlock, getChildrenArray(parentBlock.get(YjsEditorKey.block_children), sharedRoot)?.length || 0); + updateBlockParent( + sharedRoot, + currentBlock, + parentBlock, + getChildrenArray(parentBlock.get(YjsEditorKey.block_children), sharedRoot)?.length || 0 + ); if (tagName === 'pre') { const code = element.querySelector('code'); @@ -117,7 +127,6 @@ function deserializeNode(node: Node, parentBlock: YBlock, sharedRoot: YSharedRoo return; } - } if (tagName === 'span') { @@ -126,7 +135,10 @@ function deserializeNode(node: Node, parentBlock: YBlock, sharedRoot: YSharedRoo const lastChild = getBlock(lastChildId, sharedRoot); const attributes = getInlineAttributes(element); - if (lastChild && (filter(TEXT_BLOCK_TYPES, n => n !== BlockType.CodeBlock).includes(lastChild.get(YjsEditorKey.block_type)))) { + if ( + lastChild && + filter(TEXT_BLOCK_TYPES, (n) => n !== BlockType.CodeBlock).includes(lastChild.get(YjsEditorKey.block_type)) + ) { applyTextToDelta(lastChild, sharedRoot, element.textContent || '', attributes); return; } else { @@ -139,20 +151,20 @@ function deserializeNode(node: Node, parentBlock: YBlock, sharedRoot: YSharedRoo } } - Array.from(node.childNodes).forEach(childNode => { + Array.from(node.childNodes).forEach((childNode) => { deserializeNode(childNode, currentBlock, sharedRoot); }); } else if (node.nodeType === Node.TEXT_NODE) { const textContent = node.textContent || ''; if (textContent.trim()) { - console.log('===textContent', node, textContent); + console.debug('===textContent', node, textContent); const { ops } = textContentToDelta(textContent || ''); if (TEXT_BLOCK_TYPES.includes(currentBlock.get(YjsEditorKey.block_type))) { const attributes = getInlineAttributes(node.parentElement as HTMLElement); - ops.forEach(op => { + ops.forEach((op) => { applyTextToDelta(currentBlock, sharedRoot, op.insert as string, { ...op.attributes, ...attributes, @@ -167,7 +179,6 @@ function deserializeNode(node: Node, parentBlock: YBlock, sharedRoot: YSharedRoo updateBlockParent(sharedRoot, block, currentBlock, index); } - } } } @@ -177,10 +188,10 @@ function textContentToDelta(text: string) { let currentIndex = 0; const patterns = [ - { regex: /\*\*(.*?)\*\*/g, format: 'bold' }, // **bold** - { regex: /\*(.*?)\*/g, format: 'italic' }, // *italic* - { regex: /__(.*?)__/g, format: 'underline' }, // __underline__ - { regex: /~~(.*?)~~/g, format: 'strike' }, // ~~strike~~ + { regex: /\*\*(.*?)\*\*/g, format: 'bold' }, // **bold** + { regex: /\*(.*?)\*/g, format: 'italic' }, // *italic* + { regex: /__(.*?)__/g, format: 'underline' }, // __underline__ + { regex: /~~(.*?)~~/g, format: 'strike' }, // ~~strike~~ ]; type Mark = { @@ -189,7 +200,7 @@ function textContentToDelta(text: string) { text: string; format: string; length: number; - } + }; const findMarks = (): Mark[] => { const marks: Mark[] = []; @@ -216,7 +227,7 @@ function textContentToDelta(text: string) { const getFormatsAt = (index: number): Record => { const formats: Record = {}; - marks.forEach(mark => { + marks.forEach((mark) => { if (index >= mark.start && index < mark.end) { formats[mark.format] = true; } @@ -227,7 +238,7 @@ function textContentToDelta(text: string) { const findNextBreakPoint = (currentIndex: number): number => { const points = new Set(); - marks.forEach(mark => { + marks.forEach((mark) => { if (mark.start > currentIndex) points.add(mark.start); if (mark.end > currentIndex) points.add(mark.end); }); @@ -274,10 +285,14 @@ function isImageUrl(url: string): boolean { } function processTodoList(element: HTMLElement, sharedRoot: YSharedRoot, parentBlock: YBlock, blockData: BlockData) { - const checkboxBlock = createBlock(sharedRoot, { ty: BlockType.TodoListBlock, data: blockData }); - updateBlockParent(sharedRoot, checkboxBlock, parentBlock, getChildrenArray(parentBlock.get(YjsEditorKey.block_children), sharedRoot)?.length || 0); + updateBlockParent( + sharedRoot, + checkboxBlock, + parentBlock, + getChildrenArray(parentBlock.get(YjsEditorKey.block_children), sharedRoot)?.length || 0 + ); const textContent = element.nextSibling?.textContent?.trim() || ''; @@ -287,22 +302,30 @@ function processTodoList(element: HTMLElement, sharedRoot: YSharedRoot, parentBl } function processImage(sharedRoot: YSharedRoot, parentBlock: YBlock, data: ImageBlockData) { - const imageBlock = createBlock(sharedRoot, { ty: BlockType.ImageBlock, data }); - updateBlockParent(sharedRoot, imageBlock, parentBlock, getChildrenArray(parentBlock.get(YjsEditorKey.block_children), sharedRoot)?.length || 0); + updateBlockParent( + sharedRoot, + imageBlock, + parentBlock, + getChildrenArray(parentBlock.get(YjsEditorKey.block_children), sharedRoot)?.length || 0 + ); } -function processList(parentEl: HTMLElement, sharedRoot: YSharedRoot, { - ty, - data, - parent, -}: { - ty: BlockType, - data: BlockData, - parent: YBlock -}) { - Array.from(parentEl.childNodes).forEach(childNode => { +function processList( + parentEl: HTMLElement, + sharedRoot: YSharedRoot, + { + ty, + data, + parent, + }: { + ty: BlockType; + data: BlockData; + parent: YBlock; + } +) { + Array.from(parentEl.childNodes).forEach((childNode) => { const el = childNode as HTMLElement; if (!el || !el.tagName) return; @@ -310,8 +333,13 @@ function processList(parentEl: HTMLElement, sharedRoot: YSharedRoot, { const type = tagName === 'li' ? ty : BlockType.Paragraph; const block = createBlock(sharedRoot, { ty: type, data }); - updateBlockParent(sharedRoot, block, parent, getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot)?.length || 0); - Array.from(el.childNodes).forEach(childNode => { + updateBlockParent( + sharedRoot, + block, + parent, + getChildrenArray(parent.get(YjsEditorKey.block_children), sharedRoot)?.length || 0 + ); + Array.from(el.childNodes).forEach((childNode) => { deserializeNode(childNode, block, sharedRoot); }); }); @@ -365,7 +393,7 @@ function mapToBlockData(element: HTMLElement): T { if (styleString) { const styles = styleString.split(';').reduce((acc, style) => { - const [key, value] = style.split(':').map(s => s.trim()); + const [key, value] = style.split(':').map((s) => s.trim()); if (key && value) { acc[key] = value; @@ -487,7 +515,6 @@ export function deserializeHTML(html: string) { export function convertSlateFragmentTo(fragment: SlateNode[]) { const traverse = (node: SlateNode) => { - if (SlateText.isText(node)) { return node; } @@ -503,13 +530,20 @@ export function convertSlateFragmentTo(fragment: SlateNode[]) { type = BlockType.Paragraph; } - const blockChildren = isTextChildren ? [{ - textId: blockId, - children: isTextChildren ? children : [], - }] : [{ - textId: blockId, - children: [{ text: '' }], - }, ...children]; + const blockChildren = isTextChildren + ? [ + { + textId: blockId, + children: isTextChildren ? children : [], + }, + ] + : [ + { + textId: blockId, + children: [{ text: '' }], + }, + ...children, + ]; return { blockId, diff --git a/src/components/main/AppConfig.tsx b/src/components/main/AppConfig.tsx index 556297e2..56e641dc 100644 --- a/src/components/main/AppConfig.tsx +++ b/src/components/main/AppConfig.tsx @@ -40,7 +40,7 @@ function AppConfig({ children }: { children: React.ReactNode }) { useEffect(() => { return on(EventType.SESSION_VALID, () => { - console.log('session valid'); + console.debug('session valid'); setIsAuthenticated(true); }); }, []); @@ -72,7 +72,7 @@ function AppConfig({ children }: { children: React.ReactNode }) { }, []); useEffect(() => { return on(EventType.SESSION_INVALID, () => { - console.log('session invalid'); + console.debug('session invalid'); setIsAuthenticated(false); }); }, []); @@ -81,8 +81,9 @@ function AppConfig({ children }: { children: React.ReactNode }) { const [hasCheckedTimezone, setHasCheckedTimezone] = useState(false); // Handle initial timezone setup - only when timezone is not set - const handleTimezoneSetup = useCallback(async (detectedTimezone: string) => { - if (!isAuthenticated || !service || hasCheckedTimezone) return; + const handleTimezoneSetup = useCallback( + async (detectedTimezone: string) => { + if (!isAuthenticated || !service || hasCheckedTimezone) return; try { // Get current user profile to check if timezone is already set diff --git a/src/components/view-meta/TitleEditable.tsx b/src/components/view-meta/TitleEditable.tsx index 2af1bcf2..1b65d344 100644 --- a/src/components/view-meta/TitleEditable.tsx +++ b/src/components/view-meta/TitleEditable.tsx @@ -2,11 +2,33 @@ import { debounce } from 'lodash-es'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { useTranslation } from 'react-i18next'; +/** + * Title Update Flow & Echo Prevention Mechanism: + * + * 1. USER INPUT → LOCAL UPDATE + * - User types → debounced update (300ms) → send to server + * - User blurs/enters → immediate update → send to server + * - Cache sent values with timestamps for echo detection + * + * 2. REMOTE UPDATE HANDLING + * - Ignore updates while user is actively typing (500ms window) + * - Ignore updates shortly after sending (2s protection window) + * - Detect and ignore "echo" updates (values we recently sent) + * - Accept genuine remote updates and clean old cache entries + * + * 3. ECHO PREVENTION STRATEGY + * - Track sent values in Map + * - Ignore remote updates matching recently sent values + * - Auto-cleanup old cache entries (15s expiry) + * - Clear old cache when genuine remote updates arrive + */ + +// Cursor utility functions const isCursorAtEnd = (el: HTMLDivElement) => { const selection = window.getSelection(); if (!selection) return false; - + const range = selection.getRangeAt(0); const text = el.textContent || ''; @@ -17,35 +39,26 @@ const getCursorOffset = () => { const selection = window.getSelection(); if (!selection) return 0; - - const range = selection.getRangeAt(0); - - return range.startOffset; + + return selection.getRangeAt(0).startOffset; }; const setCursorPosition = (element: HTMLDivElement, position: number) => { const range = document.createRange(); const selection = window.getSelection(); - + if (!element.firstChild) return; - + const textNode = element.firstChild; const maxPosition = textNode.textContent?.length || 0; const safePosition = Math.min(position, maxPosition); - + range.setStart(textNode, safePosition); range.collapse(true); selection?.removeAllRanges(); selection?.addRange(range); }; -interface UpdateState { - localName: string; - lastConfirmedName: string; - pendingUpdate: string | null; - updateId: string | null; -} - function TitleEditable({ viewId, name, @@ -63,155 +76,134 @@ function TitleEditable({ }) { const { t } = useTranslation(); - // Use ref to manage state, avoid re-rendering - const updateStateRef = useRef({ - localName: name, - lastConfirmedName: name, - pendingUpdate: null, - updateId: null, - }); + // Component state and refs const [isFocused, setIsFocused] = useState(false); - const contentRef = useRef(null); const cursorPositionRef = useRef(0); - const initialEditValueRef = useRef(''); + + // Timing and cache refs + const lastInputTimeRef = useRef(0); + const lastUpdateSentTimeRef = useRef(0); + const sentValuesRef = useRef>(new Map()); + + // Timer refs + const inputTimerRef = useRef(); + const blurTimerRef = useRef(); + const cleanupTimerRef = useRef(); - // Generate unique update ID - const generateUpdateId = useCallback(() => { - return `${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; + // State checking functions + const isTyping = useCallback(() => { + return Date.now() - lastInputTimeRef.current < 500; // 500ms typing window }, []); - // Send update to remote - const sendUpdate = useCallback( - (newName: string) => { - const updateId = generateUpdateId(); + const isRecentlyUpdated = useCallback(() => { + return Date.now() - lastUpdateSentTimeRef.current < 2000; // 2s protection window + }, []); - console.debug('Sending update:', { newName, updateId }); + const isPotentialEcho = useCallback((value: string) => { + return sentValuesRef.current.has(value); + }, []); - updateStateRef.current = { - ...updateStateRef.current, - pendingUpdate: newName, - updateId, - }; + // Cache management + const cleanOldSentValues = useCallback(() => { + const now = Date.now(); + const maxAge = 15000; // 15 seconds + + for (const [value, timestamp] of sentValuesRef.current.entries()) { + if (now - timestamp > maxAge) { + sentValuesRef.current.delete(value); + console.debug('🧹 Cleaned old sent value:', value); + } + } + }, []); - // Call original update function - onUpdateName(newName); - }, - [onUpdateName, generateUpdateId] - ); + const scheduleCleanup = useCallback(() => { + if (cleanupTimerRef.current) { + clearTimeout(cleanupTimerRef.current); + } + + cleanupTimerRef.current = setTimeout(cleanOldSentValues, 5000); + }, [cleanOldSentValues]); - // Delayed update sending - const debounceUpdateName = useMemo(() => { - return debounce(sendUpdate, 200); + // Update functions - send changes to server and cache for echo detection + const sendUpdate = useCallback((value: string, isImmediate = false) => { + console.debug(isImmediate ? '⚡ Immediate update:' : '⏰ Debounced update:', value); + + const now = Date.now(); + + lastUpdateSentTimeRef.current = now; + sentValuesRef.current.set(value, now); + scheduleCleanup(); + onUpdateName(value); + }, [onUpdateName, scheduleCleanup]); + + const debouncedUpdate = useMemo(() => { + return debounce((value: string) => sendUpdate(value, false), 300); }, [sendUpdate]); - // Smart update sending: wait if pending, send if no pending - const smartSendUpdate = useCallback( - (newName: string, immediate = false) => { - // If there's a pending update, unless explicitly requested to send immediately, wait for confirmation - if (updateStateRef.current.pendingUpdate && !immediate) { - console.log('Pending update exists, defer sending, update local state'); - // Only update local state, don't send to remote - updateStateRef.current = { - ...updateStateRef.current, - localName: newName, - }; - return; - } + const sendUpdateImmediately = useCallback((value: string) => { + debouncedUpdate.cancel(); + sendUpdate(value, true); + }, [debouncedUpdate, sendUpdate]); - // No pending or explicitly requested to send immediately - if (immediate) { - console.log('Sending update immediately'); - sendUpdate(newName); - } else { - console.debug('Sending update with delay'); - debounceUpdateName(newName); - } - }, - [sendUpdate, debounceUpdateName] - ); - - // Handle remote name changes + // Handle remote updates with echo prevention useEffect(() => { - console.debug('Remote name changed:', { - newName: name, - pendingUpdate: updateStateRef.current.pendingUpdate, - lastConfirmedName: updateStateRef.current.lastConfirmedName, + console.debug('🌐 Remote name changed:', { + name, isFocused, + isCurrentlyTyping: isTyping(), + isRecentlyUpdated: isRecentlyUpdated(), + isPotentialEcho: isPotentialEcho(name), }); - // If focused, ignore all remote updates - if (isFocused) { + // Step 1: Ignore if user is actively interacting + if (isTyping() || isRecentlyUpdated()) { + console.debug('✋ User activity detected, ignoring remote update'); return; } - // Check if this is a confirmation of local update - if (updateStateRef.current.pendingUpdate && name === updateStateRef.current.pendingUpdate) { - console.log('Local update confirmed successfully'); - const currentLocalName = updateStateRef.current.localName; + // Step 2: Detect and ignore echo updates (values we recently sent) + if (isPotentialEcho(name)) { + console.debug('🔄 Echo detected, ignoring remote update'); + return; + } - updateStateRef.current = { - ...updateStateRef.current, - lastConfirmedName: name, - localName: name, - pendingUpdate: null, - updateId: null, - }; + // Step 3: Handle genuine remote updates + console.debug('✨ Genuine remote update detected'); + + // Clean old cache entries (keep recent ones to prevent immediate re-acceptance) + const now = Date.now(); - // If local has newer content, continue sending - if (currentLocalName !== name) { - console.log('Found newer local content after confirmation, continue sending:', currentLocalName); - smartSendUpdate(currentLocalName); + for (const [value, timestamp] of sentValuesRef.current.entries()) { + if (now - timestamp > 5000) { // Keep values from last 5 seconds + sentValuesRef.current.delete(value); } - - return; } - // If there's a pending update, remote update has overridden previous local update - if (updateStateRef.current.pendingUpdate) { - console.log('Remote update overrode local update, use latest local content'); - - // Clear pending state, use latest local content - updateStateRef.current = { - ...updateStateRef.current, - lastConfirmedName: name, - pendingUpdate: null, - updateId: null, - }; - - // If local content differs from remote, continue sending local update - if (updateStateRef.current.localName !== name) { - console.log('Continue sending latest local content:', updateStateRef.current.localName); - smartSendUpdate(updateStateRef.current.localName, true); - } - - return; - } - - console.debug('Accepting remote update'); - updateStateRef.current = { - ...updateStateRef.current, - localName: name, - lastConfirmedName: name, - }; - - // Only update UI content when not focused, avoid cursor jumping + // Step 4: Update UI if content differs if (contentRef.current) { - contentRef.current.textContent = name; + const currentContent = contentRef.current.textContent || ''; + + if (currentContent !== name) { + console.debug('✅ Applying remote update to UI'); + contentRef.current.textContent = name; + + // Preserve cursor position for focused input + if (isFocused && document.activeElement === contentRef.current) { + const cursorPos = Math.min(cursorPositionRef.current, name.length); + + // Use microtask to ensure DOM update completes first + queueMicrotask(() => { + if (contentRef.current) { + setCursorPosition(contentRef.current, cursorPos); + } + }); + } + } } - }, [name, isFocused, smartSendUpdate]); + }, [name, isTyping, isRecentlyUpdated, isPotentialEcho, isFocused]); - const focusedTextbox = useCallback(() => { - const contentBox = contentRef.current; - - if (!contentBox) return; - - const textbox = document.getElementById(`editor-${viewId}`) as HTMLElement; - - textbox?.focus(); - }, [viewId]); - - // Initialize content and handle autoFocus + // Initialize component useEffect(() => { const contentBox = contentRef.current; @@ -220,18 +212,12 @@ function TitleEditable({ return; } - // Set initial content to local state - contentBox.textContent = updateStateRef.current.localName; - initialEditValueRef.current = updateStateRef.current.localName; + contentBox.textContent = name; - // Ensure focus if autoFocus is true if (autoFocus) { - // Use requestAnimationFrame for next paint cycle to ensure DOM is fully ready requestAnimationFrame(() => { - // Double-check the element still exists and is in the document if (contentBox && document.contains(contentBox)) { contentBox.focus(); - // Move cursor to end if there's content if (contentBox.textContent) { setCursorPosition(contentBox, contentBox.textContent.length); } @@ -239,7 +225,129 @@ function TitleEditable({ }); } // eslint-disable-next-line react-hooks/exhaustive-deps - }, []); // Only execute once when component mounts - autoFocus is intentionally not in deps + }, []); + + const focusedTextbox = useCallback(() => { + const textbox = document.getElementById(`editor-${viewId}`) as HTMLElement; + + textbox?.focus(); + }, [viewId]); + + // Event handlers with useCallback optimization + const handleFocus = useCallback(() => { + console.debug('🎯 Input focused'); + + if (blurTimerRef.current) { + clearTimeout(blurTimerRef.current); + blurTimerRef.current = undefined; + } + + setIsFocused(true); + onFocus?.(); + }, [onFocus]); + + const handleBlur = useCallback(() => { + console.debug('👋 Input blurred'); + const currentText = contentRef.current?.textContent || ''; + + sendUpdateImmediately(currentText); + setIsFocused(false); + + blurTimerRef.current = setTimeout(() => { + console.debug('🧹 Cleaning input state after blur'); + lastInputTimeRef.current = 0; + if (inputTimerRef.current) { + clearTimeout(inputTimerRef.current); + } + }, 100); + }, [sendUpdateImmediately]); + + const handleInput = useCallback(() => { + if (!contentRef.current) return; + + lastInputTimeRef.current = Date.now(); + cursorPositionRef.current = getCursorOffset(); + + // Clean up browser auto-inserted
tags + if (contentRef.current.innerHTML === '
') { + contentRef.current.innerHTML = ''; + } + + const currentText = contentRef.current.textContent || ''; + + debouncedUpdate(currentText); + + if (inputTimerRef.current) { + clearTimeout(inputTimerRef.current); + } + + inputTimerRef.current = setTimeout(() => { + console.debug('⏸️ User stopped typing'); + }, 500); + }, [debouncedUpdate]); + + const handleKeyDown = useCallback((e: React.KeyboardEvent) => { + if (!contentRef.current) return; + + lastInputTimeRef.current = Date.now(); + cursorPositionRef.current = getCursorOffset(); + + if (e.key === 'Enter' || e.key === 'Escape') { + e.preventDefault(); + + if (e.key === 'Enter') { + const currentText = e.currentTarget.textContent || ''; + const offset = getCursorOffset(); + + if (offset >= currentText.length || offset <= 0) { + sendUpdateImmediately(currentText); + onEnter?.(''); + } else { + const beforeText = currentText.slice(0, offset); + const afterText = currentText.slice(offset); + + contentRef.current.textContent = beforeText; + sendUpdateImmediately(beforeText); + onEnter?.(afterText); + } + + setTimeout(() => focusedTextbox(), 0); + } else { + const currentText = contentRef.current.textContent || ''; + + sendUpdateImmediately(currentText); + } + + setTimeout(() => { + lastInputTimeRef.current = 0; + if (inputTimerRef.current) { + clearTimeout(inputTimerRef.current); + } + }, 100); + } else if (e.key === 'ArrowDown' || (e.key === 'ArrowRight' && isCursorAtEnd(contentRef.current))) { + e.preventDefault(); + focusedTextbox(); + } + }, [sendUpdateImmediately, onEnter, focusedTextbox]); + + // Cleanup timers + useEffect(() => { + return () => { + if (inputTimerRef.current) { + clearTimeout(inputTimerRef.current); + } + + if (blurTimerRef.current) { + clearTimeout(blurTimerRef.current); + } + + if (cleanupTimerRef.current) { + clearTimeout(cleanupTimerRef.current); + } + + debouncedUpdate.cancel(); + }; + }, [debouncedUpdate]); return ( @@ -247,131 +355,20 @@ function TitleEditable({ ref={contentRef} suppressContentEditableWarning={true} id={`editor-title-${viewId}`} - data-testid="page-title-input" - style={{ - wordBreak: 'break-word', - }} + data-testid='page-title-input' + style={{ wordBreak: 'break-word' }} className={ 'custom-caret relative flex-1 cursor-text whitespace-pre-wrap break-words empty:before:text-text-tertiary empty:before:content-[attr(data-placeholder)] focus:outline-none' } data-placeholder={t('menuAppHeader.defaultNewPageName')} contentEditable={true} - aria-readonly={false} autoFocus={autoFocus} - onFocus={() => { - - // Record initial value when starting to edit - if (contentRef.current) { - initialEditValueRef.current = contentRef.current.textContent || ''; - } - - setIsFocused(true); - onFocus?.(); - }} - onBlur={() => { - // Immediately save user's latest input to avoid content loss due to debounce - if (contentRef.current) { - const currentText = contentRef.current.textContent || ''; - const initialValue = initialEditValueRef.current; - - // Update local state - updateStateRef.current = { - ...updateStateRef.current, - localName: currentText, - }; - - // Cancel debounce, update immediately (but only when user really modified content) - debounceUpdateName.cancel(); - if (currentText !== initialValue) { - smartSendUpdate(currentText, true); - } - } - - // Use microtask to avoid race conditions - void Promise.resolve().then(() => { - setIsFocused(false); - }); - }} - onInput={() => { - if (!contentRef.current) return; - - // Save current cursor position - cursorPositionRef.current = getCursorOffset(); - - // Clean up browser auto-inserted
tags - if (contentRef.current.innerHTML === '
') { - contentRef.current.innerHTML = ''; - } - - const currentText = contentRef.current.textContent || ''; - - // Update local state - updateStateRef.current = { - ...updateStateRef.current, - localName: currentText, - }; - - // Smart update remote data - smartSendUpdate(currentText); - }} - onKeyDown={(e) => { - if (!contentRef.current) return; - - // Save current cursor position - cursorPositionRef.current = getCursorOffset(); - - if (e.key === 'Enter' || e.key === 'Escape') { - e.preventDefault(); - - if (e.key === 'Enter') { - const currentText = e.currentTarget.textContent || ''; - const offset = getCursorOffset(); - - if (offset >= currentText.length || offset <= 0) { - // Cursor at end or position inaccurate, keep all text - setIsFocused(false); - updateStateRef.current = { - ...updateStateRef.current, - localName: currentText, - }; - smartSendUpdate(currentText, true); - onEnter?.(''); - } else { - // Cursor in middle, split text - const beforeText = currentText.slice(0, offset); - const afterText = currentText.slice(offset); - - contentRef.current.textContent = beforeText; - setIsFocused(false); - updateStateRef.current = { - ...updateStateRef.current, - localName: beforeText, - }; - smartSendUpdate(beforeText, true); - onEnter?.(afterText); - } - - setTimeout(() => { - focusedTextbox(); - }, 0); - } else { - // Escape key: complete editing and save current content - const currentText = contentRef.current.textContent || ''; - - setIsFocused(false); - updateStateRef.current = { - ...updateStateRef.current, - localName: currentText, - }; - smartSendUpdate(currentText, true); - } - } else if (e.key === 'ArrowDown' || (e.key === 'ArrowRight' && isCursorAtEnd(contentRef.current))) { - e.preventDefault(); - focusedTextbox(); - } - }} + onFocus={handleFocus} + onBlur={handleBlur} + onInput={handleInput} + onKeyDown={handleKeyDown} /> ); } -export default memo(TitleEditable); +export default memo(TitleEditable); \ No newline at end of file diff --git a/src/components/ws/useAppflowyWebSocket.ts b/src/components/ws/useAppflowyWebSocket.ts index 2f3595cf..dce9634c 100644 --- a/src/components/ws/useAppflowyWebSocket.ts +++ b/src/components/ws/useAppflowyWebSocket.ts @@ -113,7 +113,7 @@ export const useAppflowyWebSocket = (options: Options): AppflowyWebSocketType => }, // Reconnect configuration shouldReconnect: (closeEvent) => { - console.log('Connection closed, code:', closeEvent.code, 'reason:', closeEvent.reason); + console.info('Connection closed, code:', closeEvent.code, 'reason:', closeEvent.reason); // Determine if reconnect is needed based on the close code if (closeEvent.code === CloseCode.NormalClose) { @@ -144,23 +144,18 @@ export const useAppflowyWebSocket = (options: Options): AppflowyWebSocketType => setReconnectAttempt(attemptNumber); const delay = Math.min(RECONNECT_INTERVAL * Math.pow(1.5, attemptNumber), 30000); - console.log(`Reconnect attempt ${attemptNumber}, delay ${delay}ms`); + console.info(`Reconnect attempt ${attemptNumber}, delay ${delay}ms`); return delay; }, // Connection event callback onOpen: () => { - console.log('✅ WebSocket connection opened', { deviceId: options.deviceId }); + console.info('✅ WebSocket connection opened'); setReconnectAttempt(0); }, onClose: (event) => { - console.log('❌ WebSocket connection closed', { - code: event.code, - reason: event.reason, - wasClean: event.wasClean, - deviceId: options.deviceId, - }); + console.info('❌ WebSocket connection closed', event); }, onError: (event) => { @@ -168,7 +163,7 @@ export const useAppflowyWebSocket = (options: Options): AppflowyWebSocketType => }, onReconnectStop: (numAttempts) => { - console.log('❌ Reconnect stopped, attempt number:', numAttempts); + console.info('❌ Reconnect stopped, attempt number:', numAttempts); }, }); const websocket = getWebSocket() as WebSocket | null; @@ -189,7 +184,7 @@ export const useAppflowyWebSocket = (options: Options): AppflowyWebSocketType => ); const manualReconnect = useCallback(() => { - console.log('Manual reconnect triggered'); + console.debug('Manual reconnect triggered'); const ws = getWebSocket(); if (ws) { diff --git a/src/components/ws/useSync.ts b/src/components/ws/useSync.ts index 15f13c94..481897d3 100644 --- a/src/components/ws/useSync.ts +++ b/src/components/ws/useSync.ts @@ -80,7 +80,7 @@ export const useSync = (ws: AppflowyWebSocketType, bc: BroadcastChannelType, eve const updateTimestamp = message.update?.messageId?.timestamp; const publishedAt = updateTimestamp ? new Date(updateTimestamp) : undefined; - console.log('Received broadcasted collab message:', message.collabType, publishedAt, message); + console.debug('Received broadcasted collab message:', message.collabType, publishedAt, message); setLastUpdatedCollab({ objectId, publishedAt, collabType: message.collabType as Types }); } @@ -184,7 +184,7 @@ export const useSync = (ws: AppflowyWebSocketType, bc: BroadcastChannelType, eve return existingContext; } - console.log(`Registering sync context for objectId ${context.doc.guid} with collabType ${context.collabType}`); + console.debug(`Registering sync context for objectId ${context.doc.guid} with collabType ${context.collabType}`); context.emit = (message) => { sendMessage(message); postMessage(message); diff --git a/src/styles/global.css b/src/styles/global.css index 93c74f98..796d90f6 100644 --- a/src/styles/global.css +++ b/src/styles/global.css @@ -6,15 +6,17 @@ @import "./template.css"; :root { + --shadow-color: rgba(31, 35, 41, 0.10); /* new shadow */ - --custom-shadow-sm: 0px 4px 20px 0px rgba(31, 35, 41, 0.10); - --custom-shadow-md: 0px 4px 32px 0px rgba(31, 34, 37, 0.12); + --custom-shadow-sm: 0px 4px 20px 0px var(--shadow-color); + --custom-shadow-md: 0px 4px 32px 0px var(--shadow-color); } :root[data-dark-mode=true] { + --shadow-color: rgba(0, 0, 0, 0.10); /* new shadow */ - --custom-shadow-sm: 0px 2px 16px 0px rgba(0, 0, 0, 0.48); - --custom-shadow-md: 0px 4px 32px 0px rgba(0, 0, 0, 0.48); + --custom-shadow-sm: 0px 2px 16px 0px var(--shadow-color); + --custom-shadow-md: 0px 4px 32px 0px var(--shadow-color); } @layer base { diff --git a/src/utils/time.ts b/src/utils/time.ts index c6145436..2955c581 100644 --- a/src/utils/time.ts +++ b/src/utils/time.ts @@ -1,6 +1,7 @@ -import { DateFormat, TimeFormat } from '@/application/types'; import dayjs from 'dayjs'; +import { DateFormat, TimeFormat } from '@/application/types'; + export function renderDate(date: string | number, format: string, isUnix?: boolean): string { if (isUnix) return dayjs.unix(Number(date)).format(format); return dayjs(date).format(format); @@ -90,3 +91,77 @@ export function getDateFormat(dateFormat?: DateFormat) { return 'YYYY-MM-DD'; } } + +/** + * Convert JavaScript Date to 10-digit Unix timestamp string + * @param {Date} date - The JavaScript Date object + * @returns {string} - 10-digit Unix timestamp string + */ +export function dateToUnixTimestamp(date: Date): string { + return Math.floor(date.getTime() / 1000).toString(); +} + +/** + * Convert 10-digit Unix timestamp string to JavaScript Date + * @param {string} timestamp - 10-digit Unix timestamp string + * @returns {Date} - JavaScript Date object + */ +export function unixTimestampToDate(timestamp: string): Date { + return dayjs.unix(Number(timestamp)).toDate(); +} + +/** + * Check if a Date object is at the start of day (00:00), ignoring seconds and milliseconds + * @param {Date} date - The JavaScript Date object to check + * @returns {boolean} - True if the date is at 00:00, false otherwise + */ +export function isDateStartOfDay(date: Date): boolean { + const dayjsDate = dayjs(date); + + return dayjsDate.hour() === 0 && dayjsDate.minute() === 0; +} + +/** + * Check if a Date object is at the end of day (23:59), ignoring seconds and milliseconds + * @param {Date} date - The JavaScript Date object to check + * @returns {boolean} - True if the date is at 23:59, false otherwise + */ +export function isDateEndOfDay(date: Date): boolean { + const dayjsDate = dayjs(date); + + return dayjsDate.hour() === 23 && dayjsDate.minute() === 59; +} + +/** + * Correct all-day event end time for FullCalendar display (forward correction) + * If the date is not at start of day (00:00), adjust it to next day 00:00 + * This ensures FullCalendar shows all-day events correctly across multiple days + * @param {Date} date - The end date to correct + * @returns {Date} - The corrected date for display + */ +export function correctAllDayEndForDisplay(date: Date): Date { + if (isDateStartOfDay(date)) { + // Already at 00:00, no correction needed + return date; + } + + // Move to next day at 00:00 (exclusive boundary for FullCalendar) + return dayjs(date).add(1, 'day').startOf('day').toDate(); +} + +/** + * Correct all-day event end time for storage (reverse correction) + * If the date is at start of day (00:00), adjust it to previous day 23:59 + * This ensures the stored end time reflects the actual last day of the event + * @param {Date} date - The end date to correct + * @returns {Date} - The corrected date for storage + */ +export function correctAllDayEndForStorage(date: Date): Date { + if (!isDateStartOfDay(date)) { + // Not at 00:00, no correction needed + return date; + } + + // Move to previous day at 23:59 + return dayjs(date).subtract(1, 'day').hour(23).minute(59).second(0).millisecond(0).toDate(); +} diff --git a/tsconfig.json b/tsconfig.json index b422c015..bac8d9c7 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -28,6 +28,9 @@ "jest", "cypress-real-events" ], + "typeRoots": [ + "./node_modules/@fullcalendar" + ], "baseUrl": ".", "paths": { "@/*": [ diff --git a/vite.config.ts b/vite.config.ts index 8ec335f8..9f7efe61 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -126,77 +126,32 @@ export default defineConfig({ chunkSizeWarningLimit: 1600, rollupOptions: isProd ? { - output: { - chunkFileNames: 'static/js/[name]-[hash].js', - entryFileNames: 'static/js/[name]-[hash].js', - assetFileNames: 'static/[ext]/[name]-[hash].[ext]', - manualChunks(id) { - if (id.includes('node_modules')) { - // Bundle i18n first to ensure it's loaded before editor - if (id.includes('/i18next') || id.includes('i18n')) { - return 'i18n-vendor'; - } - - if ( - id.includes('/react-is@') || - id.includes('/react-custom-scrollbars') || - id.includes('/react-virtualized-auto-sizer') || - id.includes('/react-window') - ) { - return 'react-vendor'; - } - - if ( - id.includes('/yjs@') || - id.includes('/y-indexeddb@') || - id.includes('/quill-delta') - ) { - return 'editor-vendor'; - } - - if ( - id.includes('/dexie') || - id.includes('/redux') || - id.includes('/@reduxjs') - ) { - return 'data-vendor'; - } - - if ( - id.includes('/@mui') || - id.includes('/@emotion') || - id.includes('/@popperjs') - ) { - return 'mui-vendor'; - } - - if ( - id.includes('/dayjs') || - id.includes('/smooth-scroll-into-view-if-needed') || - id.includes('/lodash') || - id.includes('/uuid') - ) { - return 'utils-vendor'; - } - - if (id.includes('/@appflowyinc/editor')) { - return 'appflowy-editor'; - } - - if (id.includes('/@appflowyinc/ai-chat')) { - return 'appflowy-ai'; - } - - if (id.includes('/react-colorful')) { - return 'color-vendor'; - } - - if (id.includes('/react-katex') || id.includes('/katex')) { - return 'katex-vendor'; - } - } - }, + output: { + chunkFileNames: 'static/js/[name]-[hash].js', + entryFileNames: 'static/js/[name]-[hash].js', + assetFileNames: 'static/[ext]/[name]-[hash].[ext]', + manualChunks(id) { + if ( + // id.includes('/react@') || + // id.includes('/react-dom@') || + id.includes('/react-is@') || + id.includes('/yjs@') || + id.includes('/y-indexeddb@') || + id.includes('/dexie') || + id.includes('/redux') || + id.includes('/react-custom-scrollbars') || + id.includes('/dayjs') || + id.includes('/smooth-scroll-into-view-if-needed') || + id.includes('/react-virtualized-auto-sizer') || + id.includes('/react-window') || + id.includes('/@popperjs') || + id.includes('/@mui/material/Dialog') || + id.includes('/quill-delta') + ) { + return 'common'; + } }, + }, } : {}, },