Skip to content
Open
2 changes: 2 additions & 0 deletions packages/super-editor/src/core/super-converter/exporter.js
Original file line number Diff line number Diff line change
Expand Up @@ -175,6 +175,8 @@ export function exportSchemaToJson(params) {
commentRangeEnd: wCommentRangeEndTranslator,
permStart: wPermStartTranslator,
permEnd: wPermEndTranslator,
permStartBlock: wPermStartTranslator,
permEndBlock: wPermEndTranslator,
commentReference: () => null,
footnoteReference: wFootnoteReferenceTranslator,
shapeContainer: pictTranslator,
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
};
Original file line number Diff line number Diff line change
Expand Up @@ -802,6 +802,8 @@ export function filterOutRootInlineNodes(content = []) {
'commentReference',
'footnoteReference',
'structuredContent',
'permStart',
'permEnd',
]);

const PRESERVABLE_INLINE_XML_NAMES = {
Expand All @@ -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) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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();
Expand Down
Original file line number Diff line number Diff line change
@@ -1,49 +1,8 @@
import { carbonCopy } from '@core/utilities/carbonCopy.js';
import { registeredHandlers } from '../../v3/handlers/index.js';
import { isInlineContext } from '@core/super-converter/utils/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}
Expand Down
Original file line number Diff line number Diff line change
@@ -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');
});
});
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
// @ts-check
import { NodeTranslator } from '@translator';
import { isInlineContext } from '@core/super-converter/utils/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 <w:permEnd> node as a SuperDoc permEnd node.
* Encode a <w:permEnd> 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,
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -21,6 +21,7 @@ describe('w:permEnd translator', () => {
},
},
],
path: [{ name: 'w:p' }],
};

const result = translator.encode(params);
Expand All @@ -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: {
Expand Down
Original file line number Diff line number Diff line change
@@ -1,22 +1,25 @@
// @ts-check
import { NodeTranslator } from '@translator';
import { isInlineContext } from '@core/super-converter/utils/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 <w:permStart> node as a SuperDoc permStart node.
* Encode a <w:permStart> 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,
};
};
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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: [
{
Expand All @@ -24,6 +24,7 @@ describe('w:permStart translator', () => {
},
},
],
path: [{ name: 'w:p' }],
};

const result = translator.encode(params);
Expand All @@ -40,7 +41,7 @@ describe('w:permStart translator', () => {
});
});

it('encodes with minimal attributes', () => {
it('encodes inline with minimal attributes', () => {
const params = {
nodes: [
{
Expand All @@ -50,6 +51,7 @@ describe('w:permStart translator', () => {
},
},
],
path: [{ name: 'w:p' }],
};

const result = translator.encode(params);
Expand All @@ -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: {
Expand Down
6 changes: 4 additions & 2 deletions packages/super-editor/src/extensions/index.js
Original file line number Diff line number Diff line change
Expand Up @@ -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';
Expand Down Expand Up @@ -194,6 +194,8 @@ const getStarterExtensions = () => {
ShapeGroup,
PermStart,
PermEnd,
PermStartBlock,
PermEndBlock,
PermissionRanges,
VerticalNavigation,
PassthroughInline,
Expand Down
Loading
Loading