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.
Note that no 'add' event will be fired for these selectors if a node is moved from one parent to another. The |
Change |
Only fired when there is a
|
Remove |
A node that was matching the selector is no longer matching it.
Note that no 'remove' event will be fired for these selectors if a node is moved from one parent to another. The |
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 addAttribute Index when looking for an element with a given attribute value. -
The selector passed to add
Mutation Hook 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 likename() = "para"
orfonto: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 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 configure
As Inline Formatting are merged (with elements matching their allow
property) when they are directly adjacent and the cursor is not placed between the elements.Merging With -
Any element configured with the
allow
property is removed when inserted inside a matching ancestor.Merging With Ancestor -
Any element configured with the
is
property is removed when its content is removed and the cursor leaves the element.Auto Removable If Empty
-
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
JavaScript
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
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
JavaScript
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
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
JavaScript
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
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 Java Script AP I to remove a node when another one is removed
Other
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();
}
});