Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions apps/web/atoms/active-data-block-id.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,3 @@
import { atom } from 'jotai';

export const activeDataBlockIdAtom = atom<string | null>(null);
1 change: 1 addition & 0 deletions apps/web/atoms/index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -19,4 +19,5 @@ export const teamNoticeDismissedAtom = atomWithStorage<RepeatingNotice>('dismiss
lastDismissed: '',
});

export * from './active-data-block-id';
export * from './personal-profile-suggested';
136 changes: 136 additions & 0 deletions apps/web/core/blocks/data/data-block-highlight.tsx
Original file line number Diff line number Diff line change
@@ -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();
}
6 changes: 5 additions & 1 deletion apps/web/core/blocks/data/use-data-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -496,16 +496,18 @@ const DataBlockContext = React.createContext<{
reset: () => void;
canJumpTo: (target: number) => boolean;
maxJumpPages: number;
onRemoveFromEditor?: () => void;
} | null>(null);

interface Props {
spaceId: string;
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);

Expand All @@ -522,6 +524,7 @@ export function DataBlockProvider({ spaceId, children, entityId, relationId }: P
reset,
canJumpTo,
maxJumpPages,
onRemoveFromEditor,
};
}, [
spaceId,
Expand All @@ -535,6 +538,7 @@ export function DataBlockProvider({ spaceId, children, entityId, relationId }: P
reset,
canJumpTo,
maxJumpPages,
onRemoveFromEditor,
]);

return <DataBlockContext.Provider value={store}>{children}</DataBlockContext.Provider>;
Expand Down
17 changes: 16 additions & 1 deletion apps/web/partials/blocks/table/table-block-context-menu.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand All @@ -38,7 +39,7 @@ type TableBlockContextMenuProps = {
export function TableBlockContextMenu({ sourceType }: TableBlockContextMenuProps) {
const [isMenuOpen, setIsMenuOpen] = React.useState(false);
const triggerRef = React.useRef<HTMLButtonElement>(null);
const { spaceId, entityId, relationId } = useDataBlockInstance();
const { spaceId, entityId, relationId, onRemoveFromEditor } = useDataBlockInstance();
const isEditing = useUserIsEditing(spaceId);
const [isEditingProperties, setIsEditingProperties] = useAtom(editingPropertiesAtom);

Expand All @@ -58,6 +59,12 @@ export function TableBlockContextMenu({ sourceType }: TableBlockContextMenuProps
}
};

const onRemoveBlock = () => {
setIsMenuOpen(false);
setIsEditingProperties(false);
onRemoveFromEditor?.();
};

const onOpenChange = (open: boolean) => {
setIsMenuOpen(open);
if (!open) {
Expand Down Expand Up @@ -122,6 +129,14 @@ export function TableBlockContextMenu({ sourceType }: TableBlockContextMenuProps
<Copy />
</div>
</MenuItem>
{isEditing && onRemoveFromEditor && (
<MenuItem className={listRowClassName} onClick={onRemoveBlock}>
<div className="flex w-full items-center justify-between gap-2">
<span>Remove</span>
<Trash color="grey-04" />
</div>
</MenuItem>
)}
</>
)}
</div>
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -103,7 +103,9 @@ export function TableBlockEditableTitle({ spaceId }: { spaceId: string }) {
<input
type="text"
ref={inputRef}
onBlur={e => 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"
Expand Down
10 changes: 8 additions & 2 deletions apps/web/partials/blocks/table/table-block.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -275,6 +275,7 @@ function useEntries(
onUpdateRelation,
shouldAutoFocusPlaceholder,
placeholderFocusKey,
hasPlaceholderRow,
};
}

Expand Down Expand Up @@ -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 })),
Expand Down Expand Up @@ -541,7 +547,7 @@ const ConfiguredTableBlock = ({
shownColumnIds={shownColumnIds}
onChangeEntry={onChangeEntry}
onLinkEntry={onLinkEntry}
onAddPlaceholder={onAddPlaceholder}
onAddPlaceholder={handleAddPlaceholder}
shouldAutoFocusPlaceholder={shouldAutoFocusPlaceholder}
placeholderFocusKey={placeholderFocusKey}
collectionTypeFilters={collectionTypeFilters}
Expand Down Expand Up @@ -673,7 +679,7 @@ const ConfiguredTableBlock = ({
<DataBlockViewMenu activeView={view} isLoading={isLoading} />
<TableBlockContextMenu sourceType={source.type} />
{renderPlusButtonAsInline && (
<button type="button" onClick={onAddPlaceholder}>
<button type="button" onClick={handleAddPlaceholder}>
<Create />
</button>
)}
Expand Down
57 changes: 57 additions & 0 deletions apps/web/partials/editor/block-separator.tsx
Original file line number Diff line number Diff line change
@@ -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;
},
}),
];
},
});
Loading
Loading