Mutation hooks

This concept page will cover what mutation hooks are. For the guide on how to work with Mutation Hooks, please refer to the guide on how to create a mutation hook

Mutation hooks are registered to react to a certain event caused by mutations. A separate event is triggered for each affected node. Mutation hook processing continues until no more events are triggered. Be careful to not create infinite loops by having mutation hooks be triggered continuously by their own or each other's mutations. Consider checking whether the intended state has already been reached before making changes to the blueprint. And note to always use the passed blueprint to make changes.

A hook will receive different events:

Event Cause
Add

A new node is matching the selector. Depending on your selector, different operations can trigger this event.

  • For a selector like self::p, the add event will be triggered when a p element is inserted.
  • For a selector like self::p[@class="paragraph"], the add event will be triggered when a 'p' element is inserted with the class attribute set to "paragraph", or when the class attribute of an existing paragraph will be set.

Note that no 'add' event will be fired for these selectors if a node is moved from one parent to another. The valueQuery should be used for that.

Change

Only fired when there is a valueQuery set for the hook. For a node that was already matching the selector before the operation, the value query has changed.

  • For a hook with the value query @class, the 'change' event will be triggered when an element's class attribute is added, removed or changed its value.
  • For a hook with the value query .., the 'change' event will be triggered whenever the parent node of the node changes.
Remove

A node that was matching the selector is no longer matching it.

  • For a selector like self::p, the remove event will be triggered when a 'p' element is removed during an operation.
  • For a selector like self::p[@class="paragraph"], the remove event will be triggered when a 'p' element that initially had the class attribute set to "paragraph" is removed, or when the class attribute of an existing paragraph will be set to a different value, or removed altogether.

Note that no 'remove' event will be fired for these selectors if a node is moved from one parent to another. The valueQuery should be used for that.

Note that for most operations, Fonto will compute whether the operation is allowed in the given context. During this process of getting the state of an operation, hooks will also be evaluated.

Performance

The callbacks of hooks are going to be executed whenever the valueQuery is changed. Both during an actual mutation and during the getState phase of an operation. Take care to write the both the callback and the query correctly. Some things to avoid are:

  • Full document searches: prevent requesting all descendants of a given node, try to avoid constructions like //* to search for all elements in a document. Try to use for example the addAttributeIndex when looking for an element with a given attribute value.
  • The selector passed to addmutationhook @ fontoxml-blueprints/src/addmutationhook.js must consist of either kind tests, name tests or a combination or these. An example of such a selector is: self::para and @class="something". We use a concept called XPath buckets, which optimizes selectors to only be executed on elements with the same node type or node name.
    Selectors with forms like name() = "para" or fonto:dita-class("topic/p") will not be optimized in the same way because our XPath engine can not yet optimize these expressions to the point that we can tell whether a given element will match.

Debugging

Hooks run when either a node matching the selector is being added, or when the valueQuery changes its value. The fn:trace function will help a lot to determine whether a query is actually being evaluated and why.

Track changes

When the fontoxml-track-changes add-on is used in conjunction with hooks that edit text, it depends on the operation that is triggering the hook whether those changes will be tracked. For instance, if a hook that inserts text is triggered by the insert-text operation, the insertion will be tracked. If that same hook is triggered by another operation, it may not be tracked. The fontoxml-document-history does not have this limitation because it does not rely on the Fonto editor to mark changes.

Limitations

  • Mutation hooks are not executed on undo or redo. Undo and redo return the documents to a previous state in which the effects of all mutation hooks have already been applied, so it should not be necessary to run them again.

  • Mutation hooks are not executed on document load and unload. In general, it is often undesirable to have changes be made to a document without user interaction. In cases where existing content needs to be modified without interaction, it's often better to solve this on the CMS side.

  • Due to technical limitations, mutation hooks are currently not executed when elements are removed due to normalization. We hope to remove this limitation in a future release. This includes the following cases:
    • Elements using configureAsInlineFormatting are merged (with elements matching their allowMergingWith property) when they are directly adjacent and the cursor is not placed between the elements.
    • Any element configured with the allowMergingWithAncestor property is removed when inserted inside a matching ancestor.
    • Any element configured with the isAutoRemovableIfEmpty property is removed when its content is removed and the cursor leaves the element.

Examples

Example: set an attribute when an element changes

The docbook schema has an attribute 'revisionflag' that can be used to indicate elements with changes. If one wants to set this attribute to all simpara elements as soon as a textual change is made, the following can be used:

