Migrate extension framework to Editor 2.0 (New Editor)
This guide helps extension authors understand what’s involved in moving their customizations from the Old Editor to the New Editor in AEM Guides, so that they can plan their transition smoothly and with minimal disruption.
Overview
- Your registration does not change: Keep using
window.extension/tcx.extension.register. - The Editor canvas is a new surface. Context-menu items must declare the new widget id
markup_editor_menu; in-editor behavior must stop touching the DOM. - Stop reading/writing the DOM: Replace
tcx.curEditor.*DOM access with theguides.editorAPI: read withrunUtil(...), write withrunCommand(...), style with decorations, and run global actions (save) through app events . - App-shell menus (repository, map viewer, file/folder) are unchanged: They still run on
the legacy framework. - Both editors coexist: Target both with arrays. When loading Register plugins unconditionally; gate only runtime actions by
guides.editor.version(which stays1.0.0until a file is open, view Detect the editor and bootstrap safely).
Why the change?
getSelection() on a root documentAny extension that mutates the DOM or any DOM changes are not retained, they get wiped out on the next rerender. The migration is fundamentally move from DOM-first to model-first.
Detect the Editor and bootstrap safely
The global guides object is the entry point for all new integrations:
guides.editor // editor interaction APIs
guides.util // bundled utility libs (lodash, async)
guides.ready(cb) // fires once at app load (view system ready) — before any file is open
guides.editor.version reports the currently open editor, so it is only meaningful once a
file is actually open:
guides.editor.version2.0.01.0.0guides.ready event occurs, no file has yet opened, so version will report as 1.0.0 regardless of whether MarkupEditor is enabled. Do not use version to determine whether plugins get registered (view Plugin Registration and Runtime Gating). Use it only to branch runtime behavior, and evaluate it at the point of execution (e.g., within a menu handler), where a file is guaranteed to be open.Plugin registration and runtime gating
-
Registration (
registerPlugin, one-time setup): Run it unconditionally inguides.ready. It is a harmless no-op on the legacy editor: the legacy editor never reads the plugin registry, and your factory runs only when a MarkupEditor is actually constructed. It does not throw. -
Runtime calls (
runCommand,runUtil,addDecoration, …): Gate by version exists and not equal to “1.0.0” at call time. They don’t throw on the legacy editor (they safely returnfalse/undefined), but gating avoids no-op warnings and lets you keep a legacy fallback.
guides.ready(() => {
// Always register — inert on legacy, applied only when a MarkupEditor opens.
guides.editor.registerPlugin(createMyPlugin);
});
function onMenuClick() {
if (guides.editor.version && guides.editor.version !== "1.0.0") {
guides.editor.runCommand('surroundWithElement', 'sup'); // MarkupEditor path
} else {
// legacy path (or no-op)
}
}
Pass a factory () => ({ plugin, css }) — to registerPlugin, never a constructed plugin instance. A non-function is the only input it rejects (throws on both editors). Do not cache the editor instance; call guides.editor.* fresh each time.
Hello world: a CSS-only highlight plugin
The smallest useful extension ships only CSS a no-op ProseMirror plugin plus styles. This
highlights every <note> element with a yellow background inside the editor:
guides.ready(() => {
guides.editor.registerPlugin(() => ({
plugin: new guides.editor.prosemirror.state.Plugin({}), // no behavior — CSS only
css: `[data-xml-element="note"] { background: #fff3cd; outline: 1px solid #ffe08a; }`
}));
});
- Every element renders as
data-xml-element="<tag>", so you can target any DITA element that way
(note,codeblock,section,table, …). - CSS must ship via the registerPlugin: the editor lives in a shadow DOM, so page/clientlib CSS can’t
reach it. - Open a DITA topic containing a
<note>to see it applied. Registration is unconditional (§2.1),
so this is safe even thoughversionis still1.0.0atguides.readytime.
Inventory your extension (grep checklist)
# DOM-first reads that will break
grep -rnE "rootDocument|rootElement|getSelection\(|selectedHtml|selectedText|\.xmlDoc|\.ancestors\b" src
# DOM/legacy writes that will break
grep -rnE "updateAttributes\(|setAttribute\(|classList\.|\.saveFile\(|resetDirty\(|validateRangeForInsertion\(" src
# The editor handle itself
grep -rn "tcx.curEditor" src
# Context-menu targeting + page CSS
grep -rnE "contextMenuWidget|dita_editor_menu|author_outline_element" src
grep -rn "dita_content_overrides" .
Every hit is a migration item. Classify each as: context-menu surface, state read, content
write, global action, rendering-only, or CSS.
Common for both the Editors
The following behaviors and structures apply identically to both the Editors:
-
Registration:
window.extension[id] = configand/ortcx.extension.register(id, config)on
thetcx-loadedevent. -
Config object shape:
{ id, contextMenuWidget, view: { items }, controller }. -
App-shell context menus keep their existing widget ids and the legacy behavior:
table 0-row-2 1-row-2 2-row-2 3-row-2 Surface Widget id (unchanged) Repository panel (file/folder) repository_panel/file_options/folder_optionsMap viewer ditamap_viewer/map_view_optionsBaseline / preset panels baseline_panel_menu/preset_item_menuItems targeting these surfaces need no change for the New Editor, do not move them to
markup_editor_menu.
API replacement reference
tcx.curEditor… / DOM)tcx.curEditor.filePathguides.editor.filePathgetSelection() / selectedHtml / selectedTextrunUtil('getSelectedXml' / 'getSelectedPlainText' / 'hasSelection')rootDocument.querySelector(tag)runUtil('findPositionRange' / 'findPositionRanges', tag).getAttribute / xmlDoc.attributesrunUtil('getAttributeAtPosition', pos, name) / getSerializableAttributes(xpath)querySelector('[concept]').id)runUtil('getAttributeAtPosition', 0, 'id')editor.ancestorsrunUtil('getAncestorsDetails' / 'getAncestorXpaths')editor.updateAttributes(attrs, root)runCommand('setNodeXmlAttributes', 0, attrs)runCommand('setNodeXmlAttribute', pos, name, value)runCommand('surroundWithElement' / 'insertXml' / 'unwrapNode', …)canInsertXmlElement / validateRangeForInsertioncanRunCommand(name, …) / canInsertXmlElement(tag)editor.focus()guides.editor.focus()tcx.curEditor.saveFile()tcx.eventHandler.next(KEYS.AUTHOR_SAVE_KEY)setAttribute / classList for stylingaddDecoration / batchDecorations / registerPluginregisterPlugin({ css }) (shadow DOM)contextMenuWidget: 'dita_editor_menu'['dita_editor_menu', 'markup_editor_menu']Migrate context-menu items (Editor canvas)
This applies only to menus that targeted the editor (dita_editor_menu,author_outline_element), i.e. the right-click / breadcrumb menu inside the editing surface.
How it routes on the New Editor
window.extension[id] ─► filtered by contextMenuWidget == 'markup_editor_menu'
─► view.items rendered in the canvas menu
(click) ───────────► fires an extension event:
• eventid is a known global key → run as a built-in editor command
• otherwise → your controller[eventid]() runs
Add the new widget id (array keeps legacy working)
// BEFORE
contextMenuWidget: 'dita_editor_menu',
// AFTER
contextMenuWidget: ['dita_editor_menu', 'markup_editor_menu'],
Keep the expected shape
- Actionable items live under
view.itemswith adata.eventid. - Each
controllermethod name matches itseventidexactly.
view: {
items: [{
displayName: 'Edit Cross Reference',
icon: 'link',
data: { eventid: 'editCrossReference' },
target: { key: 'displayName', value: 'Cut', viewState: 'prepend' }
}]
},
controller: {
editCrossReference() { /* runs on click */ }
}
Re-anchor target
The new menu resolves target against the MarkupEditor’s own menu items.
target.key:displayName | id | icon | eventidtarget.viewState:append | prepend | replace- Anchor to a stable native item such as
Cut. - If the anchor does not resolve, the item still appears but lands at the default position
(not an error, fix the anchor).
Choose the routing per item
data: { eventid: 'AUTHOR_CUT' } // built-in command → routed natively, no controller needed
data: { eventid: 'editCrossReference' } // custom → runs controller.editCrossReference()
Add readOnly: true on an item that must stay enabled in read-only content.
Rewrite the handler body
Handlers usually read the selection and mutate a node, migrate those off the DOM.
Migrate reads (DOM: runUtil)
// BEFORE — DOM selection / queries
const { editor } = tcx.curEditor;
const html = editor.selectedHtml;
const topicId = editor.rootDocument.querySelector('[data-tcx-tag="concept"]').id;
// AFTER — read from the document model
const selectedXml = guides.editor.runUtil('getSelectedXml');
const hasSel = !!guides.editor.runUtil('hasSelection'); // check if selection is empty
const topicId = guides.editor.runUtil('getAttributeAtPosition', 0, 'id'); // root = position 0
Find a node by tag, match by id, read an XML attribute:
let value = '';
for (const range of (guides.editor.runUtil('findPositionRanges', 'xref') || [])) {
const id = guides.editor.runUtil('getAttributeAtPosition', range.from, 'id');
if (String(id) !== String(targetId)) continue;
value = guides.editor.runUtil('getAttributeAtPosition', range.from, 'placeholdertext') || '';
break;
}
Read utilities: getTextPos, getNodePosition, getSelectedXml, getSelectedPlainText,hasSelection, getAncestorsNames, getAncestorsDetails, getAncestorXpaths,findPositionRange, findPositionRanges, getAttributeAtPosition, getSerializableAttributes. Refer Appendix.
Migrate writes (DOM mutation: runCommand)
// BEFORE
const root = editor.rootElement.findOne('[data-tcx-tag="concept"]');
editor.updateAttributes({ docOwner: 'Jane' }, root);
// AFTER — update the model; persists across rerenders
guides.editor.runCommand('setNodeXmlAttributes', 0, { docOwner: 'Jane' });
// Set one attribute at a found position
guides.editor.runCommand('setNodeXmlAttribute', pos, 'placeholdertext', text);
// Wrap / insert / unwrap
guides.editor.runCommand('surroundWithElement', 'sup');
guides.editor.runCommand('insertXml', '<sup></sup>', undefined, { setCursorInContent: true });
guides.editor.runCommand('unwrapNode');
Prerequisite
guides.editor.focus();
if (!guides.editor.canInsertXmlElement('xref')) {
return tcx.util.showAlert('warning', 'xref is not allowed here');
}
if (guides.editor.canRunCommand('surroundWithElement', 'sup')) {
guides.editor.runCommand('surroundWithElement', 'sup');
}
Commands: setNodeXmlAttributes, setNodeXmlAttribute, surroundWithElement, insertXml,unwrapNode. Refer Appendix.
Migrate global actions (save/focus: app events)
// BEFORE
tcx.curEditor?.saveFile?.();
// AFTER
tcx.eventHandler.next(tcx.eventHandler.KEYS.AUTHOR_SAVE_KEY);
resetDirty(...) and tcx.curEditor.html have no MarkupEditor equivalent so drop them; saving
through the event handles dirty state centrally. Use guides.editor.focus() for focus.
Migrate rendering-only logic (DOM paint: decorations)
Anything that added CSS classes, data-* attributes, or “display text” by mutating the DOM must
become a decoration, or it vanishes on rerender. Below are simple declarative cases:
guides.editor.addDecoration('important-sections', 'section', {
class: 'section-important',
computeAttributes: (node, ctx) => ({ 'data-number-label': String(ctx.index + 1) }),
filter: (node) => node.attrs?.xmlAttrs?.importance === 'high'
});
guides.editor.batchDecorations([
{ action: 'remove', id: 'legacy-numbering' },
{ action: 'add', id: 'division-numbering', selector: 'conbody', options: { class: 'division-numbering' } }
]);
guides.editor.removeDecoration('important-sections');
guides.editor.clearDecorations();
guides.editor.getDecorations();
Complex cases (custom state, broken-state via transaction meta, widget text): Register a
ProseMirror plugin once, using the exposed libraries:
const createXrefPlugin = () => {
const { Plugin, PluginKey } = guides.editor.prosemirror.state;
const { Decoration, DecorationSet } = guides.editor.prosemirror.view;
return {
plugin: new Plugin({ key: new PluginKey('xrefDisplay'), props: { decorations(state) { /* … */ } } }),
css: `.xref-broken { text-decoration: underline wavy red; }`
};
};
guides.ready(() => guides.editor.registerPlugin(createXrefPlugin));
Register plugins at app load (once), not inside dialogs or repeatedly, the registry does not dedupe. registerPlugin accepts a factory function only, not a plugin instance.guides.editor.prosemirror exposes: state, model, view, transform, commands, keymap,history, tables, dropcursor, collab, markdown.
Migrate CSS (page clientlib → shadow DOM)
The MarkupEditor renders inside a shadow DOM; page-level and AEM clientlib CSS do not reach it.
guides.editor.registerPlugin(() => ({
plugin: new guides.editor.prosemirror.state.Plugin({}), // no-op, CSS only
css: `[data-xml-element="codeblock"] { font-family: monospace; background: #f5f5f5; }`
}));
The legacy content clientlib category (apps.guides.xml_editor.dita_content_overrides) still
styles the legacy editor only, keep it if you support both, but know it is inert on MarkupEditor.
Accessing the live EditorView (plugin view prop): DOM escape hatch
Decorations and commands are the preferred approach. However, some effects can’t be implemented as decorations. In those cases, use the plugin view property to access the live EditorView and operate on editorView.dom. This is the only supported way to interact directly with the rendered editor DOM.
const createMyPlugin = () => {
const { Plugin } = guides.editor.prosemirror.state;
return {
plugin: new Plugin({
view(editorView) {
const root = editorView.dom; // the shadow-DOM editor node
const apply = () => { /* re-color / rewrite target nodes in `root` */ };
apply();
return {
update(view, prevState): apply, // re-apply after every rerender
destroy() { /* remove any listeners/observers */ },
};
},
}),
css: `/* ... */`,
};
};
guides.ready(() => guides.editor.registerPlugin(createMyPlugin));
Guardrails:
- Escape hatch only, use decorations for classes, labels, and styling.
editorView.domis the only supported handle;- Re-apply from
update()so the change survives rerenders; clean up indestroy().
Plugin registration lifecycle
registerPlugin in guides.ready only registers the factory once. The factory itself runs again
each time a file is opened — every MarkupEditor file open invokes it fresh to build that file’s
plugin instance.
Common issues
- Where DOM code addresses nodes and
Ranges, MarkupEditor addresses positions, plain integers indexing into the document (0= document start, i.e. the root). Arangeis{ from, to }, two positions bounding a span — not a DOMRange. Positions shift as the document changes, so don’t cache one across an edit. - Item doesn’t appear in the New Editor menu:
contextMenuWidgetis missingmarkup_editor_menu, or the config was registered after the editor opened (config is read
once at editor construction register at app load). - Item appears in the wrong place:
targetanchor doesn’t resolve; anchor to an item that
exists in the new menu (e.g.Cut). - Change “works” then disappears: You mutated the DOM. Use a command (write) or a decoration
(style) instead. - CSS has no effect: It’s page-level; the editor is in a shadow DOM. Use
registerPlugin({ css }). - Unsafe guards throw: Patterns like
if (!tcx.curEditor && !tcx.curEditor.editor)evaluate.editoron a falsy object. Guard onguides.editorcapabilities instead:if (!guides?.editor) return;. - Trying to migrate app-shell menus: Repository/map/file menus are not the editor canvas;
leave them on their legacy widget ids.
Verification checklist
- Context-menu items appear in both the legacy and MarkupEditor menus.
- Items land in expected position.
- Custom
eventidrunscontroller[eventid]; global keys fire the built-in command. - State reads return correct values after typing/rerender (model, not stale DOM).
- Content writes persist after save and reopen.
- Decorations survive a rerender.
- Shadow-DOM CSS visibly applies inside the editor.
- Save fires via
AUTHOR_SAVE_KEYand clears dirty state. readOnlyitems behave correctly in locked content.- Preview or side-by-side; intentional read-only DOM work is left as-is.
grep -rn "tcx.curEditor" srcis clean (or only the documented, intentional remainder).- Plugins registered exactly once, inside
guides.ready.
Suggested rollout sequence
- Bootstrap: Wrap setup in
guides.ready; register plugins unconditionally and addversiongating around runtime actions only (For details, view Plugin Registration and Runtime Gating). - Context-menu surface: Add
markup_editor_menu, fixtargetanchors. Items now appear. - Reads: Migrate selection/attribute reads to
runUtil. - Writes: Migrate mutations to
runCommand; saves to app events. - Rendering: Move DOM styling to decorations /
registerPlugin; move CSS to shadow DOM. - Harden: Fix unsafe guards, remove the editor handle, verify on both editors.
Migrate one surface at a time and keep the legacy paths working (arrays + version gating) so a
single extension build runs on both editors throughout the transition.
Appendix A: More exposed utils (examples)
Find the below utils to use through runUtil.
getTextPos(): { start, end }getValidElementNames(ancestorLevel?): ElementName[]getValidElementNamesBefore(): ElementName[]getSelectedText(): stringgetSerializableAttributes(): { [key]: string }getTagName(): string | nullhasSelection(): booleanisSelectionEditable(): booleangetAncestorPos(name): number | undefinedgetValidWrapNodeElementNames(): ElementName[]wrapNode at the current selection.getValidRenameNodeElementNames(): ElementName[]getValidSurroundElementNames(): ElementName[]surroundWithElement at the current selection.serialize(doc?): stringgetSelectedXml(range?): string{ from, to } range.getRangeXml(xpaths): stringmapToXpath(position, doc?): XPathPositioninverseMap(xpath | position, doc?): numbergetAncestorsDetails(): { ancestors, previousSibling, nextSibling, currNode } | undefinedgetAncestorsNames(): ElementName[]getPreviousSibling(): ElementName | undefinedgetNextSibling(): ElementName | undefinedgetAncestorXpaths(includeNodeAtPosition?): { tag, xpath }[]{tag, xpath} pairs — object-form xpath, not the updateAttributeByXpath string form (§8).getSelectedPlainText(range?): stringgetDecorations(): string[]getResolvedDitaDocumentTitle(props?): stringprops: doc to target a specific document, allowedPrefixElements to allow title-prefix elements.Appendix B: More exposed commands (examples)
The commands below are additional examples of what’s exposed via guides.editor.runCommand(name, ...args).
Guard any command with guides.editor.canRunCommand(name, ...args) first if it might not apply in the current context.
focusEditor()unwrapNode()surroundWithElement(elementName, attrs?, groupInline?)attrs: XML attribute map to set on the new wrapping element.insertXml(xml)replaceSelectionWithXml(xml)insertText(text)selectNodesFromXpaths(xpaths)delete()undo / redo()removeDecoration(id)clearDecorations()setFileReadOnly(readOnly: boolean)generateUniqueId()