Skip to content

Conversation

@appflowy
Copy link
Contributor

@appflowy appflowy commented Nov 26, 2025

Description


Checklist

General

  • I've included relevant documentation or comments for the changes introduced.
  • I've tested the changes in multiple environments (e.g., different browsers, operating systems).

Testing

  • I've added or updated tests to validate the changes introduced for AppFlowy Web.

Feature-Specific

  • For feature additions, I've added a preview (video, screenshot, or demo) in the "Feature Preview" section.
  • I've verified that this feature integrates seamlessly with existing functionality.

Summary by Sourcery

Add block-level drag-and-drop for editor documents, improve scrolling behavior and visual feedback for embedded databases and panels, and simplify publish/share flows and workspace UI.

New Features:

  • Enable drag-and-drop reordering of editor blocks with visual drop targets and preview, including validation to prevent invalid moves.
  • Introduce a modal-based publish site management experience directly from the publish link preview.

Bug Fixes:

  • Prevent scroll jumps and focus issues when creating or rendering embedded database blocks and when navigating slash-panel options.
  • Ensure auto-scroll behavior is correctly initialized for the editor scroll container during interactions.
  • Avoid slate selection and unintended scrolling when inserting database blocks that open in modals.
  • Fix Storybook hostname mocking so environments can be simulated via a dedicated global value.
  • Gracefully handle image copy failures by surfacing a translated error notification and simplify image/file copy actions.
  • Return a placeholder container for databases before data loads to avoid layout glitches.
  • Ensure hover controls and drag states reset correctly when blocks are mutated or removed.

Enhancements:

  • Refine share and publish panels by consolidating publish management into the publish preview modal and removing the extra publish-manage modal and related Cypress tests.
  • Always show the upgrade banner and subscription handling based on API responses instead of host detection, simplifying isOfficialHost logic and consumers.
  • Streamline invalid-link experiences by removing custom messages and standardizing on a single translation-driven message.
  • Tighten workspace list components by removing alternate button rendering paths and simplifying current workspace layout.
  • Improve mobile navigation drawer sizing and folder header layout for a more consistent mobile UX.
  • Adjust math equation and block popover dimensions for a more compact, consistent appearance.
  • Increase robustness and observability of Yjs block change handling and history tracking, including better ordering of add/delete events and a dedicated local-manual origin.
  • Switch SSR/server logging to pretty-printed file-based logs and adjust request/meta-data logging verbosity.
  • Simplify Storybook and hostname mocking utilities to rely on a single mock hostname global.

@sourcery-ai
Copy link

sourcery-ai bot commented Nov 26, 2025

Reviewer's Guide

Implements block-level drag-and-drop in the editor, stabilizes scroll behavior for embedded databases and panels, simplifies publish/share flows and subscription checks, and refactors various UI components and Storybook/dev tooling for cleaner behavior and logging.

Sequence diagram for block drag-and-drop reordering in the editor

sequenceDiagram
    actor User
    participant HoverControls
    participant ControlActions
    participant useBlockDrag
    participant AtlaskitDnd as Atlaskit_DnD
    participant Element
    participant useBlockDrop
    participant handleBlockDrop
    participant executeOperations
    participant YDoc

    User->>HoverControls: Hover over block
    HoverControls->>ControlActions: Render with blockId parentId

    Note over ControlActions,useBlockDrag: Drag handle is wired via useBlockDrag

    User->>ControlActions: MouseDown on dragHandle
    ControlActions->>useBlockDrag: Start drag
    useBlockDrag->>AtlaskitDnd: draggable(element, getInitialData)
    AtlaskitDnd-->>useBlockDrag: onDragStart
    useBlockDrag->>ControlActions: onDragChange(true)
    ControlActions->>HoverControls: isDragging=true (hide hover UI)

    Note over Element,useBlockDrop: Each block wrapper registers dropTarget via useBlockDrop

    AtlaskitDnd->>useBlockDrop: onDragEnter(sourceData)
    useBlockDrop->>useBlockDrop: validate type editor-block
    useBlockDrop->>useBlockDrop: compute closest edge (top bottom)
    useBlockDrop->>Element: isDraggingOver=true dropEdge=edge
    Element->>Element: Render DropIndicator at edge

    AtlaskitDnd->>useBlockDrop: onDrop(sourceData)
    useBlockDrop->>handleBlockDrop: onDrop(sourceBlockId targetBlockId edge)

    handleBlockDrop->>YDoc: getBlock(sourceBlockId)
    handleBlockDrop->>YDoc: getBlock(targetBlockId)
    handleBlockDrop->>YDoc: getBlock(targetParentId)
    handleBlockDrop->>YDoc: getBlockIndex(targetBlockId)
    handleBlockDrop->>handleBlockDrop: compute newIndex from edge
    handleBlockDrop->>executeOperations: operations=[moveNode] origin=CollabOrigin_LocalManual

    executeOperations->>YDoc: doc.transact(operations origin)
    YDoc-->>executeOperations: transaction applied
    executeOperations-->>handleBlockDrop: success
    handleBlockDrop-->>useBlockDrop: true

    AtlaskitDnd-->>useBlockDrag: onDrop
    useBlockDrag->>ControlActions: onDragChange(false)
    ControlActions->>HoverControls: isDragging=false

    Note over HoverControls,Element: Slate and Yjs propagate new document state to re-render blocks
Loading

Class diagram for editor block drag-and-drop components

