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

@@ -0,0 +1,84 @@
<template>
<div class="container">
<div class="header">
<div class="content">
<slot />
</div>
<ToggleDocumentationButton
v-if="docs && docs.length > 0"
v-on:show="isExpanded = true"
v-on:hide="isExpanded = false"
/>
</div>
<div
v-if="docs && docs.length > 0 && isExpanded"
class="docs"
v-bind:class="{ 'docs-expanded': isExpanded, 'docs-collapsed': !isExpanded }"
>
<DocumentationText
:docs="docs"
class="text"
v-bind:class="{
expanded: isExpanded,
collapsed: !isExpanded,
}" />
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, ref, PropType } from 'vue';
import DocumentationText from './DocumentationText.vue';
import ToggleDocumentationButton from './ToggleDocumentationButton.vue';
export default defineComponent({
components: {
DocumentationText,
ToggleDocumentationButton,
},
props: {
docs: {
type: Array as PropType<readonly string[]>,
required: true,
},
},
setup() {
const isExpanded = ref(false);
return {
isExpanded,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.container {
display: flex;
flex-direction: column;
*:not(:first-child) {
margin-left: 5px;
}
.header {
display: flex;
flex-direction: row;
.content {
flex: 1;
}
}
.docs {
background: $color-primary-darkest;
margin-top: 0.25em;
color: $color-on-primary;
text-transform: none;
padding: 0.5em;
&-collapsed {
display: none;
}
cursor: auto;
user-select: text;
}
}
</style>

View File

@@ -0,0 +1,128 @@
<template>
<div
class="documentation-text"
v-html="renderedText"
v-on:click.stop
/>
</template>
<script lang="ts">
import { defineComponent, PropType, computed } from 'vue';
import { createRenderer } from './MarkdownRenderer';
export default defineComponent({
props: {
docs: {
type: Array as PropType<ReadonlyArray<string>>,
default: () => [],
},
},
setup(props) {
const renderedText = computed<string>(() => renderText(props.docs));
return {
renderedText,
};
},
});
const renderer = createRenderer();
function renderText(docs: readonly string[] | undefined): string {
if (!docs || docs.length === 0) {
return '';
}
if (docs.length === 1) {
return renderer.render(docs[0]);
}
const bulletpoints = docs
.map((doc) => renderAsMarkdownListItem(doc))
.join('\n');
return renderer.render(bulletpoints);
}
function renderAsMarkdownListItem(content: string): string {
if (!content || content.length === 0) {
throw new Error('missing content');
}
const lines = content.split(/\r\n|\r|\n/);
return `- ${lines[0]}${lines.slice(1)
.map((line) => `\n ${line}`)
.join()}`;
}
</script>
<style lang="scss"> /* Not scoped due to element styling such as "a". */
@use "@/presentation/assets/styles/main" as *;
$text-color: $color-on-primary;
$text-size: 0.75em; // Lower looks bad on Firefox
.documentation-text {
color: $text-color;
font-size: $text-size;
font-family: $font-main;
code {
word-break: break-all; // Inline code should wrap with the line, or whole text overflows
font-family: $font-normal;
font-weight: 600;
}
a {
&[href] {
word-break: break-word; // So URLs don't overflow
}
&[href^="http"]{
&:after {
/*
Use mask element instead of content/background-image etc.
This way we can apply current font color to it to match the theme
*/
mask: url(@/presentation/assets/icons/external-link.svg) no-repeat 50% 50%;
mask-size: cover;
content: '';
display: inline-block;
width: $text-size;
height: $text-size;
background-color: $text-color;
margin-left: calc($text-size / 4);
}
/*
Match color of global hover behavior. We need to do it manually because global hover sets
`color` property but here we need to modify `background-color` property because color only
works if SVG is embedded as HTML element (as `<svg/>`) not as `url(..)` as we do. Then the
only option is to use `mask` and `background-color` properties.
*/
@include hover-or-touch {
&::after{
background-color: $globals-color-hover;
}
}
}
}
/*
Different browsers have different <p>, we should even this out.
See CSS 2.1 specification https://www.w3.org/TR/CSS21/sample.html.
*/
p {
/*
Remove surrounding padding so a markdown text that is a list (e.g. <ul>)
has same outer padding as a paragraph (</p>).
*/
margin: 0;
+ p {
margin-top: 1em;
}
}
ul {
// CSS default is 40px, if the text is a bulletpoint, it leads to unexpected padding.
padding-inline-start: 1em;
/*
Set list style explicitly, because otherwise it changes based on parent <ul>s in tree view.
We reset the style from here.
*/
list-style: square;
}
}
</style>

View File

@@ -0,0 +1,173 @@
import MarkdownIt from 'markdown-it';
import Renderer from 'markdown-it/lib/renderer';
import Token from 'markdown-it/lib/token';
export function createRenderer(): IRenderer {
const md = new MarkdownIt({
linkify: true, // Auto-convert URL-like text to links.
breaks: false, // Do not convert single '\n's into <br>.
});
openUrlsInNewTab(md);
return {
render: (markdown: string) => {
markdown = beatifyAutoLinks(markdown);
return md.render(markdown);
},
};
}
export interface IRenderer {
render(markdown: string): string;
}
function beatifyAutoLinks(content: string): string {
if (!content) {
return content;
}
return content.replaceAll(/(?<!\]\(|\[\d+\]:\s+|https?\S+|`)((?:https?):\/\/[^\s\])]*)(?:[\])](?!\()|$|\s)/gm, (_$, urlMatch) => {
return toReadableLink(urlMatch);
});
}
function toReadableLink(url: string): string {
const parts = new URL(url);
let displayName = toReadableHostName(parts.hostname);
const pageName = extractPageName(parts);
if (pageName) {
displayName += ` - ${truncateRight(capitalizeEachLetter(pageName), 50)}`;
}
return `[${displayName}](${parts.href})`;
}
function toReadableHostName(hostname: string): string {
const wwwStripped = hostname.replace(/^(www\.)/, '');
const truncated = truncateLeft(wwwStripped, 30);
return truncated;
}
function extractPageName(parts: URL): string | undefined {
const path = toReadablePath(parts.pathname);
if (path) {
return path;
}
return toReadableQuery(parts);
}
function toReadableQuery(parts: URL): string | undefined {
const queryValues = [...parts.searchParams.values()];
if (queryValues.length === 0) {
return undefined;
}
return selectMostDescriptiveName(queryValues);
}
function truncateLeft(phrase: string, threshold: number): string {
return phrase.length > threshold ? `${phrase.substring(phrase.length - threshold, phrase.length)}` : phrase;
}
function isDigit(value: string): boolean {
return /^\d+$/.test(value);
}
function toReadablePath(path: string): string | undefined {
const decodedPath = decodeURI(path); // Fixes e.g. %20 to whitespaces
const pathPart = selectMostDescriptiveName(decodedPath.split('/'));
if (!pathPart) {
return undefined;
}
const extensionStripped = removeTrailingExtension(pathPart);
const humanlyTokenized = extensionStripped.replaceAll(/[-_]/g, ' ');
return humanlyTokenized;
}
function removeTrailingExtension(value: string): string {
const parts = value.split('.');
if (parts.length === 1) {
return value;
}
if (parts.at(-1).length > 9) {
return value; // Heuristically web file extension is no longer than 9 chars (e.g. "html")
}
return parts.slice(0, -1).join('.');
}
function capitalizeEachLetter(phrase: string): string {
return phrase
.split(' ')
.map((word) => capitalizeFirstLetter(word))
.join(' ');
function capitalizeFirstLetter(str: string): string {
return str.charAt(0).toUpperCase() + str.slice(1);
}
}
function truncateRight(phrase: string, threshold: number): string {
return phrase.length > threshold ? `${phrase.substring(0, threshold)}` : phrase;
}
function selectMostDescriptiveName(parts: readonly string[]): string | undefined {
const goodParts = parts.filter(isGoodPathPart);
if (goodParts.length === 0) {
return undefined;
}
const longestGoodPart = goodParts.reduce((a, b) => (a.length > b.length ? a : b));
return longestGoodPart;
}
function isGoodPathPart(part: string): boolean {
return part
&& !isDigit(part) // E.g. article numbers, issue numbers
&& part.length > 2 // This is often non-human categories like T5 etc.
&& !/^index(?:\.\S{0,10}$|$)/.test(part) // E.g. index.html
&& !/^[A-Za-z]{2,4}([_-][A-Za-z]{4})?([_-]([A-Za-z]{2}|[0-9]{3}))$/.test(part) // Locale string e.g. fr-FR
&& !/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(part) // GUID
&& !/^[0-9a-f]{40}$/.test(part); // Git SHA (e.g. GitHub links)
}
const ExternalAnchorElementAttributes: Record<string, string> = {
target: '_blank',
rel: 'noopener noreferrer',
};
function openUrlsInNewTab(md: MarkdownIt) {
// https://github.com/markdown-it/markdown-it/blob/12.2.0/docs/architecture.md#renderer
const defaultRender = getOrDefaultRenderer(md, 'link_open');
md.renderer.rules.link_open = (tokens, idx, options, env, self) => {
const token = tokens[idx];
Object.entries(ExternalAnchorElementAttributes).forEach(([name, value]) => {
const currentValue = getAttribute(token, name);
if (!currentValue) {
token.attrPush([name, value]);
} else if (currentValue !== value) {
setAttribute(token, name, value);
}
});
return defaultRender(tokens, idx, options, env, self);
};
}
function getOrDefaultRenderer(md: MarkdownIt, ruleName: string): Renderer.RenderRule {
const renderer = md.renderer.rules[ruleName];
return renderer || defaultRenderer;
function defaultRenderer(tokens, idx, options, _env, self) {
return self.renderToken(tokens, idx, options);
}
}
function getAttribute(token: Token, name: string): string | undefined {
const attributeIndex = token.attrIndex(name);
if (attributeIndex < 0) {
return undefined;
}
const value = token.attrs[attributeIndex][1];
return value;
}
function setAttribute(token: Token, name: string, value: string): void {
const attributeIndex = token.attrIndex(name);
if (attributeIndex < 0) {
throw new Error('Attribute does not exist');
}
token.attrs[attributeIndex][1] = value;
}

View File

@@ -0,0 +1,56 @@
<template>
<a
class="button"
target="_blank"
v-bind:class="{ 'button-on': isOn }"
v-on:click.stop
v-on:click="toggle()"
>
<font-awesome-icon :icon="['fas', 'info-circle']" />
</a>
</template>
<script lang="ts">
import { defineComponent, ref } from 'vue';
export default defineComponent({
emits: [
'show',
'hide',
],
setup(_, { emit }) {
const isOn = ref(false);
function toggle() {
isOn.value = !isOn.value;
if (isOn.value) {
emit('show');
} else {
emit('hide');
}
}
return {
isOn,
toggle,
};
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
.button {
@include clickable;
vertical-align: middle;
color: $color-primary;
@include hover-or-touch {
color: $color-primary-darker;
}
&-on {
color: $color-primary-light;
}
}
</style>

View File

@@ -0,0 +1,48 @@
<template>
<DocumentableNode :docs="nodeMetadata.docs">
<div id="node">
<div class="item text">{{ nodeMetadata.text }}</div>
<RevertToggle
class="item"
v-if="nodeMetadata.isReversible"
:node="nodeMetadata" />
</div>
</DocumentableNode>
</template>
<script lang="ts">
import { defineComponent, PropType } from 'vue';
import { NodeMetadata } from './NodeMetadata';
import RevertToggle from './RevertToggle.vue';
import DocumentableNode from './Documentation/DocumentableNode.vue';
export default defineComponent({
components: {
RevertToggle,
DocumentableNode,
},
props: {
nodeMetadata: {
type: Object as PropType<NodeMetadata>,
required: true,
},
},
});
</script>
<style scoped lang="scss">
@use "@/presentation/assets/styles/main" as *;
#node {
display: flex;
flex-direction: row;
flex-wrap: wrap;
.text {
display: flex;
align-items: center;
}
.item:not(:first-child) {
margin-left: 5px;
}
}
</style>

View File

@@ -0,0 +1,13 @@
export enum NodeType {
Script,
Category,
}
export interface NodeMetadata {
readonly id: string;
readonly text: string;
readonly isReversible: boolean;
readonly docs: ReadonlyArray<string>;
readonly children?: ReadonlyArray<NodeMetadata>;
readonly type: NodeType;
}

View File

@@ -0,0 +1,86 @@
<template>
<ToggleSwitch
v-model="isChecked"
:stopClickPropagation="true"
:label="'revert'"
/>
</template>
<script lang="ts">
import {
PropType, defineComponent, ref, watch,
computed, inject,
} 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 { getReverter } from './Reverter/ReverterFactory';
import ToggleSwitch from './ToggleSwitch.vue';
export default defineComponent({
components: {
ToggleSwitch,
},
props: {
node: {
type: Object as PropType<NodeMetadata>,
required: true,
},
},
setup(props) {
const {
currentState, modifyCurrentState, onStateChange,
} = inject(InjectionKeys.useCollectionState)();
const { events } = inject(InjectionKeys.useAutoUnsubscribedEvents)();
const isReverted = ref(false);
let handler: IReverter | undefined;
watch(
() => props.node,
(node) => onNodeChanged(node),
{ immediate: true },
);
onStateChange((newState) => {
updateRevertStatusFromState(newState.selection.selectedScripts);
events.unsubscribeAllAndRegister([
newState.selection.changed.on((scripts) => updateRevertStatusFromState(scripts)),
]);
}, { immediate: true });
function onNodeChanged(node: NodeMetadata) {
handler = getReverter(node, currentState.value.collection);
updateRevertStatusFromState(currentState.value.selection.selectedScripts);
}
function updateRevertStatusFromState(scripts: ReadonlyArray<SelectedScript>) {
isReverted.value = handler?.getState(scripts) ?? false;
}
function syncReversionStatusWithState(value: boolean) {
if (value === isReverted.value) {
return;
}
modifyCurrentState((state) => {
handler.selectWithRevertState(value, state.selection);
});
}
const isChecked = computed({
get() {
return isReverted.value;
},
set: (value: boolean) => {
syncReversionStatusWithState(value);
},
});
return {
isChecked,
};
},
});
</script>

View File

@@ -0,0 +1,34 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { getCategoryId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';
export class CategoryReverter implements IReverter {
private readonly categoryId: number;
private readonly scriptReverters: ReadonlyArray<ScriptReverter>;
constructor(nodeId: string, collection: ICategoryCollection) {
this.categoryId = getCategoryId(nodeId);
this.scriptReverters = getAllSubScriptReverters(this.categoryId, collection);
}
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {
return this.scriptReverters.every((script) => script.getState(selectedScripts));
}
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
selection.addOrUpdateAllInCategory(this.categoryId, newState);
}
}
function getAllSubScriptReverters(categoryId: number, collection: ICategoryCollection) {
const category = collection.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id "${categoryId}" does not exist`);
}
const scripts = category.getAllScriptsRecursively();
return scripts.map((script) => new ScriptReverter(script.id));
}

View File

@@ -0,0 +1,7 @@
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
export interface IReverter {
getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean;
selectWithRevertState(newState: boolean, selection: IUserSelection): void;
}

View File

@@ -0,0 +1,16 @@
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { NodeMetadata, NodeType } from '../NodeMetadata';
import { IReverter } from './IReverter';
import { ScriptReverter } from './ScriptReverter';
import { CategoryReverter } from './CategoryReverter';
export function getReverter(node: NodeMetadata, collection: ICategoryCollection): IReverter {
switch (node.type) {
case NodeType.Category:
return new CategoryReverter(node.id, collection);
case NodeType.Script:
return new ScriptReverter(node.id);
default:
throw new Error('Unknown script type');
}
}

View File

@@ -0,0 +1,24 @@
import { SelectedScript } from '@/application/Context/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/Context/State/Selection/IUserSelection';
import { getScriptId } from '../../TreeViewAdapter/CategoryNodeMetadataConverter';
import { IReverter } from './IReverter';
export class ScriptReverter implements IReverter {
private readonly scriptId: string;
constructor(nodeId: string) {
this.scriptId = getScriptId(nodeId);
}
public getState(selectedScripts: ReadonlyArray<SelectedScript>): boolean {
const selectedScript = selectedScripts.find((selected) => selected.id === this.scriptId);
if (!selectedScript) {
return false;
}
return selectedScript.revert;
}
public selectWithRevertState(newState: boolean, selection: IUserSelection): void {
selection.addOrUpdateSelectedScript(this.scriptId, newState);
}
}

View File

@@ -0,0 +1,188 @@
<template>
<div
class="toggle-switch"
@click="handleClickPropagation"
>
<input
type="checkbox"
class="toggle-input"
v-model="isChecked"
>
<div class="toggle-animation">
<span class="label-off">{{ label }}</span>
<span class="label-on">{{ label }}</span>
</div>
</div>
</template>
<script lang="ts">
import { defineComponent, computed } from 'vue';
export default defineComponent({
props: {
value: Boolean,
label: {
type: String,
required: true,
},
stopClickPropagation: {
type: Boolean,
default: false,
},
},
emits: {
/* eslint-disable @typescript-eslint/no-unused-vars */
input: (isChecked: boolean) => true,
/* eslint-enable @typescript-eslint/no-unused-vars */
},
setup(props, { emit }) {
const isChecked = computed({
get() {
return props.value;
},
set(value: boolean) {
if (value === props.value) {
return;
}
emit('input', value);
},
});
function handleClickPropagation(event: Event): void {
if (props.stopClickPropagation) {
event.stopPropagation();
}
}
return {
isChecked,
handleClickPropagation,
};
},
});
</script>
<style scoped lang="scss">
@use 'sass:math';
@use "@/presentation/assets/styles/main" as *;
$color-toggle-unchecked : $color-primary-darker;
$color-toggle-checked : $color-on-secondary;
$color-text-unchecked : $color-on-primary;
$color-text-checked : $color-on-secondary;
$color-bg-unchecked : $color-primary;
$color-bg-checked : $color-secondary;
$size-height : 30px;
$size-circle : math.div($size-height * 2, 3);
$padding-horizontal : 0.40em;
$gap : 0.25em;
@mixin locateNearCircle($direction: 'left') {
$circle-width: calc(#{$size-circle} + #{$padding-horizontal});
$circle-space: calc(#{$circle-width} + #{$gap});
@if $direction == 'left' {
margin-left: $circle-space;
} @else {
margin-right: $circle-space;
}
}
@mixin setVisibility($isVisible: true) {
@if $isVisible {
display: block;
opacity: 1;
} @else {
display: none;
opacity: 0;
}
}
.toggle-switch {
display: flex;
overflow: hidden;
position: relative;
width: auto;
height: $size-height;
border-radius: $size-height;
line-height: $size-height;
font-size: math.div($size-height, 2);
input.toggle-input {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: 0;
z-index: 2;
@include clickable;
}
.toggle-animation {
display: flex;
align-items: center;
gap: $gap;
width: 100%;
height: 100%;
background-color: $color-bg-unchecked;
transition: background-color 0.25s ease-out;
&:before {
content: "";
display: block;
position: absolute;
left: $padding-horizontal;
$initial-top: 50%;
$centered-top-offset: math.div($size-circle, 2);
$centered-top: calc(#{$initial-top} - #{$centered-top-offset});
top: $centered-top;
width: $size-circle;
height: $size-circle;
border-radius: 50%;
background-color: $color-toggle-unchecked;
transition: left 0.3s ease-out;
z-index: 10;
}
}
input.toggle-input:checked + .toggle-animation {
background-color: $color-bg-checked;
flex-direction: row-reverse;
&:before {
$left-offset: calc(100% - #{$size-circle});
$padded-left-offset: calc(#{$left-offset} - #{$padding-horizontal});
left: $padded-left-offset;
background-color: $color-toggle-checked;
}
.label-off {
@include setVisibility(false);
}
.label-on {
@include setVisibility(true);
}
}
.label-off, .label-on {
text-transform: uppercase;
font-weight: 700;
transition: all 0.3s ease-out;
}
.label-off {
@include setVisibility(true);
@include locateNearCircle('left');
padding-right: $padding-horizontal;
}
.label-on {
@include setVisibility(false);
color: $color-text-checked;
@include locateNearCircle('right');
padding-left: $padding-horizontal;
}
}
</style>

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

@@ -0,0 +1,80 @@
import { ICategory, IScript } from '@/domain/ICategory';
import { ICategoryCollection } from '@/domain/ICategoryCollection';
import { NodeMetadata, NodeType } from '../NodeContent/NodeMetadata';
export function parseAllCategories(collection: ICategoryCollection): NodeMetadata[] | undefined {
return createCategoryNodes(collection.actions);
}
export function parseSingleCategory(
categoryId: number,
collection: ICategoryCollection,
): NodeMetadata[] | undefined {
const category = collection.findCategory(categoryId);
if (!category) {
throw new Error(`Category with id ${categoryId} does not exist`);
}
const tree = parseCategoryRecursively(category);
return tree;
}
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;
}
export function getCategoryNodeId(category: ICategory): string {
return `${category.id}`;
}
function parseCategoryRecursively(
parentCategory: ICategory,
): NodeMetadata[] {
return [
...createCategoryNodes(parentCategory.subCategories),
...createScriptNodes(parentCategory.scripts),
];
}
function createScriptNodes(scripts: ReadonlyArray<IScript>): NodeMetadata[] {
return (scripts || [])
.map((script) => convertScriptToNode(script));
}
function createCategoryNodes(categories: ReadonlyArray<ICategory>): NodeMetadata[] {
return (categories || [])
.map((category) => ({ category, children: parseCategoryRecursively(category) }))
.map((data) => convertCategoryToNode(data.category, data.children));
}
function convertCategoryToNode(
category: ICategory,
children: readonly NodeMetadata[],
): NodeMetadata {
return {
id: getCategoryNodeId(category),
type: NodeType.Category,
text: category.name,
children,
docs: category.docs,
isReversible: children && children.every((child) => child.isReversible),
};
}
function convertScriptToNode(script: IScript): NodeMetadata {
return {
id: getScriptNodeId(script),
type: NodeType.Script,
text: script.name,
children: undefined,
docs: script.docs,
isReversible: script.canRevert(),
};
}

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;
}