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>