Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
32 commits
Select commit Hold shift + click to select a range
d8d6852
feat: add drag handles for content blocks
jwalkingjew Aug 26, 2026
bff2820
fix: preserve block drag handle hover
jwalkingjew Aug 26, 2026
ffa177f
fix: preserve block relation identity when reordering
jwalkingjew Aug 26, 2026
934b5d3
fix: expand content block handle hover area
jwalkingjew Aug 26, 2026
f7b7a20
fix: address block reorder review feedback
jwalkingjew Aug 26, 2026
7191731
fix: handle sparse block order and relation unsets
jwalkingjew Aug 26, 2026
9430d3c
fix: add block gutter hover surface
jwalkingjew Aug 26, 2026
eed0ab8
fix: clear stale block handle after reorder
jwalkingjew Aug 26, 2026
a8154a6
fix: remeasure draggable blocks after content edits
jwalkingjew Aug 26, 2026
9583ae6
fix: prevent flicker after block drop
jwalkingjew Aug 26, 2026
fd9372c
fix: prevent focus flash after block reorder
jwalkingjew Aug 26, 2026
5dc50b8
fix: avoid viewport jump after block reorder
jwalkingjew Aug 26, 2026
929171a
fix: stabilize block layout after reorder
jwalkingjew Aug 26, 2026
5dbcf64
revert: remove unsuccessful block flicker fixes
jwalkingjew Aug 26, 2026
1db871c
refactor: migrate block reorder to next-gen dnd kit
jwalkingjew Aug 26, 2026
ac337e7
Revert "refactor: migrate block reorder to next-gen dnd kit"
jwalkingjew Aug 26, 2026
a0ffbed
fix: keep pointer drag focus in editor
jwalkingjew Aug 26, 2026
901269c
Revert "fix: keep pointer drag focus in editor"
jwalkingjew Aug 26, 2026
bdef769
Revert "fix: clear stale block handle after reorder"
jwalkingjew Aug 26, 2026
0f9cfee
Revert "fix: add block gutter hover surface"
jwalkingjew Aug 26, 2026
bcc764a
Revert "fix: handle sparse block order and relation unsets"
jwalkingjew Aug 26, 2026
1693f60
Revert "fix: address block reorder review feedback"
jwalkingjew Aug 26, 2026
4942c3b
chore: restore pre-flicker-report checkpoint
jwalkingjew Aug 26, 2026
f7f677b
Merge branch 'master' into agent/geo-2680-block-reorder
jwalkingjew Aug 26, 2026
1758e6f
fix: clarify block reorder persistence invariants
jwalkingjew Aug 26, 2026
ed5b1fb
fix: harden block reorder indexing and measurement
jwalkingjew Aug 27, 2026
1b32b4f
fix: replace identity-changing relations on publish
jwalkingjew Aug 27, 2026
fe33a09
Merge branch 'master' into agent/geo-2680-block-reorder
jwalkingjew Aug 27, 2026
f9b494a
fix: ignore no-op block drops across empty nodes
jwalkingjew Aug 27, 2026
cc59046
fix: expose block drag handles on touch devices
jwalkingjew Aug 27, 2026
80022d2
Merge branch 'master' into agent/geo-2680-block-reorder
jwalkingjew Aug 27, 2026
c6eda79
Merge branch 'master' into agent/geo-2680-block-reorder
jwalkingjew Aug 27, 2026
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
51 changes: 51 additions & 0 deletions apps/web/core/state/editor/make-block-position.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,51 @@
import { describe, expect, it } from 'vitest';

import { makeBlockPosition } from './make-block-position';

const blockRelations = [
{ block: { id: 'first' }, position: 'a0' },
{ block: { id: 'middle' }, position: 'a1' },
{ block: { id: 'last' }, position: 'a2' },
];

