Introduce new TreeView UI component

Key highlights:

- Written from scratch to cater specifically to privacy.sexy's
  needs and requirements.
- The visual look mimics the previous component with minimal changes,
  but its internal code is completely rewritten.
- Lays groundwork for future functionalities like the "expand all"
  button a flat view mode as discussed in #158.
- Facilitates the transition to Vue 3 by omitting the Vue 2.0 dependent
  `liquour-tree` as part of #230.

Improvements and features:

- Caching for quicker node queries.
- Gradual rendering of nodes that introduces a noticable boost in
  performance, particularly during search/filtering.
  - `TreeView` solely governs the check states of branch nodes.

Changes:

- Keyboard interactions now alter the background color to highlight the
  focused item. Previously, it was changing the color of the text.
- Better state management with clear separation of concerns:
  - `TreeView` exclusively manages indeterminate states.
  - `TreeView` solely governs the check states of branch nodes.
  - Introduce transaction pattern to update state in batches to minimize
    amount of events handled.
- Improve keyboard focus, style background instead of foreground. Use
  hover/touch color on keyboard focus.
- `SelectableTree` has been removed. Instead, `TreeView` is now directly
  integrated with `ScriptsTree`.
- `ScriptsTree` has been refactored to incorporate hooks for clearer
  code and separation of duties.
- Adopt Vue-idiomatic bindings instead of keeping a reference of the
  tree component.
- Simplify and change filter event management.
- Abandon global styles in favor of class-scoped styles.
- Use global mixins with descriptive names to clarify indended
  functionality.
This commit is contained in:
undergroundwires
2023-09-09 22:26:21 +02:00
parent 821cc62c4c
commit 65f121c451
120 changed files with 4537 additions and 1203 deletions

View File