install.js
addMutationHook({
	selector: 'self::simpara',
	valueQuery:
		'import module namespace hooks="http://www.fontoxml/editors/docbook"; ' +
		'trace(hooks:generate-fingerprint(.))',
	expectedResultType: evaluateXPath.STRING_TYPE,
	onEvent: {
		functionLocalName: 'on-any-change',
		namespaceURI: 'http://www.fontoxml/editors/docbook'
	}
});
hooks.xqm
xquery version "3.0";

module namespace hooks="http://www.fontoxml/editors/docbook";
import module namespace fonto-hooks = "http://www.fontoxml.com/functions/hooks";
declare namespace db="http://docbook.org/ns/docbook";

declare %public function hooks:generate-fingerprint ($node as node()) as xs:string {
	if ($node/@revisionflag = "changed") then "changed" else
	(for $node in $node/child::node() return
	typeswitch ($node)
		case element() return fn:name($node)
		case text() return fn:string($node)
		default return ()) => string-join('')
};

declare %public %updating function hooks:on-any-change ($event-type as xs:string, $node as node(), $previous-value as xs:string?, $current-value as xs:string?) {
	if ($node/@revisionflag) then ()
	else insert node attribute revisionflag {"changed"} into $node, name($node)
};

Example: remove references to a footnote when the footnote is removed

install.js
addAttributeIndex(
	'http://www.w3.org/XML/1998/namespace',
	'id',
	'http://www.fontoxml/editors/docbook',
	'id'
);


addMutationHook({
	selector: 'self::footnote',
	valueQuery:
		'import module namespace db="http://www.fontoxml/editors/docbook"; ' +
		'if (@linkend) then exists(db:id(@linkend)) else true()',
	expectedResultType: evaluateXPath.BOOLEAN_TYPE,
	onEvent: {
		functionLocalName: 'on-footnote-removed',
		namespaceURI: 'http://www.fontoxml/editors/docbook'
	}
});


hooks.xqm
xquery version "3.0";

module namespace hooks="http://www.fontoxml/editors/docbook";
import module namespace fonto-hooks = "http://www.fontoxml.com/functions/hooks";
declare namespace db="http://docbook.org/ns/docbook";

declare %public %updating function hooks:on-footnote-removed (
	$event-type as xs:string,
	$node as node(),
	$had-refs as xs:boolean?,
	$has-refs as xs:boolean?
) {
	(: If the event type is 'add', we might have to remove the ref if it not referencing anything. Or disable the operation. This code just chooses to ignore it. :)
	if ($event-type = "change" and not($has-refs)) then
		delete node $node
	else
		()
};

Example: Disallow removing an element when it is still referenced

install.js
addAttributeIndex(
	null,
	'linkend',
	'http://www.fontoxml/editors/docbook',
	'linkend'
);

addMutationHook({
	selector: 'self::footnoteref',
	valueQuery:
		'import module namespace db="http://www.fontoxml/editors/docbook"; ' +
		'if (@xml:id) then exists(db:linkend(@xml:id)) else false()',
	expectedResultType: evaluateXPath.BOOLEAN_TYPE,
	onEvent: {
		functionLocalName: 'on-footnote-removed',
		namespaceURI: 'http://www.fontoxml/editors/docbook'
	}
});


hooks.xqm
xquery version "3.0";

module namespace hooks="http://www.fontoxml/editors/docbook";
import module namespace fonto-hooks = "http://www.fontoxml.com/functions/hooks";
declare namespace db="http://docbook.org/ns/docbook";

declare %public function hooks:on-footnote-removed (
	$event-type as xs:string,
	$node as node(),
	$had-refs as xs:boolean?,
	$has-refs as xs:boolean?
) as map(*) {
	if ($event-type = "remove" and $had-refs) then
		fonto-hooks:not-allowed()
	else
		fonto-hooks:ok()
};

Example: Copy over attributes from a sibling when a new node is created

Example: use the JavaScript API to remove a node when another one is removed

addMutationHook({
	selector: 'self::someElement',
	onEvent: (event, blueprint, format, selection) => {
		if (event.type !== 'remove') {
			return MutationHookResult.ok();
		}
		const parent = blueprint.getParentNode(event.node);
		const childToRemove = evaluateXPathToFirstNode('./child::someOtherElement', parent, blueprint);
		blueprint.removeChild(parent, childToRemove);
		return MutationHookResult.ok();
	}
});
Was this page helpful?