classDiagram
    class YjsEditor {
      +sharedRoot YSharedRoot
    }

    class useBlockDrag {
      +isDragging boolean
      +isDraggable boolean
    }

    class UseBlockDragProps {
      +blockId string
      +parentId string
      +dragHandleRef RefObject
      +disabled boolean
      +onDragChange function
    }

    class useBlockDrop {
      +isDraggingOver boolean
      +dropEdge Edge
    }

    class UseBlockDropProps {
      +blockId string
      +element HTMLElement
      +onDrop function
    }

    class handleBlockDrop {
      +handleBlockDrop(editor YjsEditor, sourceBlockId string, targetBlockId string, edge Edge) boolean
    }

    class validation {
      +canDragBlock(editor YjsEditor, blockId string) boolean
      +canDropBlock(editor YjsEditor, sourceBlockId string, targetBlockId string) boolean
      +wouldCreateCircularReference(sourceBlock YBlock, targetBlock YBlock, sharedRoot YSharedRoot) boolean
    }

    class HoverControlsHooks {
      +hoveredBlockId string
      +hoveredBlockParentId string
      +ref HTMLDivElement
      +cssProperty string
    }

    class HoverControls {
      -openMenu boolean
      -isDragging boolean
    }

    class ControlActions {
      -blockId string
      -parentId string
      -dragHandleRef HTMLButtonElement
      -setOpenMenu function
      -onDraggingChange function
    }

    class ElementComponent {
      -blockElement HTMLElement
      -allowBlockDrop boolean
      -isDraggingOver boolean
      -dropEdge Edge
    }

    class executeOperations {
      +executeOperations(sharedRoot YSharedRoot, operations function[], operationName string, origin unknown) void
    }

    YjsEditor --> executeOperations : uses
    YjsEditor --> validation : uses

    HoverControls ..> HoverControlsHooks : uses hook
    HoverControls ..> ControlActions : renders
    HoverControls ..> useBlockDrag : indirect via ControlActions

    ControlActions --> UseBlockDragProps : config
    ControlActions ..> useBlockDrag : calls hook
    useBlockDrag --> YjsEditor : reads
    useBlockDrag ..> validation : calls canDragBlock

    ElementComponent ..> useBlockDrop : calls hook
    ElementComponent --> UseBlockDropProps : config
    useBlockDrop ..> handleBlockDrop : calls onDrop

    handleBlockDrop ..> executeOperations : calls
    handleBlockDrop ..> validation : calls wouldCreateCircularReference

    validation --> YjsEditor : reads

    class DropIndicator {
      +edge Edge
      +gap string
    }

    ElementComponent ..> DropIndicator : renders when isDraggingOver
Loading

Flow diagram for handling a block drop operation

flowchart TD
    A[Block drop event
sourceBlockId
targetBlockId
edge] --> B[Check editor.sharedRoot]
    B -->|missing| Z[Return false]
    B -->|exists| C[Get sourceBlock from YDoc]
    C --> D[Get targetBlock from YDoc]
    D --> E[Get targetParent from YDoc]
    E -->|missing| Z
    E --> F[Check circular reference
wouldCreateCircularReference]
    F -->|true| Z
    F -->|false| G[Get targetIndex
getBlockIndex]
    G --> H[Compute newIndex
edge top => targetIndex
edge bottom => targetIndex+1]
    H --> I[executeOperations
origin=CollabOrigin_LocalManual]
    I --> J[moveNode in YDoc
sourceBlock -> targetParent at newIndex]
    J --> K[Transaction commits
Yjs notifies Slate]
    K --> L[Editor re-renders blocks
with new order]
    L --> M[Return true]
    Z --> N[Abort drop
no document change]
Loading

Flow diagram for embedded database scroll restoration

flowchart TD
    A[DatabaseBlock mount] --> B[Find scrollContainer
via ReactEditor.toDOMNode
or document.querySelector]
    B -->|not found| X[Do nothing]
    B -->|found| C[Initialize latestScrollTop
from scrollTop]
    C --> D[Listen to scroll events]
    D --> E[On scroll
if scrollTop>0
update latestScrollTop]

    subgraph RenderCycle
      F[DatabaseContent renders
Grid or Calendar]
      F --> G[Child view calls onRendered]
      G --> H[handleRendered]
      H --> I[restore callback]
      I --> J[Locate scrollContainer again]
      J --> K{scrollTop<10
and latestScrollTop>50}
      K -->|yes| L[Set scrollTop to latestScrollTop]
      K -->|no| M[Skip restore]
      L --> N[Schedule second restore
setTimeout 50ms]
      N --> O[After 1000ms
reset latestScrollTop to 0]
    end

    E --> RenderCycle
    X --> RenderCycle
Loading

File-Level Changes

Change Details Files
Add block-level drag-and-drop support in the editor with visual indicators and safety checks.
  • Introduce reusable hooks to make blocks draggable and droppable with Atlaskit pragmatic-drag-and-drop and closest-edge hitbox APIs
  • Implement validation to prevent dragging non-draggable block types and avoid circular hierarchy references when dropping
  • Wire drag behavior into hover controls and block toolbar, including drag handle state, styling, and integration with the existing hover controls overlay
  • Update the generic Element renderer to register each block DOM node as a drop target, handle drop callbacks, and render positional drop indicators and styles
src/components/editor/components/drag-drop/useBlockDrag.ts
src/components/editor/components/drag-drop/useBlockDrop.ts
src/components/editor/components/drag-drop/validation.ts
src/components/editor/components/drag-drop/handleBlockDrop.ts
src/components/editor/components/element/Element.tsx
src/components/editor/components/toolbar/block-controls/ControlActions.tsx
src/components/editor/components/toolbar/block-controls/HoverControls.tsx
src/components/editor/components/toolbar/block-controls/HoverControls.hooks.ts
src/components/editor/editor.scss
src/application/slate-yjs/utils/yjs.ts
src/application/slate-yjs/command/index.ts
src/application/slate-yjs/utils/applyToSlate.ts
src/application/types.ts
Stabilize scrolling and layout behavior for embedded database blocks, calendar/grid views, panels, and editor auto-scroll.
  • Preserve and restore scroll position when creating Grid/Board/Calendar database views from the slash panel to avoid jumps when the modal opens
  • Track scroll position around embedded DatabaseBlock rendering and restore it if layout shifts reset scrollTop to zero
  • Expose onRendered callbacks from database content and calendar components and use them to trigger scroll restoration once content is ready
  • Hide grid virtualizer content until its parent offset is known and ensure Database returns a minimal placeholder container while loading
  • Set up editor auto-scrolling using the editor’s scroll parent and improve panel keyboard handling and keydown guards
