UNPKG

@wordpress/core-data

Version:
562 lines (561 loc) 17.7 kB
// packages/core-data/src/utils/crdt-blocks.ts import { v4 as uuidv4 } from "uuid"; import fastDeepEqual from "fast-deep-equal/es6/index.js"; import { getBlockTypes } from "@wordpress/blocks"; import { RichTextData } from "@wordpress/rich-text"; import { Y } from "@wordpress/sync"; import { asRichTextOffset, createYMap, richTextOffsetToHtmlIndex } from "./crdt-utils.mjs"; import { getCachedRichTextData } from "./crdt-text.mjs"; import { Delta } from "../sync.mjs"; var serializableBlocksCache = /* @__PURE__ */ new WeakMap(); function serializeAttributeValue(value) { if (value instanceof RichTextData) { return value.valueOf(); } if (Array.isArray(value)) { return value.map(serializeAttributeValue); } if (value && typeof value === "object") { const result = {}; for (const [k, v] of Object.entries(value)) { result[k] = serializeAttributeValue(v); } return result; } return value; } function makeBlockAttributesSerializable(blockName, attributes) { const newAttributes = { ...attributes }; for (const [key, value] of Object.entries(attributes)) { if (isLocalAttribute(blockName, key)) { delete newAttributes[key]; continue; } newAttributes[key] = serializeAttributeValue(value); } return newAttributes; } function makeBlocksSerializable(blocks) { return blocks.map((block) => { const { name, innerBlocks, attributes, /* * Any validation issues discovered when loading a block are appended * to the block node with a logging function, which cannot be serialized. * * @see import("@wordpress/blocks/src/api/parser").parseRawBlock() */ validationIssues, ...rest } = block; return { ...rest, name, attributes: makeBlockAttributesSerializable(name, attributes), innerBlocks: makeBlocksSerializable(innerBlocks) }; }); } function deserializeAttributeValue(schema, value) { if (schema?.type === "rich-text" && typeof value === "string") { return getCachedRichTextData(value); } if (Array.isArray(value)) { return value.map( (item) => deserializeAttributeValue(schema, item) ); } if (value && typeof value === "object") { const result = {}; for (const [key, innerValue] of Object.entries( value )) { result[key] = deserializeAttributeValue( schema?.query?.[key], innerValue ); } return result; } return value; } function deserializeBlockAttributes(blocks) { return blocks.map((block) => { const { name, innerBlocks, attributes, ...rest } = block; const newAttributes = { ...attributes }; for (const [key, value] of Object.entries(attributes)) { const schema = getBlockAttributeSchema(name, key); if (schema) { newAttributes[key] = deserializeAttributeValue( schema, value ); } } return { ...rest, name, attributes: newAttributes, innerBlocks: deserializeBlockAttributes(innerBlocks ?? []) }; }); } function areBlocksEqual(gblock, yblock) { const yblockAsJson = yblock.toJSON(); const overwrites = { innerBlocks: null, clientId: null }; const res = fastDeepEqual( Object.assign({}, gblock, overwrites), Object.assign({}, yblockAsJson, overwrites) ); const inners = gblock.innerBlocks || []; const yinners = yblock.get("innerBlocks"); return res && inners.length === yinners?.length && inners.every( (block, i) => areBlocksEqual(block, yinners.get(i)) ); } function createNewYAttributeMap(blockName, attributes) { return new Y.Map( Object.entries(attributes).map( ([attributeName, attributeValue]) => { return [ attributeName, createNewYAttributeValue( blockName, attributeName, attributeValue ) ]; } ) ); } function createNewYAttributeValue(blockName, attributeName, attributeValue) { const schema = getBlockAttributeSchema(blockName, attributeName); return createYValueFromSchema(schema, attributeValue); } function createYValueFromSchema(schema, value) { if (!schema) { return value; } if (schema.type === "rich-text") { return new Y.Text(value?.toString() ?? ""); } if (schema.type === "array" && schema.query && Array.isArray(value)) { const query = schema.query; const yArray = new Y.Array(); yArray.insert( 0, value.map((item) => createYMapFromQuery(query, item)) ); return yArray; } if (schema.type === "object" && schema.query && isRecord(value)) { return createYMapFromQuery(schema.query, value); } return value; } function isRecord(value) { return !!value && typeof value === "object" && !Array.isArray(value); } function createYMapFromQuery(query, obj) { if (!isRecord(obj)) { return new Y.Map(); } const entries = Object.entries(obj).map( ([key, val]) => { const subSchema = query[key]; return [key, createYValueFromSchema(subSchema, val)]; } ); return new Y.Map(entries); } function createNewYBlock(block) { return createYMap( Object.fromEntries( Object.entries(block).map(([key, value]) => { switch (key) { case "attributes": { return [ key, createNewYAttributeMap(block.name, value) ]; } case "innerBlocks": { const innerBlocks = new Y.Array(); if (!Array.isArray(value)) { return [key, innerBlocks]; } innerBlocks.insert( 0, value.map( (innerBlock) => createNewYBlock(innerBlock) ) ); return [key, innerBlocks]; } default: return [key, value]; } }) ) ); } function mergeCrdtBlocks(yblocks, incomingBlocks, attributeCursor, options = {}) { if (!serializableBlocksCache.has(incomingBlocks)) { serializableBlocksCache.set( incomingBlocks, makeBlocksSerializable(incomingBlocks) ); } const incomingBlocksToSync = serializableBlocksCache.get(incomingBlocks) ?? []; const numOfCommonEntries = Math.min( incomingBlocksToSync.length ?? 0, yblocks.length ); let left = 0; let right = 0; for (; left < numOfCommonEntries && areBlocksEqual(incomingBlocksToSync[left], yblocks.get(left)); left++) { } for (; right < numOfCommonEntries - left && areBlocksEqual( incomingBlocksToSync[incomingBlocksToSync.length - right - 1], yblocks.get(yblocks.length - right - 1) ); right++) { } const numOfUpdatesNeeded = numOfCommonEntries - left - right; const numOfInsertionsNeeded = Math.max( 0, incomingBlocksToSync.length - yblocks.length ); const numOfDeletionsNeeded = Math.max( 0, yblocks.length - incomingBlocksToSync.length ); for (let i = 0; i < numOfUpdatesNeeded; i++, left++) { const incomingYBlock = incomingBlocksToSync[left]; const localYBlock = yblocks.get(left); Object.entries(incomingYBlock).forEach( ([incomingBlockProperty, incomingBlockPropertyValue]) => { switch (incomingBlockProperty) { case "attributes": { const localAttributes = localYBlock.get( incomingBlockProperty ); const incomingAttributes = incomingBlockPropertyValue; if (!localAttributes) { localYBlock.set( incomingBlockProperty, createNewYAttributeMap( incomingYBlock.name, incomingAttributes ) ); break; } Object.entries(incomingAttributes).forEach( ([ incomingAttributeName, incomingAttributeValue ]) => { const currentAttribute = localAttributes?.get( incomingAttributeName ); const isExpectedType = isExpectedAttributeType( incomingYBlock.name, incomingAttributeName, currentAttribute ); const isYType = currentAttribute instanceof Y.AbstractType; const isAttributeChanged = !isExpectedType || isYType || !fastDeepEqual( currentAttribute, incomingAttributeValue ); if (isAttributeChanged) { updateYBlockAttribute( incomingYBlock.name, incomingYBlock.clientId, incomingAttributeName, incomingAttributeValue, localAttributes, attributeCursor ); } } ); localAttributes.forEach( (_attrValue, attrName) => { if (!incomingBlockPropertyValue.hasOwnProperty( attrName )) { localAttributes.delete(attrName); } } ); break; } case "innerBlocks": { let yInnerBlocks = localYBlock.get( incomingBlockProperty ); if (!(yInnerBlocks instanceof Y.Array)) { yInnerBlocks = new Y.Array(); localYBlock.set( incomingBlockProperty, yInnerBlocks ); } mergeCrdtBlocks( yInnerBlocks, incomingBlockPropertyValue ?? [], attributeCursor, options ); break; } case "clientId": { if (options.preserveClientIds) { break; } if (incomingBlockPropertyValue !== localYBlock.get(incomingBlockProperty)) { localYBlock.set( incomingBlockProperty, incomingBlockPropertyValue ); } break; } default: if (!fastDeepEqual( incomingYBlock[incomingBlockProperty], localYBlock.get(incomingBlockProperty) )) { localYBlock.set( incomingBlockProperty, incomingBlockPropertyValue ); } } } ); localYBlock.forEach((_v, k) => { if (!incomingYBlock.hasOwnProperty(k)) { localYBlock.delete(k); } }); } yblocks.delete(left, numOfDeletionsNeeded); for (let i = 0; i < numOfInsertionsNeeded; i++, left++) { const newBlock = [createNewYBlock(incomingBlocksToSync[left])]; yblocks.insert(left, newBlock); } const knownClientIds = /* @__PURE__ */ new Set(); for (let j = 0; j < yblocks.length; j++) { const yblock = yblocks.get(j); let clientId = yblock.get("clientId"); if (!clientId) { continue; } if (knownClientIds.has(clientId)) { clientId = uuidv4(); yblock.set("clientId", clientId); } knownClientIds.add(clientId); } } function areArrayElementsEqual(newElement, yElement) { if (yElement instanceof Y.Map && isRecord(newElement)) { return fastDeepEqual(newElement, yElement.toJSON()); } return fastDeepEqual(newElement, yElement); } function mergeYArray(yArray, newValue, schema, cursorPosition, cursorScope) { if (!schema.query) { return; } const query = schema.query; const numOfCommonEntries = Math.min(newValue.length, yArray.length); let left = 0; let right = 0; for (; left < numOfCommonEntries && areArrayElementsEqual(newValue[left], yArray.get(left)); left++) { } for (; right < numOfCommonEntries - left && areArrayElementsEqual( newValue[newValue.length - right - 1], yArray.get(yArray.length - right - 1) ); right++) { } const numOfUpdatesNeeded = numOfCommonEntries - left - right; for (let i = 0; i < numOfUpdatesNeeded; i++) { const currentElement = yArray.get(left + i); const newElement = newValue[left + i]; if (currentElement instanceof Y.Map && isRecord(newElement)) { mergeYMapValues( currentElement, newElement, query, cursorPosition, cursorScope ); } else { yArray.delete(0, yArray.length); yArray.insert( 0, newValue.map((item) => createYMapFromQuery(query, item)) ); return; } } const numOfDeletionsNeeded = Math.max(0, yArray.length - newValue.length); if (numOfDeletionsNeeded > 0) { yArray.delete(left + numOfUpdatesNeeded, numOfDeletionsNeeded); } const numOfInsertionsNeeded = Math.max( 0, newValue.length - yArray.length ); if (numOfInsertionsNeeded > 0) { const insertAt = left + numOfUpdatesNeeded; const itemsToInsert = new Array( numOfInsertionsNeeded ); for (let i = 0; i < numOfInsertionsNeeded; i++) { itemsToInsert[i] = createYMapFromQuery( query, newValue[insertAt + i] ); } yArray.insert(insertAt, itemsToInsert); } } function mergeYValue(schema, newVal, yMap, key, cursorPosition, cursorScope) { const currentVal = yMap.get(key); if (schema?.type === "rich-text" && typeof newVal === "string" && currentVal instanceof Y.Text) { mergeRichTextUpdate( currentVal, newVal, resolveRichTextCursorPosition(cursorPosition, cursorScope, newVal) ); } else if (schema?.type === "array" && schema.query && Array.isArray(newVal) && currentVal instanceof Y.Array) { mergeYArray(currentVal, newVal, schema, cursorPosition, cursorScope); } else if (schema?.type === "object" && schema.query && isRecord(newVal) && currentVal instanceof Y.Map) { mergeYMapValues( currentVal, newVal, schema.query, cursorPosition, cursorScope ); } else { const newYValue = createYValueFromSchema(schema, newVal); if (newYValue !== newVal || !fastDeepEqual(currentVal, newVal)) { yMap.set(key, newYValue); } } } function mergeYMapValues(yMap, newObj, query, cursorPosition, cursorScope) { for (const [key, newVal] of Object.entries(newObj)) { mergeYValue( query[key], newVal, yMap, key, cursorPosition, cursorScope ); } for (const key of yMap.keys()) { if (!Object.hasOwn(newObj, key)) { yMap.delete(key); } } } function updateYBlockAttribute(blockName, clientId, attributeName, attributeValue, currentAttributes, newCursorPosition) { const schema = getBlockAttributeSchema(blockName, attributeName); mergeYValue( schema, attributeValue, currentAttributes, attributeName, newCursorPosition, { attributeKey: attributeName, clientId } ); } function resolveRichTextCursorPosition(cursorPosition, cursorScope, updatedValue) { return cursorPosition && cursorPosition.clientId === cursorScope.clientId && cursorPosition.attributeKey === cursorScope.attributeKey && "number" === typeof cursorPosition.offset && Number.isInteger(cursorPosition.offset) ? richTextOffsetToHtmlIndex( updatedValue, asRichTextOffset(cursorPosition.offset) ) : null; } var cachedBlockAttributeSchemas; function getBlockAttributeSchema(blockName, attributeName) { if (!cachedBlockAttributeSchemas) { cachedBlockAttributeSchemas = /* @__PURE__ */ new Map(); for (const blockType of getBlockTypes()) { cachedBlockAttributeSchemas.set( blockType.name, new Map( Object.entries(blockType.attributes ?? {}).map( ([name, definition]) => { const { role, type, query } = definition; return [name, { role, type, query }]; } ) ) ); } } return cachedBlockAttributeSchemas.get(blockName)?.get(attributeName); } function isExpectedAttributeType(blockName, attributeName, attributeValue) { const schema = getBlockAttributeSchema(blockName, attributeName); if (schema?.type === "rich-text") { return attributeValue instanceof Y.Text; } if (schema?.type === "string") { return typeof attributeValue === "string"; } if (schema?.type === "array" && schema.query) { return attributeValue instanceof Y.Array; } if (schema?.type === "object" && schema.query) { return attributeValue instanceof Y.Map; } return true; } function isLocalAttribute(blockName, attributeName) { return "local" === getBlockAttributeSchema(blockName, attributeName)?.role; } var localDoc; function mergeRichTextUpdate(blockYText, updatedValue, htmlCursorIndex = null) { const currentValueAsDelta = new Delta(blockYText.toDelta()); const updatedValueAsDelta = new Delta([{ insert: updatedValue }]); const deltaDiff = currentValueAsDelta.diffWithCursor( updatedValueAsDelta, htmlCursorIndex ); const safeDiff = htmlCursorIndex === null || isDeltaVerificationMatch(blockYText, deltaDiff, updatedValue) ? deltaDiff : currentValueAsDelta.diff(updatedValueAsDelta); blockYText.applyDelta(safeDiff.ops); } function isDeltaVerificationMatch(blockYText, delta, expectedValue) { if (!localDoc) { localDoc = new Y.Doc(); } const verificationYText = localDoc.getText("verification-text"); verificationYText.delete(0, verificationYText.length); verificationYText.insert(0, blockYText.toString()); verificationYText.applyDelta(delta.ops); return verificationYText.toString() === expectedValue; } export { deserializeBlockAttributes, mergeCrdtBlocks, mergeRichTextUpdate }; //# sourceMappingURL=crdt-blocks.mjs.map