diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 85ecfa326f..54fae7ff51 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -172,7 +172,9 @@ export function exportSchemaToJson(params) { commentRangeStart: wCommentRangeStartTranslator, commentRangeEnd: wCommentRangeEndTranslator, permStart: wPermStartTranslator, + permStartBlock: wPermStartTranslator, permEnd: wPermEndTranslator, + permEndBlock: wPermEndTranslator, commentReference: () => null, footnoteReference: wFootnoteReferenceTranslator, shapeContainer: pictTranslator, diff --git a/packages/super-editor/src/core/super-converter/helpers/node-context.js b/packages/super-editor/src/core/super-converter/helpers/node-context.js new file mode 100644 index 0000000000..d5df220069 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/helpers/node-context.js @@ -0,0 +1,53 @@ +const INLINE_PARENT_NAMES = new Set([ + 'w:r', + 'w:hyperlink', + 'w:smartTag', + 'w:fldSimple', + 'w:proofErr', + 'w:del', + 'w:ins', + 'w:p', // Paragraph is an inline container; unknown children must be inline-safe +]); + +const INLINE_NODE_NAMES = new Set([ + 'm:oMathPara', + 'm:oMath', + 'm:t', + 'm:r', + 'm:ctrlPr', + 'm:sSupPr', + 'm:e', + 'm:sup', + 'm:sSup', +]); + +const BLOCK_BOUNDARY_NAMES = new Set(['w:body', 'w:tbl', 'w:tc', 'w:tr']); + +/** + * Determines if the current DOCX node position accepts inline-only content. + * Walks up the ancestor chain until it finds an inline parent or block boundary. + * + * @param {Array<{ name?: string }>} [path=[]] Ancestor chain leading to the current node. + * @param {string} [currentNodeName] Optional immediate node name override. + * @returns {boolean} + */ +export const isInlineContext = (path = [], currentNodeName) => { + const immediateName = currentNodeName ?? path[path.length - 1]?.name; + if (immediateName && INLINE_NODE_NAMES.has(immediateName)) { + return true; + } + if (!Array.isArray(path) || path.length === 0) return false; + + for (let i = path.length - 1; i >= 0; i--) { + const ancestorName = path[i]?.name; + if (!ancestorName) continue; + if (INLINE_NODE_NAMES.has(ancestorName) || INLINE_PARENT_NAMES.has(ancestorName)) { + return true; + } + if (BLOCK_BOUNDARY_NAMES.has(ancestorName)) { + return false; + } + } + + return false; +}; diff --git a/packages/super-editor/src/core/super-converter/v2/importer/passthroughNodeImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/passthroughNodeImporter.js index 554ab92473..a6dbcca46b 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/passthroughNodeImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/passthroughNodeImporter.js @@ -1,50 +1,7 @@ import { carbonCopy } from '@core/utilities/carbonCopy.js'; +import { isInlineContext } from '../../helpers/node-context.js'; import { registeredHandlers } from '../../v3/handlers/index.js'; -const INLINE_PARENT_NAMES = new Set([ - 'w:r', - 'w:hyperlink', - 'w:smartTag', - 'w:fldSimple', - 'w:proofErr', - 'w:del', - 'w:ins', - 'w:p', // Paragraph is an inline container; unknown children must be inline-safe -]); -const INLINE_NODE_NAMES = new Set([ - 'm:oMathPara', - 'm:oMath', - 'm:t', - 'm:r', - 'm:ctrlPr', - 'm:sSupPr', - 'm:e', - 'm:sup', - 'm:sSup', -]); -const BLOCK_BOUNDARY_NAMES = new Set(['w:body', 'w:tbl', 'w:tc', 'w:tr']); - -export const isInlineContext = (path = [], currentNodeName) => { - const immediateName = currentNodeName ?? path[path.length - 1]?.name; - if (immediateName && INLINE_NODE_NAMES.has(immediateName)) { - return true; - } - if (!Array.isArray(path) || path.length === 0) return false; - - for (let i = path.length - 1; i >= 0; i--) { - const ancestorName = path[i]?.name; - if (!ancestorName) continue; - if (INLINE_NODE_NAMES.has(ancestorName) || INLINE_PARENT_NAMES.has(ancestorName)) { - return true; - } - if (BLOCK_BOUNDARY_NAMES.has(ancestorName)) { - return false; - } - } - - return false; -}; - /** * @type {import('docxImporter').NodeHandler} */ diff --git a/packages/super-editor/src/core/super-converter/v2/importer/passthroughNodeImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/passthroughNodeImporter.test.js index d57f784622..bf52a6e64e 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/passthroughNodeImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/passthroughNodeImporter.test.js @@ -1,5 +1,6 @@ import { describe, it, expect, vi } from 'vitest'; -import { handlePassthroughNode, isInlineContext } from './passthroughNodeImporter.js'; +import { handlePassthroughNode } from './passthroughNodeImporter.js'; +import { isInlineContext } from '../../helpers/node-context.js'; const createParams = (node, extra = {}) => ({ nodes: [node], diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-end/perm-end-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-end/perm-end-translator.js index 7ce0308f4f..4e34a072aa 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-end/perm-end-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-end/perm-end-translator.js @@ -1,12 +1,21 @@ // @ts-check import { NodeTranslator } from '@translator'; +import { isInlineContext } from '../../../../helpers/node-context.js'; import validXmlAttributes from './attributes/index.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:permEnd'; /** @type {import('@translator').SuperDocNodeOrKeyName} */ -const SD_NODE_NAME = 'permEnd'; +const SD_NODE_NAMES = /** @type {const} */ (['permEnd', 'permEndBlock']); + +const INLINE_NODE_NAME = SD_NODE_NAMES[0]; +const BLOCK_NODE_NAME = SD_NODE_NAMES[1]; + +const resolveNodeType = (params) => { + const inlineContext = isInlineContext(params?.path); + return inlineContext ? INLINE_NODE_NAME : BLOCK_NODE_NAME; +}; /** * Encode a node as a SuperDoc permEnd node. @@ -14,12 +23,10 @@ const SD_NODE_NAME = 'permEnd'; * @param {import('@translator').EncodedAttributes} [encodedAttrs] * @returns {import('@translator').SCEncoderResult} */ -const encode = (params, encodedAttrs = {}) => { - return { - type: 'permEnd', - attrs: encodedAttrs, - }; -}; +const encode = (params, encodedAttrs = {}) => ({ + type: resolveNodeType(params), + attrs: encodedAttrs, +}); /** * Decode a SuperDoc permEnd node back into OOXML . @@ -43,7 +50,7 @@ const decode = (params, decodedAttrs = {}) => { /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, - sdNodeOrKeyName: SD_NODE_NAME, + sdNodeOrKeyName: SD_NODE_NAMES, type: NodeTranslator.translatorTypes.NODE, encode, decode, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-end/perm-end-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-end/perm-end-translator.test.js index 7e7ffe5775..85f8c1c916 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-end/perm-end-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-end/perm-end-translator.test.js @@ -5,7 +5,7 @@ import { NodeTranslator } from '@translator'; describe('w:permEnd translator', () => { it('exposes correct config', () => { expect(config.xmlName).toBe('w:permEnd'); - expect(config.sdNodeOrKeyName).toBe('permEnd'); + expect(config.sdNodeOrKeyName).toEqual(['permEnd', 'permEndBlock']); expect(config.type).toBe(NodeTranslator.translatorTypes.NODE); expect(config.attributes).toHaveLength(2); }); @@ -21,6 +21,7 @@ describe('w:permEnd translator', () => { }, }, ], + path: [{ name: 'w:p' }], }; const result = translator.encode(params); @@ -72,4 +73,38 @@ describe('w:permEnd translator', () => { expect(decoded).toEqual(original); }); + + it('creates inline permEnd nodes inside inline contexts', () => { + const params = { + nodes: [ + { + name: 'w:permEnd', + attributes: { + 'w:id': 'inline', + }, + }, + ], + path: [{ name: 'w:p' }], + }; + + const result = translator.encode(params); + expect(result.type).toBe('permEnd'); + }); + + it('creates block permEnd nodes when parent is block-level', () => { + const params = { + nodes: [ + { + name: 'w:permEnd', + attributes: { + 'w:id': 'block', + }, + }, + ], + path: [{ name: 'w:body' }], + }; + + const result = translator.encode(params); + expect(result.type).toBe('permEndBlock'); + }); }); diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-start/perm-start-translator.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-start/perm-start-translator.js index 9e0be7ef34..db8fb47166 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-start/perm-start-translator.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-start/perm-start-translator.js @@ -1,12 +1,21 @@ // @ts-check import { NodeTranslator } from '@translator'; +import { isInlineContext } from '../../../../helpers/node-context.js'; import validXmlAttributes from './attributes/index.js'; /** @type {import('@translator').XmlNodeName} */ const XML_NODE_NAME = 'w:permStart'; /** @type {import('@translator').SuperDocNodeOrKeyName} */ -const SD_NODE_NAME = 'permStart'; +const SD_NODE_NAMES = /** @type {const} */ (['permStart', 'permStartBlock']); + +const INLINE_NODE_NAME = SD_NODE_NAMES[0]; +const BLOCK_NODE_NAME = SD_NODE_NAMES[1]; + +const resolveNodeType = (params) => { + const inlineContext = isInlineContext(params?.path); + return inlineContext ? INLINE_NODE_NAME : BLOCK_NODE_NAME; +}; /** * Encode a node as a SuperDoc permStart node. @@ -14,12 +23,10 @@ const SD_NODE_NAME = 'permStart'; * @param {import('@translator').EncodedAttributes} [encodedAttrs] * @returns {import('@translator').SCEncoderResult} */ -const encode = (params, encodedAttrs = {}) => { - return { - type: 'permStart', - attrs: encodedAttrs, - }; -}; +const encode = (params, encodedAttrs = {}) => ({ + type: resolveNodeType(params), + attrs: encodedAttrs, +}); /** * Decode a SuperDoc permStart node back into OOXML . @@ -43,7 +50,7 @@ const decode = (params, decodedAttrs = {}) => { /** @type {import('@translator').NodeTranslatorConfig} */ export const config = { xmlName: XML_NODE_NAME, - sdNodeOrKeyName: SD_NODE_NAME, + sdNodeOrKeyName: SD_NODE_NAMES, type: NodeTranslator.translatorTypes.NODE, encode, decode, diff --git a/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-start/perm-start-translator.test.js b/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-start/perm-start-translator.test.js index 5c9613910a..b8c9308baa 100644 --- a/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-start/perm-start-translator.test.js +++ b/packages/super-editor/src/core/super-converter/v3/handlers/w/perm-start/perm-start-translator.test.js @@ -5,7 +5,7 @@ import { NodeTranslator } from '@translator'; describe('w:permStart translator', () => { it('exposes correct config', () => { expect(config.xmlName).toBe('w:permStart'); - expect(config.sdNodeOrKeyName).toBe('permStart'); + expect(config.sdNodeOrKeyName).toEqual(['permStart', 'permStartBlock']); expect(config.type).toBe(NodeTranslator.translatorTypes.NODE); expect(config.attributes).toHaveLength(5); }); @@ -24,6 +24,7 @@ describe('w:permStart translator', () => { }, }, ], + path: [{ name: 'w:p' }], }; const result = translator.encode(params); @@ -50,6 +51,7 @@ describe('w:permStart translator', () => { }, }, ], + path: [{ name: 'w:p' }], }; const result = translator.encode(params); @@ -62,6 +64,40 @@ describe('w:permStart translator', () => { }); }); + it('creates inline nodes when context allows inline content', () => { + const params = { + nodes: [ + { + name: 'w:permStart', + attributes: { + 'w:id': 'inline', + }, + }, + ], + path: [{ name: 'w:p' }], + }; + + const result = translator.encode(params); + expect(result.type).toBe('permStart'); + }); + + it('creates block nodes when context requires block content', () => { + const params = { + nodes: [ + { + name: 'w:permStart', + attributes: { + 'w:id': 'block', + }, + }, + ], + path: [{ name: 'w:body' }], + }; + + const result = translator.encode(params); + expect(result.type).toBe('permStartBlock'); + }); + it('decodes SuperDoc to OOXML', () => { const params = { node: { diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index 42a761a7eb..f5763a5a8c 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -70,8 +70,8 @@ import { CustomSelection } from './custom-selection/index.js'; import { PermissionRanges } from './permission-ranges/index.js'; // Permissions -import { PermStart } from './perm-start/index.js'; -import { PermEnd } from './perm-end/index.js'; +import { PermStart, PermStartBlock } from './perm-start/index.js'; +import { PermEnd, PermEndBlock } from './perm-end/index.js'; // Helpers import { trackChangesHelpers } from './track-changes/index.js'; @@ -189,6 +189,8 @@ const getStarterExtensions = () => { ShapeGroup, PermStart, PermEnd, + PermStartBlock, + PermEndBlock, PermissionRanges, PassthroughInline, PassthroughBlock, diff --git a/packages/super-editor/src/extensions/perm-end/perm-end.js b/packages/super-editor/src/extensions/perm-end/perm-end.js index d546b6983c..2c88c9bc5a 100644 --- a/packages/super-editor/src/extensions/perm-end/perm-end.js +++ b/packages/super-editor/src/extensions/perm-end/perm-end.js @@ -11,26 +11,30 @@ import { Node } from '@core/index.js'; * @sidebarTitle PermEnd * @snippetPath /snippets/extensions/perm-end.mdx */ -export const PermEnd = Node.create({ - name: 'permEnd', - group: 'inline', - inline: true, +const hiddenRender = () => ['span', { style: 'display: none;' }]; - renderDOM() { - return ['span', { style: 'display: none;' }]; +const sharedAttributes = () => ({ + id: { + default: null, }, - - addAttributes() { - return { - id: { - default: null, - }, - edGrp: { - default: null, - }, - displacedByCustomXml: { - default: null, - }, - }; + edGrp: { + default: null, + }, + displacedByCustomXml: { + default: null, }, }); + +const createPermEndNode = ({ name, group, inline }) => + Node.create({ + name, + group, + ...(inline ? { inline: true } : {}), + + renderDOM: hiddenRender, + + addAttributes: sharedAttributes, + }); + +export const PermEnd = createPermEndNode({ name: 'permEnd', group: 'inline', inline: true }); +export const PermEndBlock = createPermEndNode({ name: 'permEndBlock', group: 'block', inline: false }); diff --git a/packages/super-editor/src/extensions/perm-start/perm-start.js b/packages/super-editor/src/extensions/perm-start/perm-start.js index 5193715eb1..f9dd373719 100644 --- a/packages/super-editor/src/extensions/perm-start/perm-start.js +++ b/packages/super-editor/src/extensions/perm-start/perm-start.js @@ -11,32 +11,36 @@ import { Node } from '@core/index.js'; * @sidebarTitle PermStart * @snippetPath /snippets/extensions/perm-start.mdx */ -export const PermStart = Node.create({ - name: 'permStart', - group: 'inline', - inline: true, +const hiddenRender = () => ['span', { style: 'display: none;' }]; - renderDOM() { - return ['span', { style: 'display: none;' }]; +const sharedAttributes = () => ({ + id: { + default: null, }, - - addAttributes() { - return { - id: { - default: null, - }, - edGrp: { - default: null, - }, - ed: { - default: null, - }, - colFirst: { - default: null, - }, - colLast: { - default: null, - }, - }; + edGrp: { + default: null, + }, + ed: { + default: null, + }, + colFirst: { + default: null, + }, + colLast: { + default: null, }, }); + +const createPermStartNode = ({ name, group, inline }) => + Node.create({ + name, + group, + ...(inline ? { inline: true } : {}), + + renderDOM: hiddenRender, + + addAttributes: sharedAttributes, + }); + +export const PermStart = createPermStartNode({ name: 'permStart', group: 'inline', inline: true }); +export const PermStartBlock = createPermStartNode({ name: 'permStartBlock', group: 'block', inline: false }); diff --git a/packages/super-editor/src/extensions/permission-ranges/permission-ranges.js b/packages/super-editor/src/extensions/permission-ranges/permission-ranges.js index cdd4258ca7..a8c7ad3e1a 100644 --- a/packages/super-editor/src/extensions/permission-ranges/permission-ranges.js +++ b/packages/super-editor/src/extensions/permission-ranges/permission-ranges.js @@ -5,6 +5,14 @@ import { Extension } from '@core/Extension.js'; const PERMISSION_PLUGIN_KEY = new PluginKey('permissionRanges'); const EVERYONE_GROUP = 'everyone'; const EMPTY_IDENTIFIER_SET = Object.freeze(new Set()); +const PERM_START_NODE_NAMES = Object.freeze(['permStart', 'permStartBlock']); +const PERM_END_NODE_NAMES = Object.freeze(['permEnd', 'permEndBlock']); + +const getNodeName = (node) => node?.type?.name ?? null; +const isPermStartNode = (node) => PERM_START_NODE_NAMES.includes(getNodeName(node)); +const isPermEndNode = (node) => PERM_END_NODE_NAMES.includes(getNodeName(node)); +const isPermissionBoundaryNode = (node) => isPermStartNode(node) || isPermEndNode(node); +const resolvePermissionNodeType = (schema, typeName) => schema?.nodes?.[typeName] ?? null; const normalizeIdentifier = (value) => (typeof value === 'string' ? value.trim().toLowerCase() : ''); @@ -54,7 +62,7 @@ const buildPermissionState = (doc, allowedIdentifiers = EMPTY_IDENTIFIER_SET) => const openRanges = new Map(); doc.descendants((node, pos) => { - if (node.type?.name === 'permStart') { + if (isPermStartNode(node)) { const id = getPermissionNodeId(node, pos, 'permStart'); openRanges.set(id, { from: pos + node.nodeSize, @@ -63,7 +71,7 @@ const buildPermissionState = (doc, allowedIdentifiers = EMPTY_IDENTIFIER_SET) => return false; } - if (node.type?.name === 'permEnd') { + if (isPermEndNode(node)) { const id = getPermissionNodeId(node, pos, 'permEnd'); const start = openRanges.get(id); if (start && isRangeAllowedForUser(start.attrs, allowedIdentifiers)) { @@ -92,16 +100,14 @@ const buildPermissionState = (doc, allowedIdentifiers = EMPTY_IDENTIFIER_SET) => /** * Collects permStart/permEnd tags keyed by id. * @param {import('prosemirror-model').Node} doc - * @param {import('prosemirror-model').NodeType} permStartType - * @param {import('prosemirror-model').NodeType} permEndType * @returns {Map} */ -const collectPermissionTags = (doc, permStartType, permEndType) => { +const collectPermissionTags = (doc) => { /** @type {Map} */ const tags = new Map(); doc.descendants((node, pos) => { - if (node.type !== permStartType && node.type !== permEndType) { + if (!isPermissionBoundaryNode(node)) { return; } const id = node.attrs?.id; @@ -110,10 +116,11 @@ const collectPermissionTags = (doc, permStartType, permEndType) => { } const entry = tags.get(id) ?? {}; - if (node.type === permStartType) { - entry.start = { pos, attrs: node.attrs ?? {} }; - } else if (node.type === permEndType) { - entry.end = { pos, attrs: node.attrs ?? {} }; + const payload = { pos, attrs: node.attrs ?? {}, typeName: getNodeName(node) }; + if (isPermStartNode(node)) { + entry.start = payload; + } else if (isPermEndNode(node)) { + entry.end = payload; } tags.set(id, entry); }); @@ -133,17 +140,15 @@ const clampPosition = (pos, size) => { * permStart/permEnd boundaries can still be evaluated against allowed content. * @param {import('prosemirror-model').Node} doc * @param {{ from: number, to: number }} range - * @param {import('prosemirror-model').NodeType} permStartType - * @param {import('prosemirror-model').NodeType} permEndType * @returns {{ from: number, to: number }} */ -const trimPermissionTagsFromRange = (doc, range, permStartType, permEndType) => { +const trimPermissionTagsFromRange = (doc, range) => { let from = range.from; let to = range.to; while (from < to) { const node = doc.nodeAt(from); - if (!node || (node.type !== permStartType && node.type !== permEndType)) { + if (!node || !isPermissionBoundaryNode(node)) { break; } from += node.nodeSize; @@ -152,7 +157,7 @@ const trimPermissionTagsFromRange = (doc, range, permStartType, permEndType) => while (to > from) { const $pos = doc.resolve(to); const nodeBefore = $pos.nodeBefore; - if (!nodeBefore || (nodeBefore.type !== permStartType && nodeBefore.type !== permEndType)) { + if (!nodeBefore || !isPermissionBoundaryNode(nodeBefore)) { break; } to -= nodeBefore.nodeSize; @@ -277,15 +282,11 @@ export const PermissionRanges = Extension.create({ appendTransaction(transactions, oldState, newState) { if (!transactions.some((tr) => tr.docChanged)) return null; - const permStartType = newState.schema.nodes['permStart']; - const permEndType = newState.schema.nodes['permEnd']; - if (!permStartType || !permEndType) return null; - - const oldTags = collectPermissionTags(oldState.doc, permStartType, permEndType); + const oldTags = collectPermissionTags(oldState.doc); if (!oldTags.size) { return null; } - const newTags = collectPermissionTags(newState.doc, permStartType, permEndType); + const newTags = collectPermissionTags(newState.doc); const mappingToNew = new Mapping(); transactions.forEach((tr) => { @@ -300,7 +301,7 @@ export const PermissionRanges = Extension.create({ const mapped = mappingToNew.mapResult(tag.start.pos, -1); pendingInsertions.push({ pos: mapped.pos, - nodeType: permStartType, + typeName: tag.start.typeName, attrs: tag.start.attrs, priority: 0, }); @@ -309,7 +310,7 @@ export const PermissionRanges = Extension.create({ const mapped = mappingToNew.mapResult(tag.end.pos, 1); pendingInsertions.push({ pos: mapped.pos, - nodeType: permEndType, + typeName: tag.end.typeName, attrs: tag.end.attrs, priority: 1, }); @@ -330,7 +331,10 @@ export const PermissionRanges = Extension.create({ const tr = newState.tr; let offset = 0; pendingInsertions.forEach((item) => { - const node = item.nodeType.create(item.attrs); + if (!item.typeName) return; + const nodeType = resolvePermissionNodeType(newState.schema, item.typeName); + if (!nodeType) return; + const node = nodeType.create(item.attrs); const insertPos = clampPosition(item.pos + offset, tr.doc.content.size); tr.insert(insertPos, node); offset += node.nodeSize; @@ -349,12 +353,9 @@ export const PermissionRanges = Extension.create({ } const changedRanges = collectChangedRanges(tr); if (!changedRanges.length) return true; - const permStartType = state.schema.nodes['permStart']; - const permEndType = state.schema.nodes['permEnd']; - if (!permStartType || !permEndType) return true; const allRangesAllowed = changedRanges.every((range) => { - const trimmed = trimPermissionTagsFromRange(state.doc, range, permStartType, permEndType); + const trimmed = trimPermissionTagsFromRange(state.doc, range); return isRangeAllowed(trimmed, pluginState.ranges); }); diff --git a/packages/super-editor/src/extensions/permission-ranges/permission-ranges.test.js b/packages/super-editor/src/extensions/permission-ranges/permission-ranges.test.js index 0c3966c921..3938c35b77 100644 --- a/packages/super-editor/src/extensions/permission-ranges/permission-ranges.test.js +++ b/packages/super-editor/src/extensions/permission-ranges/permission-ranges.test.js @@ -21,6 +21,22 @@ const docWithPermissionRange = { ], }; +const docWithBlockPermissionRange = { + type: 'doc', + content: [ + { type: 'permStartBlock', attrs: { id: 'b1', edGrp: 'everyone' } }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Block editable section. ' }], + }, + { type: 'permEndBlock', attrs: { id: 'b1', edGrp: 'everyone' } }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Locked block section.' }], + }, + ], +}; + const docWithoutPermissionRange = { type: 'doc', content: [ @@ -107,6 +123,27 @@ describe('PermissionRanges extension', () => { expect(instance.isEditable).toBe(false); }); + it('honors block-level permission nodes', () => { + const instance = createEditor(docWithBlockPermissionRange); + expect(instance.isEditable).toBe(true); + const storedRanges = instance.storage.permissionRanges?.ranges ?? []; + expect(storedRanges.length).toBeGreaterThan(0); + + const lockedPos = findTextPos(instance.state.doc, 'Locked block'); + expect(lockedPos).toBeGreaterThan(0); + instance.view.dispatch(instance.state.tr.setSelection(TextSelection.create(instance.state.doc, lockedPos))); + const lockedTr = instance.state.tr.insertText('X', lockedPos, lockedPos); + instance.view.dispatch(lockedTr); + expect(instance.state.doc.textBetween(lockedPos, lockedPos + 1)).not.toContain('X'); + + const editablePos = findTextPos(instance.state.doc, 'Block editable'); + expect(editablePos).toBeGreaterThan(0); + instance.view.dispatch(instance.state.tr.setSelection(TextSelection.create(instance.state.doc, editablePos))); + const allowedTr = instance.state.tr.insertText('Y', editablePos, editablePos); + instance.view.dispatch(allowedTr); + expect(instance.state.doc.textBetween(editablePos, editablePos + 2)).toContain('Y'); + }); + it('blocks edits outside the permission range but allows edits inside it', () => { const instance = createEditor(docWithPermissionRange); const initialJson = instance.state.doc.toJSON(); @@ -160,6 +197,33 @@ describe('PermissionRanges extension', () => { expect(permEndCount).toBe(1); }); + it('reconstructs permEnd block nodes removed at the boundary', () => { + const instance = createEditor(docWithBlockPermissionRange); + let permEndPos = null; + let permEndSize = null; + instance.state.doc.descendants((node, pos) => { + if (node.type?.name === 'permEndBlock') { + permEndPos = pos; + permEndSize = node.nodeSize; + return false; + } + return; + }); + expect(permEndPos).toBeGreaterThan(0); + + const deleteTr = instance.state.tr.delete(permEndPos, permEndPos + permEndSize); + instance.view.dispatch(deleteTr); + + let permEndCount = 0; + instance.state.doc.descendants((node) => { + if (node.type?.name === 'permEndBlock') { + permEndCount += 1; + } + return; + }); + expect(permEndCount).toBe(1); + }); + it('reconstructs permStart nodes deleted at the range boundary', () => { const instance = createEditor(docWithPermissionRange); const editablePos = findTextPos(instance.state.doc, 'Editable'); diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index 2bb45b4491..c22e7222f3 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -1036,6 +1036,20 @@ export interface PermStartAttrs extends InlineNodeAttributes { colLast?: number | null; } +/** Block-level permission start node attributes */ +export interface PermStartBlockAttrs extends BlockNodeAttributes { + /** Permission identifier */ + id?: string | null; + /** Editor group */ + edGrp?: string | null; + /** Editor */ + ed?: string | null; + /** First column reference */ + colFirst?: number | null; + /** Last column reference */ + colLast?: number | null; +} + /** Permission end node attributes */ export interface PermEndAttrs extends InlineNodeAttributes { /** Permission identifier */ @@ -1046,6 +1060,16 @@ export interface PermEndAttrs extends InlineNodeAttributes { displacedByCustomXml?: string | null; } +/** Block-level permission end node attributes */ +export interface PermEndBlockAttrs extends BlockNodeAttributes { + /** Permission identifier */ + id?: string | null; + /** Editor group */ + edGrp?: string | null; + /** Indicates if displaced by custom XML */ + displacedByCustomXml?: string | null; +} + // ============================================ // COMMENT RANGE NODES // ============================================ @@ -1124,7 +1148,9 @@ declare module '../../core/types/NodeAttributesMap.js' { // Permissions permStart: PermStartAttrs; + permStartBlock: PermStartBlockAttrs; permEnd: PermEndAttrs; + permEndBlock: PermEndBlockAttrs; // Page elements pageReference: PageReferenceAttrs;