diff --git a/src/application/services/domains/search.ts b/src/application/services/domains/search.ts index a0785f797..75b1f225b 100644 --- a/src/application/services/domains/search.ts +++ b/src/application/services/domains/search.ts @@ -1 +1,12 @@ -export { searchWorkspace } from '../js-services/http/misc-api'; +export { + generateSearchSummary, + searchWorkspace, + searchWorkspaceDocumentPage, + searchWorkspaceDocuments, +} from '../js-services/http/misc-api'; +export type { + SearchDocumentPageResponse, + SearchDocumentResponseItem, + SearchSummary, + SearchSummaryResult, +} from '../js-services/http/misc-api'; diff --git a/src/application/services/js-services/http/__tests__/collab-api.test.ts b/src/application/services/js-services/http/__tests__/collab-api.test.ts new file mode 100644 index 000000000..7b4a2b606 --- /dev/null +++ b/src/application/services/js-services/http/__tests__/collab-api.test.ts @@ -0,0 +1,54 @@ +import { collab } from '@/proto/messages'; +import { getAxios } from '@/application/services/js-services/http/core'; + +import { collabFullSyncBatch } from '../collab-api'; + +jest.mock('@/application/services/js-services/device-id', () => ({ + getOrCreateDeviceId: jest.fn(() => 'test-device-id'), +})); + +jest.mock('@/application/services/js-services/http/core', () => ({ + executeAPIRequest: jest.fn(), + executeAPIVoidRequest: jest.fn(), + getAxios: jest.fn(), + parseRetryAfterSecs: jest.fn(), +})); + +const mockGetAxios = getAxios as unknown as jest.Mock; + +describe('collabFullSyncBatch', () => { + beforeEach(() => { + jest.clearAllMocks(); + }); + + it('sends the encoded protobuf view instead of the pooled backing buffer', async () => { + const responseBody = collab.CollabBatchSyncResponse.encode( + collab.CollabBatchSyncResponse.create({ + results: [], + responseCompression: collab.PayloadCompressionType.COMPRESSION_NONE, + }) + ).finish(); + const post = jest.fn().mockResolvedValue({ + status: 200, + data: responseBody, + headers: {}, + }); + + mockGetAxios.mockReturnValue({ post }); + + await collabFullSyncBatch('workspace-id', [ + { + objectId: 'object-id', + collabType: 0, + stateVector: new Uint8Array([1]), + docState: new Uint8Array([2]), + }, + ]); + + const [, requestBody, config] = post.mock.calls[0]; + + expect(ArrayBuffer.isView(requestBody)).toBe(true); + expect(requestBody.byteLength).toBeLessThan(requestBody.buffer.byteLength); + expect(config.transformRequest[0](requestBody)).toBe(requestBody); + }); +}); diff --git a/src/application/services/js-services/http/collab-api.ts b/src/application/services/js-services/http/collab-api.ts index 56e0a326d..15fa700bb 100644 --- a/src/application/services/js-services/http/collab-api.ts +++ b/src/application/services/js-services/http/collab-api.ts @@ -100,6 +100,9 @@ export async function collabFullSyncBatch( 'device-id': deviceId, }, responseType: 'arraybuffer', + // Axios' default transform sends typed-array `.buffer`, which can include + // protobufjs' pooled zero padding beyond this Uint8Array view. + transformRequest: [(data: Uint8Array) => data], validateStatus: (status) => status === 200 || status === 429 || status === 503, }); diff --git a/src/application/services/js-services/http/misc-api.ts b/src/application/services/js-services/http/misc-api.ts index e66612f2d..18e67130a 100644 --- a/src/application/services/js-services/http/misc-api.ts +++ b/src/application/services/js-services/http/misc-api.ts @@ -8,22 +8,113 @@ import { RepeatedChatMessage } from '@/components/chat'; import { APIResponse, executeAPIRequest, executeAPIVoidRequest, getAxios } from './core'; -export async function searchWorkspace(workspaceId: string, query: string) { +export interface SearchDocumentResponseItem { + object_id: string; + workspace_id: string; + score: number; + content: string; + preview?: string | null; + database_view_id?: string | null; + database_id?: string | null; + database_row_id?: string | null; +} + +export interface SearchDocumentPageResponse { + items: SearchDocumentResponseItem[]; + next_offset?: number | null; + has_more: boolean; +} + +export interface SearchResult { + object_id: string; + content: string; + database_view_id?: string | null; + database_id?: string | null; + database_row_id?: string | null; +} + +export interface SearchSummary { + content: string; + highlights?: string; + sources: string[]; +} + +export interface SearchSummaryResult { + summaries: SearchSummary[]; +} + +const SEARCH_RESULT_LIMIT = 10; +const SEARCH_PREVIEW_SIZE = 80; + +export async function searchWorkspaceDocuments(workspaceId: string, query: string) { const url = `/api/search/${workspaceId}`; - const payload = await executeAPIRequest< - { - object_id: string; - }[] - >(() => - getAxios()?.get>(url, { - params: { query }, + + return executeAPIRequest(() => + getAxios()?.get>(url, { + params: { query, limit: SEARCH_RESULT_LIMIT, preview_size: SEARCH_PREVIEW_SIZE }, + headers: { 'x-request-time': Date.now().toString() }, + }) + ); +} + +export async function searchWorkspaceDocumentPage(workspaceId: string, query: string, offset = 0) { + const url = `/api/search/${workspaceId}/page`; + + return executeAPIRequest(() => + getAxios()?.get>(url, { + params: { + query, + limit: SEARCH_RESULT_LIMIT, + offset, + preview_size: SEARCH_PREVIEW_SIZE, + mode: 'keyword', + }, headers: { 'x-request-time': Date.now().toString() }, }) ); +} + +export async function searchWorkspace(workspaceId: string, query: string) { + const payload = await searchWorkspaceDocuments(workspaceId, query); return payload.map((item) => item.object_id); } +export async function generateSearchSummary( + workspaceId: string, + query: string, + searchResults: SearchDocumentResponseItem[] +) { + const url = `/api/search/${workspaceId}/summary`; + const search_results: SearchResult[] = searchResults + .filter((item) => item.content) + .slice(0, SEARCH_RESULT_LIMIT) + .map((item) => ({ + object_id: item.object_id, + content: item.content, + ...(item.database_view_id ? { database_view_id: item.database_view_id } : {}), + ...(item.database_id ? { database_id: item.database_id } : {}), + ...(item.database_row_id ? { database_row_id: item.database_row_id } : {}), + })); + + if (search_results.length === 0) { + return { summaries: [] }; + } + + const payload = { + query, + search_results, + only_context: true, + }; + const headers = { 'x-request-time': Date.now().toString() }; + + return executeAPIRequest(() => + getAxios()?.post>(url, payload, { + headers, + }) + ); +} + export async function getChatMessages(workspaceId: string, chatId: string, limit?: number | undefined) { const url = `/api/chat/${workspaceId}/${chatId}/message`; @@ -52,11 +143,13 @@ export async function generateAITranslateForRow(workspaceId: string, payload: Ge [key: string]: string; }[]; }>(() => - getAxios()?.post>(url, { + getAxios()?.post< + APIResponse<{ + items: { + [key: string]: string; + }[]; + }> + >(url, { workspace_id: workspaceId, data: payload, }) @@ -81,10 +174,12 @@ export async function getQuickNoteList( quick_notes: QuickNote[]; has_more: boolean; }>(() => - getAxios()?.get>(url, { + getAxios()?.get< + APIResponse<{ + quick_notes: QuickNote[]; + has_more: boolean; + }> + >(url, { params: { offset: params.offset, limit: params.limit, @@ -102,17 +197,13 @@ export async function getQuickNoteList( export async function createQuickNote(workspaceId: string, payload: QuickNoteEditorData[]): Promise { const url = `/api/workspace/${workspaceId}/quick-note`; - return executeAPIRequest(() => - getAxios()?.post>(url, { data: payload }) - ); + return executeAPIRequest(() => getAxios()?.post>(url, { data: payload })); } export async function updateQuickNote(workspaceId: string, noteId: string, payload: QuickNoteEditorData[]) { const url = `/api/workspace/${workspaceId}/quick-note/${noteId}`; - return executeAPIVoidRequest(() => - getAxios()?.put(url, { data: payload }) - ); + return executeAPIVoidRequest(() => getAxios()?.put(url, { data: payload })); } export async function deleteQuickNote(workspaceId: string, noteId: string) { diff --git a/src/application/slate-yjs/utils/simple-table-operations.ts b/src/application/slate-yjs/utils/simple-table-operations.ts index 39e6a0883..aa52e8237 100644 --- a/src/application/slate-yjs/utils/simple-table-operations.ts +++ b/src/application/slate-yjs/utils/simple-table-operations.ts @@ -36,25 +36,49 @@ function setTableData(tableBlock: YBlock, data: Record) { tableBlock.set(YjsEditorKey.block_data, JSON.stringify(data)); } -function getRowCount(sharedRoot: ReturnType, tableBlock: YBlock): number { - const children = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); +function getColumnCount(sharedRoot: ReturnType, tableBlock: YBlock): number { + const rows = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); + + if (rows.length === 0) return 0; - return children ? children.length : 0; + return getChildEntriesOfType(sharedRoot, rows[0].block, BlockType.SimpleTableCellBlock).length; } -function getColumnCount(sharedRoot: ReturnType, tableBlock: YBlock): number { - const children = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); +interface IndexedTableChild { + id: string; + block: YBlock; + rawIndex: number; +} + +function getChildEntriesOfType( + sharedRoot: ReturnType, + parentBlock: YBlock, + type: BlockType +): IndexedTableChild[] { + const children = getChildrenArray(parentBlock.get(YjsEditorKey.block_children), sharedRoot); - if (!children || children.length === 0) return 0; + if (!children) return []; - const firstRowId = children.get(0); - const firstRow = getBlock(firstRowId, sharedRoot); + const entries: IndexedTableChild[] = []; - if (!firstRow) return 0; + for (let rawIndex = 0; rawIndex < children.length; rawIndex++) { + const id = children.get(rawIndex); + const block = getBlock(id, sharedRoot); - const rowChildren = getChildrenArray(firstRow.get(YjsEditorKey.block_children), sharedRoot); + if (!block || block.get(YjsEditorKey.block_type) !== type) continue; - return rowChildren ? rowChildren.length : 0; + entries.push({ id, block, rawIndex }); + } + + return entries; +} + +function getRawInsertionIndex(entries: IndexedTableChild[], semanticIndex: number, rawLength: number) { + if (entries.length === 0) return rawLength; + if (semanticIndex <= 0) return entries[0].rawIndex; + if (semanticIndex >= entries.length) return entries[entries.length - 1].rawIndex + 1; + + return entries[semanticIndex].rawIndex; } /** @@ -86,7 +110,11 @@ function createContainerBlock(sharedRoot: ReturnType, ty: return block as YBlock; } -const TABLE_CONTAINER_TYPES = [BlockType.SimpleTableBlock, BlockType.SimpleTableRowBlock, BlockType.SimpleTableCellBlock]; +const TABLE_CONTAINER_TYPES = [ + BlockType.SimpleTableBlock, + BlockType.SimpleTableRowBlock, + BlockType.SimpleTableCellBlock, +]; /** * Deep copy a table block, using createContainerBlock for table/row/cell blocks @@ -189,10 +217,12 @@ export function addRowToTable(editor: YjsEditor, tableBlockId: string) { if (!tableBlock) return; const colCount = getColumnCount(sharedRoot, tableBlock); - const rowCount = getRowCount(sharedRoot, tableBlock); + const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); + const rawInsertIndex = getRawInsertionIndex(rowEntries, rowEntries.length, rowChildren?.length ?? 0); const newRow = createEmptyRow(sharedRoot, colCount); - updateBlockParent(sharedRoot, newRow, tableBlock, rowCount); + updateBlockParent(sharedRoot, newRow, tableBlock, rawInsertIndex); }); executeOperations(sharedRoot, operations, 'addRowToTable'); @@ -210,10 +240,17 @@ export function insertRowAtIndex(editor: YjsEditor, tableBlockId: string, rowInd if (!tableBlock) return; + const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); const colCount = getColumnCount(sharedRoot, tableBlock); const newRow = createEmptyRow(sharedRoot, colCount); - updateBlockParent(sharedRoot, newRow, tableBlock, rowIndex); + updateBlockParent( + sharedRoot, + newRow, + tableBlock, + getRawInsertionIndex(rowEntries, rowIndex, rowChildren?.length ?? 0) + ); // Remap row attributes const data = getTableData(tableBlock); @@ -237,17 +274,16 @@ export function deleteRow(editor: YjsEditor, tableBlockId: string, rowIndex: num if (!tableBlock) return; - const rowCount = getRowCount(sharedRoot, tableBlock); + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); + const rowCount = rowEntries.length; if (rowCount <= 1) return; // Don't delete the last row - const children = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); - - if (!children || rowIndex >= children.length) return; + const rowEntry = rowEntries[rowIndex]; - const rowId = children.get(rowIndex); + if (!rowEntry) return; - deleteBlock(sharedRoot, rowId); + deleteBlock(sharedRoot, rowEntry.id); // Remap row attributes const data = getTableData(tableBlock); @@ -271,12 +307,12 @@ export function duplicateRow(editor: YjsEditor, tableBlockId: string, rowIndex: if (!tableBlock) return; - const children = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); + const rowEntry = rowEntries[rowIndex]; - if (!children || rowIndex >= children.length) return; + if (!rowEntry) return; - const sourceRowId = children.get(rowIndex); - const sourceRow = getBlock(sourceRowId, sharedRoot); + const sourceRow = rowEntry.block; if (!sourceRow) return; @@ -288,7 +324,7 @@ export function duplicateRow(editor: YjsEditor, tableBlockId: string, rowIndex: if (!newRow) return; - updateBlockParent(sharedRoot, newRow, tableBlock, rowIndex + 1); + updateBlockParent(sharedRoot, newRow, tableBlock, rowEntry.rawIndex + 1); // Remap row attributes const data = getTableData(tableBlock); @@ -318,20 +354,16 @@ export function addColumnToTable(editor: YjsEditor, tableBlockId: string) { if (!tableBlock) return; const colCount = getColumnCount(sharedRoot, tableBlock); - const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); - - if (!rowChildren) return; - - for (let i = 0; i < rowChildren.length; i++) { - const rowId = rowChildren.get(i); - const row = getBlock(rowId, sharedRoot); - - if (!row) continue; + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); - const cellCount = getChildrenArray(row.get(YjsEditorKey.block_children), sharedRoot)?.length ?? 0; + for (const rowEntry of rowEntries) { + const row = rowEntry.block; + const cellChildren = getChildrenArray(row.get(YjsEditorKey.block_children), sharedRoot); + const cellEntries = getChildEntriesOfType(sharedRoot, row, BlockType.SimpleTableCellBlock); + const rawInsertIndex = getRawInsertionIndex(cellEntries, cellEntries.length, cellChildren?.length ?? 0); const cell = createEmptyCell(sharedRoot); - updateBlockParent(sharedRoot, cell, row, cellCount); + updateBlockParent(sharedRoot, cell, row, rawInsertIndex); } // Set the new column's width to match existing columns @@ -360,19 +392,15 @@ export function insertColumnAtIndex(editor: YjsEditor, tableBlockId: string, col if (!tableBlock) return; - const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); - - if (!rowChildren) return; - - for (let i = 0; i < rowChildren.length; i++) { - const rowId = rowChildren.get(i); - const row = getBlock(rowId, sharedRoot); - - if (!row) continue; + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); + for (const rowEntry of rowEntries) { + const row = rowEntry.block; + const cellChildren = getChildrenArray(row.get(YjsEditorKey.block_children), sharedRoot); + const cellEntries = getChildEntriesOfType(sharedRoot, row, BlockType.SimpleTableCellBlock); const cell = createEmptyCell(sharedRoot); - updateBlockParent(sharedRoot, cell, row, colIndex); + updateBlockParent(sharedRoot, cell, row, getRawInsertionIndex(cellEntries, colIndex, cellChildren?.length ?? 0)); } // Remap column attributes @@ -408,23 +436,14 @@ export function deleteColumn(editor: YjsEditor, tableBlockId: string, colIndex: if (colCount <= 1) return; // Don't delete the last column - const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); - - if (!rowChildren) return; + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); - for (let i = 0; i < rowChildren.length; i++) { - const rowId = rowChildren.get(i); - const row = getBlock(rowId, sharedRoot); + for (const rowEntry of rowEntries) { + const cellEntry = getChildEntriesOfType(sharedRoot, rowEntry.block, BlockType.SimpleTableCellBlock)[colIndex]; - if (!row) continue; + if (!cellEntry) continue; - const cellChildren = getChildrenArray(row.get(YjsEditorKey.block_children), sharedRoot); - - if (!cellChildren || colIndex >= cellChildren.length) continue; - - const cellId = cellChildren.get(colIndex); - - deleteBlock(sharedRoot, cellId); + deleteBlock(sharedRoot, cellEntry.id); } // Remap column attributes @@ -449,26 +468,14 @@ export function duplicateColumn(editor: YjsEditor, tableBlockId: string, colInde if (!tableBlock) return; - const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); - - if (!rowChildren) return; - - for (let i = 0; i < rowChildren.length; i++) { - const rowId = rowChildren.get(i); - const row = getBlock(rowId, sharedRoot); - - if (!row) continue; - - const cellChildren = getChildrenArray(row.get(YjsEditorKey.block_children), sharedRoot); - - if (!cellChildren || colIndex >= cellChildren.length) continue; + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); - const sourceCellId = cellChildren.get(colIndex); - const sourceCell = getBlock(sourceCellId, sharedRoot); + for (const rowEntry of rowEntries) { + const cellEntry = getChildEntriesOfType(sharedRoot, rowEntry.block, BlockType.SimpleTableCellBlock)[colIndex]; - if (!sourceCell) continue; + if (!cellEntry) continue; - const newCellId = deepCopyTableBlock(sharedRoot, sourceCell); + const newCellId = deepCopyTableBlock(sharedRoot, cellEntry.block); if (!newCellId) continue; @@ -476,7 +483,7 @@ export function duplicateColumn(editor: YjsEditor, tableBlockId: string, colInde if (!newCell) continue; - updateBlockParent(sharedRoot, newCell, row, colIndex + 1); + updateBlockParent(sharedRoot, newCell, rowEntry.block, cellEntry.rawIndex + 1); } // Remap column attributes @@ -506,28 +513,26 @@ export function addRowAndColumnToTable(editor: YjsEditor, tableBlockId: string) if (!tableBlock) return; // First add column to all existing rows - const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); - - if (!rowChildren) return; + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); - for (let i = 0; i < rowChildren.length; i++) { - const rowId = rowChildren.get(i); - const row = getBlock(rowId, sharedRoot); - - if (!row) continue; - - const cellCount = getChildrenArray(row.get(YjsEditorKey.block_children), sharedRoot)?.length ?? 0; + for (const rowEntry of rowEntries) { + const row = rowEntry.block; + const cellChildren = getChildrenArray(row.get(YjsEditorKey.block_children), sharedRoot); + const cellEntries = getChildEntriesOfType(sharedRoot, row, BlockType.SimpleTableCellBlock); + const rawInsertIndex = getRawInsertionIndex(cellEntries, cellEntries.length, cellChildren?.length ?? 0); const cell = createEmptyCell(sharedRoot); - updateBlockParent(sharedRoot, cell, row, cellCount); + updateBlockParent(sharedRoot, cell, row, rawInsertIndex); } // Then add new row (with the new column count) const colCount = getColumnCount(sharedRoot, tableBlock); - const rowCount = getRowCount(sharedRoot, tableBlock); + const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); + const updatedRowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); + const rawInsertIndex = getRawInsertionIndex(updatedRowEntries, updatedRowEntries.length, rowChildren?.length ?? 0); const newRow = createEmptyRow(sharedRoot, colCount); - updateBlockParent(sharedRoot, newRow, tableBlock, rowCount); + updateBlockParent(sharedRoot, newRow, tableBlock, rawInsertIndex); }); executeOperations(sharedRoot, operations, 'addRowAndColumnToTable'); @@ -549,30 +554,17 @@ export function clearRowContent(editor: YjsEditor, tableBlockId: string, rowInde if (!tableBlock) return; - const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); - - if (!rowChildren || rowIndex >= rowChildren.length) return; - - const rowId = rowChildren.get(rowIndex); - const row = getBlock(rowId, sharedRoot); - - if (!row) return; + const rowEntry = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock)[rowIndex]; - const cellChildren = getChildrenArray(row.get(YjsEditorKey.block_children), sharedRoot); + if (!rowEntry) return; - if (!cellChildren) return; + const cellEntries = getChildEntriesOfType(sharedRoot, rowEntry.block, BlockType.SimpleTableCellBlock); - // Delete all cells and replace with empty ones - const cellIds = cellChildren.toArray(); - - for (const cellId of cellIds) { - deleteBlock(sharedRoot, cellId); - } - - for (let i = 0; i < cellIds.length; i++) { + for (const cellEntry of cellEntries) { + deleteBlock(sharedRoot, cellEntry.id); const cell = createEmptyCell(sharedRoot); - updateBlockParent(sharedRoot, cell, row, i); + updateBlockParent(sharedRoot, cell, rowEntry.block, cellEntry.rawIndex); } }); @@ -591,27 +583,18 @@ export function clearColumnContent(editor: YjsEditor, tableBlockId: string, colI if (!tableBlock) return; - const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); - - if (!rowChildren) return; - - for (let i = 0; i < rowChildren.length; i++) { - const rowId = rowChildren.get(i); - const row = getBlock(rowId, sharedRoot); - - if (!row) continue; - - const cellChildren = getChildrenArray(row.get(YjsEditorKey.block_children), sharedRoot); + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); - if (!cellChildren || colIndex >= cellChildren.length) continue; + for (const rowEntry of rowEntries) { + const cellEntry = getChildEntriesOfType(sharedRoot, rowEntry.block, BlockType.SimpleTableCellBlock)[colIndex]; - const cellId = cellChildren.get(colIndex); + if (!cellEntry) continue; - deleteBlock(sharedRoot, cellId); + deleteBlock(sharedRoot, cellEntry.id); const cell = createEmptyCell(sharedRoot); - updateBlockParent(sharedRoot, cell, row, colIndex); + updateBlockParent(sharedRoot, cell, rowEntry.block, cellEntry.rawIndex); } }); @@ -636,17 +619,14 @@ export function reorderRow(editor: YjsEditor, tableBlockId: string, fromIndex: n if (!tableBlock) return; - const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); - - if (!rowChildren || fromIndex >= rowChildren.length || toIndex >= rowChildren.length) return; + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); - const sourceRowId = rowChildren.get(fromIndex); - const sourceRow = getBlock(sourceRowId, sharedRoot); + if (fromIndex >= rowEntries.length || toIndex >= rowEntries.length) return; - if (!sourceRow) return; + const sourceRow = rowEntries[fromIndex]; // Deep copy, delete original, insert copy at target - const newRowId = deepCopyTableBlock(sharedRoot, sourceRow); + const newRowId = deepCopyTableBlock(sharedRoot, sourceRow.block); if (!newRowId) return; @@ -654,12 +634,13 @@ export function reorderRow(editor: YjsEditor, tableBlockId: string, fromIndex: n if (!newRow) return; - deleteBlock(sharedRoot, sourceRowId); + deleteBlock(sharedRoot, sourceRow.id); - // Adjust toIndex since we deleted a row - const adjustedToIndex = fromIndex < toIndex ? toIndex - 1 : toIndex; + const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); + const updatedRowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); + const rawInsertIndex = getRawInsertionIndex(updatedRowEntries, toIndex, rowChildren?.length ?? 0); - updateBlockParent(sharedRoot, newRow, tableBlock, adjustedToIndex); + updateBlockParent(sharedRoot, newRow, tableBlock, rawInsertIndex); // Remap row attributes const data = getTableData(tableBlock); @@ -685,26 +666,14 @@ export function reorderColumn(editor: YjsEditor, tableBlockId: string, fromIndex if (!tableBlock) return; - const rowChildren = getChildrenArray(tableBlock.get(YjsEditorKey.block_children), sharedRoot); - - if (!rowChildren) return; - - for (let i = 0; i < rowChildren.length; i++) { - const rowId = rowChildren.get(i); - const row = getBlock(rowId, sharedRoot); - - if (!row) continue; - - const cellChildren = getChildrenArray(row.get(YjsEditorKey.block_children), sharedRoot); - - if (!cellChildren || fromIndex >= cellChildren.length) continue; + const rowEntries = getChildEntriesOfType(sharedRoot, tableBlock, BlockType.SimpleTableRowBlock); - const sourceCellId = cellChildren.get(fromIndex); - const sourceCell = getBlock(sourceCellId, sharedRoot); + for (const rowEntry of rowEntries) { + const sourceCell = getChildEntriesOfType(sharedRoot, rowEntry.block, BlockType.SimpleTableCellBlock)[fromIndex]; if (!sourceCell) continue; - const newCellId = deepCopyTableBlock(sharedRoot, sourceCell); + const newCellId = deepCopyTableBlock(sharedRoot, sourceCell.block); if (!newCellId) continue; @@ -712,11 +681,13 @@ export function reorderColumn(editor: YjsEditor, tableBlockId: string, fromIndex if (!newCell) continue; - deleteBlock(sharedRoot, sourceCellId); + deleteBlock(sharedRoot, sourceCell.id); - const adjustedToIndex = fromIndex < toIndex ? toIndex - 1 : toIndex; + const cellChildren = getChildrenArray(rowEntry.block.get(YjsEditorKey.block_children), sharedRoot); + const updatedCellEntries = getChildEntriesOfType(sharedRoot, rowEntry.block, BlockType.SimpleTableCellBlock); + const rawInsertIndex = getRawInsertionIndex(updatedCellEntries, toIndex, cellChildren?.length ?? 0); - updateBlockParent(sharedRoot, newCell, row, adjustedToIndex); + updateBlockParent(sharedRoot, newCell, rowEntry.block, rawInsertIndex); } // Remap column attributes diff --git a/src/assets/icons/ai_searching_icon.svg b/src/assets/icons/ai_searching_icon.svg new file mode 100644 index 000000000..971471fd3 --- /dev/null +++ b/src/assets/icons/ai_searching_icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/icons/chat_ai_page.svg b/src/assets/icons/chat_ai_page.svg new file mode 100644 index 000000000..0b57fdd3b --- /dev/null +++ b/src/assets/icons/chat_ai_page.svg @@ -0,0 +1,4 @@ + + + + diff --git a/src/assets/icons/m_home_ai_chat_icon.svg b/src/assets/icons/m_home_ai_chat_icon.svg new file mode 100644 index 000000000..62db92995 --- /dev/null +++ b/src/assets/icons/m_home_ai_chat_icon.svg @@ -0,0 +1,14 @@ + + + + + + + + + + + + + + diff --git a/src/assets/icons/m_toolbar_link.svg b/src/assets/icons/m_toolbar_link.svg new file mode 100644 index 000000000..3593b4c3a --- /dev/null +++ b/src/assets/icons/m_toolbar_link.svg @@ -0,0 +1,3 @@ + + + diff --git a/src/components/app/search/BestMatch.tsx b/src/components/app/search/BestMatch.tsx index 12f45b6cd..1eb6fd24d 100644 --- a/src/components/app/search/BestMatch.tsx +++ b/src/components/app/search/BestMatch.tsx @@ -1,55 +1,247 @@ -import { debounce, uniq } from 'lodash-es'; -import React, { useCallback, useEffect, useMemo } from 'react'; +import { debounce } from 'lodash-es'; +import React, { useCallback, useEffect, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { View } from '@/application/types'; +import { getDatabaseIdFromExtra } from '@/application/view-utils'; import { SearchService } from '@/application/services/domains'; +import type { + SearchDocumentPageResponse, + SearchDocumentResponseItem, + SearchSummary, +} from '@/application/services/domains/search'; import { notify } from '@/components/_shared/notify'; import { findView } from '@/components/_shared/outline/utils'; -import { useAppOutline, useCurrentWorkspaceId } from '@/components/app/app.hooks'; -import ViewList from '@/components/app/search/ViewList'; +import { useAIEnabled, useAppOutline, useCurrentWorkspaceId } from '@/components/app/app.hooks'; +import { SearchAIOverview, SearchOverviewSource } from '@/components/app/search/SearchAIOverview'; +import ViewList, { SearchViewListItem } from '@/components/app/search/ViewList'; -function BestMatch ({ +function findViewByDatabaseId(views: View[], databaseId?: string | null): View | undefined { + if (!databaseId) return; + + for (const view of views) { + if (getDatabaseIdFromExtra(view) === databaseId) { + return view; + } + + const child = findViewByDatabaseId(view.children || [], databaseId); + + if (child) return child; + } +} + +function resolveSearchResultView(outline: View[], item: SearchDocumentResponseItem): View | undefined { + return ( + findView(outline, item.object_id) || + (item.database_view_id ? findView(outline, item.database_view_id) : undefined) || + findViewByDatabaseId(outline, item.database_id) + ); +} + +function previewLines(text?: string | null, limit = 2): string | undefined { + const lines = text + ?.split('\n') + .map((line) => line.trim()) + .filter(Boolean) + .slice(0, limit); + + return lines?.length ? lines.join('\n') : undefined; +} + +function searchResultKey(item: SearchDocumentResponseItem): string { + return `${item.object_id}:${item.database_row_id || ''}`; +} + +function mergeSearchResults( + current: SearchDocumentResponseItem[], + next: SearchDocumentResponseItem[] +): SearchDocumentResponseItem[] { + const seen = new Set(); + const merged: SearchDocumentResponseItem[] = []; + + for (const item of [...current, ...next]) { + const key = searchResultKey(item); + + if (seen.has(key)) continue; + + seen.add(key); + merged.push(item); + } + + return merged; +} + +function canLoadMoreSearchResults(page: SearchDocumentPageResponse, requestedOffset: number): boolean { + return Boolean(page.has_more && typeof page.next_offset === 'number' && page.next_offset !== requestedOffset); +} + +function BestMatch({ onClose, searchValue, + askingAI, + onAskAI, }: { onClose: () => void; searchValue: string; + askingAI: boolean; + onAskAI: (query: string, sourceIds?: string[]) => void; }) { - const [views, setViews] = React.useState(undefined); + const [items, setItems] = React.useState(undefined); + const [searchResults, setSearchResults] = React.useState([]); + const [summary, setSummary] = React.useState(null); + const [hasMore, setHasMore] = React.useState(false); + const [nextOffset, setNextOffset] = React.useState(null); const { t } = useTranslation(); const outline = useAppOutline(); const [loading, setLoading] = React.useState(false); - + const [summaryLoading, setSummaryLoading] = React.useState(false); + const [loadingMore, setLoadingMore] = React.useState(false); + const aiEnabled = useAIEnabled(); const currentWorkspaceId = useCurrentWorkspaceId(); - const handleSearch = useCallback(async (searchTerm: string) => { - if (!outline) return; - if (!currentWorkspaceId) return; - if (!searchTerm) { - setViews([]); - return; - } + const searchSeqRef = useRef(0); + + const buildSearchItems = useCallback( + (results: SearchDocumentResponseItem[]) => { + if (!outline) return []; + + const seenTargets = new Set(); + const items: SearchViewListItem[] = []; + + for (const item of results) { + const view = resolveSearchResultView(outline, item); + const rowId = item.database_row_id || undefined; + + if (!view || view.extra?.is_space) continue; + + const viewName = view.name.trim() || t('menuAppHeader.defaultNewPageName'); + const contentPreview = previewLines(item.content || item.preview); + const targetId = `${view.view_id}:${rowId || ''}`; - setLoading(true); + if (seenTargets.has(targetId)) continue; + + seenTargets.add(targetId); + items.push({ + id: targetId, + view, + rowId, + title: rowId + ? `${t('document.grid.referencedGridPrefix', { defaultValue: 'View of' })} ${viewName}` + : undefined, + preview: contentPreview, + }); + } + + return items; + }, + [outline, t] + ); + + const handleSearch = useCallback( + async (searchTerm: string) => { + if (!outline) return; + if (!currentWorkspaceId) return; + const searchSeq = searchSeqRef.current + 1; + + searchSeqRef.current = searchSeq; + setSummary(null); + setSummaryLoading(false); + if (!searchTerm) { + setItems([]); + setSearchResults([]); + setHasMore(false); + setNextOffset(null); + setLoading(false); + setLoadingMore(false); + return; + } + + setLoading(true); + setLoadingMore(false); + setItems(undefined); + setSearchResults([]); + setHasMore(false); + setNextOffset(null); + + try { + const page = await SearchService.searchWorkspaceDocumentPage(currentWorkspaceId, searchTerm, 0); + + if (searchSeqRef.current !== searchSeq) return; + + const res = mergeSearchResults([], page.items || []); + const shouldGenerateSummary = aiEnabled && res.some((item) => item.content); + + setSearchResults(res); + setItems(buildSearchItems(res)); + setHasMore(canLoadMoreSearchResults(page, 0)); + setNextOffset(page.next_offset ?? null); + setSummaryLoading(shouldGenerateSummary); + setLoading(false); + + if (shouldGenerateSummary) { + try { + const summaryResult = await SearchService.generateSearchSummary(currentWorkspaceId, searchTerm, res); + + if (searchSeqRef.current === searchSeq) { + setSummary(summaryResult.summaries[0] || null); + } + } catch { + if (searchSeqRef.current === searchSeq) { + setSummary(null); + } + } finally { + if (searchSeqRef.current === searchSeq) { + setSummaryLoading(false); + } + } + } + // eslint-disable-next-line + } catch (e: any) { + if (searchSeqRef.current !== searchSeq) return; + notify.error(e.message); + setSearchResults([]); + setItems([]); + setSummary(null); + setSummaryLoading(false); + setHasMore(false); + setNextOffset(null); + } finally { + if (searchSeqRef.current === searchSeq) { + setLoading(false); + setLoadingMore(false); + } + } + }, + [aiEnabled, buildSearchItems, currentWorkspaceId, outline] + ); + + const handleLoadMore = useCallback(async () => { + if (!currentWorkspaceId || !searchValue || loading || loadingMore || !hasMore || nextOffset === null) return; + + const searchSeq = searchSeqRef.current; + + setLoadingMore(true); try { - const res = await SearchService.searchWorkspace(currentWorkspaceId, searchTerm); - const views = uniq(res).map(id => { - return findView(outline, id); - }); + const page = await SearchService.searchWorkspaceDocumentPage(currentWorkspaceId, searchValue, nextOffset); + + if (searchSeqRef.current !== searchSeq) return; - setViews(views.filter(item => { - if (!item) return false; - return !item.extra?.is_space; - }) as View[]); + const mergedResults = mergeSearchResults(searchResults, page.items || []); + + setSearchResults(mergedResults); + setItems(buildSearchItems(mergedResults)); + setHasMore(canLoadMoreSearchResults(page, nextOffset)); + setNextOffset(page.next_offset ?? null); // eslint-disable-next-line } catch (e: any) { + if (searchSeqRef.current !== searchSeq) return; notify.error(e.message); + } finally { + if (searchSeqRef.current === searchSeq) { + setLoadingMore(false); + } } - - setLoading(false); - - }, [currentWorkspaceId, outline]); + }, [buildSearchItems, currentWorkspaceId, hasMore, loading, loadingMore, nextOffset, searchResults, searchValue]); const debounceSearch = useMemo(() => { return debounce(handleSearch, 300); @@ -57,14 +249,68 @@ function BestMatch ({ useEffect(() => { void debounceSearch(searchValue); + + return () => { + debounceSearch.cancel(); + }; }, [searchValue, debounceSearch]); - return ; + const overviewSources = useMemo(() => { + if (!summary || !outline) return []; + + const resultByObjectId = new Map(searchResults.map((item) => [item.object_id, item])); + const resultByRowId = new Map( + searchResults.flatMap((item) => (item.database_row_id ? [[item.database_row_id, item] as const] : [])) + ); + const seen = new Set(); + + return summary.sources.reduce((sources, sourceId) => { + const result = resultByObjectId.get(sourceId) || resultByRowId.get(sourceId); + const view = findView(outline, sourceId) || (result ? resolveSearchResultView(outline, result) : undefined); + const targetViewId = view?.view_id || result?.database_view_id || sourceId; + const targetRowId = result?.database_row_id; + const targetKey = `${targetViewId}:${targetRowId || ''}:${sourceId}`; + + if (seen.has(targetKey)) return sources; + + seen.add(targetKey); + sources.push({ + id: sourceId, + targetViewId, + targetRowId, + ragId: sourceId, + view, + name: view?.name?.trim() || t('menuAppHeader.defaultNewPageName'), + }); + + return sources; + }, []); + }, [outline, searchResults, summary, t]); + + return ( + onAskAI(searchValue, sourceIds)} + /> + } + /> + ); } -export default BestMatch; \ No newline at end of file +export default BestMatch; diff --git a/src/components/app/search/ListItem.tsx b/src/components/app/search/ListItem.tsx index da28ae298..30d61364d 100644 --- a/src/components/app/search/ListItem.tsx +++ b/src/components/app/search/ListItem.tsx @@ -11,12 +11,22 @@ import PageIcon from '@/components/_shared/view-icon/PageIcon'; import { useAppOutline, useToView } from '@/components/app/app.hooks'; function ListItem({ - selectedView, + itemId, + preview, + query, + selected, + subtitle, + title, view, onClick, onClose, }: { - selectedView: string; + itemId: string; + preview?: string; + query?: string; + selected: boolean; + subtitle?: string; + title?: string; view: View; onClick: () => void; onClose: () => void; @@ -117,30 +127,78 @@ function ListItem({ ); }, [ancestors, open, renderBreadcrumb]); + const displayTitle = title?.trim() || view.name.trim() || t('menuAppHeader.defaultNewPageName'); + const displaySubtitle = subtitle?.trim(); return (
-
-
- -
-
- {view.name.trim() || t('menuAppHeader.defaultNewPageName')} -
+
+
-
-
{breadcrumbs}
+
+
+
+ +
+ {!preview && !displaySubtitle && ( +
+ {breadcrumbs} +
+ )} +
+ {preview ? ( +
+ +
+ ) : displaySubtitle ? ( +
+ {displaySubtitle} +
+ ) : null}
); } +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function HighlightedText({ text, query }: { text: string; query?: string }) { + const keyword = query?.trim(); + + if (!keyword) return <>{text}; + + const parts = text.split(new RegExp(`(${escapeRegExp(keyword)})`, 'ig')); + + return ( + <> + {parts.map((part, index) => + part.toLowerCase() === keyword.toLowerCase() ? ( + + {part} + + ) : ( + {part} + ) + )} + + ); +} + export default ListItem; diff --git a/src/components/app/search/Search.tsx b/src/components/app/search/Search.tsx index 67855676b..2434948ee 100644 --- a/src/components/app/search/Search.tsx +++ b/src/components/app/search/Search.tsx @@ -2,23 +2,119 @@ import { Dialog, InputBase } from '@mui/material'; import React, { useCallback, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; +import { View, ViewLayout } from '@/application/types'; import { ReactComponent as CloseIcon } from '@/assets/icons/close.svg'; import { ReactComponent as SearchIcon } from '@/assets/icons/search.svg'; -import { useAppRecent } from '@/components/app/app.hooks'; +import { notify } from '@/components/_shared/notify'; +import { findAncestors } from '@/components/_shared/outline/utils'; +import { + useAIEnabled, + useAppOperations, + useAppOutline, + useAppRecent, + useAppViewId, + useCurrentWorkspaceId, + useToView, +} from '@/components/app/app.hooks'; import BestMatch from '@/components/app/search/BestMatch'; import RecentViews from '@/components/app/search/RecentViews'; import { dropdownMenuItemVariants } from '@/components/ui/dropdown-menu'; import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip'; import { createHotkey, createHotKeyLabel, HOT_KEY_NAME } from '@/utils/hotkeys'; +function getAIChatParent(outline: View[] | undefined, currentViewId: string | undefined) { + if (!outline?.length) return; + + const currentPath = currentViewId ? findAncestors(outline, currentViewId) : undefined; + const currentSpace = currentPath?.find((view) => view.extra?.is_space); + + return currentSpace || outline.find((view) => view.extra?.is_space) || outline[0]; +} + +function getErrorMessage(error: unknown) { + if (error instanceof Error) return error.message; + if (typeof error === 'string') return error; + return 'Something went wrong'; +} + export function Search() { const [open, setOpen] = React.useState(false); const { t } = useTranslation(); const [searchValue, setSearchValue] = React.useState(''); - const handleClose = () => { + const [askingAI, setAskingAI] = React.useState(false); + const outline = useAppOutline(); + const currentViewId = useAppViewId(); + const currentWorkspaceId = useCurrentWorkspaceId(); + const aiEnabled = useAIEnabled(); + const { addPage } = useAppOperations(); + const toView = useToView(); + const handleClose = useCallback(() => { setOpen(false); setSearchValue(''); - }; + }, []); + + const handleAskAI = useCallback( + async (query: string, sourceIds?: string[]) => { + if (!aiEnabled || !addPage || !currentWorkspaceId) return; + + const parent = getAIChatParent(outline, currentViewId); + + if (!parent) { + notify.error(t('search.createAIChatFailed', { defaultValue: 'Unable to create an AI chat here' })); + return; + } + + setAskingAI(true); + try { + const created = await addPage(parent.view_id, { + layout: ViewLayout.AIChat, + name: query || t('chat.newChat', { defaultValue: 'New chat' }), + prev_view_id: parent.children?.[parent.children.length - 1]?.view_id, + }); + const uniqueSourceIds = Array.from(new Set(sourceIds || [])).filter(Boolean); + let settingsError: unknown; + + if (uniqueSourceIds.length > 0 || query) { + try { + const [{ ChatRequest }, { getAxiosInstance }] = await Promise.all([ + import('@/components/chat/request'), + import('@/application/services/js-services/http'), + ]); + const axiosInstance = getAxiosInstance(); + + if (!axiosInstance) { + throw new Error('Missing axios instance'); + } + + const request = new ChatRequest(currentWorkspaceId, created.view_id, axiosInstance); + + await request.updateChatSettings({ + ...(uniqueSourceIds.length > 0 ? { rag_ids: uniqueSourceIds } : {}), + ...(query ? { metadata: { initial_prompt: query } } : {}), + }); + } catch (error) { + settingsError = error; + } + } + + if (settingsError) { + notify.error( + t('search.updateAIChatSettingsFailed', { + defaultValue: 'AI chat was created, but the context could not be attached', + }) + ); + } + + await toView(created.view_id); + handleClose(); + } catch (error) { + notify.error(getErrorMessage(error)); + } finally { + setAskingAI(false); + } + }, + [addPage, aiEnabled, currentViewId, currentWorkspaceId, handleClose, outline, t, toView] + ); const onKeyDown = useCallback((e: KeyboardEvent) => { switch (true) { @@ -112,7 +208,7 @@ export function Search() { {!searchValue ? ( ) : ( - + )} diff --git a/src/components/app/search/SearchAIOverview.tsx b/src/components/app/search/SearchAIOverview.tsx new file mode 100644 index 000000000..8a0369220 --- /dev/null +++ b/src/components/app/search/SearchAIOverview.tsx @@ -0,0 +1,264 @@ +import Popover from '@mui/material/Popover'; +import React, { useMemo, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { View } from '@/application/types'; +import { ReactComponent as AISearchingIcon } from '@/assets/icons/ai_searching_icon.svg'; +import { ReactComponent as ChatAIPageIcon } from '@/assets/icons/chat_ai_page.svg'; +import { ReactComponent as HomeAIChatIcon } from '@/assets/icons/m_home_ai_chat_icon.svg'; +import { ReactComponent as ToolbarLinkIcon } from '@/assets/icons/m_toolbar_link.svg'; +import PageIcon from '@/components/_shared/view-icon/PageIcon'; +import { useToView } from '@/components/app/app.hooks'; +import { cn } from '@/lib/utils'; + +export interface SearchOverviewSource { + id: string; + name: string; + targetViewId: string; + targetRowId?: string | null; + ragId: string; + view?: View; +} + +interface SearchAIOverviewProps { + aiEnabled: boolean; + askingAI: boolean; + loading: boolean; + query: string; + sources: SearchOverviewSource[]; + summary?: { + content: string; + highlights?: string; + } | null; + onAskAI: (sourceIds?: string[]) => void; + onClose: () => void; +} + +function escapeRegExp(value: string) { + return value.replace(/[.*+?^${}()|[\]\\]/g, '\\$&'); +} + +function HighlightedSummary({ + content, + highlights, + query, + sources, + onClose, +}: { + content: string; + highlights?: string; + query: string; + sources: SearchOverviewSource[]; + onClose: () => void; +}) { + const { t } = useTranslation(); + const [expanded, setExpanded] = useState(false); + const [anchorEl, setAnchorEl] = useState(null); + const highlightText = (highlights || query).trim(); + const isLong = content.length > 520; + const parts = useMemo(() => { + if (!highlightText) return [content]; + + return content.split(new RegExp(`(${escapeRegExp(highlightText)})`, 'ig')); + }, [content, highlightText]); + const showReference = sources.length > 0 && (!isLong || expanded); + + return ( + <> +
+ {parts.map((part, index) => + highlightText && part.toLowerCase() === highlightText.toLowerCase() ? ( + + {part} + + ) : ( + {part} + ) + )} + {showReference && ( + + )} +
+ {isLong && !expanded && ( + + )} + {sources.length > 0 && ( + setAnchorEl(null)} + onCloseSearch={onClose} + sources={sources} + /> + )} + + ); +} + +function ReferenceSources({ + anchorEl, + onClosePopover, + onCloseSearch, + sources, +}: { + anchorEl: HTMLElement | null; + onClosePopover: () => void; + onCloseSearch: () => void; + sources: SearchOverviewSource[]; +}) { + const { t } = useTranslation(); + const navigateToView = useToView(); + const open = Boolean(anchorEl); + + return ( + +
+ {t('commandPalette.aiOverviewSource', { defaultValue: 'Reference sources' })} +
+
+ {sources.map((source) => ( + + ))} +
+
+ ); +} + +function AskAIButton({ askingAI, query, onAskAI }: { askingAI: boolean; query: string; onAskAI: () => void }) { + const { t } = useTranslation(); + + return ( + + ); +} + +export function SearchAIOverview({ + aiEnabled, + askingAI, + loading, + query, + sources, + summary, + onAskAI, + onClose, +}: SearchAIOverviewProps) { + const { t } = useTranslation(); + + if (!aiEnabled) return null; + + if (loading) { + return ( +
+
+ + {t('search.searching', { defaultValue: 'Searching...' })} +
+
+ ); + } + + if (!summary?.content) { + return ( +
+ onAskAI()} /> +
+ ); + } + + return ( +
+
+ + {t('commandPalette.aiOverview', { defaultValue: 'AI overview' })} +
+ + +
+ ); +} diff --git a/src/components/app/search/ViewList.tsx b/src/components/app/search/ViewList.tsx index 940a78c7b..51ea0ebd4 100644 --- a/src/components/app/search/ViewList.tsx +++ b/src/components/app/search/ViewList.tsx @@ -7,29 +7,67 @@ import { useToView } from '@/components/app/app.hooks'; import ListItem from '@/components/app/search/ListItem'; import { createHotkey, HOT_KEY_NAME } from '@/utils/hotkeys'; +export interface SearchViewListItem { + id: string; + view: View; + rowId?: string | null; + title?: string; + subtitle?: string; + preview?: string; +} + function ViewList({ title, + items, views, onClose, loading, + loadingMore, + hasMore, + header, + query, + onLoadMore, }: { title: string; + items?: SearchViewListItem[]; views?: View[]; onClose: () => void; loading: boolean; + loadingMore?: boolean; + hasMore?: boolean; + header?: React.ReactNode; + query?: string; + onLoadMore?: () => void; }) { const { t } = useTranslation(); - const [selectedView, setSelectedView] = React.useState(''); + const [selectedItemId, setSelectedItemId] = React.useState(''); const navigateToView = useToView(); const ref = React.useRef(null); + const listItems = React.useMemo(() => { + if (items) return items; + + return views?.map((view) => ({ + id: view.view_id, + view, + })); + }, [items, views]); + + useEffect(() => { + setSelectedItemId(''); + }, [listItems]); useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { - if (!views) return; - if (createHotkey(HOT_KEY_NAME.ENTER)(e) && selectedView) { + if (!listItems?.length) return; + if (createHotkey(HOT_KEY_NAME.ENTER)(e) && selectedItemId) { e.preventDefault(); e.stopPropagation(); - void navigateToView(selectedView); + const selectedItem = listItems.find((item) => item.id === selectedItemId); + + if (selectedItem) { + void navigateToView(selectedItem.view.view_id, selectedItem.rowId || undefined); + } + onClose(); } else if ( createHotkey(HOT_KEY_NAME.DOWN)(e) || @@ -37,21 +75,21 @@ function ViewList({ createHotkey(HOT_KEY_NAME.TAB)(e) ) { e.preventDefault(); - const currentIndex = views.findIndex((view) => view.view_id === selectedView); - let nextViewId = ''; + const currentIndex = listItems.findIndex((item) => item.id === selectedItemId); + let nextItemId = ''; if (currentIndex === -1) { - nextViewId = views[0].view_id; + nextItemId = listItems[0].id; } else { if (createHotkey(HOT_KEY_NAME.DOWN)(e) || createHotkey(HOT_KEY_NAME.TAB)(e)) { - nextViewId = views[(currentIndex + 1) % views.length].view_id; + nextItemId = listItems[(currentIndex + 1) % listItems.length].id; } else { - nextViewId = views[(currentIndex - 1 + views.length) % views.length].view_id; + nextItemId = listItems[(currentIndex - 1 + listItems.length) % listItems.length].id; } } - setSelectedView(nextViewId); - const el = ref.current?.querySelector(`[data-item-id="${nextViewId}"]`); + setSelectedItemId(nextItemId); + const el = ref.current?.querySelector(`[data-item-id="${nextItemId}"]`); if (el) { el.scrollIntoView({ @@ -67,12 +105,13 @@ function ViewList({ return () => { document.removeEventListener('keydown', handleKeyDown); }; - }, [navigateToView, onClose, views, selectedView]); + }, [navigateToView, onClose, listItems, selectedItemId]); return (
+ {header}
- {!loading && views && views.length === 0 ? ( + {!loading && listItems && listItems.length === 0 ? ( t('noSearchResults') ) : ( <> @@ -82,19 +121,43 @@ function ViewList({ )}
- {views?.map((view) => ( + {listItems?.map((item) => ( { - setSelectedView(view.view_id); - void navigateToView(view.view_id); + setSelectedItemId(item.id); + void navigateToView(item.view.view_id, item.rowId || undefined); onClose(); }} onClose={onClose} /> ))} + {hasMore && ( + + )}
TAB diff --git a/src/components/chat/components/chat-messages/empty-messages.tsx b/src/components/chat/components/chat-messages/empty-messages.tsx index 9538d35ee..a2a5497d7 100644 --- a/src/components/chat/components/chat-messages/empty-messages.tsx +++ b/src/components/chat/components/chat-messages/empty-messages.tsx @@ -1,4 +1,4 @@ -import { useCallback } from 'react'; +import { useCallback, useEffect, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import { ReactComponent as Logo } from '@/assets/icons/logo.svg'; @@ -12,9 +12,34 @@ export function EmptyMessages({ currentUser }: { currentUser?: User; }) { const { t } = useTranslation(); - const { - submitQuestion, - } = useMessagesHandlerContext(); + const { chatSettings, submitQuestion, updateChatSettings } = useMessagesHandlerContext(); + const initialPromptSubmittedRef = useRef(false); + + useEffect(() => { + const metadata = chatSettings?.metadata; + const initialPrompt = typeof metadata?.initial_prompt === 'string' ? metadata.initial_prompt.trim() : ''; + const consumed = metadata?.initial_prompt_consumed === true; + + if (!initialPrompt || consumed || initialPromptSubmittedRef.current) { + return; + } + + initialPromptSubmittedRef.current = true; + void (async () => { + try { + await submitQuestion(initialPrompt); + await updateChatSettings({ + metadata: { + ...metadata, + initial_prompt_consumed: true, + }, + }); + } catch (e) { + initialPromptSubmittedRef.current = false; + console.error(e); + } + })(); + }, [chatSettings, submitQuestion, updateChatSettings]); const handleClick = useCallback(async(content: string) => { try { @@ -67,4 +92,3 @@ export function EmptyMessages({ currentUser }: {
); } - diff --git a/src/components/editor/components/blocks/simple-table/SimpleTable.tsx b/src/components/editor/components/blocks/simple-table/SimpleTable.tsx index 2aacc9867..de99470ea 100644 --- a/src/components/editor/components/blocks/simple-table/SimpleTable.tsx +++ b/src/components/editor/components/blocks/simple-table/SimpleTable.tsx @@ -1,131 +1,161 @@ import isEqual from 'lodash-es/isEqual'; -import { forwardRef, memo, useCallback, useMemo, useState } from 'react'; +import { Children, forwardRef, memo, useCallback, useMemo, useState } from 'react'; import { useReadOnly } from 'slate-react'; +import { BlockType } from '@/application/types'; import { useSimpleTable } from '@/components/editor/components/blocks/simple-table/SimpleTable.hooks'; import { SimpleTableActionButtons } from '@/components/editor/components/blocks/simple-table/SimpleTableActionButtons'; import { SimpleTableActionOverlay } from '@/components/editor/components/blocks/simple-table/SimpleTableActionOverlay'; -import { SimpleTableContext, SimpleTableContextValue } from '@/components/editor/components/blocks/simple-table/SimpleTableContext'; +import { + SimpleTableContext, + SimpleTableContextValue, +} from '@/components/editor/components/blocks/simple-table/SimpleTableContext'; import { EditorElementProps, SimpleTableNode, SimpleTableRowNode } from '@/components/editor/editor.type'; import './simple-table.scss'; import { DEFAULT_COLUMN_WIDTH } from '@/components/editor/components/blocks/simple-table/const'; +import { getSlateNodeType, isSimpleTableCellNode, isSimpleTableRowNode } from './simple-table.utils'; + const SimpleTable = memo( - forwardRef>(({ - node, - children, - className: classNameProp, - ...attributes - }, ref) => { - const readOnly = useReadOnly(); - const { data, children: rows } = node; - const { column_widths, column_colors, enable_header_column, enable_header_row } = data; - - const [isHoveringTable, setIsHoveringTable] = useState(false); - const [hoveringCell, setHoveringCell] = useState<{ row: number; col: number } | null>(null); - const [isMenuOpen, setIsMenuOpen] = useState(false); - - const columnCount = useMemo(() => { - const firstRow = rows[0] as SimpleTableRowNode; - - if (!firstRow) return 0; - - return firstRow.children.length; - }, [rows]); - - const columns = useMemo(() => { - return Array.from({ length: columnCount }, (_, index) => { - const width = column_widths?.[index] || DEFAULT_COLUMN_WIDTH; - const bgColor = column_colors?.[index] || 'transparent'; - - return { width, bgColor }; - }); - }, [columnCount, column_colors, column_widths]); - const tableWidth = useMemo(() => { - return columns.reduce((sum, column) => sum + column.width, 0); - }, [columns]); - const colGroup = useMemo(() => { - if (!columns) return null; - return - {columns.map((column, index) => ( - - ))} - ; - }, [columns]); - const { isIntersection } = useSimpleTable(node); - - const className = useMemo(() => { - const classList = ['simple-table', 'appflowy-scroller']; - - if (classNameProp) { - classList.push(classNameProp); - } - - if (enable_header_column) { - classList.push('enable-header-column'); - } - - if (enable_header_row) { - classList.push('enable-header-row'); - } - - if (isIntersection) { - classList.push('selected'); - } - - return classList.join(' '); - }, [classNameProp, enable_header_column, enable_header_row, isIntersection]); - - const handleMouseEnter = useCallback(() => { - setIsHoveringTable(true); - }, []); - - const handleMouseLeave = useCallback(() => { - if (isMenuOpen) return; // Don't clear hover state while context menu is open - setIsHoveringTable(false); - setHoveringCell(null); - }, [isMenuOpen]); - - const contextValue = useMemo(() => ({ - tableNode: node, - isHoveringTable, - hoveringCell, - readOnly, - setHoveringCell, - isMenuOpen, - setIsMenuOpen, - }), [node, isHoveringTable, hoveringCell, readOnly, isMenuOpen]); - - return ( -
- -
-
- - {colGroup} - - {children} - -
+ forwardRef>( + ({ node, children, className: classNameProp, ...attributes }, ref) => { + const readOnly = useReadOnly(); + const { data, children: rows } = node; + const { column_widths, column_colors, enable_header_column, enable_header_row } = data; + + const [isHoveringTable, setIsHoveringTable] = useState(false); + const [hoveringCell, setHoveringCell] = useState<{ row: number; col: number } | null>(null); + const [isMenuOpen, setIsMenuOpen] = useState(false); + + const rowNodes = useMemo(() => rows.filter(isSimpleTableRowNode), [rows]); + const renderedRows = useMemo( + () => + Children.toArray(children).filter( + (_, index) => getSlateNodeType(rows[index]) === BlockType.SimpleTableRowBlock + ), + [children, rows] + ); + const columnCount = useMemo(() => { + const firstRow = rowNodes[0] as SimpleTableRowNode | undefined; + + if (!firstRow) return 0; + + return firstRow.children.filter(isSimpleTableCellNode).length; + }, [rowNodes]); + const cellPositionById = useMemo(() => { + const positions = new Map(); + + rowNodes.forEach((row, rowIndex) => { + let colIndex = 0; + + row.children.forEach((child) => { + if (!isSimpleTableCellNode(child)) return; + + positions.set(child.blockId, { row: rowIndex, col: colIndex }); + colIndex += 1; + }); + }); + + return positions; + }, [rowNodes]); + + const columns = useMemo(() => { + return Array.from({ length: columnCount }, (_, index) => { + const width = column_widths?.[index] || DEFAULT_COLUMN_WIDTH; + const bgColor = column_colors?.[index] || 'transparent'; + + return { width, bgColor }; + }); + }, [columnCount, column_colors, column_widths]); + const tableWidth = useMemo(() => { + return columns.reduce((sum, column) => sum + column.width, 0); + }, [columns]); + const colGroup = useMemo(() => { + if (!columns) return null; + return ( + + {columns.map((column, index) => ( + + ))} + + ); + }, [columns]); + const { isIntersection } = useSimpleTable(node); + + const className = useMemo(() => { + const classList = ['simple-table', 'appflowy-scroller']; + + if (classNameProp) { + classList.push(classNameProp); + } + + if (enable_header_column) { + classList.push('enable-header-column'); + } + + if (enable_header_row) { + classList.push('enable-header-row'); + } + + if (isIntersection) { + classList.push('selected'); + } + + return classList.join(' '); + }, [classNameProp, enable_header_column, enable_header_row, isIntersection]); + + const handleMouseEnter = useCallback(() => { + setIsHoveringTable(true); + }, []); + + const handleMouseLeave = useCallback(() => { + if (isMenuOpen) return; // Don't clear hover state while context menu is open + setIsHoveringTable(false); + setHoveringCell(null); + }, [isMenuOpen]); + + const contextValue = useMemo( + () => ({ + tableNode: node, + cellPositionById, + rowCount: rowNodes.length, + columnCount, + isHoveringTable, + hoveringCell, + readOnly, + setHoveringCell, + isMenuOpen, + setIsMenuOpen, + }), + [node, cellPositionById, rowNodes.length, columnCount, isHoveringTable, hoveringCell, readOnly, isMenuOpen] + ); + + return ( +
+ +
+
+ + {colGroup} + {renderedRows} +
+
+ +
- - -
- -
- ); - }), - (prevProps, nextProps) => isEqual(prevProps.node, nextProps.node), + +
+ ); + } + ), + (prevProps, nextProps) => isEqual(prevProps.node, nextProps.node) ); export default SimpleTable; diff --git a/src/components/editor/components/blocks/simple-table/SimpleTableCell.tsx b/src/components/editor/components/blocks/simple-table/SimpleTableCell.tsx index 00cf1bf56..76a069105 100644 --- a/src/components/editor/components/blocks/simple-table/SimpleTableCell.tsx +++ b/src/components/editor/components/blocks/simple-table/SimpleTableCell.tsx @@ -1,86 +1,71 @@ -import { forwardRef, useCallback } from 'react'; -import { ReactEditor, useSlate } from 'slate-react'; +import { Children, forwardRef, useCallback, useMemo } from 'react'; +import { BlockType, YjsEditorKey } from '@/application/types'; import { DEFAULT_COLUMN_WIDTH, MIN_WIDTH } from '@/components/editor/components/blocks/simple-table/const'; import { EditorElementProps, SimpleTableCellBlockNode } from '@/components/editor/editor.type'; import { renderColor } from '@/utils/color'; import { SimpleTableColumnResizer } from './SimpleTableColumnResizer'; import { useSimpleTableContext } from './SimpleTableContext'; +import { getSlateNodeType } from './simple-table.utils'; -const SimpleTableCell = - forwardRef>(({ - node, - children, - ...attributes - }, ref) => { - const { blockId } = node; - const editor = useSlate(); - const context = useSimpleTableContext(); - const readOnly = context?.readOnly ?? true; +const SimpleTableCell = forwardRef>( + ({ node, children, ...attributes }, ref) => { + const { blockId } = node; + const context = useSimpleTableContext(); + const readOnly = context?.readOnly ?? true; + const cellPosition = context?.cellPositionById.get(blockId); + const rowIndex = cellPosition?.row ?? 0; + const colIndex = cellPosition?.col ?? 0; - // Use ReactEditor.findPath directly — always returns current path - let rowIndex = 0; - let colIndex = 0; + // Read styling from context (always up-to-date) + const tableData = context?.tableNode?.data; - try { - const path = ReactEditor.findPath(editor as ReactEditor, node); + const renderedChildren = useMemo( + () => + Children.toArray(children).filter((_, index) => { + const child = node.children[index]; + const childType = getSlateNodeType(child); - // Path structure: [..., tableIndex, rowIndex, cellIndex] - if (path.length >= 2) { - colIndex = path[path.length - 1]; - rowIndex = path[path.length - 2]; - } - } catch { - // fallback to 0,0 - } + return childType !== YjsEditorKey.text && childType !== BlockType.SimpleTableRowBlock; + }), + [children, node.children] + ); - // Read styling from context (always up-to-date) - const tableData = context?.tableNode?.data; + const horizontalAlign = tableData?.column_aligns?.[colIndex]; + const bgColor = tableData?.column_colors?.[colIndex]; + const width = tableData?.column_widths?.[colIndex] || DEFAULT_COLUMN_WIDTH; - const horizontalAlign = tableData?.column_aligns?.[colIndex]; - const bgColor = tableData?.column_colors?.[colIndex]; - const width = tableData?.column_widths?.[colIndex] || DEFAULT_COLUMN_WIDTH; + // Report hover state to parent context + const handleMouseEnter = useCallback(() => { + context?.setHoveringCell({ row: rowIndex, col: colIndex }); + }, [context, rowIndex, colIndex]); - // Report hover state to parent context - const handleMouseEnter = useCallback(() => { - context?.setHoveringCell({ row: rowIndex, col: colIndex }); - }, [context, rowIndex, colIndex]); - - return ( - -
- {children} -
- {!readOnly && ( - - )} - - ); - }, - ); + return ( + +
{renderedChildren}
+ {!readOnly && } + + ); + } +); export default SimpleTableCell; diff --git a/src/components/editor/components/blocks/simple-table/SimpleTableContext.tsx b/src/components/editor/components/blocks/simple-table/SimpleTableContext.tsx index 863acdd28..50b19da8e 100644 --- a/src/components/editor/components/blocks/simple-table/SimpleTableContext.tsx +++ b/src/components/editor/components/blocks/simple-table/SimpleTableContext.tsx @@ -2,8 +2,16 @@ import { createContext, useContext } from 'react'; import { SimpleTableNode } from '@/components/editor/editor.type'; +export interface SimpleTableCellPosition { + row: number; + col: number; +} + export interface SimpleTableContextValue { tableNode: SimpleTableNode; + cellPositionById: Map; + rowCount: number; + columnCount: number; isHoveringTable: boolean; hoveringCell: { row: number; col: number } | null; readOnly: boolean; diff --git a/src/components/editor/components/blocks/simple-table/SimpleTableContextMenu.tsx b/src/components/editor/components/blocks/simple-table/SimpleTableContextMenu.tsx index c2e7e62b2..38cda4f74 100644 --- a/src/components/editor/components/blocks/simple-table/SimpleTableContextMenu.tsx +++ b/src/components/editor/components/blocks/simple-table/SimpleTableContextMenu.tsx @@ -338,6 +338,13 @@ function useHighlight(type: 'row' | 'column', index: number, isOpen: boolean) { }, [isOpen, type, index, context]); } +function getTableContainerWidth(anchor: HTMLElement | null) { + const rootWrapper = anchor?.closest('.simple-table-root-wrapper'); + const scrollContainer = rootWrapper?.querySelector('.simple-table-scroll-container'); + + return scrollContainer instanceof HTMLElement ? scrollContainer.clientWidth : undefined; +} + // ============================================================================ // Action trigger icon buttons // ============================================================================ @@ -409,9 +416,8 @@ export function RowActionTrigger({ rowIndex }: { rowIndex: number }) { }, [context]); const tableBlockId = context?.tableNode.blockId ?? ''; - const rowCount = (context?.tableNode.children?.length ?? 0); - const firstRow = context?.tableNode.children?.[0]; - const colCount = firstRow ? (firstRow as { children: unknown[] }).children?.length ?? 0 : 0; + const rowCount = context?.rowCount ?? 0; + const colCount = context?.columnCount ?? 0; const actions = useMemo(() => { const items: MenuAction[] = [ @@ -470,11 +476,7 @@ export function RowActionTrigger({ rowIndex }: { rowIndex: number }) { label: 'Set to page width', icon: , onClick: () => { - // Find the scroll container that holds this table - const firstCellOfTable = context?.tableNode?.children?.[0]; - const firstCellBlockId = firstCellOfTable ? (firstCellOfTable as { children?: Array<{ blockId?: string }> }).children?.[0]?.blockId : null; - const cellEl = firstCellBlockId ? document.querySelector(`[data-block-cell="${firstCellBlockId}"]`) : null; - const containerWidth = (cellEl?.closest('.simple-table-scroll-container') ?? cellEl?.closest('.simple-table'))?.clientWidth; + const containerWidth = getTableContainerWidth(buttonRef.current); if (containerWidth && colCount > 0) { // Set to page width: divide page width equally among all columns @@ -495,11 +497,7 @@ export function RowActionTrigger({ rowIndex }: { rowIndex: number }) { label: 'Distribute columns evenly', icon: , onClick: () => { - // Distribute evenly: same as set to page width — equal width columns - const firstCellOfTable = context?.tableNode?.children?.[0]; - const firstCellBlockId = firstCellOfTable ? (firstCellOfTable as { children?: Array<{ blockId?: string }> }).children?.[0]?.blockId : null; - const cellEl = firstCellBlockId ? document.querySelector(`[data-block-cell="${firstCellBlockId}"]`) : null; - const containerWidth = (cellEl?.closest('.simple-table-scroll-container') ?? cellEl?.closest('.simple-table'))?.clientWidth; + const containerWidth = getTableContainerWidth(buttonRef.current); if (containerWidth && colCount > 0) { const evenWidth = Math.max(MIN_WIDTH, Math.floor(containerWidth / colCount)); @@ -601,8 +599,7 @@ export function ColumnActionTrigger({ colIndex }: { colIndex: number }) { }, [context]); const tableBlockId = context?.tableNode.blockId ?? ''; - const firstRow = context?.tableNode.children?.[0]; - const colCount = firstRow ? (firstRow as { children: unknown[] }).children?.length ?? 0 : 0; + const colCount = context?.columnCount ?? 0; const actions = useMemo(() => { const items: MenuAction[] = [ @@ -661,11 +658,7 @@ export function ColumnActionTrigger({ colIndex }: { colIndex: number }) { label: 'Set to page width', icon: , onClick: () => { - // Find the scroll container that holds this table - const firstCellOfTable = context?.tableNode?.children?.[0]; - const firstCellBlockId = firstCellOfTable ? (firstCellOfTable as { children?: Array<{ blockId?: string }> }).children?.[0]?.blockId : null; - const cellEl = firstCellBlockId ? document.querySelector(`[data-block-cell="${firstCellBlockId}"]`) : null; - const containerWidth = (cellEl?.closest('.simple-table-scroll-container') ?? cellEl?.closest('.simple-table'))?.clientWidth; + const containerWidth = getTableContainerWidth(buttonRef.current); if (containerWidth && colCount > 0) { const evenWidth = Math.max(MIN_WIDTH, Math.floor(containerWidth / colCount)); @@ -685,10 +678,7 @@ export function ColumnActionTrigger({ colIndex }: { colIndex: number }) { label: 'Distribute columns evenly', icon: , onClick: () => { - const firstCellOfTable = context?.tableNode?.children?.[0]; - const firstCellBlockId = firstCellOfTable ? (firstCellOfTable as { children?: Array<{ blockId?: string }> }).children?.[0]?.blockId : null; - const cellEl = firstCellBlockId ? document.querySelector(`[data-block-cell="${firstCellBlockId}"]`) : null; - const containerWidth = (cellEl?.closest('.simple-table-scroll-container') ?? cellEl?.closest('.simple-table'))?.clientWidth; + const containerWidth = getTableContainerWidth(buttonRef.current); if (containerWidth && colCount > 0) { const evenWidth = Math.max(MIN_WIDTH, Math.floor(containerWidth / colCount)); diff --git a/src/components/editor/components/blocks/simple-table/SimpleTableRow.tsx b/src/components/editor/components/blocks/simple-table/SimpleTableRow.tsx index ff181b950..8231204ea 100644 --- a/src/components/editor/components/blocks/simple-table/SimpleTableRow.tsx +++ b/src/components/editor/components/blocks/simple-table/SimpleTableRow.tsx @@ -1,47 +1,57 @@ -import { forwardRef } from 'react'; +import { Children, forwardRef, useMemo } from 'react'; import { ReactEditor, useSlate } from 'slate-react'; +import { BlockType } from '@/application/types'; import { EditorElementProps, SimpleTableRowNode } from '@/components/editor/editor.type'; import { renderColor } from '@/utils/color'; import { useSimpleTableContext } from './SimpleTableContext'; - -const SimpleTableRow = - forwardRef>(({ - node, - children, - ...attributes - }, ref) => { - const { blockId } = node; - const context = useSimpleTableContext(); - const editor = useSlate(); - const path = ReactEditor.findPath(editor, node); - - // Use the Slate path's last element as the row index — always current - const index = path[path.length - 1]; - - const tableData = context?.tableNode?.data; - const align = tableData?.row_aligns?.[index]; - const bgColor = tableData?.row_colors?.[index]; - - return ( - - {children} - - ); - }, - ); - -export default SimpleTableRow; \ No newline at end of file +import { getSlateNodeType, isSimpleTableRowNode } from './simple-table.utils'; + +const SimpleTableRow = forwardRef>( + ({ node, children, ...attributes }, ref) => { + const { blockId } = node; + const context = useSimpleTableContext(); + const editor = useSlate(); + const path = ReactEditor.findPath(editor, node); + const renderedCells = useMemo( + () => + Children.toArray(children).filter( + (_, index) => getSlateNodeType(node.children[index]) === BlockType.SimpleTableCellBlock + ), + [children, node.children] + ); + const tableRows = useMemo( + () => context?.tableNode.children.filter(isSimpleTableRowNode) ?? [], + [context?.tableNode.children] + ); + const tableRowIndex = tableRows.findIndex((row) => row.blockId === blockId); + + // Prefer the semantic table row index; pasted tables may have a hidden + // Slate text child before the first row while the view is updating. + const index = tableRowIndex >= 0 ? tableRowIndex : path[path.length - 1]; + + const tableData = context?.tableNode?.data; + const align = tableData?.row_aligns?.[index]; + const bgColor = tableData?.row_colors?.[index]; + + return ( + + {renderedCells} + + ); + } +); + +export default SimpleTableRow; diff --git a/src/components/editor/components/blocks/simple-table/simple-table.utils.ts b/src/components/editor/components/blocks/simple-table/simple-table.utils.ts new file mode 100644 index 000000000..609caa922 --- /dev/null +++ b/src/components/editor/components/blocks/simple-table/simple-table.utils.ts @@ -0,0 +1,14 @@ +import { BlockType } from '@/application/types'; +import { SimpleTableCellBlockNode, SimpleTableRowNode } from '@/components/editor/editor.type'; + +export function getSlateNodeType(node: unknown): string | undefined { + return node && typeof node === 'object' ? (node as { type?: string }).type : undefined; +} + +export function isSimpleTableRowNode(node: unknown): node is SimpleTableRowNode { + return getSlateNodeType(node) === BlockType.SimpleTableRowBlock; +} + +export function isSimpleTableCellNode(node: unknown): node is SimpleTableCellBlockNode { + return getSlateNodeType(node) === BlockType.SimpleTableCellBlock; +} diff --git a/src/components/editor/plugins/withPasted.ts b/src/components/editor/plugins/withPasted.ts index 718a4ac56..f2ca46ed7 100644 --- a/src/components/editor/plugins/withPasted.ts +++ b/src/components/editor/plugins/withPasted.ts @@ -6,9 +6,21 @@ import { YjsEditor } from '@/application/slate-yjs'; import { EditorMarkFormat } from '@/application/slate-yjs/types'; import { SOFT_BREAK_TYPES } from '@/application/slate-yjs/command/const'; import { slateContentInsertToYData } from '@/application/slate-yjs/utils/convert'; -import { getBlockEntry, getSharedRoot, getParentSimpleTableCellBlockId, isInsideSimpleTableCell } from '@/application/slate-yjs/utils/editor'; +import { + getBlockEntry, + getSharedRoot, + getParentSimpleTableCellBlockId, + isInsideSimpleTableCell, +} from '@/application/slate-yjs/utils/editor'; import { assertDocExists, getBlock, getChildrenArray, getText } from '@/application/slate-yjs/utils/yjs'; -import { BlockType, LinkPreviewBlockData, MentionType, VideoBlockData, VideoType, YjsEditorKey } from '@/application/types'; +import { + BlockType, + LinkPreviewBlockData, + MentionType, + VideoBlockData, + VideoType, + YjsEditorKey, +} from '@/application/types'; import { parseHTML } from '@/components/editor/parsers/html-parser'; import { parseMarkdown } from '@/components/editor/parsers/markdown-parser'; import { parsePlainTextFragments } from '@/components/editor/parsers/paste-fragment-detectors'; @@ -73,7 +85,7 @@ export const withPasted = (editor: ReactEditor) => { const cellTexts = extractCellTextsFromHTML(html); if (cellTexts.length > 0) { - const tsvText = cellTexts.map(row => row.join('\t')).join('\n'); + const tsvText = cellTexts.map((row) => row.join('\t')).join('\n'); return handlePasteIntoTableCells(editor as YjsEditor, blockId, tsvText); } @@ -117,11 +129,11 @@ function extractCellTextsFromHTML(html: string): string[][] { const result: string[][] = []; - rows.forEach(row => { + rows.forEach((row) => { const cells = row.querySelectorAll('td, th'); const rowTexts: string[] = []; - cells.forEach(cell => { + cells.forEach((cell) => { rowTexts.push(cell.textContent?.trim() ?? ''); }); @@ -170,7 +182,7 @@ function handlePasteIntoTableCells(editor: YjsEditor, blockId: string, text: str if (cellIndex === -1) return false; // Parse TSV: split by tabs for columns, newlines for rows - const rows = text.split('\n').filter(line => line.length > 0); + const rows = text.split('\n').filter((line) => line.length > 0); if (rows.length === 0) return false; @@ -466,7 +478,9 @@ function getInsertedURLRange(editor: ReactEditor, url: string, insertionPoint: B return cloneRange(selection); } - const end = selection ? Range.end(selection) : { path: insertionPoint.path, offset: insertionPoint.offset + url.length }; + const end = selection + ? Range.end(selection) + : { path: insertionPoint.path, offset: insertionPoint.offset + url.length }; const start = { path: [...end.path], offset: Math.max(0, end.offset - url.length), @@ -616,6 +630,14 @@ function insertBlock(editor: ReactEditor, block: { type: BlockType; data: object function parsedBlockToSlateElement(block: ParsedBlock): Element { const { type, data, children } = block; + if (SIMPLE_TABLE_CONTAINER_BLOCK_TYPES.includes(type)) { + return { + type, + data, + children: children.map(parsedBlockToSlateElement), + } as Element; + } + // Convert text + formats to Slate text nodes const textNodes = parsedBlockToTextNodes(block); @@ -632,6 +654,12 @@ function parsedBlockToSlateElement(block: ParsedBlock): Element { } as Element; } +const SIMPLE_TABLE_CONTAINER_BLOCK_TYPES = [ + BlockType.SimpleTableBlock, + BlockType.SimpleTableRowBlock, + BlockType.SimpleTableCellBlock, +]; + /** * Converts ParsedBlock text to Slate text nodes with formats */ @@ -735,8 +763,8 @@ function insertParsedBlocks(editor: ReactEditor, blocks: ParsedBlock[]): boolean if (insideTable) { // Split blocks: text-like blocks go inside the cell, table blocks go after the parent table - const cellBlocks = blocks.filter(b => !TABLE_BLOCK_TYPES.includes(b.type)); - const tableBlocks = blocks.filter(b => TABLE_BLOCK_TYPES.includes(b.type)); + const cellBlocks = blocks.filter((b) => !TABLE_BLOCK_TYPES.includes(b.type)); + const tableBlocks = blocks.filter((b) => TABLE_BLOCK_TYPES.includes(b.type)); // Insert text blocks inside the cell if (cellBlocks.length > 0) {