Font Replacement using walkDownTree

I am trying to create a plugin to replace all fonts in a document (or within a selection). It works fine sometimes but other times it fails with errors like:

Plugin Error: Plugin made a change outside the current edit context

here is my code:

function replaceFonts(selection,root) { 
    walkDownTree(root, onNode, {
        find:{
            fontFamily: selection.items[0].fontFamily,
            fontStyle: selection.items[0].fontStyle
        },
        replace:{
            fontFamily: selection.items[1].fontFamily,
            fontStyle: selection.items[1].fontStyle            
        }
    });
    
}

function onNode(node,styles){
    if (node.constructor.name == "Text"){
        if (node.fontFamily == styles.find.fontFamily && node.fontStyle == styles.find.fontStyle ){
            node.fontFamily = styles.replace.fontFamily;
            node.fontStyle = styles.replace.fontStyle;
        }
    }
}
function walkDownTree(node, command, value = null, selection) {
    command(node, value, selection);

    if (node.isContainer) {
        var childNodes = node.children;

        for (var i = 0; i < childNodes.length; i++) {
            let childNode = childNodes.at(i);

            walkDownTree(childNode, command, value);
        }
    } 
}

Any ideas why this wouldn’t work in some instances?

Hi @wzad. This is a limitation on XD’s end. Please refer to this: https://adobexdplatform.com/plugin-docs/reference/core/edit-context.html?h=edit%20context

@stevekwak thanks for your response.

Is there a different way I could go about this? For example, programatically updating the edit context while iterating through nodes?

Or is replacing a font across a whole document simply not going to be possible?

Additional question; is there a way to know whether or not a node I am iterating over is within editable context before attempting a command that requires edit context? The plugin appears to fail completely once an error occurs, it would be helpful to check in advance.

Everything can be within the edit context coming from the root except for any special nodes, such as repeat grids, mask group, boolean group, and linked graphic. This is probably the biggest limitation we have for XD extensibility with no great workaround. In order to check, you can check if any of the children are any of these special nodes.

You can guide the user to click into the text nodes when they are placed under any of those special nodes. Then, the text nodes are within the edit context.

Hope this helps!

@stevekwak thanks. that helps.So, if I update my walkDownTree function… something like this in theory should avoid edit context errors…

function walkDownTree(node, command, value = null,selection) {
    command(node, value,selection);
    if (
        (!node.mask) &&
        (node.constructor.name!="RepeatGrid") &&
        (node.constructor.name!="BooleanGroup") &&
        (node.constructor.name!="LinkedGraphic")
    ) {
        node.children.forEach(childNode => {
            walkDownTree(childNode, command, value);
        });
    }
}

@stevekwak
Just an update on this. The code in my previous post does in fact seem to work well at avoiding edit context errors.

Additionally, knowing at times there are going to be items that are outside of edit context, I figured out a way to select the artboards where there are nodes that the plugin was unable to update.

Basically, while we’re walking down the tree of children, once it encounters a node/container that will be out of context, it will continue on crawling, but pass an argument to the command so that we know not to perform unsafe actions.

In my plugin I also store the unsafe nodes in an array, then once I’m done walking the tree, I loop through all the nodes where I was not able to make any changes, and use a getAncestors function to find which artboard it’s on. Because the artboard is within edit context, I can add it to selection.items safely thereby giving some visual indication where the unaffected items are located.

Its not the prettiest, but it will have to do for now.

const {  Artboard} = require("scenegraph"); 
var commands = require("commands");
let application = require("application");
let panel;
let actionData;

function replaceArialWithHelvetica(){
    walkDownTree(root, onReplaceArialWithHelveticaNode, selection);
}
function onReplaceArialWithHelveticaNode(node, value, safe=true) {

    if (node.constructor.name == "Text") {
        if (node.fontFamily == "Arial") {
            if (safe) {
                node.fontFamily = "Helvetica";
            } else {
                actionData.push(node);
            }
        } 
    }
}
function walkDownTree(node, command, value = null) {
    command(node, value);
    if (
        (!node.mask) &&
        (node.constructor.name != "RepeatGrid") &&
        (node.constructor.name != "BooleanGroup") &&
        (node.constructor.name != "LinkedGraphic")
    ) {
        node.children.forEach(childNode => {
            walkDownTree(childNode, command, value);
            
        });
    }else{
        node.children.forEach(childNode => {
            unsafeWalkDownTree(childNode, command, value);
        });       
    }
}
function unsafeWalkDownTree(node, command, value = null) {
    command(node, value, false); //Pass false to the command, so we can store the nodes that are out of edit context
        node.children.forEach(childNode => {
            unsafeWalkDownTree(childNode, command, value);

        });
}
function getNodeAncestry(node){
    let ancestors = [];
    let currentNode = node;
    while (currentNode.parent!=null) {
        ancestors.push(currentNode.parent);
        currentNode = currentNode.parent;
    }
    return ancestors;
}

function create() {
    const HTML =
        `<style>
            .break {
                flex-wrap: wrap;
            }
            .show {
                display: block;
            }
            .hide {
                display: none;
            }
        </style>
        <form method="dialog" id="main">
            <div class="row break">
                <button id="replaceArialWithHelvetica">Global Replace Arial with Helvetica</button>
            </div>
        </form>
        `

    panel = document.createElement("div");
    panel.innerHTML = HTML;
    panel.querySelector("#replaceArialWithHelvetica").addEventListener("click", event => {
        application.editDocument( function(selection, root){
            actionData = [];
            replaceArialWithHelvetica(selection, root);
            let unaffectedArtboards = [];
            actionData.forEach(actionDataNode => {
                const unaffectedNodeAncestry = getNodeAncestry(actionDataNode);
                const unaffectedNodeArtboard = unaffectedNodeAncestry[unaffectedNodeAncestry.length - 2]
                if (unaffectedArtboards.indexOf(unaffectedNodeArtboard) === -1) {
                    unaffectedArtboards.push(unaffectedNodeArtboard);
                }
               
            });
            selection.items = unaffectedArtboards;
        });
    });

    return panel;
}



function show(event) {
    if (!panel) event.node.appendChild(create());
}



module.exports = {
    panels: {
        pluginPanel: {
            show,
            hide,
            update
        }
    }
};

Doesn’t this still require user to select the special nodes?

Yea. I don’t have a solution for that because of the XD limitation you described previously.

The point is, if you have 40 artboards and replaced all the occurences of a font, but there were some additional occurences within, let’s say, a repeat grid; at least this way you can quickly see where to find those because the art board(s) will be highlighted

Neat. thanks for sharing your code!