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 cdd4258ca..9b96c97dc 100644 --- a/packages/super-editor/src/extensions/permission-ranges/permission-ranges.js +++ b/packages/super-editor/src/extensions/permission-ranges/permission-ranges.js @@ -1,6 +1,7 @@ import { Plugin, PluginKey } from 'prosemirror-state'; import { Mapping } from 'prosemirror-transform'; import { Extension } from '@core/Extension.js'; +import { generateRandomSigned32BitIntStrId } from '@core/helpers/generateDocxRandomId.js'; const PERMISSION_PLUGIN_KEY = new PluginKey('permissionRanges'); const EVERYONE_GROUP = 'everyone'; @@ -52,6 +53,7 @@ const buildPermissionState = (doc, allowedIdentifiers = EMPTY_IDENTIFIER_SET) => const ranges = []; /** @type {Map} */ const openRanges = new Map(); + let hasAllowedRanges = false; doc.descendants((node, pos) => { if (node.type?.name === 'permStart') { @@ -66,14 +68,19 @@ const buildPermissionState = (doc, allowedIdentifiers = EMPTY_IDENTIFIER_SET) => if (node.type?.name === 'permEnd') { const id = getPermissionNodeId(node, pos, 'permEnd'); const start = openRanges.get(id); - if (start && isRangeAllowedForUser(start.attrs, allowedIdentifiers)) { + if (start) { const to = Math.max(pos, start.from); if (to > start.from) { - ranges.push({ + const range = { id, from: start.from, to, - }); + attrs: start.attrs ?? {}, + }; + ranges.push(range); + if (!hasAllowedRanges && isRangeAllowedForUser(range.attrs, allowedIdentifiers)) { + hasAllowedRanges = true; + } } } if (start) { @@ -85,7 +92,7 @@ const buildPermissionState = (doc, allowedIdentifiers = EMPTY_IDENTIFIER_SET) => return { ranges, - hasAllowedRanges: ranges.length > 0, + hasAllowedRanges, }; }; @@ -179,13 +186,19 @@ const collectChangedRanges = (tr) => { }; /** - * Checks if affected range is entirely within an allowed permission range. + * Checks if affected range is entirely within a permission range the user can edit. * @param {{ from: number, to: number }} range - * @param {Array<{ from: number, to: number }>} allowedRanges + * @param {Array<{ from: number, to: number, attrs?: any }>} permissionRanges + * @param {Set} allowedIdentifiers */ -const isRangeAllowed = (range, allowedRanges) => { - if (!allowedRanges?.length) return false; - return allowedRanges.some((allowed) => range.from >= allowed.from && range.to <= allowed.to); +const isRangeAllowed = (range, permissionRanges, allowedIdentifiers) => { + if (!permissionRanges?.length) return false; + return permissionRanges.some((allowed) => { + if (range.from < allowed.from || range.to > allowed.to) { + return false; + } + return isRangeAllowedForUser(allowed.attrs, allowedIdentifiers); + }); }; /** @@ -203,6 +216,55 @@ export const PermissionRanges = Extension.create({ }; }, + addCommands() { + return { + /** + * Wrap the current selection with w:permStart/w:permEnd tags. + * Defaults new ranges to edGrp="everyone" so they're editable in viewing mode when no ed/edGrp provided. + * @param {Object} [options] + * @param {string} [options.id] Optional identifier for the permission range + * @param {string} [options.ed] Optional w:ed attribute applied to permStart + * @param {string} [options.edGrp] Optional w:edGrp attribute applied to both nodes + */ + wrapBetweenPermission: + (options = {}) => + ({ state, dispatch, tr }) => { + const permStartType = state.schema.nodes['permStart']; + const permEndType = state.schema.nodes['permEnd']; + if (!permStartType || !permEndType) { + return false; + } + + if (this.editor?.options?.documentMode === 'viewing') { + return false; + } + + const { selection } = state; + if (!selection || selection.empty) { + return false; + } + + const resolvedId = options.id ?? generateRandomSigned32BitIntStrId(); + const providedEd = options.ed ?? null; + const providedEdGrp = options.edGrp ?? null; + const sharedGroup = providedEdGrp ?? (providedEd ? null : EVERYONE_GROUP); + + if (dispatch) { + const startAttrs = { id: resolvedId }; + if (sharedGroup) startAttrs.edGrp = sharedGroup; + if (providedEd) startAttrs.ed = providedEd; + + const startNode = permStartType.create(startAttrs); + const endNode = permEndType.create({ id: resolvedId }); + tr.insert(selection.to, endNode); + tr.insert(selection.from, startNode); + } + + return true; + }, + }; + }, + addPmPlugins() { const editor = this.editor; const storage = this.storage; @@ -353,9 +415,11 @@ export const PermissionRanges = Extension.create({ const permEndType = state.schema.nodes['permEnd']; if (!permStartType || !permEndType) return true; + const allowedIdentifiers = getAllowedIdentifiers(); + const permissionRanges = pluginState.ranges || []; const allRangesAllowed = changedRanges.every((range) => { const trimmed = trimPermissionTagsFromRange(state.doc, range, permStartType, permEndType); - return isRangeAllowed(trimmed, pluginState.ranges); + return isRangeAllowed(trimmed, permissionRanges, allowedIdentifiers); }); return allRangesAllowed; 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 8c5e47229..8ee03563a 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 @@ -288,6 +288,97 @@ describe('PermissionRanges extension', () => { user: { name: 'Viewer', email: 'viewer@example.com' }, }); expect(instance.isEditable).toBe(false); - expect(instance.storage.permissionRanges?.ranges?.length ?? 0).toBe(0); + expect(instance.storage.permissionRanges?.ranges?.length ?? 0).toBeGreaterThan(0); + }); + + it('wrapBetweenPermission inserts permission tags around the current selection', () => { + const instance = createEditor(docWithoutPermissionRange, { documentMode: 'editing' }); + const startPos = findTextPos(instance.state.doc, 'editable'); + expect(startPos).toBeGreaterThan(0); + const endPos = startPos + 'editable'.length; + const selection = TextSelection.create(instance.state.doc, startPos, endPos); + instance.view.dispatch(instance.state.tr.setSelection(selection)); + + const result = instance.commands.wrapBetweenPermission(); + expect(result).toBe(true); + + const tags = []; + instance.state.doc.descendants((node) => { + if (node.type?.name === 'permStart' || node.type?.name === 'permEnd') { + tags.push(node); + } + return; + }); + + expect(tags).toHaveLength(2); + const [startNode, endNode] = tags; + expect(startNode.type.name).toBe('permStart'); + expect(endNode.type.name).toBe('permEnd'); + expect(startNode.attrs.id).toBeTruthy(); + expect(startNode.attrs.id).toBe(endNode.attrs.id); + expect(startNode.attrs.edGrp).toBe('everyone'); + expect(endNode.attrs.edGrp).toBeNull(); + }); + + it('wrapBetweenPermission allows overriding id, edGrp, and ed', () => { + const instance = createEditor(docWithoutPermissionRange, { documentMode: 'editing' }); + const startPos = findTextPos(instance.state.doc, 'No'); + expect(startPos).toBeGreaterThanOrEqual(0); + const endPos = startPos + 'No editable ranges.'.length; + const selection = TextSelection.create(instance.state.doc, startPos, endPos); + instance.view.dispatch(instance.state.tr.setSelection(selection)); + + const result = instance.commands.wrapBetweenPermission({ + id: 'permission-900', + edGrp: 'contributors', + ed: 'superdoc.dev\\author', + }); + expect(result).toBe(true); + + const tags = []; + instance.state.doc.descendants((node) => { + if (node.type?.name === 'permStart' || node.type?.name === 'permEnd') { + tags.push(node); + } + return; + }); + + expect(tags).toHaveLength(2); + const [startNode, endNode] = tags; + expect(startNode.attrs.id).toBe('permission-900'); + expect(endNode.attrs.id).toBe('permission-900'); + expect(startNode.attrs.edGrp).toBe('contributors'); + expect(endNode.attrs.edGrp).toBeNull(); + expect(startNode.attrs.ed).toBe('superdoc.dev\\author'); + }); + + it('wrapBetweenPermission accepts only ed and skips edGrp when provided', () => { + const instance = createEditor(docWithoutPermissionRange, { documentMode: 'editing' }); + const startPos = findTextPos(instance.state.doc, 'No editable ranges.'); + expect(startPos).toBeGreaterThanOrEqual(0); + const endPos = startPos + 'No editable ranges.'.length; + const selection = TextSelection.create(instance.state.doc, startPos, endPos); + instance.view.dispatch(instance.state.tr.setSelection(selection)); + + const result = instance.commands.wrapBetweenPermission({ + id: 'permission-800', + ed: 'superdoc.dev\\author', + }); + expect(result).toBe(true); + + const tags = []; + instance.state.doc.descendants((node) => { + if (node.type?.name === 'permStart' || node.type?.name === 'permEnd') { + tags.push(node); + } + return; + }); + expect(tags).toHaveLength(2); + const [startNode, endNode] = tags; + expect(startNode.attrs.id).toBe('permission-800'); + expect(endNode.attrs.id).toBe('permission-800'); + expect(startNode.attrs.ed).toBe('superdoc.dev\\author'); + expect(startNode.attrs.edGrp).toBeNull(); + expect(endNode.attrs.edGrp).toBeNull(); }); }); diff --git a/packages/super-editor/src/extensions/types/index.ts b/packages/super-editor/src/extensions/types/index.ts index 8b6d5728d..6265759f1 100644 --- a/packages/super-editor/src/extensions/types/index.ts +++ b/packages/super-editor/src/extensions/types/index.ts @@ -17,6 +17,7 @@ import './image-commands.js'; import './comment-commands.js'; import './track-changes-commands.js'; import './miscellaneous-commands.js'; +import './permission-commands.js'; // Attribute augmentations import './node-attributes.js'; diff --git a/packages/super-editor/src/extensions/types/permission-commands.ts b/packages/super-editor/src/extensions/types/permission-commands.ts new file mode 100644 index 000000000..966a06039 --- /dev/null +++ b/packages/super-editor/src/extensions/types/permission-commands.ts @@ -0,0 +1,27 @@ +/** + * Command type augmentations for permission range helpers. + * + * @module PermissionCommands + */ + +/** Options for wrapBetweenPermission command */ +export type WrapBetweenPermissionOptions = { + /** Optional identifier shared between permStart/permEnd nodes */ + id?: string; + /** Optional w:ed attribute for the permStart node */ + ed?: string; + /** Optional w:edGrp attribute for both permStart/permEnd nodes */ + edGrp?: string; +}; + +export interface PermissionCommands { + /** + * Wrap the current selection with w:permStart/w:permEnd tags. + * @param options - Optional attributes applied to the generated nodes + */ + wrapBetweenPermission: (options?: WrapBetweenPermissionOptions) => boolean; +} + +declare module '../../core/types/ChainedCommands.js' { + interface ExtensionCommandMap extends PermissionCommands {} +}