Skip to content
Merged
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
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ export const publishedDocumentPayload: PublishedDocumentSnapshotPayload = {
namespace: 'published-namespace',
publishName: 'published-document',
view: {
viewId: 'published-document-view-id',
viewId: '6e91148b-e42a-56b1-b9a0-58fbaa31552d',
name: 'Published document',
icon: {
ty: ViewIconType.Icon,
Expand Down
61 changes: 60 additions & 1 deletion src/application/slate-yjs/__tests__/yjs-utils.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import * as Y from 'yjs';
import {
createBlock,
createEmptyDocument,
deleteBlock,
getBlock,
getChildrenArray,
getText,
Expand Down Expand Up @@ -78,11 +79,12 @@ describe('pageIdFromDocumentId', () => {

// Should be a valid UUID format
expect(pageId1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
expect(pageId1).toBe('69d1e3fb-c2f3-5156-b8e3-636273bad252');
});

it('should generate different page_ids for different document_ids', () => {
const documentId1 = '6e91148b-e42a-56b1-b9a0-58fbaa31552d';
const documentId2 = '7f02259c-f53b-67c2-c1b1-69gcbb42663e';
const documentId2 = '7f02259c-f53b-67c2-a1b1-69fcbb42663e';

const pageId1 = pageIdFromDocumentId(documentId1);
const pageId2 = pageIdFromDocumentId(documentId2);
Expand Down Expand Up @@ -171,6 +173,63 @@ describe('initializeDocumentStructure', () => {
expect(pageId).toBe(expectedPageId);
});

it('should use document-scoped initial paragraph ids when documentId is provided', () => {
const documentId = '6e91148b-e42a-56b1-b9a0-58fbaa31552d';
initializeDocumentStructure(doc, true, documentId);

const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
const document = sharedRoot.get(YjsEditorKey.document);
const pageId = document.get(YjsEditorKey.page_id);
const blocks = document.get(YjsEditorKey.blocks);
const meta = document.get(YjsEditorKey.meta);
const childrenMap = meta.get(YjsEditorKey.children_map);
const textMap = meta.get(YjsEditorKey.text_map);
const pageChildren = childrenMap.get(pageId);
const paragraphId = pageChildren.get(0);
const paragraphBlock = blocks.get(paragraphId);

expect(paragraphId).toBe('TdtM1tmgqJ');
expect(paragraphBlock.get(YjsEditorKey.block_children)).toBe('NyYr0DfnAu');
expect(paragraphBlock.get(YjsEditorKey.block_external_id)).toBe('VbaxI-lEf5');
expect(childrenMap.has('NyYr0DfnAu')).toBe(true);
expect(textMap.has('VbaxI-lEf5')).toBe(true);
});

it('should not override the local Yjs client id when initializing document structure', () => {
const documentId = '6e91148b-e42a-56b1-b9a0-58fbaa31552d';
const originalClientId = doc.clientID;

initializeDocumentStructure(doc, true, documentId);

expect(doc.clientID).toBe(originalClientId);
});

it('should clean up document-scoped paragraph children and text ids when deleted', () => {
const documentId = '6e91148b-e42a-56b1-b9a0-58fbaa31552d';

initializeDocumentStructure(doc, true, documentId);

const sharedRoot = doc.getMap(YjsEditorKey.data_section) as YSharedRoot;
const document = sharedRoot.get(YjsEditorKey.document);
const pageId = document.get(YjsEditorKey.page_id);
const blocks = document.get(YjsEditorKey.blocks);
const meta = document.get(YjsEditorKey.meta);
const childrenMap = meta.get(YjsEditorKey.children_map);
const textMap = meta.get(YjsEditorKey.text_map);
const pageChildren = childrenMap.get(pageId);
const paragraphId = pageChildren.get(0);
const paragraphBlock = blocks.get(paragraphId);
const paragraphChildrenId = paragraphBlock.get(YjsEditorKey.block_children);
const paragraphTextId = paragraphBlock.get(YjsEditorKey.block_external_id);

deleteBlock(sharedRoot, paragraphId);

expect(blocks.has(paragraphId)).toBe(false);
expect(childrenMap.has(paragraphChildrenId)).toBe(false);
expect(textMap.has(paragraphTextId)).toBe(false);
expect(pageChildren.toArray()).toEqual([]);
});

it('should skip initialization if document already exists', () => {
// First initialization
initializeDocumentStructure(doc, false);
Expand Down
39 changes: 32 additions & 7 deletions src/application/slate-yjs/utils/yjs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -24,6 +24,8 @@ import {
} from '@/application/types';
import { Log } from '@/utils/log';

const RUST_NANOID_SAFE_ALPHABET = '_-0123456789abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ';
const DEFAULT_ID_LEN = 10;
// UUID namespace OID (same as Rust's Uuid::NAMESPACE_OID)
// Note: 6ba7b812 (not 6ba7b810 which is NAMESPACE_DNS)
const UUID_NAMESPACE_OID = '6ba7b812-9dad-11d1-80b4-00c04fd430c8';
Expand Down Expand Up @@ -60,6 +62,25 @@ export function pageIdFromDocumentId(documentId: string): string {
return pageId;
}

function idFromDocumentId(documentId: string, role: string): string {
const docUuid = uuidValidate(documentId) ? documentId : uuidv5(documentId, UUID_NAMESPACE_OID);

return uuidv5(role, docUuid);
}

function nanoidFromDocumentId(documentId: string, role: string): string {
const uuid = idFromDocumentId(documentId, role).replace(/-/g, '');
let id = '';

for (let i = 0; i < DEFAULT_ID_LEN; i += 1) {
const byte = parseInt(uuid.slice(i * 2, i * 2 + 2), 16);

id += RUST_NANOID_SAFE_ALPHABET[byte & 0x3f];
}

return id;
}

export function getTextMap(sharedRoot: YSharedRoot) {
const document = sharedRoot.get(YjsEditorKey.document);
const meta = document.get(YjsEditorKey.meta) as YMeta;
Expand Down Expand Up @@ -355,21 +376,23 @@ export function initializeDocumentStructure(doc: YDoc, includeInitialParagraph =
if (includeInitialParagraph) {
// Create an empty paragraph block as child of page
// The Slate editor requires at least one text block to render properly
const paragraphId = nanoid(8);
const paragraphId = documentId ? nanoidFromDocumentId(documentId, 'block') : nanoid(8);
const paragraphChildrenId = documentId ? nanoidFromDocumentId(documentId, 'children') : paragraphId;
const paragraphTextId = documentId ? nanoidFromDocumentId(documentId, 'text') : paragraphId;
const paragraphBlock = new Y.Map();

paragraphBlock.set(YjsEditorKey.block_id, paragraphId);
paragraphBlock.set(YjsEditorKey.block_type, BlockType.Paragraph);
paragraphBlock.set(YjsEditorKey.block_children, paragraphId);
paragraphBlock.set(YjsEditorKey.block_external_id, paragraphId);
paragraphBlock.set(YjsEditorKey.block_children, paragraphChildrenId);
paragraphBlock.set(YjsEditorKey.block_external_id, paragraphTextId);
paragraphBlock.set(YjsEditorKey.block_external_type, YjsEditorKey.text);
paragraphBlock.set(YjsEditorKey.block_data, '{}');
paragraphBlock.set(YjsEditorKey.block_parent, pageId);
blocks.set(paragraphId, paragraphBlock);

pageChildren.push([paragraphId]);
childrenMap.set(paragraphId, new Y.Array());
textMap.set(paragraphId, new Y.Text());
childrenMap.set(paragraphChildrenId, new Y.Array());
textMap.set(paragraphTextId, new Y.Text());
}

childrenMap.set(pageId, pageChildren);
Expand Down Expand Up @@ -448,6 +471,8 @@ export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) {
const meta = document.get(YjsEditorKey.meta) as YMeta;
const childrenMap = meta.get(YjsEditorKey.children_map) as YChildrenMap;
const textMap = meta.get(YjsEditorKey.text_map) as YTextMap;
const blockChildrenId = block.get(YjsEditorKey.block_children);
const blockExternalId = block.get(YjsEditorKey.block_external_id);

const parent = getBlock(parentId, sharedRoot);

Expand All @@ -465,8 +490,8 @@ export function deleteBlock(sharedRoot: YSharedRoot, blockId: string) {
}

blocks.delete(blockId);
childrenMap.delete(blockId);
textMap.delete(blockId);
childrenMap.delete(blockChildrenId);
textMap.delete(blockExternalId);

// delete parent if it's empty column block
if (parentType === BlockType.ColumnBlock && afterDeletedLength === 0) {
Expand Down
Loading
Loading