@@ -76,7 +76,6 @@ export default defineComponent({
function handleCodeChange(event: ICodeChangedEvent) {
removeCurrentHighlighting();
updateCode(event.code, currentState.value.collection.scripting.language);
editor.setValue(event.code, 1);
if (event.addedScripts?.length > 0) {
reactToChanges(event, event.addedScripts);
} else if (event.changedScripts?.length > 0) {

View File

@@ -53,7 +53,7 @@ import {
inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
export default defineComponent({

View File

@@ -1,173 +0,0 @@
<template>
<span id="container">
<span v-if="nodes != null && nodes.length > 0">
<SelectableTree
:initialNodes="nodes"
:selectedNodeIds="selectedNodeIds"
:filterPredicate="filterPredicate"
:filterText="filterText"
v-on:nodeSelected="toggleNodeSelection($event)"
/>
</span>
<span v-else>Nooo 😢</span>
</span>
</template>
<script lang="ts">
import {
defineComponent, watch, ref, inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { ICategoryCollectionState, IReadOnlyCategoryCollectionState } from '@/application/Context/State/ICategoryCollectionState';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IEventSubscription } from '@/infrastructure/Events/IEventSource';
import {
parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId, getCategoryId,
getScriptId,
} from './ScriptNodeParser';
import SelectableTree from './SelectableTree/SelectableTree.vue';
import { INodeContent, NodeType } from './SelectableTree/Node/INodeContent';
import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
export default defineComponent({
props: {
categoryId: {
type: Number,
default: undefined,
},
},
components: {
SelectableTree,
},
setup(props) {
const {
modifyCurrentState, currentState, onStateChange,
} = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const nodes = ref<ReadonlyArray<INodeContent>>([]);
const selectedNodeIds = ref<ReadonlyArray<string>>([]);
const filterText = ref<string | undefined>(undefined);
let filtered: IFilterResult | undefined;
watch(
() => props.categoryId,
(newCategoryId) => setNodes(newCategoryId),
{ immediate: true },
);
onStateChange((state) => {
setCurrentFilter(state.filter.currentFilter);
if (!props.categoryId) {
nodes.value = parseAllCategories(state.collection);
}
events.unsubscribeAllAndRegister(subscribeToState(state));
}, { immediate: true });
function toggleNodeSelection(event: INodeSelectedEvent) {
modifyCurrentState((state) => {
switch (event.node.type) {
case NodeType.Category:
toggleCategoryNodeSelection(event, state);
break;
case NodeType.Script:
toggleScriptNodeSelection(event, state);
break;
default:
throw new Error(`Unknown node type: ${event.node.id}`);
}
});
}
function filterPredicate(node: INodeContent): boolean {
return containsScript(node, filtered.scriptMatches)
|| containsCategory(node, filtered.categoryMatches);
}
function setNodes(categoryId?: number) {
if (categoryId) {
nodes.value = parseSingleCategory(categoryId, currentState.value.collection);
} else {
nodes.value = parseAllCategories(currentState.value.collection);
}
selectedNodeIds.value = currentState.value.selection.selectedScripts
.map((selected) => getScriptNodeId(selected.script));
}
function subscribeToState(
state: IReadOnlyCategoryCollectionState,
): IEventSubscription[] {
return [
state.selection.changed.on((scripts) => handleSelectionChanged(scripts)),
state.filter.filterChanged.on((event) => {
event.visit({
onApply: (filter) => {
filterText.value = filter.query;
filtered = filter;
},
onClear: () => {
filterText.value = '';
},
});
}),
];
}
function setCurrentFilter(currentFilter: IFilterResult | undefined) {
filtered = currentFilter;
filterText.value = currentFilter?.query || '';
}
function handleSelectionChanged(selectedScripts: ReadonlyArray<SelectedScript>): void {
selectedNodeIds.value = selectedScripts
.map((node) => node.id);
}
return {
nodes,
selectedNodeIds,
filterText,
toggleNodeSelection,
filterPredicate,
};
},
});
function containsScript(expected: INodeContent, scripts: readonly IScript[]) {
return scripts.some((existing: IScript) => expected.id === getScriptNodeId(existing));
}
function containsCategory(expected: INodeContent, categories: readonly ICategory[]) {
return categories.some((existing: ICategory) => expected.id === getCategoryNodeId(existing));
}
function toggleCategoryNodeSelection(
event: INodeSelectedEvent,
state: ICategoryCollectionState,
): void {
const categoryId = getCategoryId(event.node.id);
if (event.isSelected) {
state.selection.addOrUpdateAllInCategory(categoryId, false);
} else {
state.selection.removeAllInCategory(categoryId);
}
}
function toggleScriptNodeSelection(
event: INodeSelectedEvent,
state: ICategoryCollectionState,
): void {
const scriptId = getScriptId(event.node.id);
const actualToggleState = state.selection.isSelected(scriptId);
const targetToggleState = event.isSelected;
if (targetToggleState && !actualToggleState) {
state.selection.addSelectedScript(scriptId, false);
} else if (!targetToggleState && actualToggleState) {
state.selection.removeSelectedScript(scriptId);
}
}
</script>

View File

@@ -1,6 +0,0 @@
import { INodeContent } from './Node/INodeContent';
export interface INodeSelectedEvent {
isSelected: boolean;
node: INodeContent;
}

View File

@@ -1,76 +0,0 @@
declare module 'liquor-tree' {
import { PluginObject } from 'vue';
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Tree.js
export interface ILiquorTree {
readonly model: ReadonlyArray<ILiquorTreeExistingNode>;
filter(query: string): void;
clearFilter(): void;
setModel(nodes: ReadonlyArray<ILiquorTreeNewNode>): void;
// getNodeById(id: string): ILiquorTreeExistingNode;
recurseDown(fn: (node: ILiquorTreeExistingNode) => void): void;
}
export interface ICustomLiquorTreeData {
type: number;
docs: ReadonlyArray<string>;
isReversible: boolean;
}
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
export interface ILiquorTreeNodeState {
checked: boolean;
indeterminate: boolean;
}
export interface ILiquorTreeNode {
id: string;
data: ICustomLiquorTreeData;
children: ReadonlyArray<ILiquorTreeNode> | undefined;
}
/**
Returned from Node tree view events.
See constructor in https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeExistingNode extends ILiquorTreeNode {
data: ILiquorTreeNodeData;
states: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeExistingNode> | undefined;
// expand(): void;
}
/**
Sent to liquor tree to define of new nodes.
https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeNewNode extends ILiquorTreeNode {
text: string;
state: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeNewNode> | undefined;
}
// https://amsik.github.io/liquor-tree/#Component-Options
export interface ILiquorTreeOptions {
multiple: boolean;
checkbox: boolean;
checkOnSelect: boolean;
autoCheckChildren: boolean;
parentSelect: boolean;
keyboardNavigation: boolean;
filter: ILiquorTreeFilter;
deletion(node: ILiquorTreeNode): boolean;
}
export interface ILiquorTreeNodeData extends ICustomLiquorTreeData {
text: string;
}
// https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue
export interface ILiquorTreeFilter {
emptyText: string;
matcher(query: string, node: ILiquorTreeExistingNode): boolean;
}
interface LiquorTreeVueComponent extends PluginObject<Vue> {
install(Vue: VueConstructor<Vue>, options?: unknown);
}
export default LiquorTree;
}

View File

@@ -1,38 +0,0 @@
import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
export class LiquorTreeOptions implements ILiquorTreeOptions {
public readonly multiple = true;
public readonly checkbox = true;
public readonly checkOnSelect = true;
/*
For checkbox mode only. Children will have the same checked state as their parent.
⚠️ Setting this false does not prevent updating indeterminate state of nodes.
It's set to false anyway because state is handled manually, and this way batch selections can
be done in more performant way.
*/
public readonly autoCheckChildren = false;
public readonly parentSelect = true;
public readonly keyboardNavigation = true;
/*
Filter is wrapped in an arrow function because setting filter directly does not work with
underling JavaScript APIs.
*/
public readonly filter = {
emptyText: this.liquorTreeFilter.emptyText,
matcher: (query: string, node: ILiquorTreeExistingNode) => {
return this.liquorTreeFilter.matcher(query, node);
},
};
constructor(private readonly liquorTreeFilter: ILiquorTreeFilter) { }
public deletion(): boolean {
return false; // no op
}
}

View File

@@ -1,19 +0,0 @@
import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
import { INodeContent } from '../../Node/INodeContent';
import { convertExistingToNode } from './NodeTranslator';
export type FilterPredicate = (node: INodeContent) => boolean;
export class NodePredicateFilter implements ILiquorTreeFilter {
public emptyText = ''; // Does not matter as a custom message is shown
constructor(private readonly filterPredicate: FilterPredicate) {
if (!filterPredicate) {
throw new Error('filterPredicate is undefined');
}
}
public matcher(query: string, node: ILiquorTreeExistingNode): boolean {
return this.filterPredicate(convertExistingToNode(node));
}
}

View File

@@ -1,64 +0,0 @@
import { ILiquorTreeNode, ILiquorTreeNodeState } from 'liquor-tree';
import { NodeType } from '../../Node/INodeContent';
export function getNewState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>,
): ILiquorTreeNodeState {
const checked = getNewCheckedState(node, selectedNodeIds);
const indeterminate = !checked && getNewIndeterminateState(node, selectedNodeIds);
return {
checked, indeterminate,
};
}
function getNewIndeterminateState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>,
): boolean {
switch (node.data.type) {
case NodeType.Script:
return false;
case NodeType.Category:
return parseAllSubScriptIds(node).some((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
}
function getNewCheckedState(
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>,
): boolean {
switch (node.data.type) {
case NodeType.Script:
return selectedNodeIds.some((id) => id === node.id);
case NodeType.Category:
return parseAllSubScriptIds(node).every((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
}
function parseAllSubScriptIds(categoryNode: ILiquorTreeNode): ReadonlyArray<string> {
if (categoryNode.data.type !== NodeType.Category) {
throw new Error('Not a category node');
}
if (!categoryNode.children) {
return [];
}
return categoryNode
.children
.flatMap((child) => getNodeIds(child));
}
function getNodeIds(node: ILiquorTreeNode): ReadonlyArray<string> {
switch (node.data.type) {
case NodeType.Script:
return [node.id];
case NodeType.Category:
return parseAllSubScriptIds(node);
default:
throw new Error('Unknown node type');
}
}

View File

@@ -1,45 +0,0 @@
import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree';
import { INodeContent } from '../../Node/INodeContent';
// Functions to translate INode to LiqourTree models and vice versa for anti-corruption
export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode): INodeContent {
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
return {
id: liquorTreeNode.id,
type: liquorTreeNode.data.type,
text: liquorTreeNode.data.text,
// selected: liquorTreeNode.states && liquorTreeNode.states.checked,
children: convertChildren(liquorTreeNode.children, convertExistingToNode),
docs: liquorTreeNode.data.docs,
isReversible: liquorTreeNode.data.isReversible,
};
}
export function toNewLiquorTreeNode(node: INodeContent): ILiquorTreeNewNode {
if (!node) { throw new Error('node is undefined'); }
return {
id: node.id,
text: node.text,
state: {
checked: false,
indeterminate: false,
},
children: convertChildren(node.children, toNewLiquorTreeNode),
data: {
docs: node.docs,
isReversible: node.isReversible,
type: node.type,
},
};
}
function convertChildren<TOldNode, TNewNode>(
oldChildren: readonly TOldNode[],
callback: (value: TOldNode) => TNewNode,
): TNewNode[] {
if (!oldChildren || oldChildren.length === 0) {
return [];
}
return oldChildren.map((childNode) => callback(childNode));
}

View File

@@ -1,196 +0,0 @@
<template>
<span>
<span v-if="initialLiquorTreeNodes?.length > 0">
<LiquorTree
:options="liquorTreeOptions"
:data="initialLiquorTreeNodes"
@node:checked="nodeSelected($event)"
@node:unchecked="nodeSelected($event)"
ref="liquorTree"
>
<template v-slot:default="{ node }">
<span class="tree-text">
<NodeContent :data="convertExistingToNode(node)" />
</span>
</template>
</LiquorTree>
</span>
<span v-else>Nooo 😢</span>
</span>
</template>
<script lang="ts">
import {
PropType, defineComponent, ref, watch,
} from 'vue';
import LiquorTree, {
ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeNode, ILiquorTreeNodeState,
} from 'liquor-tree';
import { sleep } from '@/infrastructure/Threading/AsyncSleep';
import NodeContent from './Node/NodeContent.vue';
import { INodeContent } from './Node/INodeContent';
import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeWrapper/NodeTranslator';
import { INodeSelectedEvent } from './INodeSelectedEvent';
import { getNewState } from './LiquorTree/NodeWrapper/NodeStateUpdater';
import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions';
import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodeWrapper/NodePredicateFilter';
/**
* Wrapper for Liquor Tree, reveals only abstracted INode for communication.
* Stateless to make it easier to switch out Liquor Tree to another component.
*/
export default defineComponent({
components: {
LiquorTree,
NodeContent,
},
props: {
filterPredicate: {
type: Function as PropType<FilterPredicate>,
default: undefined,
},
filterText: {
type: String,
default: undefined,
},
selectedNodeIds: {
type: Array as PropType<ReadonlyArray<string>>,
default: undefined,
},
initialNodes: {
type: Array as PropType<ReadonlyArray<INodeContent>>,
default: undefined,
},
},
setup(props, { emit }) {
const liquorTree = ref< { tree: ILiquorTree }>();
const initialLiquorTreeNodes = ref<ReadonlyArray<ILiquorTreeNewNode>>();
const liquorTreeOptions = new LiquorTreeOptions(
new NodePredicateFilter((node) => props.filterPredicate(node)),
);
function nodeSelected(node: ILiquorTreeExistingNode) {
const event: INodeSelectedEvent = {
node: convertExistingToNode(node),
isSelected: node.states.checked,
};
emit('nodeSelected', event);
}
watch(
() => props.initialNodes,
(nodes) => setInitialNodes(nodes),
{ immediate: true },
);
watch(
() => props.filterText,
(filterText) => setFilterText(filterText),
{ immediate: true },
);
watch(
() => props.selectedNodeIds,
(selectedNodeIds) => setSelectedStatus(selectedNodeIds),
);
async function setInitialNodes(nodes: readonly INodeContent[]) {
if (!nodes) {
throw new Error('missing initial nodes');
}
const initialNodes = nodes.map((node) => toNewLiquorTreeNode(node));
if (props.selectedNodeIds) {
recurseDown(
initialNodes,
(node) => {
node.state = updateState(node.state, node, props.selectedNodeIds);
},
);
}
initialLiquorTreeNodes.value = initialNodes;
const api = await getLiquorTreeApi();
api.setModel(initialLiquorTreeNodes.value);
}
async function setFilterText(filterText?: string) {
const api = await getLiquorTreeApi();
if (!filterText) {
api.clearFilter();
} else {
api.filter('filtered'); // text does not matter, it'll trigger the filterPredicate
}
}
async function setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
if (!selectedNodeIds) {
throw new Error('Selected recurseDown nodes are undefined');
}
const tree = await getLiquorTreeApi();
tree.recurseDown(
(node) => {
node.states = updateState(node.states, node, selectedNodeIds);
},
);
}
async function getLiquorTreeApi(): Promise<ILiquorTree> {
const tree = await tryUntilDefined(
() => liquorTree.value?.tree,
5,
20,
);
if (!tree) {
throw Error('Referenced tree element cannot be found. Perhaps it\'s not yet rendered?');
}
return tree;
}
return {
liquorTreeOptions,
initialLiquorTreeNodes,
convertExistingToNode,
nodeSelected,
liquorTree,
};
},
});
function updateState(
old: ILiquorTreeNodeState,
node: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>,
): ILiquorTreeNodeState {
return { ...old, ...getNewState(node, selectedNodeIds) };
}
function recurseDown(
nodes: ReadonlyArray<ILiquorTreeNewNode>,
handler: (node: ILiquorTreeNewNode) => void,
) {
for (const node of nodes) {
handler(node);
if (node.children) {
recurseDown(node.children, handler);
}
}
}
async function tryUntilDefined<T>(
accessor: () => T | undefined,
delayInMs: number,
maxTries: number,
): Promise<T | undefined> {
let triesLeft = maxTries;
let value: T;
while (triesLeft !== 0) {
value = accessor();
if (value) {
return value;
}
triesLeft--;
// eslint-disable-next-line no-await-in-loop
await sleep(delayInMs);
}
return value;
}
</script>

View File

@@ -42,7 +42,7 @@ import {
inject,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import ScriptsTree from '@/presentation/components/Scripts/View/ScriptsTree/ScriptsTree.vue';
import ScriptsTree from '@/presentation/components/Scripts/View/Tree/ScriptsTree.vue';
import CardList from '@/presentation/components/Scripts/View/Cards/CardList.vue';
import { ViewType } from '@/presentation/components/Scripts/Menu/View/ViewType';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
@@ -139,7 +139,8 @@ $margin-inner: 4px;
padding-top: 15px;
padding-bottom: 15px;
&--searching {
padding-top: 0px;
background-color: $color-primary-darker;
padding-top: 0px;
}
}
}

View File

@@ -1,18 +1,18 @@
<template>
<DocumentableNode :docs="data.docs">
<DocumentableNode :docs="nodeMetadata.docs">
<div id="node">
<div class="item text">{{ data.text }}</div>
<div class="item text">{{ nodeMetadata.text }}</div>
<RevertToggle
class="item"
v-if="data.isReversible"
:node="data" />
v-if="nodeMetadata.isReversible"
:node="nodeMetadata" />
</div>
</DocumentableNode>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { INodeContent } from './INodeContent';
import { NodeMetadata } from './NodeMetadata';
import RevertToggle from './RevertToggle.vue';
import DocumentableNode from './Documentation/DocumentableNode.vue';
@@ -22,8 +22,8 @@ export default defineComponent({
DocumentableNode,
},
props: {
data: {
type: Object as PropType<INodeContent>,
nodeMetadata: {
type: Object as PropType<NodeMetadata>,
required: true,
},
},

View File

@@ -3,11 +3,11 @@ export enum NodeType {
Category,
}
export interface INodeContent {
export interface NodeMetadata {
readonly id: string;
readonly text: string;
readonly isReversible: boolean;
readonly docs: ReadonlyArray<string>;
readonly children?: ReadonlyArray<INodeContent>;
readonly children?: ReadonlyArray<NodeMetadata>;
readonly type: NodeType;
}

View File

@@ -13,8 +13,8 @@ import {
} from 'vue';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { NodeMetadata } from '@/presentation/components/Scripts/View/Tree/NodeContent/NodeMetadata';
import { IReverter } from './Reverter/IReverter';
import { INodeContent } from './INodeContent';
import { getReverter } from './Reverter/ReverterFactory';
import ToggleSwitch from './ToggleSwitch.vue';
@@ -24,7 +24,7 @@ export default defineComponent({
},
props: {
node: {
type: Object as PropType<INodeContent>,
type: Object as PropType<NodeMetadata>,
required: true,
},
},
@@ -51,7 +51,7 @@ export default defineComponent({
]);
}, { immediate: true });
function onNodeChanged(node: INodeContent) {
function onNodeChanged(node: NodeMetadata) {
handler = getReverter(node, currentState.value.collection);
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
}

View File

@@ -1,7 +1,7 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { getCategoryId } from '../../../ScriptNodeParser';
import { getCategoryId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';

View File

@@ -1,10 +1,10 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { INodeContent, NodeType } from '../INodeContent';
import { NodeMetadata, NodeType } from '../NodeMetadata';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';
import { CategoryReverter } from './CategoryReverter';
export function getReverter(node: INodeContent, collection: ICategoryCollection): IReverter {
export function getReverter(node: NodeMetadata, collection: ICategoryCollection): IReverter {
switch (node.type) {
case NodeType.Category:
return new CategoryReverter(node.id, collection);

View File

@@ -1,6 +1,6 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { getScriptId } from '../../../ScriptNodeParser';
import { getScriptId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import { IReverter } from './IReverter';
export class ScriptReverter implements IReverter {

View File

@@ -0,0 +1,58 @@
<template>
<span id="container">
<span v-if="initialNodes.length">
<TreeView
:initialNodes="initialNodes"
:selectedLeafNodeIds="selectedScriptNodeIds"
:latestFilterEvent="latestFilterEvent"
@nodeStateChanged="handleNodeChangedEvent($event)"
>
<template v-slot:node-content="{ nodeMetadata }">
<NodeContent :nodeMetadata="nodeMetadata" />
</template>
</TreeView>
</span>
<span v-else>Nooo 😢</span>
</span>
</template>
<script lang="ts">
import { defineComponent } from 'vue';
import TreeView from './TreeView/TreeView.vue';
import NodeContent from './NodeContent/NodeContent.vue';
import { useTreeViewFilterEvent } from './TreeViewAdapter/UseTreeViewFilterEvent';
import { useTreeViewNodeInput } from './TreeViewAdapter/UseTreeViewNodeInput';
import { useCollectionSelectionStateUpdater } from './TreeViewAdapter/UseCollectionSelectionStateUpdater';
import { TreeNodeStateChangedEmittedEvent } from './TreeView/Bindings/TreeNodeStateChangedEmittedEvent';
import { useSelectedScriptNodeIds } from './TreeViewAdapter/UseSelectedScriptNodeIds';
export default defineComponent({
props: {
categoryId: {
type: [Number, undefined],
default: undefined,
},
},
components: {
TreeView,
NodeContent,
},
setup(props) {
const { selectedScriptNodeIds } = useSelectedScriptNodeIds();
const { latestFilterEvent } = useTreeViewFilterEvent();
const { treeViewInputNodes } = useTreeViewNodeInput(() => props.categoryId);
const { updateNodeSelection } = useCollectionSelectionStateUpdater();
function handleNodeChangedEvent(event: TreeNodeStateChangedEmittedEvent) {
updateNodeSelection(event);
}
return {
initialNodes: treeViewInputNodes,
selectedScriptNodeIds,
latestFilterEvent,
handleNodeChangedEvent,
};
},
});
</script>

View File

@@ -0,0 +1,38 @@
import type { ReadOnlyTreeNode } from '../Node/TreeNode';
export interface TreeViewFilterEvent {
readonly action: TreeViewFilterAction;
/**
* A simple numeric value to ensure uniqueness of each event.
*
* This property is used to guarantee that the watch function will trigger
* even if the same filter action value is emitted consecutively.
*/
readonly timestamp: Date;
readonly predicate?: TreeViewFilterPredicate;
}
export enum TreeViewFilterAction {
Triggered,
Removed,
}
export type TreeViewFilterPredicate = (node: ReadOnlyTreeNode) => boolean;
export function createFilterTriggeredEvent(
predicate: TreeViewFilterPredicate,
): TreeViewFilterEvent {
return {
action: TreeViewFilterAction.Triggered,
timestamp: new Date(),
predicate,
};
}
export function createFilterRemovedEvent(): TreeViewFilterEvent {
return {
action: TreeViewFilterAction.Removed,
timestamp: new Date(),
};
}

View File

@@ -0,0 +1,6 @@
export interface TreeInputNodeData {
readonly id: string;
readonly children?: readonly TreeInputNodeData[];
readonly parent?: TreeInputNodeData | null;
readonly data?: object;
}

View File

@@ -0,0 +1,7 @@
import { NodeStateChangedEvent } from '../Node/State/StateAccess';
import { ReadOnlyTreeNode } from '../Node/TreeNode';
export interface TreeNodeStateChangedEmittedEvent {
readonly change: NodeStateChangedEvent;
readonly node: ReadOnlyTreeNode;
}

View File

@@ -0,0 +1,197 @@
<template>
<div class="wrapper" v-if="currentNode">
<div
class="expansible-node"
@click="toggleCheck"
:style="{
'padding-left': `${currentNode.hierarchy.depthInTree * 24}px`,
}">
<div
class="expand-collapse-arrow"
:class="{
expanded: expanded,
'has-children': hasChildren,
}"
@click.stop="toggleExpand"
/>
<LeafTreeNode
:nodeId="nodeId"
:treeRoot="treeRoot"
>
<template v-slot:node-content="slotProps">
<slot name="node-content" v-bind="slotProps" />
</template>
</LeafTreeNode>
</div>
<transition name="children-transition">
<ul
v-if="hasChildren && expanded"
class="children"
>
<HierarchicalTreeNode
v-for="id in renderedNodeIds"
:key="id"
:nodeId="id"
:treeRoot="treeRoot"
:renderingStrategy="renderingStrategy"
>
<template v-slot:node-content="slotProps">
<slot name="node-content" v-bind="slotProps" />
</template>
</HierarchicalTreeNode>
</ul>
</transition>
</div>
</template>
<script lang="ts">
import {
defineComponent, computed, PropType,
} from 'vue';
import { TreeRoot } from '../TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { NodeRenderingStrategy } from '../Rendering/NodeRenderingStrategy';
import { useNodeState } from './UseNodeState';
import { TreeNode } from './TreeNode';
import LeafTreeNode from './LeafTreeNode.vue';
export default defineComponent({
name: 'HierarchicalTreeNode', // Needed due to recursion
components: {
LeafTreeNode,
},
props: {
nodeId: {
type: String,
required: true,
},
treeRoot: {
type: Object as PropType<TreeRoot>,
required: true,
},
renderingStrategy: {
type: Object as PropType<NodeRenderingStrategy>,
required: true,
},
},
setup(props) {
const { nodes } = useCurrentTreeNodes(() => props.treeRoot);
const currentNode = computed<TreeNode | undefined>(
() => nodes.value?.getNodeById(props.nodeId),
);
const { state } = useNodeState(() => currentNode.value);
const expanded = computed<boolean>(() => state.value?.isExpanded ?? false);
const renderedNodeIds = computed<readonly string[]>(
() => currentNode.value
?.hierarchy
.children
.filter((child) => props.renderingStrategy.shouldRender(child))
.map((child) => child.id)
?? [],
);
function toggleExpand() {
currentNode.value?.state.toggleExpand();
}
function toggleCheck() {
currentNode.value?.state.toggleCheck();
}
const hasChildren = computed<boolean>(
() => currentNode.value?.hierarchy.isBranchNode,
);
return {
renderedNodeIds,
expanded,
toggleCheck,
toggleExpand,
currentNode,
hasChildren,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
@use "./../tree-colors" as *;
.wrapper {
display: flex;
flex-direction: column;
cursor: pointer;
.children {
@include reset-ul;
}
}
.expansible-node {
display: flex;
flex-direction: row;
align-items: center;
@include hover-or-touch {
background: $color-node-highlight-bg;
}
.expand-collapse-arrow {
flex-shrink: 0;
height: 30px;
cursor: pointer;
margin-left: 30px;
width: 0;
&:after {
position: absolute;
display: block;
content: "";
}
&.has-children {
margin-left: 0;
width: 30px;
position: relative;
&:after {
border: 1.5px solid $color-node-arrow;
position: absolute;
border-left: 0;
border-top: 0;
left: 9px;
top: 50%;
height: 9px;
width: 9px;
transform: rotate(-45deg) translateY(-50%) translateX(0);
transition: transform .25s;
transform-origin: center;
}
&.expanded:after {
transform: rotate(45deg) translateY(-50%) translateX(-5px);
}
}
}
}
@mixin left-fade-transition($name) {
.#{$name}-enter-active,
.#{$name}-leave-active {
transition: opacity .3s, transform .3s;
transform: translateX(0);
}
.#{$name}-enter,
// Vue 2.X compatibility
.#{$name}-enter-from,
// Vue 3.X compatibility
.#{$name}-leave-to {
opacity: 0;
transform: translateX(-2em);
}
}
@include left-fade-transition('children-transition');
</style>

View File

@@ -0,0 +1,19 @@
import type { ReadOnlyTreeNode, TreeNode } from '../TreeNode';
export interface HierarchyReader {
readonly depthInTree: number;
readonly parent: ReadOnlyTreeNode | undefined;
readonly children: readonly ReadOnlyTreeNode[];
readonly isLeafNode: boolean;
readonly isBranchNode: boolean;
}
export interface HierarchyWriter {
setParent(parent: TreeNode): void;
setChildren(children: readonly TreeNode[]): void;
}
export interface HierarchyAccess extends HierarchyReader, HierarchyWriter {
readonly parent: TreeNode | undefined;
readonly children: readonly TreeNode[];
}

View File

@@ -0,0 +1,31 @@
import { TreeNode } from '../TreeNode';
import { HierarchyAccess } from './HierarchyAccess';
export class TreeNodeHierarchy implements HierarchyAccess {
public parent: TreeNode | undefined = undefined;
public get depthInTree(): number {
if (!this.parent) {
return 0;
}
return this.parent.hierarchy.depthInTree + 1;
}
public get isLeafNode(): boolean {
return this.children.length === 0;
}
public get isBranchNode(): boolean {
return this.children.length > 0;
}
public children: readonly TreeNode[];
public setChildren(children: readonly TreeNode[]): void {
this.children = children;
}
public setParent(parent: TreeNode): void {
this.parent = parent;
}
}

View File

@@ -0,0 +1,190 @@
<template>
<li
v-if="currentNode"
class="wrapper"
@click.stop="toggleCheckState"
>
<div
class="node focusable"
@focus="onNodeFocus"
tabindex="-1"
:class="{
'keyboard-focus': hasKeyboardFocus,
}"
>
<div
class="checkbox"
:class="{
checked: checked,
indeterminate: indeterminate,
}"
/>
<div class="content">
<slot
name="node-content"
:nodeMetadata="currentNode.metadata"
/>
</div>
</div>
</li>
</template>
<script lang="ts">
import {
defineComponent, computed, PropType,
} from 'vue';
import { TreeRoot } from '../TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { useNodeState } from './UseNodeState';
import { useKeyboardInteractionState } from './UseKeyboardInteractionState';
import { TreeNode } from './TreeNode';
import { TreeNodeCheckState } from './State/CheckState';
export default defineComponent({
props: {
nodeId: {
type: String,
required: true,
},
treeRoot: {
type: Object as PropType<TreeRoot>,
required: true,
},
},
setup(props) {
const { isKeyboardBeingUsed } = useKeyboardInteractionState();
const { nodes } = useCurrentTreeNodes(() => props.treeRoot);
const currentNode = computed<TreeNode | undefined>(
() => nodes.value?.getNodeById(props.nodeId),
);
const { state } = useNodeState(() => currentNode.value);
const hasFocus = computed<boolean>(() => state.value?.isFocused ?? false);
const checked = computed<boolean>(() => state.value?.checkState === TreeNodeCheckState.Checked);
const indeterminate = computed<boolean>(
() => state.value?.checkState === TreeNodeCheckState.Indeterminate,
);
const hasKeyboardFocus = computed<boolean>(() => {
if (!isKeyboardBeingUsed.value) {
return false;
}
return hasFocus.value;
});
const onNodeFocus = () => {
if (!currentNode.value) {
return;
}
props.treeRoot.focus.setSingleFocus(currentNode.value);
};
function toggleCheckState() {
currentNode.value?.state.toggleCheck();
}
return {
onNodeFocus,
toggleCheckState,
indeterminate,
checked,
currentNode,
hasKeyboardFocus,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
@use "./../tree-colors" as *;
.wrapper {
flex: 1;
padding-bottom: 3px;
padding-top: 3px;
.focusable {
outline: none; // We handle keyboard focus through own styling
}
.node {
display: flex;
align-items: center;
padding-bottom: 3px;
padding-top: 3px;
padding-right: 6px;
cursor: pointer;
width: 100%;
box-sizing: border-box;
&.keyboard-focus {
background: $color-node-highlight-bg;
}
@include hover-or-touch {
background: $color-node-highlight-bg;
}
.checkbox {
flex-shrink: 0;
position: relative;
width: 30px;
height: 30px;
box-sizing: border-box;
border: 1px solid $color-node-checkbox-border-unchecked;
border-radius: 2px;
transition: border-color .25s, background-color .25s;
background: $color-node-checkbox-bg-unchecked;
&:after {
position: absolute;
display: block;
content: "";
}
&.indeterminate {
border-color: $color-node-checkbox-border-unchecked;
&:after {
background-color: $color-node-checkbox-border-indeterminate;
top: 50%;
left: 20%;
right: 20%;
height: 2px;
}
}
&.checked {
background: $color-node-checkbox-bg-checked;
border-color: $color-node-checkbox-border-checked;
&:after {
box-sizing: content-box;
border: 1.5px solid $color-node-checkbox-tick-checked;
/* probably width would be rounded in most cases */
border-left: 0;
border-top: 0;
left: 9px;
top: 3px;
height: 15px;
width: 8px;
transform: rotate(45deg) scaleY(1);
transition: transform .25s;
transform-origin: center;
}
}
}
.content {
padding-left: 9px;
padding-right: 6px;
flex-grow: 2;
text-decoration: none;
color: $color-node-fg;
line-height: 24px;
user-select: none;
font-size: 1.5em;
}
}
}
</style>

View File

@@ -0,0 +1,5 @@
export enum TreeNodeCheckState {
Unchecked = 0,
Checked = 1,
Indeterminate = 2,
}

View File

@@ -0,0 +1,43 @@
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { TreeNodeStateDescriptor } from './StateDescriptor';
import { TreeNodeCheckState } from './CheckState';
export interface NodeStateChangedEvent {
readonly oldState: TreeNodeStateDescriptor;
readonly newState: TreeNodeStateDescriptor;
}
export interface TreeNodeStateReader {
readonly current: TreeNodeStateDescriptor;
readonly changed: IEventSource<NodeStateChangedEvent>;
}
/*
The transactional approach allows for batched state changes.
Instead of firing a state change event for every single operation,
multiple changes can be batched into a single transaction.
This ensures that listeners to the state change event are
only notified once per batch of changes, optimizing performance
and reducing potential event handling overhead.
*/
export interface TreeNodeStateTransactor {
beginTransaction(): TreeNodeStateTransaction;
commitTransaction(transaction: TreeNodeStateTransaction): void;
}
export interface TreeNodeStateTransaction {
withExpansionState(isExpanded: boolean): TreeNodeStateTransaction;
withMatchState(isMatched: boolean): TreeNodeStateTransaction;
withFocusState(isFocused: boolean): TreeNodeStateTransaction;
withVisibilityState(isVisible: boolean): TreeNodeStateTransaction;
withCheckState(checkState: TreeNodeCheckState): TreeNodeStateTransaction;
readonly updatedState: Partial<TreeNodeStateDescriptor>;
}
export interface TreeNodeStateWriter extends TreeNodeStateTransactor {
toggleCheck(): void;
toggleExpand(): void;
}
export interface TreeNodeStateAccess
extends TreeNodeStateReader, TreeNodeStateWriter { }

View File

@@ -0,0 +1,9 @@
import { TreeNodeCheckState } from './CheckState';
export interface TreeNodeStateDescriptor {
readonly checkState: TreeNodeCheckState;
readonly isExpanded: boolean;
readonly isVisible: boolean;
readonly isMatched: boolean;
readonly isFocused: boolean;
}

View File

@@ -0,0 +1,66 @@
import { EventSource } from '@/infrastructure/Events/EventSource';
import { NodeStateChangedEvent, TreeNodeStateAccess, TreeNodeStateTransaction } from './StateAccess';
import { TreeNodeStateDescriptor } from './StateDescriptor';
import { TreeNodeCheckState } from './CheckState';
import { TreeNodeStateTransactionDescriber } from './TreeNodeStateTransactionDescriber';
export class TreeNodeState implements TreeNodeStateAccess {
public current: TreeNodeStateDescriptor = {
checkState: TreeNodeCheckState.Unchecked,
isExpanded: false,
isVisible: true,
isMatched: false,
isFocused: false,
};
public readonly changed = new EventSource<NodeStateChangedEvent>();
public beginTransaction(): TreeNodeStateTransaction {
return new TreeNodeStateTransactionDescriber();
}
public commitTransaction(transaction: TreeNodeStateTransaction): void {
const oldState = this.current;
const newState: TreeNodeStateDescriptor = {
...this.current,
...transaction.updatedState,
};
if (areEqual(oldState, newState)) {
return;
}
this.current = newState;
const event: NodeStateChangedEvent = {
oldState,
newState,
};
this.changed.notify(event);
}
public toggleCheck(): void {
const checkStateTransitions: {
readonly [K in TreeNodeCheckState]: TreeNodeCheckState;
} = {
[TreeNodeCheckState.Checked]: TreeNodeCheckState.Unchecked,
[TreeNodeCheckState.Unchecked]: TreeNodeCheckState.Checked,
[TreeNodeCheckState.Indeterminate]: TreeNodeCheckState.Unchecked,
};
this.commitTransaction(
this.beginTransaction().withCheckState(checkStateTransitions[this.current.checkState]),
);
}
public toggleExpand(): void {
this.commitTransaction(
this.beginTransaction().withExpansionState(!this.current.isExpanded),
);
}
}
function areEqual(first: TreeNodeStateDescriptor, second: TreeNodeStateDescriptor): boolean {
return first.isFocused === second.isFocused
&& first.isMatched === second.isMatched
&& first.isVisible === second.isVisible
&& first.isExpanded === second.isExpanded
&& first.checkState === second.checkState;
}

View File

@@ -0,0 +1,44 @@
import { TreeNodeCheckState } from './CheckState';
import { TreeNodeStateTransaction } from './StateAccess';
import { TreeNodeStateDescriptor } from './StateDescriptor';
export class TreeNodeStateTransactionDescriber implements TreeNodeStateTransaction {
constructor(public updatedState: Partial<TreeNodeStateDescriptor> = {}) { }
public withExpansionState(isExpanded: boolean): TreeNodeStateTransaction {
return this.describeChange({
isExpanded,
});
}
public withMatchState(isMatched: boolean): TreeNodeStateTransaction {
return this.describeChange({
isMatched,
});
}
public withFocusState(isFocused: boolean): TreeNodeStateTransaction {
return this.describeChange({
isFocused,
});
}
public withVisibilityState(isVisible: boolean): TreeNodeStateTransaction {
return this.describeChange({
isVisible,
});
}
public withCheckState(checkState: TreeNodeCheckState): TreeNodeStateTransaction {
return this.describeChange({
checkState,
});
}
private describeChange(changedState: Partial<TreeNodeStateDescriptor>): TreeNodeStateTransaction {
return new TreeNodeStateTransactionDescriber({
...this.updatedState,
...changedState,
});
}
}

View File

@@ -0,0 +1,14 @@
import type { HierarchyAccess, HierarchyReader } from './Hierarchy/HierarchyAccess';
import type { TreeNodeStateAccess, TreeNodeStateReader } from './State/StateAccess';
export interface ReadOnlyTreeNode {
readonly id: string;
readonly state: TreeNodeStateReader;
readonly hierarchy: HierarchyReader;
readonly metadata?: object;
}
export interface TreeNode extends ReadOnlyTreeNode {
readonly state: TreeNodeStateAccess;
readonly hierarchy: HierarchyAccess;
}

View File

@@ -0,0 +1,21 @@
import { TreeNode } from './TreeNode';
import { TreeNodeStateAccess } from './State/StateAccess';
import { TreeNodeState } from './State/TreeNodeState';
import { HierarchyAccess } from './Hierarchy/HierarchyAccess';
import { TreeNodeHierarchy } from './Hierarchy/TreeNodeHierarchy';
export class TreeNodeManager implements TreeNode {
public readonly state: TreeNodeStateAccess;
public readonly hierarchy: HierarchyAccess;
constructor(public readonly id: string, public readonly metadata?: object) {
if (!id) {
throw new Error('missing id');
}
this.hierarchy = new TreeNodeHierarchy();
this.state = new TreeNodeState();
}
}

View File

@@ -0,0 +1,36 @@
import { ref, onMounted, onUnmounted } from 'vue';
export function useKeyboardInteractionState(window: WindowWithEventListeners = globalThis.window) {
const isKeyboardBeingUsed = ref(false);
const enableKeyboardFocus = () => {
if (isKeyboardBeingUsed.value) {
return;
}
isKeyboardBeingUsed.value = true;
};
const disableKeyboardFocus = () => {
if (!isKeyboardBeingUsed.value) {
return;
}
isKeyboardBeingUsed.value = false;
};
onMounted(() => {
window.addEventListener('keydown', enableKeyboardFocus, true);
window.addEventListener('click', disableKeyboardFocus, true);
});
onUnmounted(() => {
window.removeEventListener('keydown', enableKeyboardFocus);
window.removeEventListener('click', disableKeyboardFocus);
});
return { isKeyboardBeingUsed };
}
export interface WindowWithEventListeners {
addEventListener: typeof global.window.addEventListener;
removeEventListener: typeof global.window.removeEventListener;
}

View File

@@ -0,0 +1,30 @@
import {
WatchSource, inject, ref, watch,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { ReadOnlyTreeNode } from './TreeNode';
import { TreeNodeStateDescriptor } from './State/StateDescriptor';
export function useNodeState(
nodeWatcher: WatchSource<ReadOnlyTreeNode | undefined>,
) {
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const state = ref<TreeNodeStateDescriptor>();
watch(nodeWatcher, (node: ReadOnlyTreeNode) => {
if (!node) {
return;
}
state.value = node.state.current;
events.unsubscribeAllAndRegister([
node.state.changed.on((change) => {
state.value = change.newState;
}),
]);
}, { immediate: true });
return {
state,
};
}

View File

@@ -0,0 +1,5 @@
import { TreeNode } from '../Node/TreeNode';
export interface NodeRenderingStrategy {
shouldRender(node: TreeNode): boolean;
}

View File

@@ -0,0 +1,117 @@
import {
WatchSource, computed, shallowRef, triggerRef, watch,
} from 'vue';
import { ReadOnlyTreeNode } from '../Node/TreeNode';
import { useNodeStateChangeAggregator } from '../UseNodeStateChangeAggregator';
import { TreeRoot } from '../TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { NodeRenderingStrategy } from './NodeRenderingStrategy';
/**
* Renders tree nodes gradually to prevent UI freeze when loading large amounts of nodes.
*/
export function useGradualNodeRendering(
treeWatcher: WatchSource<TreeRoot>,
): NodeRenderingStrategy {
const nodesToRender = new Set<ReadOnlyTreeNode>();
const nodesBeingRendered = shallowRef(new Set<ReadOnlyTreeNode>());
let isFirstRender = true;
let isRenderingInProgress = false;
const renderingDelayInMs = 50;
const initialBatchSize = 30;
const subsequentBatchSize = 5;
const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher);
const { nodes } = useCurrentTreeNodes(treeWatcher);
const orderedNodes = computed<readonly ReadOnlyTreeNode[]>(() => nodes.value.flattenedNodes);
watch(() => orderedNodes.value, (newNodes) => {
newNodes.forEach((node) => updateNodeRenderQueue(node));
}, { immediate: true });
function updateNodeRenderQueue(node: ReadOnlyTreeNode) {
if (node.state.current.isVisible
&& !nodesToRender.has(node)
&& !nodesBeingRendered.value.has(node)) {
nodesToRender.add(node);
if (!isRenderingInProgress) {
scheduleRendering();
}
} else if (!node.state.current.isVisible) {
if (nodesToRender.has(node)) {
nodesToRender.delete(node);
}
if (nodesBeingRendered.value.has(node)) {
nodesBeingRendered.value.delete(node);
triggerRef(nodesBeingRendered);
}
}
}
onNodeStateChange((node, change) => {
if (change.newState.isVisible === change.oldState.isVisible) {
return;
}
updateNodeRenderQueue(node);
});
scheduleRendering();
function scheduleRendering() {
if (isFirstRender) {
renderNodeBatch();
isFirstRender = false;
} else {
const delayScheduler = new DelayScheduler(renderingDelayInMs);
delayScheduler.schedule(renderNodeBatch);
}
}
function renderNodeBatch() {
if (nodesToRender.size === 0) {
isRenderingInProgress = false;
return;
}
isRenderingInProgress = true;
const batchSize = isFirstRender ? initialBatchSize : subsequentBatchSize;
const sortedNodes = Array.from(nodesToRender).sort(
(a, b) => orderedNodes.value.indexOf(a) - orderedNodes.value.indexOf(b),
);
const currentBatch = sortedNodes.slice(0, batchSize);
currentBatch.forEach((node) => {
nodesToRender.delete(node);
nodesBeingRendered.value.add(node);
});
triggerRef(nodesBeingRendered);
if (nodesToRender.size > 0) {
scheduleRendering();
}
}
function shouldNodeBeRendered(node: ReadOnlyTreeNode) {
return nodesBeingRendered.value.has(node);
}
return {
shouldRender: shouldNodeBeRendered,
};
}
class DelayScheduler {
private timeoutId: ReturnType<typeof setTimeout> = null;
constructor(private delay: number) {}
schedule(callback: () => void) {
this.clear();
this.timeoutId = setTimeout(callback, this.delay);
}
clear() {
if (this.timeoutId) {
clearTimeout(this.timeoutId);
this.timeoutId = null;
}
}
}

View File

@@ -0,0 +1,21 @@
import { ReadOnlyTreeNode, TreeNode } from '../../Node/TreeNode';
import { TreeNodeCollection } from '../NodeCollection/TreeNodeCollection';
import { SingleNodeFocusManager } from './SingleNodeFocusManager';
export class SingleNodeCollectionFocusManager implements SingleNodeFocusManager {
public get currentSingleFocusedNode(): TreeNode | undefined {
const focusedNodes = this.collection.nodes.flattenedNodes.filter(
(node) => node.state.current.isFocused,
);
return focusedNodes.length === 1 ? focusedNodes[0] : undefined;
}
public setSingleFocus(focusedNode: ReadOnlyTreeNode): void {
this.collection.nodes.flattenedNodes.forEach((node) => {
const isFocused = node === focusedNode;
node.state.commitTransaction(node.state.beginTransaction().withFocusState(isFocused));
});
}
constructor(private readonly collection: TreeNodeCollection) { }
}

View File

@@ -0,0 +1,6 @@
import { ReadOnlyTreeNode, TreeNode } from '../../Node/TreeNode';
export interface SingleNodeFocusManager {
readonly currentSingleFocusedNode: TreeNode | undefined;
setSingleFocus(focusedNode: ReadOnlyTreeNode): void;
}

View File

@@ -0,0 +1,15 @@
import { ReadOnlyTreeNode, TreeNode } from '../../../Node/TreeNode';
export interface ReadOnlyQueryableNodes {
readonly rootNodes: readonly ReadOnlyTreeNode[];
readonly flattenedNodes: readonly ReadOnlyTreeNode[];
getNodeById(id: string): ReadOnlyTreeNode;
}
export interface QueryableNodes extends ReadOnlyQueryableNodes {
readonly rootNodes: readonly TreeNode[];
readonly flattenedNodes: readonly TreeNode[];
getNodeById(id: string): TreeNode;
}

View File

@@ -0,0 +1,28 @@
import { TreeNode } from '../../../Node/TreeNode';
import { QueryableNodes } from './QueryableNodes';
export class TreeNodeNavigator implements QueryableNodes {
public readonly flattenedNodes: readonly TreeNode[];
constructor(public readonly rootNodes: readonly TreeNode[]) {
this.flattenedNodes = flattenNodes(rootNodes);
}
public getNodeById(id: string): TreeNode {
const foundNode = this.flattenedNodes.find((node) => node.id === id);
if (!foundNode) {
throw new Error(`Node could not be found: ${id}`);
}
return foundNode;
}
}
function flattenNodes(nodes: readonly TreeNode[]): TreeNode[] {
return nodes.reduce((flattenedNodes, node) => {
flattenedNodes.push(node);
if (node.hierarchy.children) {
flattenedNodes.push(...flattenNodes(node.hierarchy.children));
}
return flattenedNodes;
}, new Array<TreeNode>());
}

View File

@@ -0,0 +1,26 @@
import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData';
import { TreeNode } from '../../Node/TreeNode';
import { TreeNodeManager } from '../../Node/TreeNodeManager';
export function parseTreeInput(
input: readonly TreeInputNodeData[],
): TreeNode[] {
if (!input) {
throw new Error('missing input');
}
if (!Array.isArray(input)) {
throw new Error('input data must be an array');
}
const nodes = input.map((nodeData) => createNode(nodeData));
return nodes;
}
function createNode(input: TreeInputNodeData): TreeNode {
const node = new TreeNodeManager(input.id, input.data);
node.hierarchy.setChildren(input.children?.map((child) => {
const childNode = createNode(child);
childNode.hierarchy.setParent(node);
return childNode;
}) ?? []);
return node;
}

View File

@@ -0,0 +1,15 @@
import { IEventSource } from '@/infrastructure/Events/IEventSource';
import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData';
import { QueryableNodes, ReadOnlyQueryableNodes } from './Query/QueryableNodes';
export interface ReadOnlyTreeNodeCollection {
readonly nodes: ReadOnlyQueryableNodes;
readonly nodesUpdated: IEventSource<ReadOnlyQueryableNodes>;
updateRootNodes(rootNodes: readonly TreeInputNodeData[]): void;
}
export interface TreeNodeCollection extends ReadOnlyTreeNodeCollection {
readonly nodes: QueryableNodes;
readonly nodesUpdated: IEventSource<QueryableNodes>;
updateRootNodes(rootNodes: readonly TreeInputNodeData[]): void;
}

View File

@@ -0,0 +1,23 @@
import { EventSource } from '@/infrastructure/Events/EventSource';
import { TreeInputNodeData } from '../../Bindings/TreeInputNodeData';
import { TreeNodeCollection } from './TreeNodeCollection';
import { parseTreeInput } from './TreeInputParser';
import { TreeNodeNavigator } from './Query/TreeNodeNavigator';
import { QueryableNodes } from './Query/QueryableNodes';
export class TreeNodeInitializerAndUpdater implements TreeNodeCollection {
public nodes: QueryableNodes = new TreeNodeNavigator([]);
public nodesUpdated = new EventSource<QueryableNodes>();
public updateRootNodes(rootNodesData: readonly TreeInputNodeData[]): void {
if (!rootNodesData?.length) {
throw new Error('missing data');
}
const rootNodes = this.treeNodeParser(rootNodesData);
this.nodes = new TreeNodeNavigator(rootNodes);
this.nodesUpdated.notify(this.nodes);
}
constructor(private readonly treeNodeParser = parseTreeInput) { }
}

View File

@@ -0,0 +1,7 @@
import { SingleNodeFocusManager } from './Focus/SingleNodeFocusManager';
import { TreeNodeCollection } from './NodeCollection/TreeNodeCollection';
export interface TreeRoot {
readonly collection: TreeNodeCollection;
readonly focus: SingleNodeFocusManager;
}

View File

@@ -0,0 +1,69 @@
<template>
<ul
class="tree-root"
>
<HierarchicalTreeNode
v-for="nodeId in renderedNodeIds"
:key="nodeId"
:nodeId="nodeId"
:treeRoot="treeRoot"
:renderingStrategy="renderingStrategy"
>
<template v-slot:node-content="slotProps">
<slot v-bind="slotProps" />
</template>
</HierarchicalTreeNode>
</ul>
</template>
<script lang="ts">
import {
defineComponent, computed, PropType,
} from 'vue';
import HierarchicalTreeNode from '../Node/HierarchicalTreeNode.vue';
import { useCurrentTreeNodes } from '../UseCurrentTreeNodes';
import { NodeRenderingStrategy } from '../Rendering/NodeRenderingStrategy';
import { TreeRoot } from './TreeRoot';
export default defineComponent({
components: {
HierarchicalTreeNode,
},
props: {
treeRoot: {
type: Object as PropType<TreeRoot>,
required: true,
},
renderingStrategy: {
type: Object as PropType<NodeRenderingStrategy>,
required: true,
},
},
setup(props) {
const { nodes } = useCurrentTreeNodes(() => props.treeRoot);
const renderedNodeIds = computed<string[]>(() => {
return nodes
.value
.rootNodes
.filter((node) => props.renderingStrategy.shouldRender(node))
.map((node) => node.id);
});
return {
renderedNodeIds,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.tree-root {
@include reset-ul;
margin-block-start: 1em;
margin-block-end: 1em;
padding-inline-start: 3px;
}
</style>

View File

@@ -0,0 +1,21 @@
import { TreeRoot } from './TreeRoot';
import { TreeNodeInitializerAndUpdater } from './NodeCollection/TreeNodeInitializerAndUpdater';
import { TreeNodeCollection } from './NodeCollection/TreeNodeCollection';
import { SingleNodeFocusManager } from './Focus/SingleNodeFocusManager';
import { SingleNodeCollectionFocusManager } from './Focus/SingleNodeCollectionFocusManager';
export class TreeRootManager implements TreeRoot {
public readonly collection: TreeNodeCollection;
public readonly focus: SingleNodeFocusManager;
constructor(
collection: TreeNodeCollection = new TreeNodeInitializerAndUpdater(),
createFocusManager: (
collection: TreeNodeCollection
) => SingleNodeFocusManager = (nodes) => new SingleNodeCollectionFocusManager(nodes),
) {
this.collection = collection;
this.focus = createFocusManager(this.collection);
}
}

View File

@@ -0,0 +1,97 @@
<template>
<div
class="tree"
ref="treeContainerElement"
>
<TreeRoot :treeRoot="tree" :renderingStrategy="nodeRenderingScheduler">
<template v-slot="slotProps">
<slot name="node-content" v-bind="slotProps" />
</template>
</TreeRoot>
</div>
</template>
<script lang="ts">
import {
defineComponent, onMounted, watch,
ref, PropType,
} from 'vue';
import { TreeRootManager } from './TreeRoot/TreeRootManager';
import TreeRoot from './TreeRoot/TreeRoot.vue';
import { TreeInputNodeData } from './Bindings/TreeInputNodeData';
import { TreeViewFilterEvent } from './Bindings/TreeInputFilterEvent';
import { useTreeQueryFilter } from './UseTreeQueryFilter';
import { useTreeKeyboardNavigation } from './UseTreeKeyboardNavigation';
import { useNodeStateChangeAggregator } from './UseNodeStateChangeAggregator';
import { useLeafNodeCheckedStateUpdater } from './UseLeafNodeCheckedStateUpdater';
import { TreeNodeStateChangedEmittedEvent } from './Bindings/TreeNodeStateChangedEmittedEvent';
import { useAutoUpdateParentCheckState } from './UseAutoUpdateParentCheckState';
import { useAutoUpdateChildrenCheckState } from './UseAutoUpdateChildrenCheckState';
import { useGradualNodeRendering } from './Rendering/UseGradualNodeRendering';
export default defineComponent({
components: {
TreeRoot,
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
nodeStateChanged: (node: TreeNodeStateChangedEmittedEvent) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
props: {
initialNodes: {
type: Array as PropType<readonly TreeInputNodeData[]>,
default: () => [],
},
latestFilterEvent: {
type: Object as PropType<TreeViewFilterEvent | undefined>,
default: () => undefined,
},
selectedLeafNodeIds: {
type: Array as PropType<ReadonlyArray<string>>,
default: () => [],
},
},
setup(props, { emit }) {
const treeContainerElement = ref<HTMLElement | undefined>();
const tree = new TreeRootManager();
useTreeKeyboardNavigation(tree, treeContainerElement);
useTreeQueryFilter(
() => props.latestFilterEvent,
() => tree,
);
useLeafNodeCheckedStateUpdater(() => tree, () => props.selectedLeafNodeIds);
useAutoUpdateParentCheckState(() => tree);
useAutoUpdateChildrenCheckState(() => tree);
const nodeRenderingScheduler = useGradualNodeRendering(() => tree);
const { onNodeStateChange } = useNodeStateChangeAggregator(() => tree);
onNodeStateChange((node, change) => {
emit('nodeStateChanged', { node, change });
});
onMounted(() => {
watch(() => props.initialNodes, (nodes) => {
tree.collection.updateRootNodes(nodes);
}, { immediate: true });
});
return {
treeContainerElement,
nodeRenderingScheduler,
tree,
};
},
});
</script>
<style scoped lang="scss">
@use "./tree-colors" as *;
.tree {
overflow: auto;
background: $color-tree-bg;
}
</style>

View File

@@ -0,0 +1,44 @@
import { WatchSource } from 'vue';
import { TreeRoot } from './TreeRoot/TreeRoot';
import { useNodeStateChangeAggregator } from './UseNodeStateChangeAggregator';
import { HierarchyAccess } from './Node/Hierarchy/HierarchyAccess';
import { TreeNodeCheckState } from './Node/State/CheckState';
export function useAutoUpdateChildrenCheckState(
treeWatcher: WatchSource<TreeRoot>,
) {
const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher);
onNodeStateChange((node, change) => {
if (change.newState.checkState === change.oldState.checkState) {
return;
}
updateChildrenCheckedState(node.hierarchy, change.newState.checkState);
});
}
function updateChildrenCheckedState(
node: HierarchyAccess,
newParentState: TreeNodeCheckState,
) {
if (node.isLeafNode) {
return;
}
if (!shouldUpdateChildren(newParentState)) {
return;
}
const { children } = node;
children.forEach((childNode) => {
if (childNode.state.current.checkState === newParentState) {
return;
}
childNode.state.commitTransaction(
childNode.state.beginTransaction().withCheckState(newParentState),
);
});
}
function shouldUpdateChildren(newParentState: TreeNodeCheckState) {
return newParentState === TreeNodeCheckState.Checked
|| newParentState === TreeNodeCheckState.Unchecked;
}

View File

@@ -0,0 +1,47 @@
import { WatchSource } from 'vue';
import { TreeRoot } from './TreeRoot/TreeRoot';
import { useNodeStateChangeAggregator } from './UseNodeStateChangeAggregator';
import { HierarchyAccess } from './Node/Hierarchy/HierarchyAccess';
import { TreeNodeCheckState } from './Node/State/CheckState';
import { ReadOnlyTreeNode } from './Node/TreeNode';
export function useAutoUpdateParentCheckState(
treeWatcher: WatchSource<TreeRoot>,
) {
const { onNodeStateChange } = useNodeStateChangeAggregator(treeWatcher);
onNodeStateChange((node, change) => {
if (change.newState.checkState === change.oldState.checkState) {
return;
}
updateNodeParentCheckedState(node.hierarchy);
});
}
function updateNodeParentCheckedState(
node: HierarchyAccess,
) {
const { parent } = node;
if (!parent) {
return;
}
const newState = getNewStateCheckedStateBasedOnChildren(parent);
if (newState === parent.state.current.checkState) {
return;
}
parent.state.commitTransaction(
parent.state.beginTransaction().withCheckState(newState),
);
}
function getNewStateCheckedStateBasedOnChildren(node: ReadOnlyTreeNode): TreeNodeCheckState {
const { children } = node.hierarchy;
const childrenStates = children.map((child) => child.state.current.checkState);
if (childrenStates.every((state) => state === TreeNodeCheckState.Unchecked)) {
return TreeNodeCheckState.Unchecked;
}
if (childrenStates.every((state) => state === TreeNodeCheckState.Checked)) {
return TreeNodeCheckState.Checked;
}
return TreeNodeCheckState.Indeterminate;
}

View File

@@ -0,0 +1,27 @@
import {
WatchSource, watch, inject, readonly, ref,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { TreeRoot } from './TreeRoot/TreeRoot';
import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
export function useCurrentTreeNodes(treeWatcher: WatchSource<TreeRoot>) {
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const tree = ref<TreeRoot>();
const nodes = ref<QueryableNodes | undefined>();
watch(treeWatcher, (newTree) => {
tree.value = newTree;
nodes.value = newTree.collection.nodes;
events.unsubscribeAllAndRegister([
newTree.collection.nodesUpdated.on((newNodes) => {
nodes.value = newNodes;
}),
]);
}, { immediate: true });
return {
nodes: readonly(nodes),
};
}

View File

@@ -0,0 +1,43 @@
import { WatchSource, watch } from 'vue';
import { TreeRoot } from './TreeRoot/TreeRoot';
import { TreeNode } from './Node/TreeNode';
import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
import { useCurrentTreeNodes } from './UseCurrentTreeNodes';
import { TreeNodeCheckState } from './Node/State/CheckState';
export function useLeafNodeCheckedStateUpdater(
treeWatcher: WatchSource<TreeRoot>,
leafNodeIdsWatcher: WatchSource<readonly string[]>,
) {
const { nodes } = useCurrentTreeNodes(treeWatcher);
watch(
[leafNodeIdsWatcher, () => nodes.value],
([nodeIds, actualNodes]) => {
updateNodeSelections(actualNodes, nodeIds);
},
{ immediate: true },
);
}
function updateNodeSelections(
nodes: QueryableNodes,
selectedNodeIds: readonly string[],
) {
nodes.flattenedNodes.forEach((node) => {
updateNodeSelection(node, selectedNodeIds);
});
}
function updateNodeSelection(
node: TreeNode,
selectedNodeIds: readonly string[],
) {
if (!node.hierarchy.isLeafNode) {
return;
}
const newState = selectedNodeIds.includes(node.id)
? TreeNodeCheckState.Checked
: TreeNodeCheckState.Unchecked;
node.state.commitTransaction(node.state.beginTransaction().withCheckState(newState));
}

View File

@@ -0,0 +1,35 @@
import { WatchSource, inject, watch } from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { TreeRoot } from './TreeRoot/TreeRoot';
import { TreeNode } from './Node/TreeNode';
import { useCurrentTreeNodes } from './UseCurrentTreeNodes';
import { NodeStateChangedEvent } from './Node/State/StateAccess';
type NodeStateChangeEventCallback = (
node: TreeNode,
stateChange: NodeStateChangedEvent,
) => void;
export function useNodeStateChangeAggregator(treeWatcher: WatchSource<TreeRoot>) {
const { nodes } = useCurrentTreeNodes(treeWatcher);
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const onNodeChangeCallbacks = new Array<NodeStateChangeEventCallback>();
watch(() => nodes.value, (newNodes) => {
events.unsubscribeAll();
newNodes.flattenedNodes.forEach((node) => {
events.register([
node.state.changed.on((stateChange) => {
onNodeChangeCallbacks.forEach((callback) => callback(node, stateChange));
}),
]);
});
});
return {
onNodeStateChange: (
callback: NodeStateChangeEventCallback,
) => onNodeChangeCallbacks.push(callback),
};
}

View File

@@ -0,0 +1,166 @@
import { onMounted, onUnmounted, Ref } from 'vue';
import { TreeRoot } from './TreeRoot/TreeRoot';
import { TreeNodeCheckState } from './Node/State/CheckState';
import { SingleNodeFocusManager } from './TreeRoot/Focus/SingleNodeFocusManager';
import { QueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
import { TreeNode } from './Node/TreeNode';
type TreeNavigationKeyCodes = 'ArrowLeft' | 'ArrowUp' | 'ArrowRight' | 'ArrowDown' | ' ' | 'Enter';
export function useTreeKeyboardNavigation(
treeRoot: TreeRoot,
treeElementRef: Ref<HTMLElement | undefined>,
) {
useKeyboardListener(treeElementRef, (event) => {
if (!treeElementRef.value) {
return; // Not yet initialized?
}
const keyCode = event.key as TreeNavigationKeyCodes;
if (!treeRoot.focus.currentSingleFocusedNode) {
return;
}
const action = KeyToActionMapping[keyCode];
if (!action) {
return;
}
event.preventDefault();
event.stopPropagation();
action({
focus: treeRoot.focus,
nodes: treeRoot.collection.nodes,
});
});
}
function useKeyboardListener(
elementRef: Ref<HTMLElement | undefined>,
handleKeyboardEvent: (event: KeyboardEvent) => void,
) {
onMounted(() => {
elementRef.value?.addEventListener('keydown', handleKeyboardEvent, true);
});
onUnmounted(() => {
elementRef.value?.removeEventListener('keydown', handleKeyboardEvent);
});
}
interface TreeNavigationContext {
readonly focus: SingleNodeFocusManager;
readonly nodes: QueryableNodes;
}
const KeyToActionMapping: Record<
TreeNavigationKeyCodes,
(context: TreeNavigationContext) => void
> = {
ArrowLeft: collapseNodeOrFocusParent,
ArrowUp: focusPreviousVisibleNode,
ArrowRight: expandNodeOrFocusFirstChild,
ArrowDown: focusNextVisibleNode,
' ': toggleTreeNodeCheckStatus,
Enter: toggleTreeNodeCheckStatus,
};
function focusPreviousVisibleNode(context: TreeNavigationContext): void {
const previousVisibleNode = findPreviousVisibleNode(
context.focus.currentSingleFocusedNode,
context.nodes,
);
if (!previousVisibleNode) {
return;
}
context.focus.setSingleFocus(previousVisibleNode);
}
function focusNextVisibleNode(context: TreeNavigationContext): void {
const focusedNode = context.focus.currentSingleFocusedNode;
const nextVisibleNode = findNextVisibleNode(focusedNode, context.nodes);
if (!nextVisibleNode) {
return;
}
context.focus.setSingleFocus(nextVisibleNode);
}
function toggleTreeNodeCheckStatus(context: TreeNavigationContext): void {
const focusedNode = context.focus.currentSingleFocusedNode;
const nodeState = focusedNode.state;
let transaction = nodeState.beginTransaction();
if (nodeState.current.checkState === TreeNodeCheckState.Checked) {
transaction = transaction.withCheckState(TreeNodeCheckState.Unchecked);
} else {
transaction = transaction.withCheckState(TreeNodeCheckState.Checked);
}
nodeState.commitTransaction(transaction);
}
function collapseNodeOrFocusParent(context: TreeNavigationContext): void {
const focusedNode = context.focus.currentSingleFocusedNode;
const nodeState = focusedNode.state;
const parentNode = focusedNode.hierarchy.parent;
if (focusedNode.hierarchy.isBranchNode && nodeState.current.isExpanded) {
nodeState.commitTransaction(
nodeState.beginTransaction().withExpansionState(false),
);
} else {
context.focus.setSingleFocus(parentNode);
}
}
function expandNodeOrFocusFirstChild(context: TreeNavigationContext): void {
const focusedNode = context.focus.currentSingleFocusedNode;
const nodeState = focusedNode.state;
if (focusedNode.hierarchy.isBranchNode && !nodeState.current.isExpanded) {
nodeState.commitTransaction(
nodeState.beginTransaction().withExpansionState(true),
);
return;
}
if (focusedNode.hierarchy.children.length === 0) {
return;
}
const firstChildNode = focusedNode.hierarchy.children[0];
if (firstChildNode) {
context.focus.setSingleFocus(firstChildNode);
}
}
function findNextVisibleNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined {
if (node.hierarchy.children.length && node.state.current.isExpanded) {
return node.hierarchy.children[0];
}
const nextNode = findNextNode(node, nodes);
const parentNode = node.hierarchy.parent;
if (!nextNode && parentNode) {
const nextSibling = findNextNode(parentNode, nodes);
return nextSibling;
}
return nextNode;
}
function findNextNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined {
const index = nodes.flattenedNodes.indexOf(node);
return nodes.flattenedNodes[index + 1] || undefined;
}
function findPreviousVisibleNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined {
const previousNode = findPreviousNode(node, nodes);
if (!previousNode) {
return node.hierarchy.parent;
}
if (previousNode.hierarchy.children.length && previousNode.state.current.isExpanded) {
return previousNode.hierarchy.children[previousNode.hierarchy.children.length - 1];
}
return previousNode;
}
function findPreviousNode(node: TreeNode, nodes: QueryableNodes): TreeNode | undefined {
const index = nodes.flattenedNodes.indexOf(node);
return nodes.flattenedNodes[index - 1] || undefined;
}

View File

@@ -0,0 +1,204 @@
import { WatchSource, watch } from 'vue';
import { TreeViewFilterAction, TreeViewFilterEvent, TreeViewFilterPredicate } from './Bindings/TreeInputFilterEvent';
import { ReadOnlyTreeNode, TreeNode } from './Node/TreeNode';
import { TreeRoot } from './TreeRoot/TreeRoot';
import { useCurrentTreeNodes } from './UseCurrentTreeNodes';
import { QueryableNodes, ReadOnlyQueryableNodes } from './TreeRoot/NodeCollection/Query/QueryableNodes';
import { TreeNodeStateTransaction } from './Node/State/StateAccess';
import { TreeNodeStateDescriptor } from './Node/State/StateDescriptor';
export function useTreeQueryFilter(
latestFilterEventWatcher: WatchSource<TreeViewFilterEvent | undefined>,
treeWatcher: WatchSource<TreeRoot>,
) {
const { nodes } = useCurrentTreeNodes(treeWatcher);
let isFiltering = false;
const statesBeforeFiltering = new NodeStateRestorer();
statesBeforeFiltering.saveStateBeforeFilter(nodes.value);
setupWatchers({
filterEventWatcher: latestFilterEventWatcher,
nodesWatcher: () => nodes.value,
onFilterTrigger: (predicate, newNodes) => runFilter(
newNodes,
predicate,
),
onFilterReset: () => resetFilter(nodes.value),
});
function resetFilter(currentNodes: QueryableNodes) {
if (!isFiltering) {
return;
}
isFiltering = false;
currentNodes.flattenedNodes.forEach((node: TreeNode) => {
let transaction = node.state.beginTransaction()
.withMatchState(false);
transaction = statesBeforeFiltering.applyOriginalState(node, transaction);
node.state.commitTransaction(transaction);
});
statesBeforeFiltering.clear();
}
function runFilter(currentNodes: QueryableNodes, predicate: TreeViewFilterPredicate) {
if (!isFiltering) {
statesBeforeFiltering.saveStateBeforeFilter(currentNodes);
isFiltering = true;
}
const { matchedNodes, unmatchedNodes } = partitionNodesByMatchCriteria(currentNodes, predicate);
const nodeTransactions = getNodeChangeTransactions(matchedNodes, unmatchedNodes);
nodeTransactions.forEach((transaction, node) => {
node.state.commitTransaction(transaction);
});
}
}
function getNodeChangeTransactions(
matchedNodes: Iterable<TreeNode>,
unmatchedNodes: Iterable<TreeNode>,
) {
const transactions = new Map<TreeNode, TreeNodeStateTransaction>();
for (const unmatchedNode of unmatchedNodes) {
addOrUpdateTransaction(unmatchedNode, (builder) => builder
.withVisibilityState(false)
.withMatchState(false));
}
for (const matchedNode of matchedNodes) {
addOrUpdateTransaction(matchedNode, (builder) => {
let transaction = builder
.withVisibilityState(true)
.withMatchState(true);
if (matchedNode.hierarchy.isBranchNode) {
transaction = transaction.withExpansionState(false);
}
return transaction;
});
traverseAllChildren(matchedNode, (childNode) => {
addOrUpdateTransaction(childNode, (builder) => builder
.withVisibilityState(true));
});
traverseAllParents(matchedNode, (parentNode) => {
addOrUpdateTransaction(parentNode, (builder) => builder
.withVisibilityState(true)
.withExpansionState(true));
});
}
function addOrUpdateTransaction(
node: TreeNode,
builder: (transaction: TreeNodeStateTransaction) => TreeNodeStateTransaction,
) {
let transaction = transactions.get(node) ?? node.state.beginTransaction();
transaction = builder(transaction);
transactions.set(node, transaction);
}
return transactions;
}
function partitionNodesByMatchCriteria(
currentNodes: QueryableNodes,
predicate: TreeViewFilterPredicate,
) {
const matchedNodes = new Set<TreeNode>();
const unmatchedNodes = new Set<TreeNode>();
currentNodes.flattenedNodes.forEach((node) => {
if (predicate(node)) {
matchedNodes.add(node);
} else {
unmatchedNodes.add(node);
}
});
return {
matchedNodes,
unmatchedNodes,
};
}
function traverseAllParents(node: TreeNode, handler: (node: TreeNode) => void) {
const parentNode = node.hierarchy.parent;
if (parentNode) {
handler(parentNode);
traverseAllParents(parentNode, handler);
}
}
function traverseAllChildren(node: TreeNode, handler: (node: TreeNode) => void) {
node.hierarchy.children.forEach((childNode) => {
handler(childNode);
traverseAllChildren(childNode, handler);
});
}
class NodeStateRestorer {
private readonly originalStates = new Map<ReadOnlyTreeNode, Partial<TreeNodeStateDescriptor>>();
public saveStateBeforeFilter(nodes: ReadOnlyQueryableNodes) {
nodes
.flattenedNodes
.forEach((node) => {
this.originalStates.set(node, {
isExpanded: node.state.current.isExpanded,
isVisible: node.state.current.isVisible,
});
});
}
public applyOriginalState(
node: TreeNode,
transaction: TreeNodeStateTransaction,
): TreeNodeStateTransaction {
if (!this.originalStates.has(node)) {
return transaction;
}
const originalState = this.originalStates.get(node);
if (originalState.isExpanded !== undefined) {
transaction = transaction.withExpansionState(originalState.isExpanded);
}
transaction = transaction.withVisibilityState(originalState.isVisible);
return transaction;
}
public clear() {
this.originalStates.clear();
}
}
function setupWatchers(options: {
filterEventWatcher: WatchSource<TreeViewFilterEvent | undefined>,
nodesWatcher: WatchSource<QueryableNodes>,
onFilterReset: () => void,
onFilterTrigger: (
predicate: TreeViewFilterPredicate,
nodes: QueryableNodes,
) => void,
}) {
watch(
[
options.filterEventWatcher,
options.nodesWatcher,
],
([filterEvent, nodes]) => {
if (!filterEvent) {
return;
}
switch (filterEvent.action) {
case TreeViewFilterAction.Triggered:
options.onFilterTrigger(filterEvent.predicate, nodes);
break;
case TreeViewFilterAction.Removed:
options.onFilterReset();
break;
default:
throw new Error(`Unknown action: ${TreeViewFilterAction[filterEvent.action]}`);
}
},
{ immediate: true },
);
}

View File

@@ -0,0 +1,13 @@
@use "@/presentation/assets/styles/main" as *;
/* Tree colors, based on global colors */
$color-tree-bg : $color-primary-darker;
$color-node-arrow : $color-on-primary;
$color-node-fg : $color-on-primary;
$color-node-highlight-bg : $color-primary-dark;
$color-node-checkbox-bg-checked : $color-secondary;
$color-node-checkbox-bg-unchecked : $color-primary-darkest;
$color-node-checkbox-border-checked : $color-secondary;
$color-node-checkbox-border-unchecked : $color-on-primary;
$color-node-checkbox-border-indeterminate : $color-on-primary;
$color-node-checkbox-tick-checked : $color-on-secondary;

View File

@@ -1,15 +1,15 @@
import { ICategory, IScript } from '@/domain/ICategory';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { INodeContent, NodeType } from './SelectableTree/Node/INodeContent';
import { NodeMetadata, NodeType } from '../NodeContent/NodeMetadata';
export function parseAllCategories(collection: ICategoryCollection): INodeContent[] | undefined {
export function parseAllCategories(collection: ICategoryCollection): NodeMetadata[] | undefined {
return createCategoryNodes(collection.actions);
}
export function parseSingleCategory(
categoryId: number,
collection: ICategoryCollection,
): INodeContent[] | undefined {
): NodeMetadata[] | undefined {
const category = collection.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id ${categoryId} does not exist`);
@@ -21,9 +21,11 @@ export function parseSingleCategory(
export function getScriptNodeId(script: IScript): string {
return script.id;
}
export function getScriptId(nodeId: string): string {
return nodeId;
}
export function getCategoryId(nodeId: string): number {
return +nodeId;
}
@@ -34,22 +36,19 @@ export function getCategoryNodeId(category: ICategory): string {
function parseCategoryRecursively(
parentCategory: ICategory,
): INodeContent[] {
if (!parentCategory) {
throw new Error('parentCategory is undefined');
}
): NodeMetadata[] {
return [
...createCategoryNodes(parentCategory.subCategories),
...createScriptNodes(parentCategory.scripts),
];
}
function createScriptNodes(scripts: ReadonlyArray<IScript>): INodeContent[] {
function createScriptNodes(scripts: ReadonlyArray<IScript>): NodeMetadata[] {
return (scripts || [])
.map((script) => convertScriptToNode(script));
}
function createCategoryNodes(categories: ReadonlyArray<ICategory>): INodeContent[] {
function createCategoryNodes(categories: ReadonlyArray<ICategory>): NodeMetadata[] {
return (categories || [])
.map((category) => ({ category, children: parseCategoryRecursively(category) }))
.map((data) => convertCategoryToNode(data.category, data.children));
@@ -57,8 +56,8 @@ function createCategoryNodes(categories: ReadonlyArray<ICategory>): INodeContent
function convertCategoryToNode(
category: ICategory,
children: readonly INodeContent[],
): INodeContent {
children: readonly NodeMetadata[],
): NodeMetadata {
return {
id: getCategoryNodeId(category),
type: NodeType.Category,
@@ -69,7 +68,7 @@ function convertCategoryToNode(
};
}
function convertScriptToNode(script: IScript): INodeContent {
function convertScriptToNode(script: IScript): NodeMetadata {
return {
id: getScriptNodeId(script),
type: NodeType.Script,

View File

@@ -0,0 +1,33 @@
import { NodeMetadata } from '../NodeContent/NodeMetadata';
import { ReadOnlyTreeNode } from '../TreeView/Node/TreeNode';
import { TreeInputNodeData } from '../TreeView/Bindings/TreeInputNodeData';
export function getNodeMetadata(
treeNode: ReadOnlyTreeNode,
): NodeMetadata {
if (!treeNode) { throw new Error('missing tree node'); }
const data = treeNode.metadata as NodeMetadata;
if (!data) {
throw new Error('Provided node does not contain the expected metadata.');
}
return data;
}
export function convertToNodeInput(metadata: NodeMetadata): TreeInputNodeData {
if (!metadata) { throw new Error('missing metadata'); }
return {
id: metadata.id,
children: convertChildren(metadata.children, convertToNodeInput),
data: metadata,
};
}
function convertChildren<TOldNode, TNewNode>(
oldChildren: readonly TOldNode[],
callback: (value: TOldNode) => TNewNode,
): TNewNode[] {
if (!oldChildren || oldChildren.length === 0) {
return [];
}
return oldChildren.map((childNode) => callback(childNode));
}

View File

@@ -0,0 +1,38 @@
import { inject } from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { TreeNodeCheckState } from '../TreeView/Node/State/CheckState';
import { TreeNodeStateChangedEmittedEvent } from '../TreeView/Bindings/TreeNodeStateChangedEmittedEvent';
export function useCollectionSelectionStateUpdater() {
const { modifyCurrentState, currentState } = inject(InjectionKeys.useCollectionState)();
const updateNodeSelection = (event: TreeNodeStateChangedEmittedEvent) => {
const { node } = event;
if (node.hierarchy.isBranchNode) {
return; // A category, let TreeView handle this
}
if (event.change.oldState.checkState === event.change.newState.checkState) {
return;
}
if (node.state.current.checkState === TreeNodeCheckState.Checked) {
if (currentState.value.selection.isSelected(node.id)) {
return;
}
modifyCurrentState((state) => {
state.selection.addSelectedScript(node.id, false);
});
}
if (node.state.current.checkState === TreeNodeCheckState.Unchecked) {
if (!currentState.value.selection.isSelected(node.id)) {
return;
}
modifyCurrentState((state) => {
state.selection.removeSelectedScript(node.id);
});
}
};
return {
updateNodeSelection,
};
}

View File

@@ -0,0 +1,40 @@
import {
computed, inject, readonly, ref,
} from 'vue';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { getScriptNodeId } from './CategoryNodeMetadataConverter';
export function useSelectedScriptNodeIds(scriptNodeIdParser = getScriptNodeId) {
const { selectedScripts } = useSelectedScripts();
const selectedNodeIds = computed<readonly string[]>(() => {
return selectedScripts
.value
.map((selected) => scriptNodeIdParser(selected.script));
});
return {
selectedScriptNodeIds: readonly(selectedNodeIds),
};
}
function useSelectedScripts() {
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
const selectedScripts = ref<readonly SelectedScript[]>([]);
onStateChange((state) => {
selectedScripts.value = state.selection.selectedScripts;
events.unsubscribeAllAndRegister([
state.selection.changed.on((scripts) => {
selectedScripts.value = scripts;
}),
]);
}, { immediate: true });
return {
selectedScripts: readonly(selectedScripts),
};
}

View File

@@ -0,0 +1,85 @@
import {
Ref, inject, readonly, ref,
} from 'vue';
import { IScript } from '@/domain/IScript';
import { ICategory } from '@/domain/ICategory';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { IReadOnlyUserFilter } from '@/application/Context/State/Filter/IUserFilter';
import { IFilterResult } from '@/application/Context/State/Filter/IFilterResult';
import { TreeViewFilterEvent, createFilterRemovedEvent, createFilterTriggeredEvent } from '../TreeView/Bindings/TreeInputFilterEvent';
import { NodeMetadata } from '../NodeContent/NodeMetadata';
import { ReadOnlyTreeNode } from '../TreeView/Node/TreeNode';
import { getNodeMetadata } from './TreeNodeMetadataConverter';
import { getCategoryNodeId, getScriptNodeId } from './CategoryNodeMetadataConverter';
type TreeNodeFilterResultPredicate = (
node: ReadOnlyTreeNode,
filterResult: IFilterResult,
) => boolean;
export function useTreeViewFilterEvent() {
const { onStateChange } = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const latestFilterEvent = ref<TreeViewFilterEvent | undefined>(undefined);
const treeNodePredicate: TreeNodeFilterResultPredicate = (node, filterResult) => filterMatches(
getNodeMetadata(node),
filterResult,
);
onStateChange((newState) => {
latestFilterEvent.value = createFilterEvent(newState.filter.currentFilter, treeNodePredicate);
events.unsubscribeAllAndRegister([
subscribeToFilterChanges(newState.filter, latestFilterEvent, treeNodePredicate),
]);
}, { immediate: true });
return {
latestFilterEvent: readonly(latestFilterEvent),
};
}
function subscribeToFilterChanges(
filter: IReadOnlyUserFilter,
latestFilterEvent: Ref<TreeViewFilterEvent>,
filterPredicate: TreeNodeFilterResultPredicate,
) {
return filter.filterChanged.on((event) => {
event.visit({
onApply: (result) => {
latestFilterEvent.value = createFilterTriggeredEvent(
(node) => filterPredicate(node, result),
);
},
onClear: () => {
latestFilterEvent.value = createFilterRemovedEvent();
},
});
});
}
function createFilterEvent(
filter: IFilterResult | undefined,
filterPredicate: TreeNodeFilterResultPredicate,
): TreeViewFilterEvent {
if (!filter) {
return createFilterRemovedEvent();
}
return createFilterTriggeredEvent(
(node) => filterPredicate(node, filter),
);
}
function filterMatches(node: NodeMetadata, filter: IFilterResult): boolean {
return containsScript(node, filter.scriptMatches)
|| containsCategory(node, filter.categoryMatches);
}
function containsScript(expected: NodeMetadata, scripts: readonly IScript[]) {
return scripts.some((existing: IScript) => expected.id === getScriptNodeId(existing));
}
function containsCategory(expected: NodeMetadata, categories: readonly ICategory[]) {
return categories.some((existing: ICategory) => expected.id === getCategoryNodeId(existing));
}

View File

@@ -0,0 +1,53 @@
import {
WatchSource, computed, inject,
ref, watch,
} from 'vue';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { InjectionKeys } from '@/presentation/injectionSymbols';
import { TreeInputNodeData } from '../TreeView/Bindings/TreeInputNodeData';
import { NodeMetadata } from '../NodeContent/NodeMetadata';
import { convertToNodeInput } from './TreeNodeMetadataConverter';
import { parseSingleCategory, parseAllCategories } from './CategoryNodeMetadataConverter';
export function useTreeViewNodeInput(
categoryIdWatcher: WatchSource<number | undefined>,
parser: CategoryNodeParser = {
parseSingle: parseSingleCategory,
parseAll: parseAllCategories,
},
nodeConverter = convertToNodeInput,
) {
const { currentState } = inject(InjectionKeys.useCollectionState)();
const categoryId = ref<number | undefined>();
watch(categoryIdWatcher, (newCategoryId) => {
categoryId.value = newCategoryId;
}, { immediate: true });
const nodes = computed<readonly TreeInputNodeData[]>(() => {
const nodeMetadataList = parseNodes(categoryId.value, currentState.value.collection, parser);
const nodeInputs = nodeMetadataList.map((node) => nodeConverter(node));
return nodeInputs;
});
return {
treeViewInputNodes: nodes,
};
}
function parseNodes(
categoryId: number | undefined,
categoryCollection: ICategoryCollection,
parser: CategoryNodeParser,
): NodeMetadata[] {
if (categoryId !== undefined) {
return parser.parseSingle(categoryId, categoryCollection);
}
return parser.parseAll(categoryCollection);
}
export interface CategoryNodeParser {
readonly parseSingle: typeof parseSingleCategory;
readonly parseAll: typeof parseAllCategories;
}