From 5d48456ad0df8237ed7065987c6789046e79b578 Mon Sep 17 00:00:00 2001 From: Juan Mardikian Date: Fri, 22 May 2026 10:13:15 -0400 Subject: [PATCH 1/3] fix: highlight data block table when users select it to detect selected query data block or detect them --- apps/web/core/blocks/data/use-data-block.tsx | 6 +- .../blocks/table/table-block-context-menu.tsx | 17 +++++- apps/web/partials/editor/block-separator.tsx | 57 +++++++++++++++++++ apps/web/partials/editor/data-node.tsx | 51 +++++++++++++++-- apps/web/partials/editor/editor.tsx | 23 ++++++++ apps/web/partials/editor/extensions.tsx | 2 + apps/web/styles/tiptap.css | 27 +++++++++ 7 files changed, 176 insertions(+), 7 deletions(-) create mode 100644 apps/web/partials/editor/block-separator.tsx diff --git a/apps/web/core/blocks/data/use-data-block.tsx b/apps/web/core/blocks/data/use-data-block.tsx index 50b68e2e9c..0980d9396d 100644 --- a/apps/web/core/blocks/data/use-data-block.tsx +++ b/apps/web/core/blocks/data/use-data-block.tsx @@ -496,6 +496,7 @@ const DataBlockContext = React.createContext<{ reset: () => void; canJumpTo: (target: number) => boolean; maxJumpPages: number; + onRemoveFromEditor?: () => void; } | null>(null); interface Props { @@ -503,9 +504,10 @@ interface Props { children: React.ReactNode; entityId: string; relationId: string; + onRemoveFromEditor?: () => void; } -export function DataBlockProvider({ spaceId, children, entityId, relationId }: Props) { +export function DataBlockProvider({ spaceId, children, entityId, relationId, onRemoveFromEditor }: Props) { const { pageNumber, currentAfter, currentOffset, setPage, recordEndCursor, reset, canJumpTo, maxJumpPages } = usePagination(entityId); @@ -522,6 +524,7 @@ export function DataBlockProvider({ spaceId, children, entityId, relationId }: P reset, canJumpTo, maxJumpPages, + onRemoveFromEditor, }; }, [ spaceId, @@ -535,6 +538,7 @@ export function DataBlockProvider({ spaceId, children, entityId, relationId }: P reset, canJumpTo, maxJumpPages, + onRemoveFromEditor, ]); return {children}; diff --git a/apps/web/partials/blocks/table/table-block-context-menu.tsx b/apps/web/partials/blocks/table/table-block-context-menu.tsx index 75d103bd36..12f9022979 100644 --- a/apps/web/partials/blocks/table/table-block-context-menu.tsx +++ b/apps/web/partials/blocks/table/table-block-context-menu.tsx @@ -16,6 +16,7 @@ import { Cog } from '~/design-system/icons/cog'; import { Context } from '~/design-system/icons/context'; import { Copy } from '~/design-system/icons/copy'; import { Relation } from '~/design-system/icons/relation'; +import { Trash } from '~/design-system/icons/trash'; import { MenuItem } from '~/design-system/menu'; import { trapWheelToElement } from '~/design-system/trap-wheel-scroll'; import { useAdaptiveDropdownPlacement } from '~/design-system/use-adaptive-dropdown-placement'; @@ -38,7 +39,7 @@ type TableBlockContextMenuProps = { export function TableBlockContextMenu({ sourceType }: TableBlockContextMenuProps) { const [isMenuOpen, setIsMenuOpen] = React.useState(false); const triggerRef = React.useRef(null); - const { spaceId, entityId, relationId } = useDataBlockInstance(); + const { spaceId, entityId, relationId, onRemoveFromEditor } = useDataBlockInstance(); const isEditing = useUserIsEditing(spaceId); const [isEditingProperties, setIsEditingProperties] = useAtom(editingPropertiesAtom); @@ -58,6 +59,12 @@ export function TableBlockContextMenu({ sourceType }: TableBlockContextMenuProps } }; + const onRemoveBlock = () => { + setIsMenuOpen(false); + setIsEditingProperties(false); + onRemoveFromEditor?.(); + }; + const onOpenChange = (open: boolean) => { setIsMenuOpen(open); if (!open) { @@ -122,6 +129,14 @@ export function TableBlockContextMenu({ sourceType }: TableBlockContextMenuProps + {isEditing && onRemoveFromEditor && ( + +
+ Remove + +
+
+ )} )} diff --git a/apps/web/partials/editor/block-separator.tsx b/apps/web/partials/editor/block-separator.tsx new file mode 100644 index 0000000000..ae0b019ebc --- /dev/null +++ b/apps/web/partials/editor/block-separator.tsx @@ -0,0 +1,57 @@ +import { Extension } from '@tiptap/core'; +import { Plugin, PluginKey } from '@tiptap/pm/state'; + +const BLOCK_SEPARATOR_TYPES = new Set(['tableNode', 'image', 'video']); + +function needsSeparator(typeName: string) { + return BLOCK_SEPARATOR_TYPES.has(typeName); +} + +/** + * Ensures an empty paragraph exists between data blocks, images, and videos. + */ +export const BlockSeparator = Extension.create({ + name: 'blockSeparator', + + addProseMirrorPlugins() { + const key = new PluginKey(this.name); + + return [ + new Plugin({ + key, + appendTransaction: (_transactions, _oldState, state) => { + if (!this.editor.isEditable) { + return; + } + + const { doc, schema } = state; + const paragraph = schema.nodes.paragraph; + if (!paragraph) { + return; + } + + const inserts: number[] = []; + + doc.forEach((node, offset, index) => { + if (index === 0) return; + const prev = doc.child(index - 1); + if (needsSeparator(prev.type.name) && needsSeparator(node.type.name)) { + inserts.push(offset); + } + }); + + if (inserts.length === 0) { + return; + } + + const tr = state.tr; + for (let i = inserts.length - 1; i >= 0; i--) { + tr.insert(inserts[i], paragraph.create()); + } + + return tr; + }, + }), + ]; + }, +}); diff --git a/apps/web/partials/editor/data-node.tsx b/apps/web/partials/editor/data-node.tsx index 96efa4f933..e9019d0800 100644 --- a/apps/web/partials/editor/data-node.tsx +++ b/apps/web/partials/editor/data-node.tsx @@ -1,11 +1,14 @@ import type { NodeViewProps } from '@tiptap/core'; import { Node, NodeViewWrapper, ReactNodeViewRenderer, mergeAttributes } from '@tiptap/react'; +import { NodeSelection } from '@tiptap/pm/state'; +import cx from 'classnames'; import * as React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; import { DataBlockProvider } from '~/core/blocks/data/use-data-block'; +import { useUserIsEditing } from '~/core/hooks/use-user-is-editing'; import { useEditorInstance } from '~/core/state/editor/editor-provider'; import { useEditorStoreLite } from '~/core/state/editor/use-editor'; import { reportBoundaryError } from '~/core/telemetry/logger'; @@ -18,9 +21,36 @@ export const DataNode = Node.create({ name: 'tableNode', group: 'block', atom: true, - allowGapCursor: false, + allowGapCursor: true, defining: true, + addKeyboardShortcuts() { + return { + ArrowUp: ({ editor }) => { + const { selection } = editor.state; + if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'tableNode') { + return false; + } + + const pos = selection.from; + if (pos <= 0) return false; + + return editor.commands.setTextSelection(pos - 1); + }, + ArrowDown: ({ editor }) => { + const { selection } = editor.state; + if (!(selection instanceof NodeSelection) || selection.node.type.name !== 'tableNode') { + return false; + } + + const pos = selection.to; + if (pos >= editor.state.doc.content.size) return false; + + return editor.commands.setTextSelection(pos); + }, + }; + }, + addAttributes() { return { initialDataSource: { @@ -52,9 +82,11 @@ export const DataNode = Node.create({ }, }); -function DataNodeComponent({ node, updateAttributes }: NodeViewProps) { +function DataNodeComponent({ node, updateAttributes, selected, deleteNode }: NodeViewProps) { const { spaceId } = useEditorInstance(); + const isEditing = useUserIsEditing(spaceId); const { id } = node.attrs; + const showSelected = selected && isEditing; const { blockRelations } = useEditorStoreLite(); const relation = blockRelations.find(b => b.block.id === id); @@ -90,10 +122,19 @@ function DataNodeComponent({ node, updateAttributes }: NodeViewProps) { }; return ( - -
+ +
} onError={reportBoundaryError}> - + { + queueMicrotask(() => { + clearEditorBlockSelection(editor); + }); + }, onBlur: onBlur, + onSelectionUpdate: ({ editor }) => { + if (!editor.isEditable) { + clearEditorBlockSelection(editor); + } + }, onUpdate: ({ editor }) => { if (editable) { const editorContent = editor.getJSON().content ?? []; @@ -438,6 +453,14 @@ const useSuppressFlushSyncWarning = () => { }, []); }; +function clearEditorBlockSelection(editor: TiptapEditor) { + if (editor.state.selection instanceof NodeSelection) { + editor.commands.setTextSelection(editor.state.doc.content.size); + } + + editor.commands.blur(); +} + function normalizeEditorContent(content: JSONContent): JSONContent { const normalizedAttrs = content.attrs ? Object.fromEntries( diff --git a/apps/web/partials/editor/extensions.tsx b/apps/web/partials/editor/extensions.tsx index 5882b66e4d..4c2cd5cb03 100644 --- a/apps/web/partials/editor/extensions.tsx +++ b/apps/web/partials/editor/extensions.tsx @@ -7,6 +7,7 @@ import Text from '@tiptap/extension-text'; import Underline from '@tiptap/extension-underline'; import { Focus, Gapcursor, Placeholder, UndoRedo } from '@tiptap/extensions'; +import { BlockSeparator } from './block-separator'; import { CodeBlockNode } from './code-block-node'; import { DataNode } from './data-node'; import { FloatingToolbarExtension } from './floating-toolbar-extension'; @@ -45,6 +46,7 @@ export const tiptapExtensions = [ HeadingNode, HardBreak, Gapcursor, + BlockSeparator, TrailingNode, BulletList, ListItem, diff --git a/apps/web/styles/tiptap.css b/apps/web/styles/tiptap.css index d48c938780..a1d72086ff 100644 --- a/apps/web/styles/tiptap.css +++ b/apps/web/styles/tiptap.css @@ -158,6 +158,33 @@ @apply not-first:mt-12; } +/* Selected atomic blocks (data / image / video) — edit mode only */ +.not-editable .ProseMirror .data-node-selected { + box-shadow: none; +} + +.ProseMirror .data-node-selected { + @apply rounded-lg; + box-shadow: + 0 0px 10px rgba(57, 99, 254, 0.08), + 0 12px 18px rgba(57, 99, 254, 0.04); + padding: 8; +} + +.ProseMirror-gapcursor { + @apply border-l-2 border-ctaPrimary; +} + +.ProseMirror-gapcursor::after { + @apply border-ctaPrimary; +} + +/* Empty lines between stacked data blocks */ +.ProseMirror p.is-empty:has(+ .node-tableNode), +.ProseMirror .node-tableNode + p.is-empty { + min-height: 1.5rem; +} + .ProseMirror ul { @apply contents; } From f9a07967c6b44c5cd73b7da37ca6a30cc8552a04 Mon Sep 17 00:00:00 2001 From: Juan Mardikian Date: Fri, 22 May 2026 19:07:53 -0400 Subject: [PATCH 2/3] fix: hightlight data block based on filter's change and hide 'Add content' text between data blocks --- .../core/blocks/data/data-block-highlight.tsx | 28 +++++++++++++++++++ .../table/data-block-scope-dropdown.tsx | 2 ++ .../blocks/table/data-block-sort-menu.tsx | 2 ++ .../blocks/table/data-block-view-menu.tsx | 2 ++ .../blocks/table/table-block-context-menu.tsx | 2 ++ .../table/table-block-editable-title.tsx | 10 ++++++- .../table/table-block-properties-menu.tsx | 2 ++ .../web/partials/blocks/table/table-block.tsx | 16 +++++++++-- apps/web/partials/editor/data-node.tsx | 12 +++++++- apps/web/styles/tiptap.css | 10 +++++-- 10 files changed, 79 insertions(+), 7 deletions(-) create mode 100644 apps/web/core/blocks/data/data-block-highlight.tsx diff --git a/apps/web/core/blocks/data/data-block-highlight.tsx b/apps/web/core/blocks/data/data-block-highlight.tsx new file mode 100644 index 0000000000..0fbf38b7cb --- /dev/null +++ b/apps/web/core/blocks/data/data-block-highlight.tsx @@ -0,0 +1,28 @@ +'use client'; + +import * as React from 'react'; + +import { atom, useSetAtom } from 'jotai'; + +import { useDataBlockInstance } from './use-data-block'; + +export const activeDataBlockIdAtom = atom(null); + +export function useDataBlockInteraction(active: boolean) { + const { entityId } = useDataBlockInstance(); + const setActiveDataBlockId = useSetAtom(activeDataBlockIdAtom); + + React.useEffect(() => { + if (!active) return; + setActiveDataBlockId(entityId); + }, [active, entityId, setActiveDataBlockId]); +} + +export function useActivateDataBlock() { + const { entityId } = useDataBlockInstance(); + const setActiveDataBlockId = useSetAtom(activeDataBlockIdAtom); + + return React.useCallback(() => { + setActiveDataBlockId(entityId); + }, [entityId, setActiveDataBlockId]); +} diff --git a/apps/web/partials/blocks/table/data-block-scope-dropdown.tsx b/apps/web/partials/blocks/table/data-block-scope-dropdown.tsx index 585c3ee6fa..7e3e0fa669 100644 --- a/apps/web/partials/blocks/table/data-block-scope-dropdown.tsx +++ b/apps/web/partials/blocks/table/data-block-scope-dropdown.tsx @@ -6,6 +6,7 @@ import * as React from 'react'; import cx from 'classnames'; +import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { type Source, sourceStableKey } from '~/core/blocks/data/source'; import { useDataBlockInstance } from '~/core/blocks/data/use-data-block'; import { PLACEHOLDER_SPACE_IMAGE } from '~/core/constants'; @@ -169,6 +170,7 @@ export function DataBlockScopeDropdown({ setSourceRef.current = setSource; const sourceKey = sourceStableKey(source); const [open, setOpen] = React.useState(false); + useDataBlockInteraction(open); // Cache of id → name for spaces the user has interacted with this session. // Lets the trigger label and the new SPACE_FILTER's valueName render with // the chosen name immediately, before useSpacesByIds / diff --git a/apps/web/partials/blocks/table/data-block-sort-menu.tsx b/apps/web/partials/blocks/table/data-block-sort-menu.tsx index 5e736ebc61..9d30c78705 100644 --- a/apps/web/partials/blocks/table/data-block-sort-menu.tsx +++ b/apps/web/partials/blocks/table/data-block-sort-menu.tsx @@ -7,6 +7,7 @@ import * as React from 'react'; import cx from 'classnames'; +import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { ID } from '~/core/id'; import { Property } from '~/core/types'; import { ColumnSortState, SORTABLE_DATA_TYPES } from '~/core/utils/column-sort'; @@ -55,6 +56,7 @@ export function DataBlockSortMenu({ }: DataBlockSortMenuProps) { const triggerRef = React.useRef(null); const [isMenuOpen, setIsMenuOpen] = React.useState(false); + useDataBlockInteraction(isMenuOpen); const [pickDirectionForColumnId, setPickDirectionForColumnId] = React.useState(null); const { align, side } = useAdaptiveDropdownPlacement(triggerRef, { diff --git a/apps/web/partials/blocks/table/data-block-view-menu.tsx b/apps/web/partials/blocks/table/data-block-view-menu.tsx index 7dac6002a5..9a857c82d1 100644 --- a/apps/web/partials/blocks/table/data-block-view-menu.tsx +++ b/apps/web/partials/blocks/table/data-block-view-menu.tsx @@ -6,6 +6,7 @@ import * as Dropdown from '@radix-ui/react-dropdown-menu'; import * as React from 'react'; import { useCallback } from 'react'; +import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { useDataBlock } from '~/core/blocks/data/use-data-block'; import { DataBlockView, useView } from '~/core/blocks/data/use-view'; import { useUserIsEditing } from '~/core/hooks/use-user-is-editing'; @@ -31,6 +32,7 @@ type TableBlockViewMenuProps = { export function DataBlockViewMenu({ activeView, isLoading }: TableBlockViewMenuProps) { const [isMenuOpen, setIsMenuOpen] = React.useState(false); + useDataBlockInteraction(isMenuOpen); const triggerRef = React.useRef(null); const { align, side } = useAdaptiveDropdownPlacement(triggerRef, { isOpen: isMenuOpen, diff --git a/apps/web/partials/blocks/table/table-block-context-menu.tsx b/apps/web/partials/blocks/table/table-block-context-menu.tsx index 12f9022979..4a4676f01e 100644 --- a/apps/web/partials/blocks/table/table-block-context-menu.tsx +++ b/apps/web/partials/blocks/table/table-block-context-menu.tsx @@ -7,6 +7,7 @@ import * as React from 'react'; import { useAtom } from 'jotai'; import type { Source } from '~/core/blocks/data/source'; +import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { useDataBlockInstance } from '~/core/blocks/data/use-data-block'; import { useUserIsEditing } from '~/core/hooks/use-user-is-editing'; import { NavUtils } from '~/core/utils/utils'; @@ -38,6 +39,7 @@ type TableBlockContextMenuProps = { export function TableBlockContextMenu({ sourceType }: TableBlockContextMenuProps) { const [isMenuOpen, setIsMenuOpen] = React.useState(false); + useDataBlockInteraction(isMenuOpen); const triggerRef = React.useRef(null); const { spaceId, entityId, relationId, onRemoveFromEditor } = useDataBlockInstance(); const isEditing = useUserIsEditing(spaceId); diff --git a/apps/web/partials/blocks/table/table-block-editable-title.tsx b/apps/web/partials/blocks/table/table-block-editable-title.tsx index e3a1079ef3..fe4316fd25 100644 --- a/apps/web/partials/blocks/table/table-block-editable-title.tsx +++ b/apps/web/partials/blocks/table/table-block-editable-title.tsx @@ -2,6 +2,7 @@ import * as React from 'react'; +import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { useDataBlock } from '~/core/blocks/data/use-data-block'; import { useFilters } from '~/core/blocks/data/use-filters'; import { useSource } from '~/core/blocks/data/use-source'; @@ -34,6 +35,9 @@ export function TableBlockEditableTitle({ spaceId }: { spaceId: string }) { shouldSkipFocus: isPersonalProfileSkillsRelationFocusRegionActive, }); + const [titleFocused, setTitleFocused] = React.useState(false); + useDataBlockInteraction(titleFocused); + return (
{source.type === 'GEO' && ( @@ -103,7 +107,11 @@ export function TableBlockEditableTitle({ spaceId }: { spaceId: string }) { setName(e.currentTarget.value)} + onFocus={() => setTitleFocused(true)} + onBlur={e => { + setTitleFocused(false); + setName(e.currentTarget.value); + }} defaultValue={name ?? undefined} placeholder={titlePlaceholder} className="w-full shrink-0 grow appearance-none text-mediumTitle text-text outline-hidden placeholder:text-grey-03" diff --git a/apps/web/partials/blocks/table/table-block-properties-menu.tsx b/apps/web/partials/blocks/table/table-block-properties-menu.tsx index d6ff412ef6..e950f0a791 100644 --- a/apps/web/partials/blocks/table/table-block-properties-menu.tsx +++ b/apps/web/partials/blocks/table/table-block-properties-menu.tsx @@ -25,6 +25,7 @@ import * as React from 'react'; import { useSetAtom } from 'jotai'; +import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import type { Source } from '~/core/blocks/data/source'; import { useDebouncedValue } from '~/core/hooks/use-debounced-value'; import { ID } from '~/core/id'; @@ -153,6 +154,7 @@ export function TableBlockPropertiesMenu({ disabled, }: TableBlockPropertiesMenuProps) { const [open, setOpen] = React.useState(false); + useDataBlockInteraction(open); const [search, setSearch] = React.useState(''); const setEditingProperties = useSetAtom(editingPropertiesAtom); const { store } = useSyncEngine(); diff --git a/apps/web/partials/blocks/table/table-block.tsx b/apps/web/partials/blocks/table/table-block.tsx index f8f87a66fc..694a0b8aa1 100644 --- a/apps/web/partials/blocks/table/table-block.tsx +++ b/apps/web/partials/blocks/table/table-block.tsx @@ -12,6 +12,7 @@ import { produce } from 'immer'; import { upsertCollectionItemRelation } from '~/core/blocks/data/collection'; import { Filter, FilterMode } from '~/core/blocks/data/filters'; import { Source } from '~/core/blocks/data/source'; +import { useActivateDataBlock, useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { useDataBlock, useDataBlockInstance } from '~/core/blocks/data/use-data-block'; import { useFilters } from '~/core/blocks/data/use-filters'; import { useSource } from '~/core/blocks/data/use-source'; @@ -275,6 +276,7 @@ function useEntries( onUpdateRelation, shouldAutoFocusPlaceholder, placeholderFocusKey, + hasPlaceholderRow, }; } @@ -354,6 +356,7 @@ const ConfiguredTableBlock = ({ onConsumedInitialFiltersOpen, }: Props) => { const [isFilterOpen, setIsFilterOpen] = React.useState(false); + useDataBlockInteraction(isFilterOpen); const filterPromptRef = React.useRef(null); const { entityId, relationId } = useDataBlockInstance(); const { setEditable } = useEditable(); @@ -454,8 +457,17 @@ const ConfiguredTableBlock = ({ onUpdateRelation, shouldAutoFocusPlaceholder, placeholderFocusKey, + hasPlaceholderRow, } = useEntries(rows, properties, spaceId, activeFilters, relations, source, canEdit, isFetched && !isLoading); + const activateDataBlock = useActivateDataBlock(); + useDataBlockInteraction(hasPlaceholderRow); + + const handleAddPlaceholder = React.useCallback(() => { + activateDataBlock(); + onAddPlaceholder(); + }, [activateDataBlock, onAddPlaceholder]); + const collectionTypeFilters = React.useMemo( () => activeFilters.filter(f => f.columnId === SystemIds.TYPES_PROPERTY).map(f => ({ id: f.value, name: f.valueName })), @@ -541,7 +553,7 @@ const ConfiguredTableBlock = ({ shownColumnIds={shownColumnIds} onChangeEntry={onChangeEntry} onLinkEntry={onLinkEntry} - onAddPlaceholder={onAddPlaceholder} + onAddPlaceholder={handleAddPlaceholder} shouldAutoFocusPlaceholder={shouldAutoFocusPlaceholder} placeholderFocusKey={placeholderFocusKey} collectionTypeFilters={collectionTypeFilters} @@ -673,7 +685,7 @@ const ConfiguredTableBlock = ({ {renderPlusButtonAsInline && ( - )} diff --git a/apps/web/partials/editor/data-node.tsx b/apps/web/partials/editor/data-node.tsx index e9019d0800..987b26278c 100644 --- a/apps/web/partials/editor/data-node.tsx +++ b/apps/web/partials/editor/data-node.tsx @@ -3,10 +3,12 @@ import { Node, NodeViewWrapper, ReactNodeViewRenderer, mergeAttributes } from '@ import { NodeSelection } from '@tiptap/pm/state'; import cx from 'classnames'; +import { useAtomValue, useSetAtom } from 'jotai'; import * as React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; +import { activeDataBlockIdAtom } from '~/core/blocks/data/data-block-highlight'; import { DataBlockProvider } from '~/core/blocks/data/use-data-block'; import { useUserIsEditing } from '~/core/hooks/use-user-is-editing'; import { useEditorInstance } from '~/core/state/editor/editor-provider'; @@ -86,7 +88,15 @@ function DataNodeComponent({ node, updateAttributes, selected, deleteNode }: Nod const { spaceId } = useEditorInstance(); const isEditing = useUserIsEditing(spaceId); const { id } = node.attrs; - const showSelected = selected && isEditing; + const activeDataBlockId = useAtomValue(activeDataBlockIdAtom); + const setActiveDataBlockId = useSetAtom(activeDataBlockIdAtom); + const showSelected = isEditing && activeDataBlockId === id; + + React.useEffect(() => { + if (selected) { + setActiveDataBlockId(id); + } + }, [selected, id, setActiveDataBlockId]); const { blockRelations } = useEditorStoreLite(); const relation = blockRelations.find(b => b.block.id === id); diff --git a/apps/web/styles/tiptap.css b/apps/web/styles/tiptap.css index a1d72086ff..b68d3ab557 100644 --- a/apps/web/styles/tiptap.css +++ b/apps/web/styles/tiptap.css @@ -179,12 +179,16 @@ @apply border-ctaPrimary; } -/* Empty lines between stacked data blocks */ -.ProseMirror p.is-empty:has(+ .node-tableNode), -.ProseMirror .node-tableNode + p.is-empty { +.ProseMirror :is(.node-tableNode, .node-image, .node-video) + + .is-empty:is(:has(+ .node-tableNode), :has(+ .node-image), :has(+ .node-video)) { min-height: 1.5rem; } +.ProseMirror :is(.node-tableNode, .node-image, .node-video) + + .is-empty:not(.has-focus):is(:has(+ .node-tableNode), :has(+ .node-image), :has(+ .node-video))::before { + content: none; +} + .ProseMirror ul { @apply contents; } From d7a7932e3f1e93e6827b8dc0439fae61ced068ee Mon Sep 17 00:00:00 2001 From: Juan Mardikian Date: Wed, 27 May 2026 10:12:15 -0400 Subject: [PATCH 3/3] fix: update Data Block UI --- apps/web/atoms/active-data-block-id.ts | 3 + apps/web/atoms/index.ts | 1 + .../core/blocks/data/data-block-highlight.tsx | 136 ++++++++++++++++-- .../table/data-block-scope-dropdown.tsx | 2 - .../blocks/table/data-block-sort-menu.tsx | 2 - .../blocks/table/data-block-view-menu.tsx | 2 - .../blocks/table/table-block-context-menu.tsx | 2 - .../table/table-block-editable-title.tsx | 6 - .../table/table-block-properties-menu.tsx | 2 - .../web/partials/blocks/table/table-block.tsx | 8 +- apps/web/partials/editor/data-node.tsx | 14 +- apps/web/partials/editor/editor.tsx | 13 ++ apps/web/styles/tiptap.css | 13 +- 13 files changed, 156 insertions(+), 48 deletions(-) create mode 100644 apps/web/atoms/active-data-block-id.ts diff --git a/apps/web/atoms/active-data-block-id.ts b/apps/web/atoms/active-data-block-id.ts new file mode 100644 index 0000000000..49381ec1aa --- /dev/null +++ b/apps/web/atoms/active-data-block-id.ts @@ -0,0 +1,3 @@ +import { atom } from 'jotai'; + +export const activeDataBlockIdAtom = atom(null); diff --git a/apps/web/atoms/index.ts b/apps/web/atoms/index.ts index 0308cc8d40..aab40df7c3 100644 --- a/apps/web/atoms/index.ts +++ b/apps/web/atoms/index.ts @@ -19,4 +19,5 @@ export const teamNoticeDismissedAtom = atomWithStorage('dismiss lastDismissed: '', }); +export * from './active-data-block-id'; export * from './personal-profile-suggested'; diff --git a/apps/web/core/blocks/data/data-block-highlight.tsx b/apps/web/core/blocks/data/data-block-highlight.tsx index 0fbf38b7cb..2900b909d8 100644 --- a/apps/web/core/blocks/data/data-block-highlight.tsx +++ b/apps/web/core/blocks/data/data-block-highlight.tsx @@ -1,28 +1,136 @@ 'use client'; +import type { Editor } from '@tiptap/core'; +import { NodeSelection } from '@tiptap/pm/state'; import * as React from 'react'; -import { atom, useSetAtom } from 'jotai'; +import { getDefaultStore, useSetAtom } from 'jotai'; -import { useDataBlockInstance } from './use-data-block'; +import { activeDataBlockIdAtom } from '~/atoms'; -export const activeDataBlockIdAtom = atom(null); +const DATA_BLOCK_SELECTOR = '[data-block-id]'; -export function useDataBlockInteraction(active: boolean) { - const { entityId } = useDataBlockInstance(); - const setActiveDataBlockId = useSetAtom(activeDataBlockIdAtom); +const DATA_BLOCK_OVERLAY_SELECTOR = [ + '[data-radix-popper-content-wrapper]', + '[data-radix-portal]', + '[data-radix-select-content]', + '[role="menu"]', + '[role="dialog"]', + '[role="listbox"]', +].join(', '); - React.useEffect(() => { - if (!active) return; - setActiveDataBlockId(entityId); - }, [active, entityId, setActiveDataBlockId]); +export function clearActiveDataBlockId(): void { + getDefaultStore().set(activeDataBlockIdAtom, null); +} + +export function getDataBlockIdFromTarget(target: EventTarget | null): string | null { + if (!(target instanceof Element)) return null; + + const el = target.closest(DATA_BLOCK_SELECTOR); + const id = el?.getAttribute('data-block-id'); + + return typeof id === 'string' && id.length > 0 ? id : null; +} + +function isDataBlockOverlayTarget(target: EventTarget | null): boolean { + if (!(target instanceof Element)) return false; + + return Boolean(target.closest(DATA_BLOCK_OVERLAY_SELECTOR)); +} + +function isEditorTextFocus(): boolean { + const el = document.activeElement; + if (!el) return false; + if (el.closest('.data-node')) return false; + + return el.classList.contains('ProseMirror') || Boolean(el.closest('.ProseMirror')); } -export function useActivateDataBlock() { - const { entityId } = useDataBlockInstance(); +/** Apply highlight when the editor has a NodeSelection on a data block. Does not clear. */ +export function applyNodeSelectionHighlight(editor: Editor): void { + const { selection } = editor.state; + + if (selection instanceof NodeSelection && selection.node.type.name === 'tableNode') { + const id = selection.node.attrs.id; + if (typeof id === 'string') { + getDefaultStore().set(activeDataBlockIdAtom, id); + } + } +} + +/** + * Central highlight controller for the entity editor. + * - No highlight on load / entity change + * - Click inside a data block → highlight that block + * - Click editor text → clear unless the block is node-selected + * - Click outside → clear + */ +export function useDataBlockHighlightSync(editor: Editor | null, enabled: boolean, entityId: string) { const setActiveDataBlockId = useSetAtom(activeDataBlockIdAtom); - return React.useCallback(() => { - setActiveDataBlockId(entityId); + React.useEffect(() => { + setActiveDataBlockId(null); }, [entityId, setActiveDataBlockId]); + + React.useEffect(() => { + if (!enabled) { + setActiveDataBlockId(null); + } + }, [enabled, setActiveDataBlockId]); + + React.useEffect(() => { + if (!editor || !enabled) return; + + const boundEditor: Editor = editor; + + function handlePointerDown(event: PointerEvent) { + if (isDataBlockOverlayTarget(event.target)) { + return; + } + + const blockId = getDataBlockIdFromTarget(event.target); + if (blockId) { + setActiveDataBlockId(blockId); + return; + } + + const proseMirror = boundEditor.view.dom; + if (event.target instanceof Element && proseMirror.contains(event.target)) { + requestAnimationFrame(() => { + const { selection } = boundEditor.state; + if (selection instanceof NodeSelection && selection.node.type.name === 'tableNode') { + applyNodeSelectionHighlight(boundEditor); + } else { + setActiveDataBlockId(null); + } + }); + return; + } + + setActiveDataBlockId(null); + } + + document.addEventListener('pointerdown', handlePointerDown, { capture: true }); + + return () => { + document.removeEventListener('pointerdown', handlePointerDown, { capture: true }); + }; + }, [editor, enabled, setActiveDataBlockId]); +} + +/** Called from editor onSelectionUpdate */ +export function handleEditorSelectionHighlight(editor: Editor): void { + const { selection } = editor.state; + + if (selection instanceof NodeSelection && selection.node.type.name === 'tableNode') { + applyNodeSelectionHighlight(editor); + return; + } + + // Keep highlighted when the user clicked inside a data block. + if (!isEditorTextFocus()) { + return; + } + + clearActiveDataBlockId(); } diff --git a/apps/web/partials/blocks/table/data-block-scope-dropdown.tsx b/apps/web/partials/blocks/table/data-block-scope-dropdown.tsx index 7e3e0fa669..585c3ee6fa 100644 --- a/apps/web/partials/blocks/table/data-block-scope-dropdown.tsx +++ b/apps/web/partials/blocks/table/data-block-scope-dropdown.tsx @@ -6,7 +6,6 @@ import * as React from 'react'; import cx from 'classnames'; -import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { type Source, sourceStableKey } from '~/core/blocks/data/source'; import { useDataBlockInstance } from '~/core/blocks/data/use-data-block'; import { PLACEHOLDER_SPACE_IMAGE } from '~/core/constants'; @@ -170,7 +169,6 @@ export function DataBlockScopeDropdown({ setSourceRef.current = setSource; const sourceKey = sourceStableKey(source); const [open, setOpen] = React.useState(false); - useDataBlockInteraction(open); // Cache of id → name for spaces the user has interacted with this session. // Lets the trigger label and the new SPACE_FILTER's valueName render with // the chosen name immediately, before useSpacesByIds / diff --git a/apps/web/partials/blocks/table/data-block-sort-menu.tsx b/apps/web/partials/blocks/table/data-block-sort-menu.tsx index 9d30c78705..5e736ebc61 100644 --- a/apps/web/partials/blocks/table/data-block-sort-menu.tsx +++ b/apps/web/partials/blocks/table/data-block-sort-menu.tsx @@ -7,7 +7,6 @@ import * as React from 'react'; import cx from 'classnames'; -import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { ID } from '~/core/id'; import { Property } from '~/core/types'; import { ColumnSortState, SORTABLE_DATA_TYPES } from '~/core/utils/column-sort'; @@ -56,7 +55,6 @@ export function DataBlockSortMenu({ }: DataBlockSortMenuProps) { const triggerRef = React.useRef(null); const [isMenuOpen, setIsMenuOpen] = React.useState(false); - useDataBlockInteraction(isMenuOpen); const [pickDirectionForColumnId, setPickDirectionForColumnId] = React.useState(null); const { align, side } = useAdaptiveDropdownPlacement(triggerRef, { diff --git a/apps/web/partials/blocks/table/data-block-view-menu.tsx b/apps/web/partials/blocks/table/data-block-view-menu.tsx index 9a857c82d1..7dac6002a5 100644 --- a/apps/web/partials/blocks/table/data-block-view-menu.tsx +++ b/apps/web/partials/blocks/table/data-block-view-menu.tsx @@ -6,7 +6,6 @@ import * as Dropdown from '@radix-ui/react-dropdown-menu'; import * as React from 'react'; import { useCallback } from 'react'; -import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { useDataBlock } from '~/core/blocks/data/use-data-block'; import { DataBlockView, useView } from '~/core/blocks/data/use-view'; import { useUserIsEditing } from '~/core/hooks/use-user-is-editing'; @@ -32,7 +31,6 @@ type TableBlockViewMenuProps = { export function DataBlockViewMenu({ activeView, isLoading }: TableBlockViewMenuProps) { const [isMenuOpen, setIsMenuOpen] = React.useState(false); - useDataBlockInteraction(isMenuOpen); const triggerRef = React.useRef(null); const { align, side } = useAdaptiveDropdownPlacement(triggerRef, { isOpen: isMenuOpen, diff --git a/apps/web/partials/blocks/table/table-block-context-menu.tsx b/apps/web/partials/blocks/table/table-block-context-menu.tsx index 4a4676f01e..12f9022979 100644 --- a/apps/web/partials/blocks/table/table-block-context-menu.tsx +++ b/apps/web/partials/blocks/table/table-block-context-menu.tsx @@ -7,7 +7,6 @@ import * as React from 'react'; import { useAtom } from 'jotai'; import type { Source } from '~/core/blocks/data/source'; -import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { useDataBlockInstance } from '~/core/blocks/data/use-data-block'; import { useUserIsEditing } from '~/core/hooks/use-user-is-editing'; import { NavUtils } from '~/core/utils/utils'; @@ -39,7 +38,6 @@ type TableBlockContextMenuProps = { export function TableBlockContextMenu({ sourceType }: TableBlockContextMenuProps) { const [isMenuOpen, setIsMenuOpen] = React.useState(false); - useDataBlockInteraction(isMenuOpen); const triggerRef = React.useRef(null); const { spaceId, entityId, relationId, onRemoveFromEditor } = useDataBlockInstance(); const isEditing = useUserIsEditing(spaceId); diff --git a/apps/web/partials/blocks/table/table-block-editable-title.tsx b/apps/web/partials/blocks/table/table-block-editable-title.tsx index fe4316fd25..d6c538cf05 100644 --- a/apps/web/partials/blocks/table/table-block-editable-title.tsx +++ b/apps/web/partials/blocks/table/table-block-editable-title.tsx @@ -2,7 +2,6 @@ import * as React from 'react'; -import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { useDataBlock } from '~/core/blocks/data/use-data-block'; import { useFilters } from '~/core/blocks/data/use-filters'; import { useSource } from '~/core/blocks/data/use-source'; @@ -35,9 +34,6 @@ export function TableBlockEditableTitle({ spaceId }: { spaceId: string }) { shouldSkipFocus: isPersonalProfileSkillsRelationFocusRegionActive, }); - const [titleFocused, setTitleFocused] = React.useState(false); - useDataBlockInteraction(titleFocused); - return (
{source.type === 'GEO' && ( @@ -107,9 +103,7 @@ export function TableBlockEditableTitle({ spaceId }: { spaceId: string }) { setTitleFocused(true)} onBlur={e => { - setTitleFocused(false); setName(e.currentTarget.value); }} defaultValue={name ?? undefined} diff --git a/apps/web/partials/blocks/table/table-block-properties-menu.tsx b/apps/web/partials/blocks/table/table-block-properties-menu.tsx index e950f0a791..d6ff412ef6 100644 --- a/apps/web/partials/blocks/table/table-block-properties-menu.tsx +++ b/apps/web/partials/blocks/table/table-block-properties-menu.tsx @@ -25,7 +25,6 @@ import * as React from 'react'; import { useSetAtom } from 'jotai'; -import { useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import type { Source } from '~/core/blocks/data/source'; import { useDebouncedValue } from '~/core/hooks/use-debounced-value'; import { ID } from '~/core/id'; @@ -154,7 +153,6 @@ export function TableBlockPropertiesMenu({ disabled, }: TableBlockPropertiesMenuProps) { const [open, setOpen] = React.useState(false); - useDataBlockInteraction(open); const [search, setSearch] = React.useState(''); const setEditingProperties = useSetAtom(editingPropertiesAtom); const { store } = useSyncEngine(); diff --git a/apps/web/partials/blocks/table/table-block.tsx b/apps/web/partials/blocks/table/table-block.tsx index 694a0b8aa1..4c34840c75 100644 --- a/apps/web/partials/blocks/table/table-block.tsx +++ b/apps/web/partials/blocks/table/table-block.tsx @@ -12,7 +12,6 @@ import { produce } from 'immer'; import { upsertCollectionItemRelation } from '~/core/blocks/data/collection'; import { Filter, FilterMode } from '~/core/blocks/data/filters'; import { Source } from '~/core/blocks/data/source'; -import { useActivateDataBlock, useDataBlockInteraction } from '~/core/blocks/data/data-block-highlight'; import { useDataBlock, useDataBlockInstance } from '~/core/blocks/data/use-data-block'; import { useFilters } from '~/core/blocks/data/use-filters'; import { useSource } from '~/core/blocks/data/use-source'; @@ -356,7 +355,6 @@ const ConfiguredTableBlock = ({ onConsumedInitialFiltersOpen, }: Props) => { const [isFilterOpen, setIsFilterOpen] = React.useState(false); - useDataBlockInteraction(isFilterOpen); const filterPromptRef = React.useRef(null); const { entityId, relationId } = useDataBlockInstance(); const { setEditable } = useEditable(); @@ -460,13 +458,9 @@ const ConfiguredTableBlock = ({ hasPlaceholderRow, } = useEntries(rows, properties, spaceId, activeFilters, relations, source, canEdit, isFetched && !isLoading); - const activateDataBlock = useActivateDataBlock(); - useDataBlockInteraction(hasPlaceholderRow); - const handleAddPlaceholder = React.useCallback(() => { - activateDataBlock(); onAddPlaceholder(); - }, [activateDataBlock, onAddPlaceholder]); + }, [onAddPlaceholder]); const collectionTypeFilters = React.useMemo( () => diff --git a/apps/web/partials/editor/data-node.tsx b/apps/web/partials/editor/data-node.tsx index 987b26278c..305a7a6f36 100644 --- a/apps/web/partials/editor/data-node.tsx +++ b/apps/web/partials/editor/data-node.tsx @@ -3,12 +3,12 @@ import { Node, NodeViewWrapper, ReactNodeViewRenderer, mergeAttributes } from '@ import { NodeSelection } from '@tiptap/pm/state'; import cx from 'classnames'; -import { useAtomValue, useSetAtom } from 'jotai'; +import { useAtomValue } from 'jotai'; import * as React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; -import { activeDataBlockIdAtom } from '~/core/blocks/data/data-block-highlight'; +import { activeDataBlockIdAtom } from '~/atoms'; import { DataBlockProvider } from '~/core/blocks/data/use-data-block'; import { useUserIsEditing } from '~/core/hooks/use-user-is-editing'; import { useEditorInstance } from '~/core/state/editor/editor-provider'; @@ -84,20 +84,13 @@ export const DataNode = Node.create({ }, }); -function DataNodeComponent({ node, updateAttributes, selected, deleteNode }: NodeViewProps) { +function DataNodeComponent({ node, updateAttributes, deleteNode }: NodeViewProps) { const { spaceId } = useEditorInstance(); const isEditing = useUserIsEditing(spaceId); const { id } = node.attrs; const activeDataBlockId = useAtomValue(activeDataBlockIdAtom); - const setActiveDataBlockId = useSetAtom(activeDataBlockIdAtom); const showSelected = isEditing && activeDataBlockId === id; - React.useEffect(() => { - if (selected) { - setActiveDataBlockId(id); - } - }, [selected, id, setActiveDataBlockId]); - const { blockRelations } = useEditorStoreLite(); const relation = blockRelations.find(b => b.block.id === id); @@ -134,6 +127,7 @@ function DataNodeComponent({ node, updateAttributes, selected, deleteNode }: Nod return (
{ + clearActiveDataBlockId(); queueMicrotask(() => { clearEditorBlockSelection(editor); }); @@ -242,7 +249,11 @@ export function Editor({ onSelectionUpdate: ({ editor }) => { if (!editor.isEditable) { clearEditorBlockSelection(editor); + clearActiveDataBlockId(); + return; } + + handleEditorSelectionHighlight(editor); }, onUpdate: ({ editor }) => { if (editable) { @@ -266,6 +277,8 @@ export function Editor({ // Keep editorRef in sync so the edit→view transition effect can persist state editorRef.current = editor; + useDataBlockHighlightSync(editor, editable, activeEntityId); + const editorWrapperRef = React.useRef(null); React.useEffect(() => { diff --git a/apps/web/styles/tiptap.css b/apps/web/styles/tiptap.css index b68d3ab557..11675a9cd8 100644 --- a/apps/web/styles/tiptap.css +++ b/apps/web/styles/tiptap.css @@ -155,7 +155,13 @@ } .ProseMirror .node-tableNode { - @apply not-first:mt-12; + @apply not-first:mt-4; + margin-bottom: 8px; +} + +.ProseMirror .node-tableNode[data-selected='true'] { + @apply not-first:mt-8; + margin-bottom: 16px; } /* Selected atomic blocks (data / image / video) — edit mode only */ @@ -181,6 +187,11 @@ .ProseMirror :is(.node-tableNode, .node-image, .node-video) + .is-empty:is(:has(+ .node-tableNode), :has(+ .node-image), :has(+ .node-video)) { + min-height: 0.375rem; +} + +.ProseMirror :is(.node-tableNode, .node-image, .node-video) + + .is-empty.has-focus:is(:has(+ .node-tableNode), :has(+ .node-image), :has(+ .node-video)) { min-height: 1.5rem; }