sunrize
Version:
Sunrize — A Multi-Platform X3D Editor
1,541 lines (1,196 loc) • 124 kB
JavaScript
"use strict";
const
$ = require ("jquery"),
electron = require ("electron"),
util = require ("util"),
jstree = require ("jstree"),
X3D = require ("../X3D"),
Interface = require ("../Application/Interface"),
ActionKeys = require ("../Application/ActionKeys"),
Traverse = require ("x3d-traverse") (X3D),
X3DUOM = require ("../Bits/X3DUOM"),
_ = require ("../Application/GetText");
const
_expanded = Symbol (),
_fullExpanded = Symbol ();
module .exports = class OutlineView extends Interface
{
naturalCompare = new Intl .Collator (undefined, { numeric: true, sensitivity: "base" }) .compare;
constructor (element)
{
super (`Sunrize.OutlineEditor.${element .attr ("id")}.`);
this .outlineEditor = element;
this .objects = new Map (); // <id, node>
this .onDemandToolNodes = new Set ();
this .config .global .setDefaultValues ({
expandExternProtoDeclarations: true,
expandPrototypeInstances: true,
expandInlineNodes: true,
});
this .treeView = $("<div><div/>")
.attr ("tabindex", "0")
.addClass ("tree-view")
.appendTo (this .outlineEditor);
this .resizeObserver = new ResizeObserver (this .onresize .bind (this));
this .resizeObserver .observe (this .treeView [0]);
this .sceneGraph = $("<div><div/>")
.addClass (["tree", "scene-graph", "scene"])
.on ("dragenter dragover", this .onDragEnter .bind (this))
.on ("dragleave dragend drop", this .onDragLeave .bind (this))
.on ("drop", this .onDrop .bind (this))
.on ("dragend", this .onDragEnd .bind (this))
.appendTo (this .treeView);
this .browser .getBrowserOptions () ._ColorSpace .addInterest ("updateSceneGraph", this);
this .browser ._activeLayer .addInterest ("updateActiveLayer", this);
electron .ipcRenderer .on ("select-all", () => this .selectAll ());
electron .ipcRenderer .on ("deselect-all", () => this .deselectAll ());
electron .ipcRenderer .on ("hide-unselected-objects", () => this .hideUnselectedObjects ());
electron .ipcRenderer .on ("show-selected-objects", () => this .showSelectedObjects ());
electron .ipcRenderer .on ("show-all-objects", () => this .showAllObjects ());
electron .ipcRenderer .on ("expand-extern-proto-declarations", (event, value) => this .expandExternProtoDeclarations = value);
electron .ipcRenderer .on ("expand-prototype-instances", (event, value) => this .expandPrototypeInstances = value);
electron .ipcRenderer .on ("expand-inline-nodes", (event, value) => this .expandInlineNodes = value);
electron .ipcRenderer .on ("close", () => this .saveExpanded (this .config .file));
$(window) .on ("close", () => this .saveExpanded (this .config .file));
}
get expandExternProtoDeclarations ()
{
return this .config .global .expandExternProtoDeclarations;
}
set expandExternProtoDeclarations (value)
{
this .config .global .expandExternProtoDeclarations = value;
this .updateSceneGraph ();
}
get expandPrototypeInstances ()
{
return this .config .global .expandPrototypeInstances;
}
set expandPrototypeInstances (value)
{
this .config .global .expandPrototypeInstances = value;
this .updateSceneGraph ();
}
get expandInlineNodes ()
{
return this .config .global .expandInlineNodes;
}
set expandInlineNodes (value)
{
this .config .global .expandInlineNodes = value;
this .updateSceneGraph ();
}
get autoExpandMaxChildren ()
{
return 30;
}
accessTypes = {
[X3D .X3DConstants .initializeOnly]: "initializeOnly",
[X3D .X3DConstants .inputOnly]: "inputOnly",
[X3D .X3DConstants .outputOnly]: "outputOnly",
[X3D .X3DConstants .inputOutput]: "inputOutput",
};
configure ()
{
if (this .executionContext)
{
this .saveExpanded (this .config .last);
this .removeSubtree (this .sceneGraph);
this .executionContext .profile_changed .removeInterest ("updateComponents", this);
this .executionContext .components .removeInterest ("updateComponents", this);
}
this .executionContext = this .browser .currentScene;
this .executionContext .profile_changed .addInterest ("updateComponents", this);
this .executionContext .components .addInterest ("updateComponents", this);
this .updateComponents ();
// Clear tree.
this .objects .clear ();
this .objects .set (this .executionContext .getId (), this .executionContext);
this .sceneGraph .empty ();
this .sceneGraph .attr ("node-id", this .executionContext .getId ());
// Expand scene.
this .expandScene (this .sceneGraph, this .executionContext);
this .restoreExpanded ();
}
updateComponents ()
{
this .onDemandToolNodes = new Set ([
X3D .X3DConstants .DirectionalLight,
X3D .X3DConstants .EnvironmentLight,
X3D .X3DConstants .ListenerPointSource,
X3D .X3DConstants .PointLight,
X3D .X3DConstants .Sound,
X3D .X3DConstants .SpatialSound,
X3D .X3DConstants .SpotLight,
X3D .X3DConstants .ViewpointGroup,
X3D .X3DConstants .X3DEnvironmentalSensorNode,
X3D .X3DConstants .X3DTextureProjectorNode,
X3D .X3DConstants .X3DViewpointNode,
]);
}
updateSceneGraph ()
{
this .updateScene (this .sceneGraph, this .executionContext);
}
updateScene (parent, scene)
{
if (!parent .prop ("isConnected"))
return;
this .saveScrollPositions ();
this .saveExpanded (this .config .file);
this .removeSubtree (parent);
this .expandScene (parent, scene);
this .restoreExpanded ();
this .restoreScrollPositions ();
}
expandScene (parent, scene)
{
parent
.data ("expanded", true)
.data ("full-expanded", false);
if (scene instanceof X3D .X3DScene)
scene .units .addInterest ("updateScene", this, parent, scene);
// Generate subtrees.
const
externprotos = this .expandSceneExternProtoDeclarations (parent, scene),
protos = this .expandSceneProtoDeclarations (parent, scene),
rootNodes = this .expandSceneRootNodes (parent, scene),
importedNodes = this .expandSceneImportedNodes (parent, scene),
exportedNodes = this .expandSceneExportedNodes (parent, scene);
if (!externprotos .is (":empty") || !protos .is (":empty") || !rootNodes .is (":empty") || !importedNodes .is (":empty") || !exportedNodes .is (":empty"))
{
return;
}
// Add empty scene.
const child = $("<div></div>")
.addClass (["empty-scene", "subtree"]);
const ul = $("<ul></ul>")
.appendTo (child);
$("<li></li>")
.addClass (["empty-scene", "description", "no-select"])
.text ("Empty Scene")
.appendTo (ul);
this .connectSceneSubtree (parent, child);
}
connectSceneSubtree (parent, child)
{
// Make jsTree.
child
.jstree ()
.off ("keypress.jstree dblclick.jstree")
.on ("before_open.jstree", this .nodeBeforeOpen .bind (this))
.on ("close_node.jstree", this .nodeCloseNode .bind (this))
.appendTo (parent)
.hide ();
child
.removeAttr ("tabindex")
.find (".jstree-anchor")
.removeAttr ("href")
.removeAttr ("tabindex")
.on ("click", this .selectNone .bind (this));
child .find (".externproto, .proto, .node, .imported-node, .exported-node")
.on ("dblclick", this .activateNode .bind (this));
child .find (".jstree-ocl")
.addClass ("material-icons")
.text ("arrow_right")
.on ("click", this .selectExpander .bind (this))
.on ("dblclick", this .activateExpander .bind (this));
child .find (".jstree-node")
.wrapInner ("<div class=\"item no-select\"/>")
.find (".item") .append ("<div class=\"route-curves-wrapper\"><canvas class=\"route-curves\"></canvas></div>");
// Connect actions.
this .connectNodeActions (parent, child);
// Expand children.
const
specialElements = child .find (".externproto, .proto, .imported-node, .exported-node"),
elements = child .find (".node");
child .show ();
this .expandSceneSubtreeComplete (specialElements, elements);
}
connectNodeActions (parent, child)
{
if (this .isEditable (parent))
{
child .find (".externproto > .item")
.attr ("draggable", "true")
.on ("dragstart", this .onDragStartExternProto .bind (this));
child .find (".proto > .item")
.attr ("draggable", "true")
.on ("dragstart", this .onDragStartProto .bind (this));
if (this .getField (parent) ?.getAccessType () !== X3D .X3DConstants .outputOnly)
{
child .find (".node > .item")
.attr ("draggable", "true")
.on ("dragstart", this .onDragStartNode .bind (this));
}
child .find (".imported-node > .item")
.attr ("draggable", "true")
.on ("dragstart", this .onDragStartImportedNode .bind (this));
}
child .find (":is(.externproto, .proto, .node, .imported-node, .exported-node) :where(.name, .icon)")
.on ("click", this .selectNode .bind (this))
.on ("mouseenter", this .updateNodeTitle .bind (this));
child .find ("[action]")
.on ("click", this .nodeAction .bind (this));
}
nodeAction (event)
{
const button = $(event .target);
switch (button .attr ("action"))
{
case "toggle-visibility":
this .toggleVisibility (event);
break;
case "toggle-tool":
this .toggleTool (event);
break;
case "proxy-display":
this .proxyDisplay (event);
break;
case "activate-layer":
this .activateLayer (event);
break;
case "bind-node":
this .bindNode (event);
break;
case "play-node":
this .playNode (event);
break;
case "stop-node":
this .stopNode (event);
break;
case "loop-node":
this .loopNode (event);
break;
case "reload-node":
this .reloadNode (event);
break;
case "show-preview":
this .showPreview (event);
break;
case "show-branch":
this .showBranch (event);
break;
}
}
connectFieldActions (child)
{
child .find ("area.input-selector")
.on ("mouseenter", this .hoverInSingleConnector .bind (this, "input"))
.on ("mouseleave", this .hoverOutSingleConnector .bind (this, "input"))
.on ("click", this .selectSingleConnector .bind (this, "input"));
child .find ("area.output-selector")
.on ("mouseenter", this .hoverInSingleConnector .bind (this, "output"))
.on ("mouseleave", this .hoverOutSingleConnector .bind (this, "output"))
.on ("click", this .selectSingleConnector .bind (this, "output"));
child .find ("area.input-routes-selector")
.on ("click", this .selectSingleRoute .bind (this, "input"));
child .find ("area.output-routes-selector")
.on ("click", this .selectSingleRoute .bind (this, "output"));
}
expandSceneSubtreeComplete (specialElements, elements)
{
this .requestUpdateRouteGraph ();
this .updateQtips ();
// Reopen externprotos, protos, imported, exported nodes.
for (const e of specialElements)
{
const
element = $(e),
node = this .getNode (element);
if (node ?.getUserData (_expanded) && element .jstree ("is_closed", element))
{
element .jstree ("open_node", element);
}
}
// Reopen nodes.
for (const e of elements)
{
const
element = $(e),
node = this .getNode (element);
if (node ?.getUserData (_expanded) && element .jstree ("is_closed", element))
{
element .data ("auto-expand", true);
element .jstree ("open_node", element);
}
}
}
updateSceneSubtree (parent, scene, type, func)
{
if (scene .externprotos .length || scene .protos .length || scene .rootNodes .length)
{
this .saveScrollPositions ();
const oldSubtree = parent .find (`> .${type}.subtree`);
this .disconnectSubtree (oldSubtree);
const newSubtree = this [func] (parent, scene);
oldSubtree .replaceWith (newSubtree .detach ());
parent .find ("> .empty-scene.subtree") .detach ();
this .restoreScrollPositions ();
}
else
{
this .updateScene (parent, scene);
}
}
expandSceneExternProtoDeclarations (parent, scene)
{
scene .externprotos .addInterest ("updateSceneSubtree", this, parent, scene, "externprotos", "expandSceneExternProtoDeclarations");
const child = $("<div></div>")
.addClass (["externprotos", "subtree"]);
if (!scene .externprotos .length)
return child .appendTo (parent);
const ul = $("<ul></ul>")
.appendTo (child);
$("<li></li>")
.addClass (["externprotos", "description", "no-select"])
.text ("Extern Prototypes")
.appendTo (ul);
let index = 0;
for (const externproto of scene .externprotos)
ul .append (this .createNodeElement ("externproto", parent, externproto, index ++));
this .connectSceneSubtree (parent, child);
return child;
}
expandSceneProtoDeclarations (parent, scene)
{
scene .protos .addInterest ("updateSceneSubtree", this, parent, scene, "protos", "expandSceneProtoDeclarations");
const child = $("<div></div>")
.addClass (["protos", "subtree"]);
if (!scene .protos .length)
return child .appendTo (parent);
const ul = $("<ul></ul>")
.appendTo (child);
$("<li></li>")
.addClass (["protos", "description", "no-select"])
.text ("Prototypes")
.appendTo (ul);
let index = 0;
for (const proto of scene .protos)
ul .append (this .createNodeElement ("proto", parent, proto, index ++));
this .connectSceneSubtree (parent, child);
return child;
}
#updateSceneRootNodesSymbol = Symbol ();
expandSceneRootNodes (parent, scene)
{
scene .rootNodes .addFieldCallback (this .#updateSceneRootNodesSymbol, this .updateSceneRootNodes .bind (this, parent, scene, "root-nodes", "expandSceneRootNodes"));
parent .attr ("index", scene .rootNodes .length);
const child = $("<div></div>")
.addClass (["root-nodes", "subtree"]);
if (!scene .rootNodes .length)
return child .appendTo (parent);
const ul = $("<ul></ul>")
.appendTo (child);
$("<li></li>")
.addClass (["root-nodes", "description", "no-select"])
.text ("Root Nodes")
.appendTo (ul);
let index = 0;
for (const rootNode of scene .rootNodes)
ul .append (this .createNodeElement ("node", parent, rootNode ?.getValue (), index ++));
// Added to prevent bug, that last route is not drawn right.
$("<li></li>")
.addClass (["last", "no-select"])
.appendTo (ul);
this .connectSceneSubtree (parent, child);
return child;
}
updateSceneRootNodes (parent, scene, type, func)
{
if (this .#changing)
return;
this .updateSceneSubtree (parent, scene, type, func);
}
expandSceneImportedNodes (parent, scene)
{
scene .importedNodes .addInterest ("updateSceneSubtree", this, parent, scene, "imported-nodes", "expandSceneImportedNodes");
const child = $("<div></div>")
.addClass (["imported-nodes", "subtree"]);
if (!scene .importedNodes .length)
return child .appendTo (parent);
const importedNodes = Array .from (scene .importedNodes) .sort ((a, b) =>
{
return this .naturalCompare (a .getImportedName (), b .getImportedName ());
});
const ul = $("<ul></ul>")
.appendTo (child);
$("<li></li>")
.addClass (["imported-nodes", "description", "no-select"])
.text ("Imported Nodes")
.appendTo (ul);
for (const [index, importedNode] of importedNodes .entries ())
ul .append (this .createImportedNodeElement (["imported-node"], parent, scene, importedNode .getExportedNode (), index));
// Added to prevent bug, that last route is not drawn right.
$("<li></li>")
.addClass (["last", "no-select"])
.appendTo (ul);
this .connectSceneSubtree (parent, child);
return child;
}
expandSceneExportedNodes (parent, scene)
{
if (!(scene instanceof X3D .X3DScene))
return $("<div></div>")
const child = $("<div></div>")
.addClass (["exported-nodes", "subtree"])
scene .exportedNodes .addInterest ("updateSceneSubtree", this, parent, scene, "exported-nodes", "expandSceneExportedNodes")
if (!scene .exportedNodes .length)
return child .appendTo (parent)
const exportedNodes = Array .from (scene .exportedNodes) .sort ((a, b) =>
{
return this .naturalCompare (a .getExportedName (), b .getExportedName ())
})
.sort ((a, b) =>
{
return this .naturalCompare (a .getLocalNode () .getTypeName (), b .getLocalNode () .getTypeName ())
})
const ul = $("<ul></ul>")
.appendTo (child)
$("<li></li>")
.addClass (["exported-nodes", "description", "no-select"])
.text ("Exported Nodes")
.appendTo (ul)
for (const exportedNode of exportedNodes)
{
ul .append (this .createExportedNodeElement ("exported-node", parent, exportedNode) .prop ("outerHTML"))
}
// Added to prevent bug, that last route is not drawn right.
$("<li></li>")
.addClass (["last", "no-select"])
.appendTo (ul)
this .connectSceneSubtree (parent, child)
return child
}
createSceneElement (scene, typeName, classes)
{
this .objects .set (scene .getId (), scene)
// Scene
const child = $("<li></li>")
.addClass ("scene")
.addClass (classes)
.attr ("node-id", scene .getId ())
// Icon
const icon = $("<img></img>")
.addClass ("icon")
.attr ("src", "../images/OutlineEditor/Node/X3DExecutionContext.svg")
.appendTo (child)
// Name
const name = $("<div></div>")
.addClass ("name")
.appendTo (child)
$("<span></span>")
.addClass ("field-name")
.text (typeName)
.appendTo (name)
// Append empty tree to enable expander.
$("<ul><li></li></ul>") .appendTo (child)
return child
}
updateNode (parent, node, full)
{
if (!parent .prop ("isConnected"))
return;
this .saveScrollPositions ();
this .removeSubtree (parent);
this .expandNode (parent, node, full);
this .restoreScrollPositions ();
}
#updateNodeSymbol = Symbol ();
expandNode (parent, node, full)
{
parent
.data ("expanded", true)
.data ("full-expanded", full);
// Generate tree.
const child = $("<div></div>")
.addClass ("subtree");
const ul = $("<ul></ul>")
.appendTo (child);
// Fields
let fields = full ? node .getFields () : node .getChangedFields (true);
if (!fields .length)
fields = node .getFields ();
if (node .canUserDefinedFields ())
{
// Move user-defined fields on top.
const userDefinedFields = node .getUserDefinedFields ();
fields .sort ((a, b) =>
{
const
ua = userDefinedFields .get (a .getName ()) === a,
ub = userDefinedFields .get (b .getName ()) === b;
return ub - ua;
});
// Move metadata field on top.
fields .sort ((a, b) =>
{
const
ma = a === node ._metadata,
mb = b === node ._metadata;
return mb - ma;
});
// Proto fields, user-defined fields.
// Instances are updated, because they completely change.
node .getPredefinedFields () .addInterest ("updateNode", this, parent, node, full);
node .getUserDefinedFields () .addInterest ("updateNode", this, parent, node, full);
}
for (const field of fields)
ul .append (this .createFieldElement (parent, node, field));
// Extern proto
if (parent .hasClass ("externproto"))
{
// URL
ul .append (this .createFieldElement (parent, node, node ._url, "special"));
// Proto
node .getLoadState () .addFieldCallback (this .#updateNodeSymbol, this .updateNode .bind (this, parent, node, full));
if (this .expandExternProtoDeclarations && node .checkLoadState () === X3D .X3DConstants .COMPLETE_STATE)
ul .append (this .createNodeElement ("proto", parent, node .getProtoDeclaration ()));
else
ul .append (this .createLoadStateElement (node .checkLoadState (), "Extern Prototype"));
}
// Proto Body or Instance Body
if (node instanceof X3D .X3DProtoDeclaration)
{
ul .append (this .createSceneElement (node .getBody (), "Body", "proto-scene"));
}
else if (this .expandPrototypeInstances && node .getType () .includes (X3D .X3DConstants .X3DPrototypeInstance))
{
if (node .getBody ())
ul .append (this .createSceneElement (node .getBody (), "Body", "instance-scene"));
else if (node .getProtoNode () .isExternProto)
ul .append (this .createLoadStateElement (node .getProtoNode () .checkLoadState (), node .getTypeName ()));
}
// X3DUrlObject scene or load state
if (parent .is (".node, .imported-node, .exported-node") && node .getType () .includes (X3D .X3DConstants .X3DUrlObject))
{
// X3DUrlObject
if (node .getType () .includes (X3D .X3DConstants .Inline))
{
node .getLoadState () .addFieldCallback (this .#updateNodeSymbol, this .updateNode .bind (this, parent, node, full));
}
else
{
node .getLoadState () .addFieldCallback (this .#updateNodeSymbol, this .updateFieldLoadState .bind (this, node));
}
if (node .checkLoadState () === X3D .X3DConstants .COMPLETE_STATE && this .expandInlineNodes && node .getType () .includes (X3D .X3DConstants .Inline))
{
ul .append (this .createSceneElement (node .getInternalScene (), "Scene", "internal-scene"))
}
else
{
ul .append (this .createLoadStateElement (node .checkLoadState (), node .getTypeName ()) .addClass (`load-state-${node .getId ()}`));
}
}
// Make jsTree.
child
.jstree ()
.off ("keypress.jstree dblclick.jstree")
.on ("before_open.jstree", this .fieldBeforeOpen .bind (this))
.on ("close_node.jstree", this .fieldCloseNode .bind (this))
.appendTo (parent)
.hide ();
child
.removeAttr ("tabindex")
.find (".jstree-anchor")
.removeAttr ("href")
.removeAttr ("tabindex")
.on ("click", this .selectNone .bind (this));
child .find ("li")
.on ("dblclick", this .activateField .bind (this));
child .find (".jstree-ocl")
.addClass ("material-icons")
.text ("arrow_right")
.on ("click", this .selectExpander .bind (this))
.on ("dblclick", this .activateExpander .bind (this));
child .find (".jstree-node")
.wrapInner ("<div class=\"item no-select\"/>")
.find (".item") .append ("<div class=\"route-curves-wrapper\"><canvas class=\"route-curves\"></canvas></div>");
child .find (".field .name, .field .icon, .special .name, .special .icon")
.on ("click", this .selectField .bind (this))
child .find (".field .name, .special .name")
.on ("mouseenter", this .updateFieldTitle .bind (this));
child .find ("area.input-selector")
.on ("mouseenter", this .hoverInConnector .bind (this, "input"))
.on ("mouseleave", this .hoverOutConnector .bind (this, "input"))
.on ("click", this .selectConnector .bind (this, "input"));
child .find ("area.output-selector")
.on ("mouseenter", this .hoverInConnector .bind (this, "output"))
.on ("mouseleave", this .hoverOutConnector .bind (this, "output"))
.on ("click", this .selectConnector .bind (this, "output"));
child .find ("area.input-routes-selector")
.on ("click", this .selectRoutes .bind (this, "input"));
child .find ("area.output-routes-selector")
.on ("click", this .selectRoutes .bind (this, "output"));
if (this .isEditable (parent))
{
child .find (".field > .item")
.attr ("draggable", "true")
.on ("dragstart", this .onDragStartField .bind (this));
}
// Add special field buttons.
this .addFieldButtons (parent, child, node);
// Expand children.
const
protos = child .find (".proto"),
scenes = child .find (".scene"),
elements = child .find (".field, .special");
child .show ();
this .expandNodeComplete (protos, scenes, elements);
}
expandNodeComplete (protos, scenes, elements)
{
this .requestUpdateRouteGraph ();
this .updateQtips ();
// Auto expand SFNodes
for (const e of elements .filter ("li[type-name=SFNode]"))
{
const
element = $(e),
field = this .getField (element);
if (field .getValue ())
{
element .data ("auto-expand", true);
element .jstree ("open_node", element);
}
}
// Auto expand MFNodes
for (const e of elements .filter ("li[type-name=MFNode]"))
{
const
element = $(e),
field = this .getField (element);
if (field .length && field .length <= this .autoExpandMaxChildren)
{
element .data ("auto-expand", true);
element .jstree ("open_node", element);
}
}
// Reopen protos.
for (const e of protos)
{
const
element = $(e),
node = this .getNode (element);
if (node .getUserData (_expanded) && element .jstree ("is_closed", element))
{
element .jstree ("open_node", element);
}
}
// Reopen scenes.
for (const e of scenes)
{
const
element = $(e),
scene = this .getNode (element);
if (scene .getUserData (_expanded) && element .jstree ("is_closed", element))
{
element .data ("auto-expand", true);
element .jstree ("open_node", element);
}
}
// Reopen fields.
for (const e of elements)
{
const
element = $(e),
field = this .getField (element);
if (field .getUserData (_expanded) && element .jstree ("is_closed", element))
{
element .data ("auto-expand", true);
element .jstree ("open_node", element);
}
}
}
updateFieldLoadState (node)
{
const [className, description] = this .getLoadState (node .checkLoadState (), node .getTypeName ());
this .sceneGraph .find (`.load-state-${node .getId ()}`)
.removeClass (["not-started-state", "in-progress-state", "complete-state", "failed-state"])
.addClass (className)
.find (".load-state-text")
.text (description);
}
createLoadStateElement (loadState, typeName)
{
const [className, description] = this .getLoadState (loadState, typeName);
return $("<li></li>")
.addClass (["description", "load-state", className, "no-select"])
.append ($("<span></span>") .addClass ("load-state-text") .text (description));
}
getLoadState (loadState, typeName)
{
switch (loadState)
{
case X3D .X3DConstants .NOT_STARTED_STATE:
return ["not-started-state", util .format (_("Loading %s not started."), typeName)];
case X3D .X3DConstants .IN_PROGRESS_STATE:
return ["in-progress-state", util .format (_("Loading %s in progress."), typeName)];
case X3D .X3DConstants .COMPLETE_STATE:
return ["complete-state", util .format (_("Loading %s completed."), typeName)];
case X3D .X3DConstants .FAILED_STATE:
return ["failed-state", util .format (_("Loading %s failed."), typeName)];
}
}
#nodeSymbol = Symbol ();
#updateNodeVisibilitySymbol = Symbol ();
#updateNodeBoundSymbol = Symbol ();
#updateNodeLoadStateSymbol = Symbol ();
#updateNodePlaySymbol = Symbol ();
createNodeElement (type, parent, node, index)
{
if (node instanceof X3D .X3DImportedNodeProxy)
return this .createImportedNodeElement (["imported-node", "proxy"], parent, node .getExecutionContext (), node, index);
if (node)
{
if (!node .isInitialized ())
{
// Setup nodes in protos, disable some init functions.
for (const type of node .getType ())
{
switch (type)
{
case X3D .X3DConstants .Script:
node .initialize__ = Function .prototype;
break;
case X3D .X3DConstants .X3DTimeDependentNode:
node .set_start = Function .prototype;
break;
}
}
node .setup ();
}
this .objects .set (node .getId (), node .valueOf ());
// These fields are observed and must never be disconnected, because clones would also lose connection.
node .typeName_changed .addFieldCallback (this .#nodeSymbol, this .updateNodeTypeName .bind (this, node));
node .name_changed .addFieldCallback (this .#nodeSymbol, this .updateNodeName .bind (this, node));
node .parents_changed .addFieldCallback (this .#nodeSymbol, this .updateCloneCount .bind (this, node));
}
// Classes
const classes = [type];
if (node)
{
const selection = require ("../Application/Selection");
if (selection .has (node))
classes .push ("selected");
if (this .isInParents (parent, node))
classes .push ("circular-reference", "no-expand");
}
else
{
classes .push ("no-expand");
}
// Node
const child = $("<li></li>")
.addClass (classes)
.attr ("node-id", node ? node .getId () : "NULL")
.attr ("index", index);
// Icon
const icon = $("<img></img>")
.addClass ("icon")
.attr ("src", `../images/OutlineEditor/Node/${this .nodeIcons .get (type)}.svg`)
.appendTo (child);
if (node)
{
// Name
const name = $("<div></div>")
.addClass ("name")
.appendTo (child);
$("<span></span>")
.addClass ("node-type-name")
.text (this .typeNames .get (node .getTypeName ()) ?? node .getTypeName ())
.appendTo (name);
name .append (document .createTextNode (" "));
$("<span></span>")
.addClass ("node-name")
.text (node .getDisplayName ())
.appendTo (name);
name .append (document .createTextNode (" "));
const cloneCount = node .getCloneCount ?.() ?? 0
$("<span></span>")
.addClass ("clone-count")
.text (cloneCount > 1 ? `[${cloneCount}]` : "")
.appendTo (name);
// Add buttons to name.
this .addNodeButtons (this .getNode (parent), node, name);
// Append empty tree to enable expander.
if (!this .isInParents (parent, node))
$("<ul><li></li></ul>") .appendTo (child);
}
else
{
$("<div></div>")
.addClass ("name")
.append ($("<span></span>") .addClass ("node-type-name") .text ("NULL"))
.appendTo (child);
}
return child;
}
addNodeButtons (parent, node, name)
{
// Add buttons to name.
const buttons = [ ];
if (!(node .getExecutionContext () .getOuterNode () instanceof X3D .X3DProtoDeclaration))
{
if (node ._hidden)
{
buttons .push ($("<span></span>")
.attr ("order", "0")
.attr ("title", "Toggle visibility.")
.attr ("action", "toggle-visibility")
.addClass (["button", "material-symbols-outlined"])
.addClass (node .isHidden () ? "off" : "on")
.text (node .isHidden () ? "visibility_off" : "visibility"));
node ._hidden .addFieldCallback (this .#updateNodeVisibilitySymbol, this .updateNodeVisibility .bind (this, node));
}
if (node .getType () .some (type => this .onDemandToolNodes .has (type)))
{
buttons .push ($("<span></span>")
.attr ("order", "1")
.attr ("title", _("Toggle display tool."))
.attr ("action", "toggle-tool")
.addClass (["button", "material-symbols-outlined"])
.addClass (node .valueOf () === node ? "off" : "on")
.text ("build_circle"));
}
}
for (const type of node .getType ())
{
switch (type)
{
case X3D .X3DConstants .Collision:
{
buttons .push ($("<span></span>")
.attr ("order", "2")
.attr ("title", _("Display proxy node."))
.attr ("action", "proxy-display")
.addClass (["button", "material-symbols-outlined"])
.addClass (node .getProxyDisplay () ? "on" : "off")
.text ("highlight_mouse_cursor"));
break;
}
case X3D .X3DConstants .X3DLayerNode:
{
if (node .getExecutionContext () !== this .executionContext)
continue;
buttons .push ($("<span></span>")
.attr ("order", "3")
.attr ("title", _("Activate layer."))
.attr ("action", "activate-layer")
.addClass (["button", "material-symbols-outlined"])
.addClass (this .browser .getActiveLayer () === node ? "on" : "off")
.text ("check_circle"));
continue;
}
case X3D .X3DConstants .X3DBindableNode:
{
if (node .getExecutionContext () .getOuterNode () instanceof X3D .X3DProtoDeclaration)
continue;
node ._isBound .addFieldCallback (this .#updateNodeBoundSymbol, this .updateNodeBound .bind (this, node));
buttons .push ($("<span></span>")
.attr ("order", "4")
.attr ("title", _("Bind node."))
.attr ("action", "bind-node")
.addClass (["button", "material-symbols-outlined"])
.addClass (node ._isBound .getValue () ? "on" : "off")
.text (node ._isBound .getValue () ? "radio_button_checked" : "radio_button_unchecked"));
continue;
}
case X3D .X3DConstants .X3DTimeDependentNode:
{
if (node .getExecutionContext () !== this .executionContext)
continue;
node ._enabled .addFieldCallback (this .#updateNodePlaySymbol, this .updateNodePlay .bind (this, node));
node ._isActive .addFieldCallback (this .#updateNodePlaySymbol, this .updateNodePlay .bind (this, node));
node ._isPaused .addFieldCallback (this .#updateNodePlaySymbol, this .updateNodePlay .bind (this, node));
node ._loop .addFieldCallback (this .#updateNodePlaySymbol, this .updateNodePlay .bind (this, node));
buttons .push ($("<span></span>")
.attr ("order", "5")
.attr ("title", node ._isActive .getValue () && !node ._isPaused .getValue () ? _("Pause timer.") : _("Start timer."))
.attr ("action", "play-node")
.addClass (["button", "material-icons"])
.addClass (node ._isPaused .getValue () ? "on" : "off")
.text (node ._isActive .getValue () ? "pause" : "play_arrow"));
buttons .push ($("<span></span>")
.attr ("order", "6")
.attr ("title", _("Stop timer."))
.attr ("action", "stop-node")
.addClass (["button", "material-icons"])
.addClass (node ._isActive .getValue () ? "on" : "off")
.text ("stop"));
buttons .push ($("<span></span>")
.attr ("order", "7")
.attr ("title", _("Toggle loop."))
.attr ("action", "loop-node")
.addClass (["button", "material-icons"])
.addClass (node ._loop .getValue () ? "on" : "off")
.text ("repeat"));
if (!node ._enabled .getValue ())
buttons .slice (-3) .forEach (button => button .hide ());
continue;
}
case X3D .X3DConstants .X3DUrlObject:
{
if (node .getExecutionContext () .getOuterNode () instanceof X3D .X3DProtoDeclaration)
{
if (!node .getType () .includes (X3D .X3DConstants .Inline))
continue;
}
const [className] = this .getLoadState (node .checkLoadState (), node .getTypeName ());
node .getLoadState () .addFieldCallback (this .#updateNodeLoadStateSymbol, this .updateNodeLoadState .bind (this, node));
buttons .push ($("<span></span>")
.attr ("order", "8")
.attr ("title", "Load now.")
.attr ("action", "reload-node")
.addClass (["button", "material-symbols-outlined", className])
.text ("autorenew"));
continue;
}
case X3D .X3DConstants .AudioClip:
case X3D .X3DConstants .BufferAudioSource:
case X3D .X3DConstants .X3DMaterialNode:
case X3D .X3DConstants .X3DSingleTextureNode:
{
buttons .push ($("<span></span>")
.attr ("order", "9")
.attr ("title", _("Show preview."))
.attr ("action", "show-preview")
.addClass (["button", "material-symbols-outlined", "off"])
.css ("top", "2px")
.text ("preview"));
continue;
}
}
}
for (const type of parent .getType ())
{
switch (type)
{
case X3D .X3DConstants .LOD:
case X3D .X3DConstants .Switch:
{
buttons .push ($("<span></span>")
.attr ("order", "10")
.attr ("title", _("Show branch."))
.attr ("action", "show-branch")
.addClass (["button", "material-symbols-outlined"])
.addClass (parent .getEditChild () === node ? "on" : "off")
.text ("highlight_mouse_cursor"));
break;
}
}
}
buttons .sort ((a, b) => a .attr ("order") - b .attr ("order"))
for (const button of buttons)
{
name .append (document .createTextNode (" "));
name .append (button);
}
}
updateNodeTypeName (node)
{
this .sceneGraph
.find (`.node[node-id=${node .getId ()}], .exported-node[node-id=${node .getId ()}]`)
.find ("> .item .node-type-name")
.text (node .getTypeName ())
}
updateNodeName (node)
{
this .sceneGraph
.find (`.node[node-id=${node .getId ()}], .exported-node[node-id=${node .getId ()}]`)
.find ("> .item .node-name")
.text (node .getDisplayName ())
}
updateExportedNodeName (exportedNode)
{
const
node = exportedNode .getLocalNode (),
name = this .sceneGraph .find (`.exported-node[node-id=${node .getId ()}]`) .find ("> .item .name");
name .find (".node-name") .text (node .getName ());
name .find (".as-name") .text (exportedNode .getExportedName ());
if (exportedNode .getExportedName () === node .getName ())
name .find (".node-name") .nextAll () .hide ();
else
name .find (".node-name") .nextAll () .show ();
}
updateCloneCount (node)
{
const cloneCount = node .getCloneCount ?.() ?? 0;
this .sceneGraph
.find (`:is(.node, .imported-node)[node-id="${node .getId ()}"]`)
.find ("> .item .clone-count")
.text (cloneCount > 1 ? `[${cloneCount}]` : "");
}
updateActiveLayer ()
{
const node = this .browser .getActiveLayer ();
this .sceneGraph .find ("[action=activate-layer]") .addClass ("off");
if (!node)
return;
this .sceneGraph
.find (`.node[node-id=${node .getId ()}],
.imported-node[node-id=${node .getId ()}],
.exported-node[node-id=${node .getId ()}]`)
.find ("> .item [action=activate-layer]")
.removeClass ("off")
.addClass ("on");
}
updateNodeVisibility (node)
{
this .sceneGraph
.find (`.node[node-id=${node .getId ()}],
.imported-node[node-id=${node .getId ()}],
.exported-node[node-id=${node .getId ()}]`)
.find ("> .item [action=toggle-visibility]")
.removeClass (["on", "off"])
.addClass (node .isHidden () ? "off" : "on")
.text (node .isHidden () ? "visibility_off" : "visibility");
}
updateNodeBound (node)
{
this .sceneGraph
.find (`.node[node-id=${node .getId ()}],
.imported-node[node-id=${node .getId ()}],
.exported-node[node-id=${node .getId ()}]`)
.find ("> .item [action=bind-node]")
.removeClass (["on", "off"])
.addClass (node ._isBound .getValue () ? "on" : "off")
.text (node ._isBound .getValue () ? "radio_button_checked" : "radio_button_unchecked");
}
updateNodeLoadState (node)
{
const [className] = this .getLoadState (node .checkLoadState (), node .getTypeName ());
this .sceneGraph
.find (`.node[node-id=${node .getId ()}],
.imported-node[node-id=${node .getId ()}],
.exported-node[node-id=${node .getId ()}],
.externproto[node-id=${node .getId ()}]`)
.find ("> .item .reload-node")
.removeClass (["not-started-state", "in-progress-state", "complete-state", "failed-state"])
.addClass (className);
}
updateNodePlay (node)
{
const buttons = [ ];
buttons .push (this .sceneGraph
.find (`.node[node-id=${node .getId ()}],
.imported-node[node-id=${node .getId ()}],
.exported-node[node-id=${node .getId ()}]`)
.find ("> .item [action=play-node]")
.removeClass (["on", "off"])
.addClass (node ._isPaused .getValue () ? "on" : "off")
.attr ("title", node ._isActive .getValue () && !node ._isPaused .getValue () ? _("Pause timer.") : _("Start timer."))
.text (node ._isActive .getValue () ? "pause" : "play_arrow"));
buttons .push (this .sceneGraph
.find (`.node[node-id=${node .getId ()}],
.imported-node[node-id=${node .getId ()}],
.exported-node[node-id=${node .getId ()}]`)
.find ("> .item [action=stop-node]")
.removeClass (["on", "off"])
.addClass (node ._isActive .getValue () ? "on" : "off"));
buttons .push (this .sceneGraph
.find (`.node[node-id=${node .getId ()}],
.imported-node[node-id=${node .getId ()}],
.exported-node[node-id=${node .getId ()}]`)
.find ("> .item [action=loop-node]")
.removeClass (["on", "off"])
.addClass (node ._loop .getValue () ? "on" : "off"));
if (node ._enabled .getValue ())
buttons .slice (-3) .forEach (button => button .show ());
else
buttons .slice (-3) .forEach (button => button .hide ());
if (!node ._isActive .getValue ())
node ._evenLive = false;
}
isInParents (parent, node)
{
return parent .closest (`.node[node-id=${node .getId ()}]`, this .sceneGraph) .length;
}
#importedNodeSymbol = Symbol ();
createImportedNodeElement (type, parent, scene, node, index)
{
const importedNode = node .getImportedNode ();
if (!importedNode)
return this .createNodeElement ("node", parent, null, index);
// These fields are observed and must never be disconnected, because clones would also lose connection.
node .name_changed .addFieldCallback (this .#importedNodeSymbol, this .updateImportedNodeName .bind (this, importedNode));
node .parents_changed .addFieldCallback (this .#nodeSymbol, this .updateCloneCount .bind (this, node));
importedNode .getInlineNode () .getLoadState () .addFieldCallback (this .#importedNodeSymbol, this .updateScene .bind (this, parent .closest (".scene"), scene));
this .objects .set (node .getId (), node);
this .objects .set (importedNode .getId (), importedNode);
// Node
const classes = type;
if (importedNode .getExportedNode () .getSharedNode ())
{
const selection = require ("../Application/Selection");
if (selection .has (node))
classes .push ("selected");
}
else
{
classes .push ("no-expand");
}
const child = $("<li></li>")
.addClass (classes)
.attr ("node-id", node .getId ())
.attr ("imported-node-id", importedNode .getId ())
.attr ("index", index)
.attr ("title", importedNode .getDescription ());
// Icon
const icon = $("<img></img>")
.addClass ("icon")
.attr ("src", `../images/OutlineEditor/Node/${this .nodeIcons .get (type [0])}.svg`)
.appendTo (child);
// Name
const name = $("<div></div>")
.addClass ("name")
.appendTo (child);
$("<span></span>")
.addClass ("node-type-name")
.text (node .getTypeName ())
.appendTo (name);
name .append (document .createTextNode (" "));
$("<span></span>")
.addClass ("node-name")
.text (importedNode .getExportedName ())
.appendTo (name);
const nodeAsName = $("<span></span>")
.addClass ("node-as-name")
.append (document .createTextNode (" "))
.append ($("<span></span>") .addClass ("as") .text ("AS"))
.append (document .createTextNode (" "))
.append ($("<span></span>") .addClass ("as-name") .text (importedNode .getImportedName ()))
.appendTo (name);
if (importedNode .getExportedName () === importedNode .getImportedName ())
nodeAsName .hide ();
name .append (document .createTextNode (" "));
const cloneCount = node .getCloneCount ?.() ?? 0
$("<span></span>")
.addClass ("clone-count")
.text (cloneCount > 1 ? `[${cloneCount}]` : "")
.appendTo (name);
// Add buttons to name.
this .addNodeButtons (this .getNode (parent), node, name);
// Append empty tree to enable expander.
$("<ul><li></li></ul>") .appendTo (child);
return child;
}
updateImportedNodeName (importedNode)
{
const nodeAsName = this .sceneGraph
.find (`.imported-node[imported-node-id="${importedNode .getId ()}"]`)
.find ("> .item .node-as-name");
nodeAsName .find (".as-name") .text (importedNode .getImportedName ());
if (importedNode .getExportedName () === importedNode .getImportedName ())
nodeAsName .hide ();
else
nodeAsName .show ();
}
#exportedNodeSymbol = Symbol ();
createExportedNodeElement (type, parent, exportedNode)
{
const node = exportedNode .getLocalNode ();
this .objects .set (exportedNode .getId (), exportedNode);
this .objects .set (node .getId (), node .valueOf ());
// These fields are observed and must never be disconnected, because clones would also lose connection.
node .typeName_changed .addFieldCallback (this .#exportedNodeSymbol