src/components/editor/components/panels/slash-panel/SlashPanel.tsx
src/components/editor/components/blocks/database/DatabaseBlock.tsx
src/components/database/fullcalendar/FullCalendar.tsx
src/components/database/components/grid/grid-table/useGridVirtualizer.ts
src/components/database/components/grid/grid-table/GridVirtualizer.tsx
src/components/editor/components/blocks/database/components/DatabaseContent.tsx
src/components/editor/Editable.tsx
src/components/editor/components/panels/PanelsContext.tsx
src/components/database/Database.tsx
Simplify publish/share flows and subscription gating, moving publish management into the publish link preview and removing dev-only host checks.
  • Remove the separate PublishManage modal entry point from ShareButton and ShareTabs in favor of opening it from within PublishLinkPreview
  • Embed the PublishManage modal directly inside PublishLinkPreview with its own internal open state and remove related Cypress tests and selectors
  • Drop isOfficialHost logic from subscription gating so SharePanel and InviteMember always load subscriptions from the backend and always show UpgradeBanner
  • Change InvalidLink to ignore backend-provided messages and centralize copy in i18n, updating invite/conversion pages to use the simplified component
src/components/app/share/ShareButton.tsx
src/components/app/share/ShareTabs.tsx
src/components/app/share/PublishPanel.tsx
src/components/app/share/PublishLinkPreview.tsx
src/components/app/publish-manage/PublishManage.tsx
src/utils/subscription.ts
src/components/app/share/SharePanel.tsx
src/components/app/workspaces/InviteMember.tsx
src/components/app/landing-pages/InviteCode.tsx
src/components/app/landing-pages/ApproveConversion.tsx
src/components/app/landing-pages/AsGuest.tsx
src/components/_shared/landing-page/InvalidLink.tsx
cypress/e2e/page/publish-page.cy.ts
cypress/support/selectors.ts
Adjust Storybook, host mocking, and SSR/server logging behavior to decouple from APP_CONFIG and improve diagnostics.
  • Replace Storybook hostname mocking based on window.APP_CONFIG with a simple window.STORYBOOK_MOCK_HOSTNAME flag and update decorators/hooks to use it
  • Align isOfficialHost with hostname-based checks only and add Storybook support via the mock hostname global
  • Switch SSR server logging to always use pino-pretty with a file destination and increase log verbosity for request lifecycle and metadata fetching
  • Update supervisord config to log bun stdout/stderr into files instead of Docker stdio and remove the entrypoint version banner
.storybook/decorators.tsx
src/components/billing/UpgradePlan.stories.tsx
src/utils/subscription.ts
deploy/server.ts
docker/entrypoint-ssr.sh
docker/supervisord-ssr.conf
Refine various UI behaviors and clean up dead code in workspace and mobile UI, file/image toolbars, and editor/panels.
  • Simplify WorkspaceItem and WorkspaceList to always render DropdownMenuItem, and adjust CurrentWorkspace and MobileWorkspaces/MobileFolder layout to use a fixed avatar size and flex-only content
  • Change MobileDrawer and MobileTopBar to drop the topOffset logic and compute swipeAreaWidth directly from window width
  • Improve image and file toolbar copy actions by using fetchImageBlob + Clipboard API for images and copyTextToClipboard for file URLs with user feedback
  • Adjust popover sizes for block popovers and math equation editors, tweak tooltips and drag handle styling, and ensure ViewModal and View navigation do not over-constrain focus/scroll
src/components/app/workspaces/WorkspaceItem.tsx
src/components/app/workspaces/WorkspaceList.tsx
src/components/app/workspaces/CurrentWorkspace.tsx
src/components/app/workspaces/MobileWorkspaces.tsx
src/components/_shared/mobile-topbar/MobileFolder.tsx
src/components/_shared/mobile-topbar/MobileTopBar.tsx
src/components/_shared/mobile-drawer/MobileDrawer.tsx
src/components/editor/components/blocks/image/ImageToolbar.tsx
src/components/editor/components/blocks/file/FileToolbar.tsx
src/components/editor/components/block-popover/MathEquationPopoverContent.tsx
src/components/editor/components/block-popover/index.tsx
src/components/app/ViewModal.tsx
src/components/chat/components/ai-writer/writing-input.tsx
src/components/app/hooks/useViewNavigation.ts
src/components/app/hooks/useViewSync.ts
Miscellaneous editor, database, and dev tooling tweaks and removals to support the new drag-and-drop and collaboration flows.
  • Ensure UndoManager tracks a new CollabOrigin.LocalManual origin for manual Yjs operations and pass it through executeOperations when handling block drop moves
  • Sort Yjs block map events so delete actions are processed before add/update to avoid inconsistent state during collaborative updates
  • Improve fetchImageBlob to share fetch logic between storage and non-storage URLs and fail gracefully
  • Drop unused namespaceRedirectPlugin from Vite dev config and remove some database tab/component dead imports and stories/tests that no longer apply
