Fonto Why & How: How do I customize enter behavior?!

Fonto Why & How: How do I customize enter behavior?!

In this weekly series, Martin describes a question that was raised by a Fonto developer, how it was resolved and why Fonto behaved like that in the first place. This week, a partner set up a list, but one where the list items can be one of two elements: steps and intermediate results. However, Fonto kept inserting intermediate results where steps should be!

This list format is not like HTML-style lists at, so let’s start with the XML:

<instructions>
    <step>Step a</step>
    <intermediate>Intermediate result</intermediate>
    <step>Step b</step>
</instructions>

The family configuration currently is like this:

configureAsListElements(sxModule, {
  list: {
    selector: "self::instructions",
    style: ["\u2192"],
  },
  item: {
    nodeName: "intermediate",
  },
})

configureProperties(sxModule, "self::intermediate", {
  defaultTextContainer: "step"
})

The intended behavior is that when an author presses enter in the <step/>, a new <step/> element is created (optionally split). But when the author presses enter at the end of the <intermediate/>, a new <step/> should be created as well. The partner mentioned they configured the default text container for both the <instructions/> element and the <intermediate/> element, but pressing enter kept inserting <intermediate/> elements when they press enter!

The partner already tried to specialize the enter behavior by adding a new operation that has a keyBinding set to enter, but that just removed all normal enter behavior. They were on the right track though. In Fonto operations, there exists alternatives that try to do some ‘forking’ in operations. If an operation contains an alternatives key instead of steps, the alternative branches are evaluated one by one, and the first branch that is actually enabled will be executed. By setting up the operation using alternatives, we can hook into the enter operation and add our own operation as the first alternative.

Because the hard-return operation is actually declared on the platform, we can not easily overwrite it. Luckily, there is a more generic way to add alternatives to all operations that are executed by a key-binding: operationsManager#addAlternativeOperation. By passing the name of the keybinding as the key, it will be added as an alternative operation for that key.

The crucial part of the alternative is making sure the alternative does not kick in if we do not want it. In this case, we went for a transform that will be disabled if it finds anything it does not like. Right now it is pretty strict, user testing and author feedback may fine tune it in the future. The transform we got to works something like this:

addTransform('disable-if-not-at-end-of-intermediate', (stepData) => {
    const selectionPosition = {container: selectionManager.getEndContainer(), offset: selectionManager.getEndOffset()};
    if (domInfo.isTextNode(selectionPosition.container)) {
        // If we are in a textnode, but not at the end, we are disabled!
        if (readOnlyBlueprint.getData(selectionPosition.container).length !== selectionPosition.offset) {
            stepData.operationState = {enabled: false};
            return stepData;

        }
        selectionPosition.offset = blueprintQuery.getParentChildIndex(readOnlyBlueprint, selectionPosition.container);
        selectionPosition.container = readOnlyBlueprint.getParentNode(selectionPosition.container);
    }
    while (!domInfo.isDocument(selectionPosition.container)) {
        if (domInfo.isElement(selectionPosition.container, 'intermediate')) {
            // If we are the `intermediate`, we are done with the transform and we are at the end
            return stepData;
        }
        selectionPosition.offset = blueprintQuery.getParentChildIndex(readOnlyBlueprint, selectionPosition.container);
        selectionPosition.container = readOnlyBlueprint.getParentNode(selectionPosition.container);

        if ((selectionPosition.container as FontoElementNode<'readable'>).childNodes.length !== selectionPosition.offset) {
            stepData.operationState = {enabled: false};
            return stepData;
        }
    }

    // If we got to here, we are all the way to the document, meaning we are not at the end of an 'intermediate'
    stepData.operationState = {enabled: false};
    return stepData;
});

The operation looks like this:

"custom-enter": {
  "steps": [
    {
      "type": "transform/disable-if-not-at-end-of-intermediate"
    },
    {
      "type": "operation/vertical-replace",
      "data": {
        "childNodeStructure": [
          "step",
          [{ "bindTo": "selection", "empty": true }]
        ]
      }
    }
  ]
},

Finally, the operation needs to be set up as an alternative, and it needs to be invalidated whenever the selection changes:

// Make the operation bind to enter, with a high prio
operationsManager.addAlternativeOperation('enter', 'st4-enter', 1000);

selectionManager.selectionChangeNotifier.addCallback(() => {
    operationsManager.invalidateOperationStatesByStepType(
        'transform',
        'disable-if-not-at-end-of-intermediateresult'
    );
});

This works! Pressing enter at the exact end of an intermediate actually inserts a step, if we have a selection to the end of the intermediate, it empties out and inserts a new step, but if we are anywhere else with the selection, it just executes the default enter command!

But Why?

But why did we have to do this in the first time? Why are the defaultTextContainers not working?

The enter command is very complex, but it attempts to do the following:

  1. If we are at the end of a block, insert an empty copy of the current block after it, and place the cursor there
  2. If we are at the end of something with a block layout, but we cannot split that element, see if we can type next to the element. If we can, place the cursor there and allow the author to type there.
  3. If we are half-way a block, split it and place the selection in the other halve
  4. … Way more cases, including detection of consecutive blocks, inserting implicit blocks, etcetera

In this list with two elements, we are running into the effects of the first case: insert a copy of the current block. This causes multiple intermediate elements to appear! By handling that special case exactly, we can address that behavior without impacting the rest of the enter command! If you also choose to override built-in cursor of keyboard behavior, try to be as specific as you can. Any case that is overridden that is not strictly needed will break a bit of enter behavior: it is very easy to make an editor that responds oddly to user input! With great power comes great responsibility!

I hope this explained how Fonto works and why it works like that. During the years we built quite the product, and we are aware some parts work in unexpected ways for those who have not been with it from the start. If you have any points of Fonto you would like some focus on, we are always ready and willing to share! Reach out on Twitter to Martin Middel or file a support issue!

Stay up-to-dateFonto Why & How posts direct in your inbox

Receive updates on new Fonto Why & How blog posts by email

Leave a Comment

Your email address will not be published. Required fields are marked *

Scroll to Top