diff --git a/packages/super-editor/src/core/commands/core-command-map.d.ts b/packages/super-editor/src/core/commands/core-command-map.d.ts index 6c7618dec..13e14015c 100644 --- a/packages/super-editor/src/core/commands/core-command-map.d.ts +++ b/packages/super-editor/src/core/commands/core-command-map.d.ts @@ -40,6 +40,7 @@ type CoreCommandNames = | 'insertContentAt' | 'undoInputRule' | 'setSectionPageMarginsAtSelection' + | 'setProtectionMode' | 'toggleList' | 'increaseListIndent' | 'decreaseListIndent' diff --git a/packages/super-editor/src/core/commands/index.js b/packages/super-editor/src/core/commands/index.js index a3b3363fa..253d542bb 100644 --- a/packages/super-editor/src/core/commands/index.js +++ b/packages/super-editor/src/core/commands/index.js @@ -38,6 +38,7 @@ export * from './setBodyHeaderFooter.js'; export * from './setSectionHeaderFooterAtSelection.js'; export * from './setSectionPageMarginsAtSelection.js'; export * from './insertSectionBreakAtSelection.js'; +export * from './setProtectionMode.js'; // Paragraph export * from './textIndent.js'; diff --git a/packages/super-editor/src/core/commands/setProtectionMode.js b/packages/super-editor/src/core/commands/setProtectionMode.js new file mode 100644 index 000000000..4b63e634d --- /dev/null +++ b/packages/super-editor/src/core/commands/setProtectionMode.js @@ -0,0 +1,87 @@ +// @ts-check +import { carbonCopy } from '@core/utilities/carbonCopy.js'; + +const SETTINGS_PATH = 'word/settings.xml'; +const DOC_PROTECTION_NODE = 'w:documentProtection'; + +const PROTECTION_VALUE_MAP = Object.freeze({ + noProtection: null, + allowOnlyRevisions: 'trackedChanges', + allowOnlyComments: 'comments', + allowOnlyFormFields: 'forms', + allowOnlyReading: 'readOnly', +}); + +const DEFAULT_MODE = 'noProtection'; + +/** + * Normalize the caller provided document protection mode. + * @param {string} mode + * @returns {keyof typeof PROTECTION_VALUE_MAP} + */ +function normalizeMode(mode = '') { + const normalized = typeof mode === 'string' ? mode.trim() : ''; + console.log('normalized', normalized); + return /** @type {keyof typeof PROTECTION_VALUE_MAP} */ (normalized || DEFAULT_MODE); +} + +/** + * Build the document protection XML node. + * @param {string} editValue + * @returns {Object} + */ +function createDocProtectionNode(editValue) { + return { + type: 'element', + name: DOC_PROTECTION_NODE, + attributes: { + 'w:edit': editValue, + 'w:enforcement': '1', + }, + }; +} + +/** + * Update the DOCX settings to enforce or remove document protection. + * @param {'noProtection' | 'allowOnlyRevisions' | 'allowOnlyComments' | 'allowOnlyFormFields' | 'allowOnlyReading'} mode + * @returns {import('./types').Command} + */ +export const setProtectionMode = (mode = 'noProtection') => { + return ({ editor }) => { + const convertedXml = editor?.converter?.convertedXml; + if (!convertedXml) return false; + + const normalizedMode = normalizeMode(mode); + console.log('normalizedMode', normalizedMode); + console.log('PROTECTION_VALUE_MAP', PROTECTION_VALUE_MAP, normalizedMode in PROTECTION_VALUE_MAP); + if (!(normalizedMode in PROTECTION_VALUE_MAP)) return false; + const settingsXml = convertedXml[SETTINGS_PATH]; + const settingsRoot = settingsXml?.elements?.[0]; + if (!settingsRoot) return false; + + const updatedSettings = carbonCopy(settingsXml); + const updatedRoot = updatedSettings.elements?.[0]; + if (!updatedRoot) return false; + + if (!Array.isArray(updatedRoot.elements)) { + updatedRoot.elements = []; + } + + const elements = updatedRoot.elements; + const existingIndex = elements.findIndex((node) => node?.name === DOC_PROTECTION_NODE); + if (existingIndex !== -1) { + elements.splice(existingIndex, 1); + } + + const mappedValue = PROTECTION_VALUE_MAP[normalizedMode]; + if (mappedValue) { + const protectionNode = createDocProtectionNode(mappedValue); + const insertIndex = existingIndex >= 0 ? existingIndex : 0; + elements.splice(insertIndex, 0, protectionNode); + } + + convertedXml[SETTINGS_PATH] = updatedSettings; + editor.updateInternalXmlFile(SETTINGS_PATH, updatedSettings); + return true; + }; +}; diff --git a/packages/super-editor/src/core/commands/setProtectionMode.test.js b/packages/super-editor/src/core/commands/setProtectionMode.test.js new file mode 100644 index 000000000..7364cd41d --- /dev/null +++ b/packages/super-editor/src/core/commands/setProtectionMode.test.js @@ -0,0 +1,109 @@ +import { describe, it, expect, vi, beforeEach } from 'vitest'; +import { setProtectionMode } from './setProtectionMode.js'; + +const SETTINGS_PATH = 'word/settings.xml'; + +const createBaseSettings = () => ({ + elements: [ + { + type: 'element', + name: 'w:settings', + attributes: { + 'xmlns:w': 'http://schemas.openxmlformats.org/wordprocessingml/2006/main', + }, + elements: [ + { + type: 'element', + name: 'w:zoom', + attributes: { 'w:percent': '120' }, + }, + ], + }, + ], +}); + +const clone = (obj) => JSON.parse(JSON.stringify(obj)); + +const buildEditor = (settings = createBaseSettings()) => { + const editor = { + converter: { + convertedXml: { + [SETTINGS_PATH]: clone(settings), + }, + }, + updateInternalXmlFile: vi.fn(), + }; + + return editor; +}; + +describe('setProtectionMode command', () => { + beforeEach(() => { + vi.clearAllMocks(); + }); + + it('inserts docProtection node for the requested mode', () => { + const editor = buildEditor(); + const command = setProtectionMode('allowOnlyReading'); + const result = command({ editor }); + + expect(result).toBe(true); + const updatedSettings = editor.converter.convertedXml[SETTINGS_PATH]; + const rootElements = updatedSettings.elements[0].elements; + expect(rootElements[0].name).toBe('w:documentProtection'); + expect(rootElements[0].attributes).toMatchObject({ 'w:edit': 'readOnly', 'w:enforcement': '1' }); + expect(editor.updateInternalXmlFile).toHaveBeenCalledWith(SETTINGS_PATH, updatedSettings); + }); + + it('supports AllowOnlyFormFields casing and replaces existing node', () => { + const base = createBaseSettings(); + base.elements[0].elements.unshift({ + type: 'element', + name: 'w:documentProtection', + attributes: { 'w:edit': 'comments', 'w:enforcement': '1' }, + }); + + const editor = buildEditor(base); + const command = setProtectionMode('allowOnlyFormFields'); + const success = command({ editor }); + + expect(success).toBe(true); + const updated = editor.converter.convertedXml[SETTINGS_PATH].elements[0].elements; + expect(updated[0].attributes['w:edit']).toBe('forms'); + expect(updated).toHaveLength(base.elements[0].elements.length); + }); + + it('removes docProtection node when switching to noProtection', () => { + const base = createBaseSettings(); + base.elements[0].elements.unshift({ + type: 'element', + name: 'w:documentProtection', + attributes: { 'w:edit': 'trackedChanges', 'w:enforcement': '1' }, + }); + + const editor = buildEditor(base); + const removed = setProtectionMode('noProtection')({ editor }); + + expect(removed).toBe(true); + const nodes = editor.converter.convertedXml[SETTINGS_PATH].elements[0].elements; + expect(nodes.find((node) => node.name === 'w:documentProtection')).toBeUndefined(); + }); + + it('returns false when settings.xml is missing', () => { + const editor = buildEditor(); + delete editor.converter.convertedXml[SETTINGS_PATH]; + const result = setProtectionMode('allowOnlyComments')({ editor }); + expect(result).toBe(false); + expect(editor.updateInternalXmlFile).not.toHaveBeenCalled(); + }); + + it('rejects invalid modes without mutating the XML', () => { + const editor = buildEditor(); + const before = clone(editor.converter.convertedXml[SETTINGS_PATH]); + const result = setProtectionMode('unsupported-mode')({ editor }); + + expect(result).toBe(false); + expect(editor.converter.convertedXml[SETTINGS_PATH]).toEqual(before); + expect(editor.updateInternalXmlFile).not.toHaveBeenCalled(); + }); +}); diff --git a/packages/super-editor/src/extensions/document/document.js b/packages/super-editor/src/extensions/document/document.js index fdd732611..044d66756 100644 --- a/packages/super-editor/src/extensions/document/document.js +++ b/packages/super-editor/src/extensions/document/document.js @@ -2,6 +2,7 @@ import { Node } from '@core/index.js'; import { setSectionPageMarginsAtSelection } from '@core/commands/setSectionPageMarginsAtSelection.js'; +import { setProtectionMode } from '@core/commands/setProtectionMode.js'; /** * Configuration options for Document @@ -105,6 +106,11 @@ export const Document = Node.create({ * Set section page margins (top/right/bottom/left) for the section at the current selection. */ setSectionPageMarginsAtSelection, + + /** + * Set document protection mode (updates word/settings.xml docProtection element). + */ + setProtectionMode, }; }, }); diff --git a/packages/super-editor/src/extensions/types/miscellaneous-commands.ts b/packages/super-editor/src/extensions/types/miscellaneous-commands.ts index ad9270546..ce5f20284 100644 --- a/packages/super-editor/src/extensions/types/miscellaneous-commands.ts +++ b/packages/super-editor/src/extensions/types/miscellaneous-commands.ts @@ -93,6 +93,17 @@ export type DocumentStats = { paragraphs: number; }; +/** Supported document protection modes for document settings + * + * Reference : https://learn.microsoft.com/pt-br/javascript/api/word/word.document?view=word-js-preview#word-word-document-protect-member(1) + */ +export type DocumentProtectionMode = + | 'noProtection' + | 'allowOnlyRevisions' + | 'allowOnlyComments' + | 'allowOnlyFormFields' + | 'allowOnlyReading'; + export interface MiscellaneousCommands { // ============================================ // FIELD ANNOTATION COMMANDS @@ -281,6 +292,11 @@ export interface MiscellaneousCommands { * console.log(`${stats.words} words`) */ getDocumentStats: () => DocumentStats; + + /** + * Set the document protection mode (updates docProtection in settings.xml) + */ + setProtectionMode: (mode?: DocumentProtectionMode) => boolean; } declare module '../../core/types/ChainedCommands.js' {