describe('makeBlockPosition', () => {
it('places an existing last block before the current first block', () => {
const position = makeBlockPosition({
blockId: 'last',
nextBlockIds: ['last', 'first', 'middle'],
blockRelations,
newBlocks: [],
});

expect(position < 'a0').toBe(true);
});

it('places an existing first block after the current last block', () => {
const position = makeBlockPosition({
blockId: 'first',
nextBlockIds: ['middle', 'last', 'first'],
blockRelations,
newBlocks: [],
});

expect(position > 'a2').toBe(true);
});

it('places multiple adjacent new blocks before the existing first block', () => {
const firstNewPosition = makeBlockPosition({
blockId: 'new-1',
nextBlockIds: ['new-1', 'new-2', 'first', 'middle', 'last'],
blockRelations,
newBlocks: [],
});
const secondNewPosition = makeBlockPosition({
blockId: 'new-2',
nextBlockIds: ['new-1', 'new-2', 'first', 'middle', 'last'],
blockRelations,
newBlocks: [{ toEntity: { id: 'new-1' }, position: firstNewPosition }],
});

expect(firstNewPosition < secondNewPosition).toBe(true);
expect(secondNewPosition < 'a0').toBe(true);
});
});
54 changes: 54 additions & 0 deletions apps/web/core/state/editor/make-block-position.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,54 @@
import { Position } from '@geoprotocol/geo-sdk/lite';

type ExistingBlockRelation = {
position?: string | null;
block: { id: string };
};

type NewBlockRelation = {
position?: string | null;
toEntity: { id: string };
};

