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 new file mode 100644 index 0000000000..2900b909d8 --- /dev/null +++ b/apps/web/core/blocks/data/data-block-highlight.tsx @@ -0,0 +1,136 @@ +'use client'; + +import type { Editor } from '@tiptap/core'; +import { NodeSelection } from '@tiptap/pm/state'; +import * as React from 'react'; + +import { getDefaultStore, useSetAtom } from 'jotai'; + +import { activeDataBlockIdAtom } from '~/atoms'; + +const DATA_BLOCK_SELECTOR = '[data-block-id]'; + +const DATA_BLOCK_OVERLAY_SELECTOR = [ + '[data-radix-popper-content-wrapper]', + '[data-radix-portal]', + '[data-radix-select-content]', + '[role="menu"]', + '[role="dialog"]', + '[role="listbox"]', +].join(', '); + +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')); +} + +/** 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); + + 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/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/blocks/table/table-block-editable-title.tsx b/apps/web/partials/blocks/table/table-block-editable-title.tsx index e3a1079ef3..d6c538cf05 100644 --- a/apps/web/partials/blocks/table/table-block-editable-title.tsx +++ b/apps/web/partials/blocks/table/table-block-editable-title.tsx @@ -103,7 +103,9 @@ export function TableBlockEditableTitle({ spaceId }: { spaceId: string }) { setName(e.currentTarget.value)} + onBlur={e => { + 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.tsx b/apps/web/partials/blocks/table/table-block.tsx index f8f87a66fc..4c34840c75 100644 --- a/apps/web/partials/blocks/table/table-block.tsx +++ b/apps/web/partials/blocks/table/table-block.tsx @@ -275,6 +275,7 @@ function useEntries( onUpdateRelation, shouldAutoFocusPlaceholder, placeholderFocusKey, + hasPlaceholderRow, }; } @@ -454,8 +455,13 @@ const ConfiguredTableBlock = ({ onUpdateRelation, shouldAutoFocusPlaceholder, placeholderFocusKey, + hasPlaceholderRow, } = useEntries(rows, properties, spaceId, activeFilters, relations, source, canEdit, isFetched && !isLoading); + const handleAddPlaceholder = React.useCallback(() => { + onAddPlaceholder(); + }, [onAddPlaceholder]); + const collectionTypeFilters = React.useMemo( () => activeFilters.filter(f => f.columnId === SystemIds.TYPES_PROPERTY).map(f => ({ id: f.value, name: f.valueName })), @@ -541,7 +547,7 @@ const ConfiguredTableBlock = ({ shownColumnIds={shownColumnIds} onChangeEntry={onChangeEntry} onLinkEntry={onLinkEntry} - onAddPlaceholder={onAddPlaceholder} + onAddPlaceholder={handleAddPlaceholder} shouldAutoFocusPlaceholder={shouldAutoFocusPlaceholder} placeholderFocusKey={placeholderFocusKey} collectionTypeFilters={collectionTypeFilters} @@ -673,7 +679,7 @@ const ConfiguredTableBlock = ({ {renderPlusButtonAsInline && ( - )} 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..305a7a6f36 100644 --- a/apps/web/partials/editor/data-node.tsx +++ b/apps/web/partials/editor/data-node.tsx @@ -1,11 +1,16 @@ 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 { useAtomValue } from 'jotai'; import * as React from 'react'; import { ErrorBoundary } from 'react-error-boundary'; +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'; import { useEditorStoreLite } from '~/core/state/editor/use-editor'; import { reportBoundaryError } from '~/core/telemetry/logger'; @@ -18,9 +23,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 +84,12 @@ export const DataNode = Node.create({ }, }); -function DataNodeComponent({ node, updateAttributes }: NodeViewProps) { +function DataNodeComponent({ node, updateAttributes, deleteNode }: NodeViewProps) { const { spaceId } = useEditorInstance(); + const isEditing = useUserIsEditing(spaceId); const { id } = node.attrs; + const activeDataBlockId = useAtomValue(activeDataBlockIdAtom); + const showSelected = isEditing && activeDataBlockId === id; const { blockRelations } = useEditorStoreLite(); const relation = blockRelations.find(b => b.block.id === id); @@ -90,10 +125,20 @@ function DataNodeComponent({ node, updateAttributes }: NodeViewProps) { }; return ( - -
+ +
} onError={reportBoundaryError}> - + { + clearActiveDataBlockId(); + queueMicrotask(() => { + clearEditorBlockSelection(editor); + }); + }, onBlur: onBlur, + onSelectionUpdate: ({ editor }) => { + if (!editor.isEditable) { + clearEditorBlockSelection(editor); + clearActiveDataBlockId(); + return; + } + + handleEditorSelectionHighlight(editor); + }, onUpdate: ({ editor }) => { if (editable) { const editorContent = editor.getJSON().content ?? []; @@ -251,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(() => { @@ -438,6 +466,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..11675a9cd8 100644 --- a/apps/web/styles/tiptap.css +++ b/apps/web/styles/tiptap.css @@ -155,7 +155,49 @@ } .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 */ +.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; +} + +.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; +} + +.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 {