Introduce new TreeView UI component
Key highlights: - Written from scratch to cater specifically to privacy.sexy's needs and requirements. - The visual look mimics the previous component with minimal changes, but its internal code is completely rewritten. - Lays groundwork for future functionalities like the "expand all" button a flat view mode as discussed in #158. - Facilitates the transition to Vue 3 by omitting the Vue 2.0 dependent `liquour-tree` as part of #230. Improvements and features: - Caching for quicker node queries. - Gradual rendering of nodes that introduces a noticable boost in performance, particularly during search/filtering. - `TreeView` solely governs the check states of branch nodes. Changes: - Keyboard interactions now alter the background color to highlight the focused item. Previously, it was changing the color of the text. - Better state management with clear separation of concerns: - `TreeView` exclusively manages indeterminate states. - `TreeView` solely governs the check states of branch nodes. - Introduce transaction pattern to update state in batches to minimize amount of events handled. - Improve keyboard focus, style background instead of foreground. Use hover/touch color on keyboard focus. - `SelectableTree` has been removed. Instead, `TreeView` is now directly integrated with `ScriptsTree`. - `ScriptsTree` has been refactored to incorporate hooks for clearer code and separation of duties. - Adopt Vue-idiomatic bindings instead of keeping a reference of the tree component. - Simplify and change filter event management. - Abandon global styles in favor of class-scoped styles. - Use global mixins with descriptive names to clarify indended functionality.
This commit is contained in:
@@ -0,0 +1,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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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>
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user