/** Generates a block position from its neighbours in the requested document order. */
export function makeBlockPosition({
blockId,
nextBlockIds,
blockRelations,
newBlocks,
}: {
blockId: string;
nextBlockIds: string[];
blockRelations: ExistingBlockRelation[];
newBlocks: NewBlockRelation[];
}) {
const position = nextBlockIds.indexOf(blockId);
const beforeBlockId = nextBlockIds[position - 1];
const afterBlockId = nextBlockIds[position + 1];

// Insertions are absent from these collections. Reorders are present with
// their stale position, so exclude the moved block before finding fallback
// neighbours at the start or end of the list.
const allRelations = [
...blockRelations.map(relation => ({
blockId: relation.block.id,
// @TODO(migration): default position
position: relation.position ?? 'a0',
})),
...newBlocks.map(relation => ({
blockId: relation.toEntity.id,
// @TODO(migration): default position
position: relation.position ?? 'a0',
})),
]
.filter(relation => relation.blockId !== blockId)
.sort((a, b) => (a.position < b.position ? -1 : 1));

const beforePosition = allRelations.find(relation => relation.blockId === beforeBlockId)?.position;
const beforeRelationIndex = allRelations.findIndex(relation => relation.blockId === beforeBlockId);
const afterPosition =
allRelations.find(relation => relation.blockId === afterBlockId)?.position ??
(beforeRelationIndex >= 0 ? allRelations[beforeRelationIndex + 1]?.position : allRelations[0]?.position);

return Position.generateBetween(beforePosition ?? null, afterPosition ?? null);
}
67 changes: 17 additions & 50 deletions apps/web/core/state/editor/use-editor.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,7 @@ import { EntityId } from '../../io/substream-schema';
import { getRelationForBlockType } from './block-types';
import { useActiveTabIdForEditor, useEditorBlocks, useEditorInstance } from './editor-provider';
import { getBlockPositionChanges } from './get-block-position-changes';
import { makeBlockPosition } from './make-block-position';
import { markdownToEditorJson } from './markdown-adapter';
import {
PROFILE_OVERVIEW_TAIL_BLOCK_SENTINEL,
Expand Down Expand Up @@ -67,40 +68,12 @@ function makeNewBlockRelation({
}: MakeNewBlockArgs) {
const newRelationId = ID.createEntityId();

const position = nextBlockIds.indexOf(addedBlock.id);

// @TODO: noUncheckedIndexAccess
const beforeBlockIndex = nextBlockIds[position - 1] as string | undefined;
const afterBlockIndex = nextBlockIds[position + 1] as string | undefined;

// Create a unified array with consistent structure for both blockRelations and newBlocks
const allRelations = [
...blockRelations.map(r => ({
toEntity: { id: r.block.id },
// @TODO(migration): default position
position: r.position ?? 'a0',
})),
...newBlocks.map(b => ({
toEntity: { id: b.toEntity.id },
// @TODO(migration): default position
position: b.position ?? 'a0',
})),
].sort((a, b) => (a.position < b.position ? -1 : 1));

// Check both the existing blocks and any that are created as part of this update
// tick. This is necessary as right now we don't update the Geo state until the
// user blurs the editor. See the comment earlier in this function.
const beforeCollectionItemIndex = allRelations.find(c => c.toEntity.id === beforeBlockIndex)?.position;

// When the afterCollectionItemIndex is undefined, we need to use the next block of beforeBlockIndex
const afterCollectionItemIndex =
allRelations.find(c => c.toEntity.id === afterBlockIndex)?.position ??
allRelations[allRelations.findIndex(c => c.position === beforeCollectionItemIndex) + 1]?.position;

const newBlockOrdering = Position.generateBetween(
beforeCollectionItemIndex ?? null,
afterCollectionItemIndex ?? null
);
const newBlockOrdering = makeBlockPosition({
blockId: addedBlock.id,
nextBlockIds,
blockRelations,
newBlocks,
});

const renderableType = ((): RenderableEntityType => {
switch (tiptapBlock.type) {
Expand All @@ -124,8 +97,8 @@ function makeNewBlockRelation({
}
})();

const newRelation: Relation = {
spaceId: spaceId,
return {
spaceId,
id: newRelationId,
position: newBlockOrdering,
verified: false,
Expand All @@ -144,9 +117,7 @@ function makeNewBlockRelation({
id: entityPageId,
name: null,
},
};

return newRelation;
} satisfies Relation;
}

interface UpsertBlocksRelationsArgs {
Expand All @@ -161,7 +132,7 @@ interface UpsertBlocksRelationsArgs {

// Helper function to create or update the block IDs on an entity
// Since we don't currently support array value types, we store all ordered blocks as a single stringified array
const makeBlocksRelations = async ({
const makeBlocksRelations = ({
nextBlocks,
blockRelations,
spaceId,
Expand Down Expand Up @@ -207,22 +178,18 @@ const makeBlocksRelations = async ({

for (const movedBlock of movedBlocks) {
const relationForMovedBlock = blockRelations.find(r => r.block.id === movedBlock.id);
if (!relationForMovedBlock) continue;

if (relationForMovedBlock) {
storage.relations.delete(relationForMovedBlock);
}

const newRelation = makeNewBlockRelation({
tiptapBlock: nextBlocks.find(b => b.id === movedBlock.id)!,
addedBlock: movedBlock,
const position = makeBlockPosition({
blockId: movedBlock.id,
nextBlockIds,
blockRelations,
spaceId,
newBlocks,
entityPageId,
});

storage.relations.set(newRelation);
storage.relations.update(relationForMovedBlock, draft => {
draft.position = position;
});
}
};

Expand Down
96 changes: 96 additions & 0 deletions apps/web/core/sync/relation-update.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,96 @@
import { describe, expect, it } from 'vitest';

import { Relation } from '../types';
import {
getRelationUpdateUnsetFields,
isExistingRelationWithUnchangedIdentity,
requiresRelationIdentityReplacement,
} from './relation-update';

const existingRelation: Relation = {
id: 'existing-relation',
entityId: 'relation-entity',
type: { id: 'blocks', name: 'Blocks' },
fromEntity: { id: 'page', name: 'Page' },
toEntity: { id: 'block', name: 'Block', value: 'block' },
renderableType: 'TEXT',
position: 'a0',
spaceId: 'space',
};

describe('isExistingRelationWithUnchangedIdentity', () => {
it('preserves an existing relation when its identity fields are unchanged', () => {
expect(isExistingRelationWithUnchangedIdentity(existingRelation, { ...existingRelation, position: 'a1' })).toBe(
true
);
});

it('does not imply that non-identity fields are publishable', () => {
expect(isExistingRelationWithUnchangedIdentity(existingRelation, { ...existingRelation, verified: true })).toBe(
true
);
});

it('keeps an unpublished local relation as a create', () => {
const localRelation = { ...existingRelation, isLocal: true, hasBeenPublished: false };

expect(isExistingRelationWithUnchangedIdentity(localRelation, { ...localRelation, position: 'a1' })).toBe(false);
});

it('does not use updateRelation for endpoint changes the SDK cannot update', () => {
expect(
isExistingRelationWithUnchangedIdentity(existingRelation, {
...existingRelation,
toEntity: { id: 'different-block', name: 'Different block', value: 'different-block' },
})
).toBe(false);
});
});

describe('requiresRelationIdentityReplacement', () => {
it('replaces an existing relation when an endpoint changes', () => {
expect(
requiresRelationIdentityReplacement(existingRelation, {
...existingRelation,
fromEntity: { id: 'different-page', name: 'Different page' },
})
).toBe(true);
});

it('allows an unpublished relation identity to change before its first create', () => {
const localRelation = { ...existingRelation, isLocal: true, hasBeenPublished: false };

expect(
requiresRelationIdentityReplacement(localRelation, {
...localRelation,
fromEntity: { id: 'different-page', name: 'Different page' },
})
).toBe(false);
});

it('keeps position-only changes on the existing relation', () => {
expect(requiresRelationIdentityReplacement(existingRelation, { ...existingRelation, position: 'a1' })).toBe(
false
);
});
});

describe('getRelationUpdateUnsetFields', () => {
it('explicitly unsets a removed to-space reference', () => {
const relationWithSpace = { ...existingRelation, toSpaceId: 'target-space' };

expect(getRelationUpdateUnsetFields(relationWithSpace, { ...relationWithSpace, toSpaceId: undefined })).toEqual([
'toSpace',
]);
});

it('preserves a pending unset until a to-space reference is selected again', () => {
const pendingUnset = {
...existingRelation,
relationUpdateUnsetFields: ['toSpace'] as Array<'toSpace'>,
};

expect(getRelationUpdateUnsetFields(pendingUnset, pendingUnset)).toEqual(['toSpace']);
expect(getRelationUpdateUnsetFields(pendingUnset, { ...pendingUnset, toSpaceId: 'target-space' })).toEqual([]);
});
});
38 changes: 38 additions & 0 deletions apps/web/core/sync/relation-update.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
import { Relation } from '../types';

/** Whether a relation already has a remote identity that must not be reused. */
export function isExistingRelation(relation: Relation) {
return relation.isLocal !== true || relation.hasBeenPublished === true || relation.isRelationUpdate === true;
}

/** Whether a relation change requires deleting the old edge and creating a new one. */
export function requiresRelationIdentityReplacement(base: Relation, changed: Relation) {
if (!isExistingRelation(base)) return false;

return (
base.id !== changed.id ||
base.entityId !== changed.entityId ||
base.type.id !== changed.type.id ||
base.fromEntity.id !== changed.fromEntity.id ||
base.toEntity.id !== changed.toEntity.id ||
base.spaceId !== changed.spaceId
);
}

/** Whether a remote relation can retain its identity after a local change. */
export function isExistingRelationWithUnchangedIdentity(base: Relation, changed: Relation) {
return isExistingRelation(base) && !requiresRelationIdentityReplacement(base, changed);
}

/** Tracks optional relation fields that must be explicitly cleared remotely. */
export function getRelationUpdateUnsetFields(base: Relation, changed: Relation) {
const unsetFields = new Set(base.relationUpdateUnsetFields ?? []);

if (changed.toSpaceId) {
unsetFields.delete('toSpace');
} else if (base.toSpaceId) {
unsetFields.add('toSpace');
}

return Array.from(unsetFields);
}
Loading
Loading