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:
@@ -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(),
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export interface TreeInputNodeData {
|
||||
readonly id: string;
|
||||
readonly children?: readonly TreeInputNodeData[];
|
||||
readonly parent?: TreeInputNodeData | null;
|
||||
readonly data?: object;
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { NodeStateChangedEvent } from '../Node/State/StateAccess';
|
||||
import { ReadOnlyTreeNode } from '../Node/TreeNode';
|
||||
|
||||
export interface TreeNodeStateChangedEmittedEvent {
|
||||
readonly change: NodeStateChangedEvent;
|
||||
readonly node: ReadOnlyTreeNode;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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[];
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -0,0 +1,5 @@
|
||||
export enum TreeNodeCheckState {
|
||||
Unchecked = 0,
|
||||
Checked = 1,
|
||||
Indeterminate = 2,
|
||||
}
|
||||
@@ -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 { }
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
});
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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,
|
||||
};
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { TreeNode } from '../Node/TreeNode';
|
||||
|
||||
export interface NodeRenderingStrategy {
|
||||
shouldRender(node: TreeNode): boolean;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
import { ReadOnlyTreeNode, TreeNode } from '../../Node/TreeNode';
|
||||
|
||||
export interface SingleNodeFocusManager {
|
||||
readonly currentSingleFocusedNode: TreeNode | undefined;
|
||||
setSingleFocus(focusedNode: ReadOnlyTreeNode): void;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>());
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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) { }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { SingleNodeFocusManager } from './Focus/SingleNodeFocusManager';
|
||||
import { TreeNodeCollection } from './NodeCollection/TreeNodeCollection';
|
||||
|
||||
export interface TreeRoot {
|
||||
readonly collection: TreeNodeCollection;
|
||||
readonly focus: SingleNodeFocusManager;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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),
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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 },
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
Reference in New Issue
Block a user