From bd1881d79d2a2c32d4e5886042d972dc6c37b0ac Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 9 Feb 2026 14:22:14 -0300 Subject: [PATCH 01/10] feat: add block-level nodes for handling permissions --- packages/super-editor/src/extensions/index.js | 6 ++- .../src/extensions/perm-end/perm-end.js | 42 +++++++++++---- .../src/extensions/perm-start/perm-start.js | 54 +++++++++++++------ .../src/extensions/types/node-attributes.ts | 2 + 4 files changed, 74 insertions(+), 30 deletions(-) diff --git a/packages/super-editor/src/extensions/index.js b/packages/super-editor/src/extensions/index.js index 98d69876ed..828a5968ba 100644 --- a/packages/super-editor/src/extensions/index.js +++ b/packages/super-editor/src/extensions/index.js @@ -73,8 +73,8 @@ import { PermissionRanges } from './permission-ranges/index.js'; import { VerticalNavigation } from './vertical-navigation/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'; @@ -194,6 +194,8 @@ const getStarterExtensions = () => { ShapeGroup, PermStart, PermEnd, + PermStartBlock, + PermEndBlock, PermissionRanges, VerticalNavigation, PassthroughInline, 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..452b4860cb 100644 --- a/packages/super-editor/src/extensions/perm-end/perm-end.js +++ b/packages/super-editor/src/extensions/perm-end/perm-end.js @@ -11,6 +11,18 @@ import { Node } from '@core/index.js'; * @sidebarTitle PermEnd * @snippetPath /snippets/extensions/perm-end.mdx */ +const sharedAttributes = () => ({ + id: { + default: null, + }, + edGrp: { + default: null, + }, + displacedByCustomXml: { + default: null, + }, +}); + export const PermEnd = Node.create({ name: 'permEnd', group: 'inline', @@ -21,16 +33,24 @@ export const PermEnd = Node.create({ }, addAttributes() { - return { - id: { - default: null, - }, - edGrp: { - default: null, - }, - displacedByCustomXml: { - default: null, - }, - }; + return sharedAttributes(); + }, +}); + +export const PermEndBlock = Node.create({ + name: 'permEndBlock', + group: 'block', + inline: false, + atom: true, + draggable: false, + selectable: false, + defining: true, + + renderDOM() { + return ['div', { style: 'display: none;' }]; + }, + + addAttributes() { + return sharedAttributes(); }, }); 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..3fd37e9011 100644 --- a/packages/super-editor/src/extensions/perm-start/perm-start.js +++ b/packages/super-editor/src/extensions/perm-start/perm-start.js @@ -11,6 +11,24 @@ import { Node } from '@core/index.js'; * @sidebarTitle PermStart * @snippetPath /snippets/extensions/perm-start.mdx */ +const sharedAttributes = () => ({ + id: { + default: null, + }, + edGrp: { + default: null, + }, + ed: { + default: null, + }, + colFirst: { + default: null, + }, + colLast: { + default: null, + }, +}); + export const PermStart = Node.create({ name: 'permStart', group: 'inline', @@ -21,22 +39,24 @@ export const PermStart = Node.create({ }, addAttributes() { - return { - id: { - default: null, - }, - edGrp: { - default: null, - }, - ed: { - default: null, - }, - colFirst: { - default: null, - }, - colLast: { - default: null, - }, - }; + return sharedAttributes(); + }, +}); + +export const PermStartBlock = Node.create({ + name: 'permStartBlock', + group: 'block', + inline: false, + atom: true, + draggable: false, + selectable: false, + defining: true, + + renderDOM() { + return ['div', { style: 'display: none;' }]; + }, + + addAttributes() { + return sharedAttributes(); }, }); diff --git a/packages/super-editor/src/extensions/types/node-attributes.ts b/packages/super-editor/src/extensions/types/node-attributes.ts index dbb95aa8fc..4f5319353a 100644 --- a/packages/super-editor/src/extensions/types/node-attributes.ts +++ b/packages/super-editor/src/extensions/types/node-attributes.ts @@ -1151,6 +1151,8 @@ declare module '../../core/types/NodeAttributesMap.js' { // Permissions permStart: PermStartAttrs; permEnd: PermEndAttrs; + permStartBlock: PermStartAttrs; + permEndBlock: PermEndAttrs; // Page elements pageReference: PageReferenceAttrs; From 6af68154d05f05f6ef29cf305daec02317d1d424 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 9 Feb 2026 14:37:39 -0300 Subject: [PATCH 02/10] refactor: extract helper for identifying inline context --- .../v2/importer/inlineContext.js | 43 ++++++++++++++++++ .../v2/importer/passthroughNodeImporter.js | 45 +------------------ 2 files changed, 45 insertions(+), 43 deletions(-) create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/inlineContext.js diff --git a/packages/super-editor/src/core/super-converter/v2/importer/inlineContext.js b/packages/super-editor/src/core/super-converter/v2/importer/inlineContext.js new file mode 100644 index 0000000000..82693c2bab --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/inlineContext.js @@ -0,0 +1,43 @@ +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; +}; 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..9c6a92194d 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,49 +1,8 @@ import { carbonCopy } from '@core/utilities/carbonCopy.js'; import { registeredHandlers } from '../../v3/handlers/index.js'; +import { isInlineContext } from './inlineContext.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; -}; +export { isInlineContext }; /** * @type {import('docxImporter').NodeHandler} From b697eda919ec77d5eb2f83278267894384e91888 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 9 Feb 2026 14:39:51 -0300 Subject: [PATCH 03/10] feat: import block-level permission nodes --- .../w/perm-end/perm-end-translator.js | 9 ++++-- .../w/perm-end/perm-end-translator.test.js | 30 ++++++++++++++++-- .../w/perm-start/perm-start-translator.js | 9 ++++-- .../perm-start/perm-start-translator.test.js | 31 +++++++++++++++++-- 4 files changed, 68 insertions(+), 11 deletions(-) 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..d33479a2e0 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,22 +1,25 @@ // @ts-check import { NodeTranslator } from '@translator'; +import { isInlineContext } from '../../../../v2/importer/inlineContext.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_NAME = ['permEnd', 'permEndBlock']; /** - * Encode a node as a SuperDoc permEnd node. + * Encode a node as a SuperDoc permEnd/permEndBlock node. * @param {import('@translator').SCEncoderConfig} params * @param {import('@translator').EncodedAttributes} [encodedAttrs] * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { + const node = params?.nodes?.[0]; + const isInline = isInlineContext(params?.path || [], node?.name); return { - type: 'permEnd', + type: isInline ? 'permEnd' : 'permEndBlock', attrs: encodedAttrs, }; }; 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..b44ea2d49f 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,12 +5,12 @@ 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); }); - it('encodes OOXML to SuperDoc', () => { + it('encodes OOXML to SuperDoc inline', () => { const params = { nodes: [ { @@ -21,6 +21,7 @@ describe('w:permEnd translator', () => { }, }, ], + path: [{ name: 'w:p' }], }; const result = translator.encode(params); @@ -34,6 +35,31 @@ describe('w:permEnd translator', () => { }); }); + it('encodes block-level nodes when not in inline context', () => { + const params = { + nodes: [ + { + name: 'w:permEnd', + attributes: { + 'w:id': '7', + 'w:displacedByCustomXml': 'prev', + }, + }, + ], + path: [], + }; + + const result = translator.encode(params); + + expect(result).toEqual({ + type: 'permEndBlock', + attrs: { + id: '7', + displacedByCustomXml: 'prev', + }, + }); + }); + it('decodes SuperDoc to OOXML', () => { const params = { node: { 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..a91c3b8689 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,22 +1,25 @@ // @ts-check import { NodeTranslator } from '@translator'; +import { isInlineContext } from '../../../../v2/importer/inlineContext.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_NAME = ['permStart', 'permStartBlock']; /** - * Encode a node as a SuperDoc permStart node. + * Encode a node as a SuperDoc permStart/permStartBlock node. * @param {import('@translator').SCEncoderConfig} params * @param {import('@translator').EncodedAttributes} [encodedAttrs] * @returns {import('@translator').SCEncoderResult} */ const encode = (params, encodedAttrs = {}) => { + const node = params?.nodes?.[0]; + const isInline = isInlineContext(params?.path || [], node?.name); return { - type: 'permStart', + type: isInline ? 'permStart' : 'permStartBlock', attrs: encodedAttrs, }; }; 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..4fc7988e99 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,12 +5,12 @@ 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); }); - it('encodes OOXML to SuperDoc with all attributes', () => { + it('encodes OOXML to SuperDoc inline with all attributes', () => { const params = { nodes: [ { @@ -24,6 +24,7 @@ describe('w:permStart translator', () => { }, }, ], + path: [{ name: 'w:p' }], }; const result = translator.encode(params); @@ -40,7 +41,7 @@ describe('w:permStart translator', () => { }); }); - it('encodes with minimal attributes', () => { + it('encodes inline with minimal attributes', () => { const params = { nodes: [ { @@ -50,6 +51,7 @@ describe('w:permStart translator', () => { }, }, ], + path: [{ name: 'w:p' }], }; const result = translator.encode(params); @@ -62,6 +64,29 @@ describe('w:permStart translator', () => { }); }); + it('encodes block-level nodes when not in inline context', () => { + const params = { + nodes: [ + { + name: 'w:permStart', + attributes: { + 'w:id': '21', + }, + }, + ], + path: [], + }; + + const result = translator.encode(params); + + expect(result).toEqual({ + type: 'permStartBlock', + attrs: { + id: '21', + }, + }); + }); + it('decodes SuperDoc to OOXML', () => { const params = { node: { From 44726a58a6f4f89ba0be49ed408c4244e009cbaa Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 9 Feb 2026 14:52:12 -0300 Subject: [PATCH 04/10] feat: update permission ranges plugin to support block nodes --- .../permission-ranges/permission-ranges.js | 95 +++++++++++++------ .../permission-ranges.test.js | 77 +++++++++++++++ 2 files changed, 141 insertions(+), 31 deletions(-) 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..15a4ecd2e8 100644 --- a/packages/super-editor/src/extensions/permission-ranges/permission-ranges.js +++ b/packages/super-editor/src/extensions/permission-ranges/permission-ranges.js @@ -35,6 +35,28 @@ const isRangeAllowedForUser = (attrs, allowedIdentifiers) => { return normalizedEd && allowedIdentifiers.has(normalizedEd); }; +const getPermissionTypeInfo = (schema) => { + const startTypes = []; + const endTypes = []; + const permStartType = schema?.nodes?.['permStart']; + const permStartBlockType = schema?.nodes?.['permStartBlock']; + const permEndType = schema?.nodes?.['permEnd']; + const permEndBlockType = schema?.nodes?.['permEndBlock']; + + if (permStartType) startTypes.push(permStartType); + if (permStartBlockType) startTypes.push(permStartBlockType); + if (permEndType) endTypes.push(permEndType); + if (permEndBlockType) endTypes.push(permEndBlockType); + + return { + startTypes, + endTypes, + startTypeSet: new Set(startTypes), + endTypeSet: new Set(endTypes), + allTypeSet: new Set([...startTypes, ...endTypes]), + }; +}; + /** * Generates the identifier used to match permStart/permEnd pairs. * @param {import('prosemirror-model').Node} node @@ -46,15 +68,18 @@ const getPermissionNodeId = (node, pos, fallbackPrefix) => String(node.attrs?.id /** * Parse permStart/permEnd pairs and return ranges. * @param {import('prosemirror-model').Node} doc + * @param {{ startTypeSet: Set, endTypeSet: Set }} permTypes * @returns {{ ranges: Array<{ id: string, from: number, to: number }>, hasAllowedRanges: boolean }} */ -const buildPermissionState = (doc, allowedIdentifiers = EMPTY_IDENTIFIER_SET) => { +const buildPermissionState = (doc, allowedIdentifiers = EMPTY_IDENTIFIER_SET, permTypes) => { const ranges = []; /** @type {Map} */ const openRanges = new Map(); + const startTypeSet = permTypes?.startTypeSet ?? new Set(); + const endTypeSet = permTypes?.endTypeSet ?? new Set(); doc.descendants((node, pos) => { - if (node.type?.name === 'permStart') { + if (startTypeSet.has(node.type)) { const id = getPermissionNodeId(node, pos, 'permStart'); openRanges.set(id, { from: pos + node.nodeSize, @@ -63,7 +88,7 @@ const buildPermissionState = (doc, allowedIdentifiers = EMPTY_IDENTIFIER_SET) => return false; } - if (node.type?.name === 'permEnd') { + if (endTypeSet.has(node.type)) { const id = getPermissionNodeId(node, pos, 'permEnd'); const start = openRanges.get(id); if (start && isRangeAllowedForUser(start.attrs, allowedIdentifiers)) { @@ -92,16 +117,18 @@ 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} + * @param {import('prosemirror-model').NodeType[]} permStartTypes + * @param {import('prosemirror-model').NodeType[]} permEndTypes + * @returns {Map} */ -const collectPermissionTags = (doc, permStartType, permEndType) => { - /** @type {Map} */ +const collectPermissionTags = (doc, permStartTypes, permEndTypes) => { + /** @type {Map} */ const tags = new Map(); + const permStartTypeSet = new Set(permStartTypes); + const permEndTypeSet = new Set(permEndTypes); doc.descendants((node, pos) => { - if (node.type !== permStartType && node.type !== permEndType) { + if (!permStartTypeSet.has(node.type) && !permEndTypeSet.has(node.type)) { return; } const id = node.attrs?.id; @@ -110,10 +137,10 @@ 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 ?? {} }; + if (permStartTypeSet.has(node.type)) { + entry.start = { pos, attrs: node.attrs ?? {}, nodeType: node.type }; + } else if (permEndTypeSet.has(node.type)) { + entry.end = { pos, attrs: node.attrs ?? {}, nodeType: node.type }; } tags.set(id, entry); }); @@ -133,17 +160,16 @@ 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 + * @param {Set} permTagTypes * @returns {{ from: number, to: number }} */ -const trimPermissionTagsFromRange = (doc, range, permStartType, permEndType) => { +const trimPermissionTagsFromRange = (doc, range, permTagTypes) => { 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 || !permTagTypes.has(node.type)) { break; } from += node.nodeSize; @@ -152,7 +178,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 || !permTagTypes.has(nodeBefore.type)) { break; } to -= nodeBefore.nodeSize; @@ -246,7 +272,11 @@ export const PermissionRanges = Extension.create({ key: PERMISSION_PLUGIN_KEY, state: { init(_, state) { - const permissionState = buildPermissionState(state.doc, getAllowedIdentifiers()); + const permissionState = buildPermissionState( + state.doc, + getAllowedIdentifiers(), + getPermissionTypeInfo(state.schema), + ); storage.ranges = permissionState.ranges; updateEditableState(permissionState.hasAllowedRanges); return permissionState; @@ -255,7 +285,11 @@ export const PermissionRanges = Extension.create({ apply(tr, value, _oldState, newState) { let permissionState = value; if (tr.docChanged) { - permissionState = buildPermissionState(newState.doc, getAllowedIdentifiers()); + permissionState = buildPermissionState( + newState.doc, + getAllowedIdentifiers(), + getPermissionTypeInfo(newState.schema), + ); storage.ranges = permissionState.ranges; updateEditableState(permissionState.hasAllowedRanges); } @@ -277,15 +311,14 @@ 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 permTypes = getPermissionTypeInfo(newState.schema); + if (!permTypes.startTypes.length || !permTypes.endTypes.length) return null; - const oldTags = collectPermissionTags(oldState.doc, permStartType, permEndType); + const oldTags = collectPermissionTags(oldState.doc, permTypes.startTypes, permTypes.endTypes); if (!oldTags.size) { return null; } - const newTags = collectPermissionTags(newState.doc, permStartType, permEndType); + const newTags = collectPermissionTags(newState.doc, permTypes.startTypes, permTypes.endTypes); const mappingToNew = new Mapping(); transactions.forEach((tr) => { @@ -300,7 +333,7 @@ export const PermissionRanges = Extension.create({ const mapped = mappingToNew.mapResult(tag.start.pos, -1); pendingInsertions.push({ pos: mapped.pos, - nodeType: permStartType, + nodeType: tag.start.nodeType, attrs: tag.start.attrs, priority: 0, }); @@ -309,7 +342,7 @@ export const PermissionRanges = Extension.create({ const mapped = mappingToNew.mapResult(tag.end.pos, 1); pendingInsertions.push({ pos: mapped.pos, - nodeType: permEndType, + nodeType: tag.end.nodeType, attrs: tag.end.attrs, priority: 1, }); @@ -330,6 +363,7 @@ export const PermissionRanges = Extension.create({ const tr = newState.tr; let offset = 0; pendingInsertions.forEach((item) => { + if (!item.nodeType) return; const node = item.nodeType.create(item.attrs); const insertPos = clampPosition(item.pos + offset, tr.doc.content.size); tr.insert(insertPos, node); @@ -349,12 +383,11 @@ 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 permTypes = getPermissionTypeInfo(state.schema); + if (!permTypes.startTypes.length || !permTypes.endTypes.length) return true; const allRangesAllowed = changedRanges.every((range) => { - const trimmed = trimPermissionTagsFromRange(state.doc, range, permStartType, permEndType); + const trimmed = trimPermissionTagsFromRange(state.doc, range, permTypes.allTypeSet); 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 8c5e47229d..1a89d1b7c8 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 @@ -23,6 +23,22 @@ const docWithPermissionRange = { ], }; +const docWithBlockPermissionRange = { + type: 'doc', + content: [ + { type: 'permStartBlock', attrs: { id: 'block-1', edGrp: 'everyone' } }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Editable block section. ' }], + }, + { type: 'permEndBlock', attrs: { id: 'block-1', edGrp: 'everyone' } }, + { + type: 'paragraph', + content: [{ type: 'text', text: 'Locked block section.' }], + }, + ], +}; + const docWithoutPermissionRange = { type: 'doc', content: [ @@ -108,6 +124,14 @@ describe('PermissionRanges extension', () => { expect(instance.isEditable).toBe(true); }); + it('keeps viewing mode editable when the document contains a block-level everyone range', () => { + const instance = createEditor(docWithBlockPermissionRange); + expect(instance.options.documentMode).toBe(VIEWING_MODE); + const storedRanges = instance.storage.permissionRanges?.ranges ?? []; + expect(storedRanges.length).toBeGreaterThan(0); + expect(instance.isEditable).toBe(true); + }); + it('stays read-only when there are no approved ranges', () => { const instance = createEditor(docWithoutPermissionRange); expect(instance.options.documentMode).toBe(VIEWING_MODE); @@ -133,6 +157,59 @@ describe('PermissionRanges extension', () => { expect(instance.state.doc.textBetween(editablePos, editablePos + 2)).toContain('Y'); }); + it('blocks edits outside the block permission range but allows edits inside it', () => { + const instance = createEditor(docWithBlockPermissionRange); + const initialJson = instance.state.doc.toJSON(); + + 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.toJSON()).toEqual(initialJson); + + const editablePos = findTextPos(instance.state.doc, 'Editable block'); + 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('reconstructs permEndBlock nodes removed while deleting at the range boundary', () => { + const instance = createEditor(docWithBlockPermissionRange); + const editableText = 'Editable block section. '; + const editablePos = findTextPos(instance.state.doc, editableText); + expect(editablePos).toBeGreaterThan(0); + + let permEndPos = null; + let permEndSize = null; + instance.state.doc.descendants((node, pos) => { + if (node.type?.name === 'permEndBlock' && node.attrs?.id === 'block-1') { + permEndPos = pos; + permEndSize = node.nodeSize; + return false; + } + return; + }); + expect(permEndPos).toBeGreaterThan(0); + expect(permEndSize).toBeGreaterThan(0); + + const lastEditableCharPos = editablePos + editableText.length - 1; + const deleteTr = instance.state.tr.delete(lastEditableCharPos, permEndPos + permEndSize); + instance.view.dispatch(deleteTr); + + let permEndCount = 0; + instance.state.doc.descendants((node) => { + if (node.type?.name === 'permEndBlock' && node.attrs?.id === 'block-1') { + permEndCount += 1; + } + return; + }); + + expect(permEndCount).toBe(1); + }); + it('reconstructs permEnd nodes removed while deleting at the range boundary', () => { const instance = createEditor(docWithPermissionRange); const editableText = 'Editable section. '; From 32ec5f9ae7724233ef59b9245f3e47ca332dd1ca Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 9 Feb 2026 14:57:21 -0300 Subject: [PATCH 05/10] feat: export block-level permission nodes --- packages/super-editor/src/core/super-converter/exporter.js | 2 ++ 1 file changed, 2 insertions(+) diff --git a/packages/super-editor/src/core/super-converter/exporter.js b/packages/super-editor/src/core/super-converter/exporter.js index 23ef014b4c..d68f23dce1 100644 --- a/packages/super-editor/src/core/super-converter/exporter.js +++ b/packages/super-editor/src/core/super-converter/exporter.js @@ -175,6 +175,8 @@ export function exportSchemaToJson(params) { commentRangeEnd: wCommentRangeEndTranslator, permStart: wPermStartTranslator, permEnd: wPermEndTranslator, + permStartBlock: wPermStartTranslator, + permEndBlock: wPermEndTranslator, commentReference: () => null, footnoteReference: wFootnoteReferenceTranslator, shapeContainer: pictTranslator, From f81558dae3bed28fa0297bc68f015a9ee2181653 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Mon, 9 Feb 2026 15:43:45 -0300 Subject: [PATCH 06/10] feat: convert inline permission nodes to block ones when under root node --- .../v2/importer/docxImporter.js | 10 +++++++ .../v2/importer/docxImporter.test.js | 8 +++++ .../v2/importer/permRangeImporter.test.js | 30 +++++++++++++++++++ 3 files changed, 48 insertions(+) create mode 100644 packages/super-editor/src/core/super-converter/v2/importer/permRangeImporter.test.js diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js index 02c5b63586..f54ee0004e 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.js @@ -802,6 +802,8 @@ export function filterOutRootInlineNodes(content = []) { 'commentReference', 'footnoteReference', 'structuredContent', + 'permStart', + 'permEnd', ]); const PRESERVABLE_INLINE_XML_NAMES = { @@ -827,6 +829,14 @@ export function filterOutRootInlineNodes(content = []) { return; } + if (type === 'permStart' || type === 'permEnd') { + result.push({ + ...node, + type: type === 'permStart' ? 'permStartBlock' : 'permEndBlock', + }); + return; + } + if (!INLINE_TYPES.has(type)) { result.push(node); } else if (preservableNodeName) { diff --git a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js index 0f7327b5a8..4121e168ec 100644 --- a/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js +++ b/packages/super-editor/src/core/super-converter/v2/importer/docxImporter.test.js @@ -79,6 +79,14 @@ describe('filterOutRootInlineNodes', () => { expect(result[1].type).toBe('paragraph'); }); + it('converts root permission tags into block nodes', () => { + const input = [n('permStart', { id: '1' }), n('permEnd', { id: '1' })]; + const result = filterOutRootInlineNodes(input); + expect(result.map((node) => node.type)).toEqual(['permStartBlock', 'permEndBlock']); + expect(result[0].attrs.id).toBe('1'); + expect(result[1].attrs.id).toBe('1'); + }); + it('derives inline types from schema when provided', () => { // Build a minimal fake schema map using Map with forEach(name, nodeType) const nodes = new Map(); diff --git a/packages/super-editor/src/core/super-converter/v2/importer/permRangeImporter.test.js b/packages/super-editor/src/core/super-converter/v2/importer/permRangeImporter.test.js new file mode 100644 index 0000000000..5929d7bd23 --- /dev/null +++ b/packages/super-editor/src/core/super-converter/v2/importer/permRangeImporter.test.js @@ -0,0 +1,30 @@ +import { describe, it, expect } from 'vitest'; +import { handlePermStartNode } from './permStartImporter.js'; +import { handlePermEndNode } from './permEndImporter.js'; + +const createParams = (node, extra = {}) => ({ + nodes: [node], + docx: {}, + nodeListHandler: { handler: () => [], handlerEntities: [] }, + ...extra, +}); + +describe('permission range importers', () => { + it('creates block-level permStart when not in inline context', () => { + const node = { name: 'w:permStart', attributes: { 'w:id': '1' } }; + const { nodes, consumed } = handlePermStartNode(createParams(node, { path: [{ name: 'w:body' }] })); + expect(consumed).toBe(1); + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe('permStartBlock'); + expect(nodes[0].attrs.id).toBe('1'); + }); + + it('creates block-level permEnd when not in inline context', () => { + const node = { name: 'w:permEnd', attributes: { 'w:id': '2', 'w:displacedByCustomXml': 'prev' } }; + const { nodes, consumed } = handlePermEndNode(createParams(node, { path: [{ name: 'w:body' }] })); + expect(consumed).toBe(1); + expect(nodes).toHaveLength(1); + expect(nodes[0].type).toBe('permEndBlock'); + expect(nodes[0].attrs.id).toBe('2'); + }); +}); From 8e3a3eb5ab95ad7d9a23ae55502786003bb9b8ff Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 11 Feb 2026 15:23:49 -0300 Subject: [PATCH 07/10] refactor: add factory for creating permission block nodes --- .../src/extensions/perm-end/perm-end.js | 18 +++-------------- .../src/extensions/perm-start/perm-start.js | 18 +++-------------- .../shared/permission-block-marker-factory.js | 20 +++++++++++++++++++ 3 files changed, 26 insertions(+), 30 deletions(-) create mode 100644 packages/super-editor/src/extensions/shared/permission-block-marker-factory.js 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 452b4860cb..f85810e47b 100644 --- a/packages/super-editor/src/extensions/perm-end/perm-end.js +++ b/packages/super-editor/src/extensions/perm-end/perm-end.js @@ -1,4 +1,5 @@ import { Node } from '@core/index.js'; +import { createPermissionBlockMarkerNode } from '../shared/permission-block-marker-factory.js'; /** * Configuration options for PermEnd @@ -37,20 +38,7 @@ export const PermEnd = Node.create({ }, }); -export const PermEndBlock = Node.create({ +export const PermEndBlock = createPermissionBlockMarkerNode({ name: 'permEndBlock', - group: 'block', - inline: false, - atom: true, - draggable: false, - selectable: false, - defining: true, - - renderDOM() { - return ['div', { style: 'display: none;' }]; - }, - - addAttributes() { - return sharedAttributes(); - }, + attributes: sharedAttributes, }); 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 3fd37e9011..733f126581 100644 --- a/packages/super-editor/src/extensions/perm-start/perm-start.js +++ b/packages/super-editor/src/extensions/perm-start/perm-start.js @@ -1,4 +1,5 @@ import { Node } from '@core/index.js'; +import { createPermissionBlockMarkerNode } from '../shared/permission-block-marker-factory.js'; /** * Configuration options for PermStart @@ -43,20 +44,7 @@ export const PermStart = Node.create({ }, }); -export const PermStartBlock = Node.create({ +export const PermStartBlock = createPermissionBlockMarkerNode({ name: 'permStartBlock', - group: 'block', - inline: false, - atom: true, - draggable: false, - selectable: false, - defining: true, - - renderDOM() { - return ['div', { style: 'display: none;' }]; - }, - - addAttributes() { - return sharedAttributes(); - }, + attributes: sharedAttributes, }); diff --git a/packages/super-editor/src/extensions/shared/permission-block-marker-factory.js b/packages/super-editor/src/extensions/shared/permission-block-marker-factory.js new file mode 100644 index 0000000000..7b0210c1ba --- /dev/null +++ b/packages/super-editor/src/extensions/shared/permission-block-marker-factory.js @@ -0,0 +1,20 @@ +import { Node } from '@core/index.js'; + +export const createPermissionBlockMarkerNode = ({ name, attributes }) => + Node.create({ + name, + group: 'block', + inline: false, + atom: true, + draggable: false, + selectable: false, + defining: true, + + renderDOM() { + return ['div', { style: 'display: none;' }]; + }, + + addAttributes() { + return attributes(); + }, + }); From 85a3d30863edaa98316ac76328bcdc21f41938c6 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 11 Feb 2026 15:24:09 -0300 Subject: [PATCH 08/10] fix: set atom to true for inline permission nodes --- packages/super-editor/src/extensions/perm-end/perm-end.js | 1 + packages/super-editor/src/extensions/perm-start/perm-start.js | 1 + 2 files changed, 2 insertions(+) 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 f85810e47b..89e2ba0d94 100644 --- a/packages/super-editor/src/extensions/perm-end/perm-end.js +++ b/packages/super-editor/src/extensions/perm-end/perm-end.js @@ -28,6 +28,7 @@ export const PermEnd = Node.create({ name: 'permEnd', group: 'inline', inline: true, + atom: true, renderDOM() { return ['span', { style: 'display: none;' }]; 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 733f126581..8a98770a3a 100644 --- a/packages/super-editor/src/extensions/perm-start/perm-start.js +++ b/packages/super-editor/src/extensions/perm-start/perm-start.js @@ -34,6 +34,7 @@ export const PermStart = Node.create({ name: 'permStart', group: 'inline', inline: true, + atom: true, renderDOM() { return ['span', { style: 'display: none;' }]; From 9344bc21c63544056f7527b4ff832f79f180bc17 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 11 Feb 2026 15:33:00 -0300 Subject: [PATCH 09/10] refactor: move helper function --- .../super-converter/{v2/importer => utils}/inlineContext.js | 0 .../core/super-converter/v2/importer/passthroughNodeImporter.js | 2 +- .../v3/handlers/w/perm-end/perm-end-translator.js | 2 +- .../v3/handlers/w/perm-start/perm-start-translator.js | 2 +- 4 files changed, 3 insertions(+), 3 deletions(-) rename packages/super-editor/src/core/super-converter/{v2/importer => utils}/inlineContext.js (100%) diff --git a/packages/super-editor/src/core/super-converter/v2/importer/inlineContext.js b/packages/super-editor/src/core/super-converter/utils/inlineContext.js similarity index 100% rename from packages/super-editor/src/core/super-converter/v2/importer/inlineContext.js rename to packages/super-editor/src/core/super-converter/utils/inlineContext.js 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 9c6a92194d..a72ea5b147 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,6 +1,6 @@ import { carbonCopy } from '@core/utilities/carbonCopy.js'; import { registeredHandlers } from '../../v3/handlers/index.js'; -import { isInlineContext } from './inlineContext.js'; +import { isInlineContext } from '@core/super-converter/utils/inlineContext.js'; export { isInlineContext }; 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 d33479a2e0..09eb18582e 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,6 +1,6 @@ // @ts-check import { NodeTranslator } from '@translator'; -import { isInlineContext } from '../../../../v2/importer/inlineContext.js'; +import { isInlineContext } from '@core/super-converter/utils/inlineContext.js'; import validXmlAttributes from './attributes/index.js'; /** @type {import('@translator').XmlNodeName} */ 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 a91c3b8689..69d95d1a23 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,6 +1,6 @@ // @ts-check import { NodeTranslator } from '@translator'; -import { isInlineContext } from '../../../../v2/importer/inlineContext.js'; +import { isInlineContext } from '@core/super-converter/utils/inlineContext.js'; import validXmlAttributes from './attributes/index.js'; /** @type {import('@translator').XmlNodeName} */ From 75cc73a6375aa4674efc24464e7f2b56b2ec23a1 Mon Sep 17 00:00:00 2001 From: Luccas Correa Date: Wed, 11 Feb 2026 15:38:13 -0300 Subject: [PATCH 10/10] refactor: cache permission types --- .../permission-ranges/permission-ranges.js | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) 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 15a4ecd2e8..e30c337dbc 100644 --- a/packages/super-editor/src/extensions/permission-ranges/permission-ranges.js +++ b/packages/super-editor/src/extensions/permission-ranges/permission-ranges.js @@ -267,16 +267,14 @@ export const PermissionRanges = Extension.create({ }; } + const permissionTypeInfo = getPermissionTypeInfo(editor?.state?.schema); + return [ new Plugin({ key: PERMISSION_PLUGIN_KEY, state: { init(_, state) { - const permissionState = buildPermissionState( - state.doc, - getAllowedIdentifiers(), - getPermissionTypeInfo(state.schema), - ); + const permissionState = buildPermissionState(state.doc, getAllowedIdentifiers(), permissionTypeInfo); storage.ranges = permissionState.ranges; updateEditableState(permissionState.hasAllowedRanges); return permissionState; @@ -285,11 +283,7 @@ export const PermissionRanges = Extension.create({ apply(tr, value, _oldState, newState) { let permissionState = value; if (tr.docChanged) { - permissionState = buildPermissionState( - newState.doc, - getAllowedIdentifiers(), - getPermissionTypeInfo(newState.schema), - ); + permissionState = buildPermissionState(newState.doc, getAllowedIdentifiers(), permissionTypeInfo); storage.ranges = permissionState.ranges; updateEditableState(permissionState.hasAllowedRanges); }