src/application/types.ts
src/application/slate-yjs/plugins/withHistory.ts
src/application/slate-yjs/utils/yjs.ts
src/application/slate-yjs/utils/applyToSlate.ts
src/utils/image.ts
vite.config.ts
src/components/database/components/tabs/DatabaseViewTabs.tsx
src/components/database/components/tabs/DatabaseTabItem.tsx
src/components/as-template/icons.tsx
src/components/chat/components/ai-writer/view-tree/index.tsx
src/components/chat/components/chat-input/related-views/index.tsx
src/components/chat/components/chat-messages/assistant-message.tsx
src/components/database/components/grid/grid-table/useGridDnd.ts
src/utils/download.ts
src/components/_shared/landing-page/InvalidLink.stories.tsx
cypress/e2e/app/upgrade-plan.cy.ts
cypress/e2e/embeded/image/copy_image.cy.ts
docker/Dockerfile.ssr
pnpm-lock.yaml
package.json

Tips and commands

Interacting with Sourcery

  • Trigger a new review: Comment @sourcery-ai review on the pull request.
  • Continue discussions: Reply directly to Sourcery's review comments.
  • Generate a GitHub issue from a review comment: Ask Sourcery to create an
    issue from a review comment by replying to it. You can also reply to a
    review comment with @sourcery-ai issue to create an issue from it.
  • Generate a pull request title: Write @sourcery-ai anywhere in the pull
    request title to generate a title at any time. You can also comment
    @sourcery-ai title on the pull request to (re-)generate the title at any time.
  • Generate a pull request summary: Write @sourcery-ai summary anywhere in
    the pull request body to generate a PR summary at any time exactly where you
    want it. You can also comment @sourcery-ai summary on the pull request to
    (re-)generate the summary at any time.
  • Generate reviewer's guide: Comment @sourcery-ai guide on the pull
    request to (re-)generate the reviewer's guide at any time.
  • Resolve all Sourcery comments: Comment @sourcery-ai resolve on the
    pull request to resolve all Sourcery comments. Useful if you've already
    addressed all the comments and don't want to see them anymore.
  • Dismiss all Sourcery reviews: Comment @sourcery-ai dismiss on the pull
    request to dismiss all existing Sourcery reviews. Especially useful if you
    want to start fresh with a new review - don't forget to comment
    @sourcery-ai review to trigger a new review!

Customizing Your Experience

Access your dashboard to:

  • Enable or disable review features such as the Sourcery-generated pull request
    summary, the reviewer's guide, and others.
  • Change the review language.
  • Add, remove or edit custom review instructions.
  • Adjust other review settings.

Getting Help

Copy link

@sourcery-ai sourcery-ai bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Hey there - I've reviewed your changes and found some issues that need to be addressed.

  • In useBlockDrag.generateDragPreview, the originalImages / clonedImages mapping probably never runs as intended because you query container.querySelectorAll('img') before clone is appended to container; the image lookup should be done on the cloned subtree (e.g. clone.querySelectorAll('img')) after it has been attached.
  • The isOfficialHost implementation was simplified to only inspect window.location.hostname (and Storybook’s mock) and now explicitly excludes localhost/127.0.0.1; this changes subscription gating semantics for self‑hosted and local environments and may cause unintended calls to getSubscriptions where they were previously short‑circuited—worth double‑checking that this behavior change is desired.
  • The SSR server logging in deploy/server.ts now always uses pino-pretty with a local file destination and hard‑coded level: 'info', ignoring LOG_LEVEL and typical production best practices (JSON to stdout/stderr); consider whether you want pretty‑printed logs and file writes in production or if this should remain environment‑dependent.
Prompt for AI Agents
Please address the comments from this code review:

## Overall Comments
- In `useBlockDrag.generateDragPreview`, the `originalImages` / `clonedImages` mapping probably never runs as intended because you query `container.querySelectorAll('img')` before `clone` is appended to `container`; the image lookup should be done on the cloned subtree (e.g. `clone.querySelectorAll('img')`) after it has been attached.
- The `isOfficialHost` implementation was simplified to only inspect `window.location.hostname` (and Storybook’s mock) and now explicitly excludes localhost/127.0.0.1; this changes subscription gating semantics for self‑hosted and local environments and may cause unintended calls to `getSubscriptions` where they were previously short‑circuited—worth double‑checking that this behavior change is desired.
- The SSR server logging in `deploy/server.ts` now always uses `pino-pretty` with a local file destination and hard‑coded `level: 'info'`, ignoring `LOG_LEVEL` and typical production best practices (JSON to stdout/stderr); consider whether you want pretty‑printed logs and file writes in production or if this should remain environment‑dependent.

## Individual Comments

### Comment 1
<location> `src/components/_shared/mobile-topbar/MobileTopBar.tsx:53` </location>
<code_context>
     >
       <MobileDrawer
-        swipeAreaWidth={folderDrawerWidth}
+        swipeAreaWidth={window.innerWidth - 56}
         onOpen={handleOpenFolder}
         onClose={handleCloseFolder}
</code_context>

<issue_to_address>
**issue (bug_risk):** Accessing `window.innerWidth` directly in render can break SSR and initial render in non-browser environments.

This used to be guarded (e.g. via `useMemo` and `typeof window === 'undefined'`) to avoid accessing `window` during SSR or in tests. Please reintroduce a guard and compute `swipeAreaWidth` outside of render (e.g. memo/layout effect) with a safe fallback when `window` is unavailable.
</issue_to_address>

### Comment 2
<location> `src/components/editor/components/drag-drop/useBlockDrag.ts:68-70` </location>
<code_context>
+    direction: computedStyle.direction,
+  });
+
+  // Explicitly handle images to ensure they render correctly in the ghost
+  const originalImages = sourceElement.querySelectorAll('img');
+  const clonedImages = container.querySelectorAll('img');
+
+  originalImages.forEach((orig, index) => {
</code_context>

<issue_to_address>
**issue (bug_risk):** Image-specific drag-preview logic never runs because you query cloned images before appending the clone to the container.

Here `container` has no children yet, so `container.querySelectorAll('img')` returns an empty NodeList and the image-handling loop never runs. To make the image logic apply to the drag preview, either:

- Query on `clone` instead: `const clonedImages = clone.querySelectorAll('img');`, or
- Append the clone first, then query: `container.appendChild(clone); const clonedImages = container.querySelectorAll('img');`.
</issue_to_address>

### Comment 3
<location> `src/components/editor/components/blocks/database/DatabaseBlock.tsx:41` </location>
<code_context>
+      let scrollContainer: HTMLElement | null = null;
+      
+      try {
+        const domNode = ReactEditor.toDOMNode(editor, editor);
+        
+        scrollContainer = domNode.closest('.appflowy-scroll-container');
</code_context>

<issue_to_address>
**issue (bug_risk):** Using `ReactEditor.toDOMNode(editor, editor)` is likely incorrect and may throw at runtime.

`toDOMNode` expects `(editor, SlateNode)`, so passing `editor` as the node is invalid and may throw (e.g., when asserting `Element.isElement(node)`). This pattern appears here and in the slash-panel DB creation handlers.

If you just need the editor root element, either use a known Slate node (e.g. `editor.children[0]`) or reuse an existing ref to the editor DOM element. It may also be worth centralizing the logic for finding the `.appflowy-scroll-container` for an editor to avoid duplicating this fragile lookup across components.
</issue_to_address>

### Comment 4
<location> `src/components/editor/components/panels/slash-panel/SlashPanel.tsx:728` </location>
<code_context>
+      let scrollContainer: HTMLElement | null = null;
+      
+      try {
+        const domNode = ReactEditor.toDOMNode(editor, editor);
+        
+        scrollContainer = domNode.closest('.appflowy-scroll-container');
</code_context>

<issue_to_address>
**issue (bug_risk):** The slash-panel scroll preservation logic also uses `toDOMNode(editor, editor)`, which is likely an invalid node reference.

As with `DatabaseBlock`, `ReactEditor.toDOMNode` should receive a Slate node, not the editor instance. If this throws, the logic silently falls back to `document.querySelector('.appflowy-scroll-container')`, which is less precise and may break with multiple editors.

Recommend using a valid Slate node or a dedicated ref to the editor container, and sharing a common helper so this scroll-preservation logic is implemented safely in both places.
</issue_to_address>

### Comment 5
<location> `src/utils/subscription.ts:12-14` </location>
<code_context>
+export function InvalidLink() {
   const { t } = useTranslation();

   return (
     <LandingPage
       Logo={InvalidLinkLogo}
</code_context>

<issue_to_address>
**suggestion:** The implementation no longer matches the doc comment and has dead commented-out localhost handling.

The header says `localhost` is an official host for development, but the code now only allows `beta.appflowy.cloud` and `test.appflowy.cloud`, with the localhost checks commented out. Please either update/remove the comment and dead code if localhost is no longer supported, or reinstate the localhost checks to match the documented behavior.
</issue_to_address>

### Comment 6
<location> `src/components/editor/components/panels/slash-panel/SlashPanel.tsx:723` </location>
<code_context>
         keywords: ['grid', 'table', 'database'],
         onClick: async () => {
           if (!viewId || !addPage || !openPageModal) return;
+
+          let scrollContainer: Element | null = null;
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the repeated scroll-container, selection, and scroll-into-view logic into small helper utilities to keep SlashPanel handlers linear and focused on orchestration rather than DOM details.

You can reduce the added complexity by extracting the repeated DOM/selection logic into small helpers. That keeps the handlers linear and centralizes the imperative bits.

### 1) Factor out scroll-container discovery + restoration

The Grid/Board/Calendar handlers repeat the same scroll-container lookup and restore logic. A small utility keeps this behavior consistent and easier to adjust later:

```ts
// utils/scroll.ts
import { ReactEditor } from 'slate-react';
import type { Editor } from 'slate';

export function getScrollContainer(editor: Editor): Element | null {
  try {
    const domNode = ReactEditor.toDOMNode(editor as any, editor);
    const fromEditor = domNode.closest('.appflowy-scroll-container');
    if (fromEditor) return fromEditor;
  } catch {
    // ignore
  }
  return document.querySelector('.appflowy-scroll-container');
}

export function preserveScrollAround<T>(
  editor: Editor,
  fn: () => Promise<T> | T,
): Promise<T> | T {
  const container = getScrollContainer(editor);
  const savedScrollTop = container?.scrollTop;

  const run = async () => {
    const result = await fn();
    if (savedScrollTop !== undefined) {
      setTimeout(() => {
        const current = document.querySelector('.appflowy-scroll-container');
        if (current) current.scrollTop = savedScrollTop;
      }, 50);
    }
    return result;
  };

  return run();
}
```

Usage in each `onClick` becomes much simpler:

```ts
onClick: async () => {
  if (!viewId || !addPage || !openPageModal) return;

  await preserveScrollAround(editor, async () => {
    const newViewId = await addPage(viewId, {
      layout: ViewLayout.Grid,
      name: t('document.slashMenu.name.grid'),
    });

    turnInto(BlockType.GridBlock, {
      view_id: newViewId,
      parent_id: viewId,
    } as DatabaseNodeData);

    openPageModal(newViewId);
  });
}
```

Same pattern for Board/Calendar — only the `layout` and `BlockType` differ.

---

### 2) Extract selection logic for new embed blocks

The embed selection logic now has an inline special case for database blocks. Pulling that into a helper keeps the main flow linear:

```ts
// utils/selection.ts
import { Transforms } from 'slate';
import type { Editor } from 'slate';
import { BlockType } from '@/application/slate-yjs';
import { findSlateEntryByBlockId } from '@/application/slate-yjs/utils/editor';

const DATABASE_BLOCK_TYPES = [
  BlockType.GridBlock,
  BlockType.BoardBlock,
  BlockType.CalendarBlock,
];

export function positionSelectionForNewBlock(
  editor: Editor,
  type: BlockType,
  newBlockId: string,
) {
  const isDatabaseBlock = DATABASE_BLOCK_TYPES.includes(type);

  if (isDatabaseBlock) {
    // Database blocks open a modal and should not scroll the editor
    Transforms.deselect(editor);
    return;
  }

  const entry = findSlateEntryByBlockId(editor, newBlockId);
  if (!entry) return;

  const [, path] = entry;
  editor.select(editor.start(path));
}
```

Call site becomes:

```ts
if (newBlockId && isEmbedBlockTypes(type)) {
  positionSelectionForNewBlock(editor, type, newBlockId);
}
```

---

### 3) Extract custom `scrollIntoView` math

The manual scroll calculation inside the effect can be moved into a reusable utility, clarifying intent and reducing inline math:

```ts
// utils/dom.ts
export function scrollChildIntoView(
  container: HTMLElement,
  child: HTMLElement,
) {
  const elOffsetTop = child.offsetTop;
  const elHeight = child.offsetHeight;
  const menuScrollTop = container.scrollTop;
  const menuHeight = container.clientHeight;

  if (elOffsetTop < menuScrollTop) {
    container.scrollTop = elOffsetTop;
  } else if (elOffsetTop + elHeight > menuScrollTop + menuHeight) {
    container.scrollTop = elOffsetTop + elHeight - menuHeight;
  }
}
```

Effect becomes:

```ts
useEffect(() => {
  selectedOptionRef.current = selectedOption;
  if (!selectedOption) return;

  const el = optionsRef.current?.querySelector(
    `[data-option-key="${selectedOption}"]`,
  ) as HTMLButtonElement | null;

  if (el && optionsRef.current) {
    scrollChildIntoView(optionsRef.current, el);
  }
}, [selectedOption]);
```

These extractions preserve all current behavior but reduce duplication and keep `SlashPanel` focused on orchestrating behavior rather than embedding DOM/scroll/selection details inline.
</issue_to_address>

### Comment 7
<location> `src/components/editor/components/blocks/database/DatabaseBlock.tsx:34` </location>
<code_context>
       loadViewMeta: context?.loadViewMeta,
     });

+    // Track latest valid scroll position to restore if layout shift resets it
+    const latestScrollTop = useRef<number>(0);
+
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the scroll restoration logic into shared helpers or a hook and replacing magic numbers with named constants to make the behavior clearer and less repetitive.

You can keep the new behavior but reduce complexity and duplication by extracting the scroll logic into small, focused helpers/hooks and by lifting magic numbers into named constants.

### 1. Deduplicate scroll-container lookup

Right now the DOM lookup is duplicated in both the `useEffect` and `restore` function. Extract it to a tiny helper:

```ts
// utils/scroll.ts
import { ReactEditor } from 'slate-react';

export function getScrollContainer(editor: ReactEditor): HTMLElement | null {
  try {
    const domNode = ReactEditor.toDOMNode(editor, editor);
    const container = domNode.closest('.appflowy-scroll-container') as HTMLElement | null;
    if (container) return container;
  } catch {
    // ignore
  }

  return document.querySelector('.appflowy-scroll-container');
}
```

Then both places just call this:

```ts
// in DatabaseBlock
import { getScrollContainer } from '@/utils/scroll';

useEffect(() => {
  const scrollContainer = getScrollContainer(editor);
  if (!scrollContainer) return;

  if (scrollContainer.scrollTop > 0) {
    latestScrollTop.current = scrollContainer.scrollTop;
  }

  const handleScroll = () => {
    if (scrollContainer.scrollTop > 0) {
      latestScrollTop.current = scrollContainer.scrollTop;
    }
  };

  scrollContainer.addEventListener('scroll', handleScroll);
  return () => {
    scrollContainer.removeEventListener('scroll', handleScroll);
  };
}, [editor]);

const handleRendered = useCallback(() => {
  const restore = () => {
    try {
      const scrollContainer = getScrollContainer(editor);
      if (
        scrollContainer &&
        scrollContainer.scrollTop < SCROLL_RESET_THRESHOLD &&
        latestScrollTop.current > SCROLL_RESTORE_MIN_SCROLL
      ) {
        scrollContainer.scrollTop = latestScrollTop.current;
      }
    } catch {
      // ignore
    }
  };

  restore();
  setTimeout(restore, SCROLL_RETRY_DELAY_MS);
  setTimeout(() => {
    latestScrollTop.current = 0;
  }, SCROLL_CLEAR_DELAY_MS);
}, [editor]);
```

### 2. Replace magic numbers with named constants

The thresholds and timeouts are currently “magic” and make the behavior harder to reason about. Small named constants make intent clear and keep behavior exactly the same:

```ts
// utils/scroll.ts or local to the component
const SCROLL_RESET_THRESHOLD = 10;      // "close to top" ⇒ treat as reset
const SCROLL_RESTORE_MIN_SCROLL = 50;   // ignore tiny scrolls
const SCROLL_RETRY_DELAY_MS = 50;       // handle async layout shifts
const SCROLL_CLEAR_DELAY_MS = 1000;     // allow future 0-scrolls after restore
```

Used in your logic:

```ts
if (
  scrollContainer &&
  scrollContainer.scrollTop < SCROLL_RESET_THRESHOLD &&
  latestScrollTop.current > SCROLL_RESTORE_MIN_SCROLL
) {
  scrollContainer.scrollTop = latestScrollTop.current;
}
```

### 3. Optional: move scroll concern into a hook

If you want to further isolate scroll concerns from `DatabaseBlock`, you can wrap the above into a hook that exposes the single callback you need:

```ts
// hooks/useDatabaseScrollRestoration.ts
import { useCallback, useEffect, useRef } from 'react';
import type { ReactEditor } from 'slate-react';
import { getScrollContainer } from '@/utils/scroll';

export function useDatabaseScrollRestoration(editor: ReactEditor) {
  const latestScrollTop = useRef(0);

  useEffect(() => {
    const scrollContainer = getScrollContainer(editor);
    if (!scrollContainer) return;

    if (scrollContainer.scrollTop > 0) {
      latestScrollTop.current = scrollContainer.scrollTop;
    }

    const handleScroll = () => {
      if (scrollContainer.scrollTop > 0) {
        latestScrollTop.current = scrollContainer.scrollTop;
      }
    };

    scrollContainer.addEventListener('scroll', handleScroll);
    return () => scrollContainer.removeEventListener('scroll', handleScroll);
  }, [editor]);

  const handleRendered = useCallback(() => {
    const restore = () => {
      try {
        const scrollContainer = getScrollContainer(editor);
        if (
          scrollContainer &&
          scrollContainer.scrollTop < SCROLL_RESET_THRESHOLD &&
          latestScrollTop.current > SCROLL_RESTORE_MIN_SCROLL
        ) {
          scrollContainer.scrollTop = latestScrollTop.current;
        }
      } catch {}
    };

    restore();
    setTimeout(restore, SCROLL_RETRY_DELAY_MS);
    setTimeout(() => {
      latestScrollTop.current = 0;
    }, SCROLL_CLEAR_DELAY_MS);
  }, [editor]);

  return { handleRendered };
}
```

Usage in `DatabaseBlock`:

```ts
const editor = useSlateStatic();
const { handleRendered } = useDatabaseScrollRestoration(editor);

<DatabaseContent
  // ...
  onRendered={handleRendered}
/>;
```

These changes keep all behavior intact (same thresholds and timing) while making the scroll logic easier to understand, test, and reuse, and they pull non-render concerns out of the main component.
</issue_to_address>

### Comment 8
<location> `src/components/editor/components/drag-drop/useBlockDrag.ts:20` </location>
<code_context>
+/**
+ * Generates a custom drag preview element
+ */
+function generateDragPreview(sourceElement: HTMLElement): HTMLElement {
+  const container = document.createElement('div');
+  const clone = sourceElement.cloneNode(true) as HTMLElement;
</code_context>

<issue_to_address>
**issue (complexity):** Consider extracting the drag preview DOM/image logic into a separate helper module so that `useBlockDrag` focuses only on drag wiring and state.

`useBlockDrag` is taking on a lot of responsibility, and the reviewer’s suggestion to separate preview construction from drag wiring will reduce complexity and make this easier to maintain.

You can keep the behavior but push the heavy DOM/image logic behind a small API and break it up internally:

```ts
// dragPreview.ts
interface BlockPreviewOptions {
  isImageBlock: boolean;
}

export function createBlockDragPreview(
  sourceElement: HTMLElement,
  { isImageBlock }: BlockPreviewOptions,
): HTMLElement {
  const container = createPreviewContainer(sourceElement, isImageBlock);
  const clone = cloneForPreview(sourceElement);

  container.appendChild(clone);
  handlePreviewImages(sourceElement, clone);

  document.body.appendChild(container);
  return container;
}

function createPreviewContainer(
  sourceElement: HTMLElement,
  isImage: boolean,
): HTMLDivElement {
  const container = document.createElement('div');
  const computedStyle = window.getComputedStyle(sourceElement);
  let targetWidth = sourceElement.offsetWidth;

  if (isImage) {
    const img = sourceElement.querySelector('img');
    if (img && img.offsetWidth > 0) {
      targetWidth = img.offsetWidth;
    }
  }

  Object.assign(container.style, {
    width: `${targetWidth}px`,
    maxWidth: '600px',
    maxHeight: isImage ? '1000px' : '150px',
    backgroundColor: 'var(--bg-body, #ffffff)',
    borderRadius: '8px',
    boxShadow: 'var(--shadows-sm, 0 4px 20px rgba(0, 0, 0, 0.1))',
    overflow: 'hidden',
    position: 'absolute',
    top: '-1000px',
    left: '-1000px',
    zIndex: '9999',
    pointerEvents: 'none',
    border: '1px solid var(--line-divider, rgba(0, 0, 0, 0.1))',
    display: 'block',
    fontFamily: computedStyle.fontFamily,
    color: computedStyle.color,
    lineHeight: computedStyle.lineHeight,
    textAlign: computedStyle.textAlign,
    direction: computedStyle.direction,
  });

  return container;
}

function cloneForPreview(sourceElement: HTMLElement): HTMLElement {
  const clone = sourceElement.cloneNode(true) as HTMLElement;
  clone.classList.remove('block-element--dragging');
  clone.style.margin = '0';
  clone.style.width = '100%';
  clone.style.pointerEvents = 'none';
  return clone;
}

function handlePreviewImages(source: HTMLElement, clone: HTMLElement) {
  const originalImages = source.querySelectorAll('img');
  const clonedImages = clone.querySelectorAll('img');

  originalImages.forEach((orig, index) => {
    const clonedImg = clonedImages[index];
    if (!clonedImg) return;

    try {
      if (orig.complete && orig.naturalWidth > 0) {
        const canvas = document.createElement('canvas');
        canvas.width = orig.offsetWidth;
        canvas.height = orig.offsetHeight;

        const ctx = canvas.getContext('2d');
        if (ctx) {
          ctx.drawImage(orig, 0, 0, canvas.width, canvas.height);
          canvas.style.maxWidth = '100%';
          canvas.style.height = 'auto';
          canvas.style.display = 'block';
          canvas.style.opacity = '1';
          canvas.style.pointerEvents = 'none';
          clonedImg.parentNode?.replaceChild(canvas, clonedImg);
          return;
        }
      }
    } catch {
      // fall through to img fallback
    }

    clonedImg.src = orig.currentSrc || orig.src;
    clonedImg.loading = 'eager';
    clonedImg.style.maxWidth = '100%';
    clonedImg.style.height = 'auto';
    clonedImg.style.opacity = '1';
    clonedImg.style.display = 'block';
  });
}
```

Then `useBlockDrag` becomes more linear and focused on drag wiring:

```ts
import { createBlockDragPreview } from './dragPreview';

export function useBlockDrag({...}: UseBlockDragProps) {
  // ...

  useEffect(() => {
    const element = dragHandleRef.current;
    if (!element || !blockId || !isDraggable || disabled) return;

    return draggable({
      element,
      getInitialData: () => ({ type: 'editor-block', blockId, parentId }),
      onGenerateDragPreview: ({ nativeSetDragImage }) => {
        try {
          const entry = findSlateEntryByBlockId(editor, blockId);
          if (!entry) return;

          const [node] = entry;
          const blockElement = ReactEditor.toDOMNode(editor, node);
          if (!blockElement) return;

          const isImageBlock =
            blockElement.getAttribute('data-block-type') === 'image';

          const preview = createBlockDragPreview(blockElement, {
            isImageBlock,
          });

          nativeSetDragImage?.(preview, 0, 0);
          setTimeout(() => document.body.removeChild(preview), 0);
        } catch (e) {
          console.warn('Failed to generate drag preview:', e);
        }
      },
      // ...
    });
  }, [blockId, parentId, dragHandleRef, isDraggable, disabled, onDragChange, editor]);
}
```

This keeps all current behavior (including the image/canvas handling) but:

- `useBlockDrag` only coordinates drag behavior and preview creation, making it easier to skim.
- Preview-specific DOM and styling logic is isolated and testable.
- Individual concerns (`createPreviewContainer`, `cloneForPreview`, `handlePreviewImages`) are small and easier to tweak without re-reading the entire hook.
</issue_to_address>

### Comment 9
<location> `cypress/e2e/embeded/database/database-bottom-scroll-simple.cy.ts:67` </location>
<code_context>
      const scrollHeight = $container[0].scrollHeight;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
      const {scrollHeight} = $container[0];
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

### Comment 10
<location> `cypress/e2e/embeded/database/database-bottom-scroll-simple.cy.ts:68` </location>
<code_context>
      const clientHeight = $container[0].clientHeight;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
      const {clientHeight} = $container[0];
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

### Comment 11
<location> `cypress/e2e/embeded/database/database-bottom-scroll.cy.ts:105` </location>
<code_context>
        const scrollHeight = $container[0].scrollHeight;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
        const {scrollHeight} = $container[0];
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

### Comment 12
<location> `cypress/e2e/embeded/database/database-bottom-scroll.cy.ts:106` </location>
<code_context>
        const clientHeight = $container[0].clientHeight;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
        const {clientHeight} = $container[0];
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

### Comment 13
<location> `cypress/e2e/embeded/database/database-bottom-scroll.cy.ts:189` </location>
<code_context>
        const scrollHeight = $container[0].scrollHeight;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
        const {scrollHeight} = $container[0];
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

### Comment 14
<location> `cypress/e2e/embeded/database/database-bottom-scroll.cy.ts:190` </location>
<code_context>
        const clientHeight = $container[0].clientHeight;

</code_context>

<issue_to_address>
**suggestion (code-quality):** Prefer object destructuring when accessing and using properties. ([`use-object-destructuring`](https://docs.sourcery.ai/Reference/Rules-and-In-Line-Suggestions/TypeScript/Default-Rules/use-object-destructuring))

```suggestion
        const {clientHeight} = $container[0];
```

<br/><details><summary>Explanation</summary>Object destructuring can often remove an unnecessary temporary reference, as well as making your code more succinct.

From the [Airbnb Javascript Style Guide](https://airbnb.io/javascript/#destructuring--object)
</details>
</issue_to_address>

Sourcery is free for open source - if you like our reviews please consider sharing them ✨
Help me be more useful! Please click 👍 or 👎 on each comment and I'll use the feedback to improve your reviews.

Comment on lines +68 to +70
// Explicitly handle images to ensure they render correctly in the ghost
const originalImages = sourceElement.querySelectorAll('img');
const clonedImages = container.querySelectorAll('img');
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Image-specific drag-preview logic never runs because you query cloned images before appending the clone to the container.

Here container has no children yet, so container.querySelectorAll('img') returns an empty NodeList and the image-handling loop never runs. To make the image logic apply to the drag preview, either:

  • Query on clone instead: const clonedImages = clone.querySelectorAll('img');, or
  • Append the clone first, then query: container.appendChild(clone); const clonedImages = container.querySelectorAll('img');.

let scrollContainer: HTMLElement | null = null;

try {
const domNode = ReactEditor.toDOMNode(editor, editor);
Copy link

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

issue (bug_risk): Using ReactEditor.toDOMNode(editor, editor) is likely incorrect and may throw at runtime.

toDOMNode expects (editor, SlateNode), so passing editor as the node is invalid and may throw (e.g., when asserting Element.isElement(node)). This pattern appears here and in the slash-panel DB creation handlers.

If you just need the editor root element, either use a known Slate node (e.g. editor.children[0]) or reuse an existing ref to the editor DOM element. It may also be worth centralizing the logic for finding the .appflowy-scroll-container for an editor to avoid duplicating this fragile lookup across components.

@appflowy appflowy closed this Nov 26, 2025
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants