add auto-highlighting of selected/updated code

This commit is contained in:
undergroundwires
2020-08-25 16:52:38 +01:00
parent 5df458739d
commit b789250cb8
39 changed files with 1163 additions and 175 deletions

View File

@@ -1,30 +1,38 @@
import { CodeChangedEvent } from './Event/CodeChangedEvent';
import { CodePosition } from './Position/CodePosition';
import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IUserSelection } from '@/application/State/Selection/IUserSelection';
import { UserScriptGenerator } from './UserScriptGenerator';
import { UserScriptGenerator } from './Generation/UserScriptGenerator';
import { Signal } from '@/infrastructure/Events/Signal';
import { IApplicationCode } from './IApplicationCode';
import { IUserScriptGenerator } from './IUserScriptGenerator';
import { IUserScriptGenerator } from './Generation/IUserScriptGenerator';
export class ApplicationCode implements IApplicationCode {
public readonly changed = new Signal<string>();
public readonly changed = new Signal<ICodeChangedEvent>();
public current: string;
private readonly generator: IUserScriptGenerator = new UserScriptGenerator();
private scriptPositions = new Map<SelectedScript, CodePosition>();
constructor(
userSelection: IUserSelection,
private readonly version: string) {
private readonly version: string,
private readonly generator: IUserScriptGenerator = new UserScriptGenerator()) {
if (!userSelection) { throw new Error('userSelection is null or undefined'); }
if (!version) { throw new Error('version is null or undefined'); }
this.generator = new UserScriptGenerator();
if (!generator) { throw new Error('generator is null or undefined'); }
this.setCode(userSelection.selectedScripts);
userSelection.changed.on((scripts) => {
this.setCode(scripts);
});
}
private setCode(scripts: ReadonlyArray<SelectedScript>) {
this.current = scripts.length === 0 ? '' : this.generator.buildCode(scripts, this.version);
this.changed.notify(this.current);
private setCode(scripts: ReadonlyArray<SelectedScript>): void {
const oldScripts = Array.from(this.scriptPositions.keys());
const code = this.generator.buildCode(scripts, this.version);
this.current = code.code;
this.scriptPositions = code.scriptPositions;
const event = new CodeChangedEvent(code.code, oldScripts, code.scriptPositions);
this.changed.notify(event);
}
}

View File

@@ -0,0 +1,64 @@
import { ICodeChangedEvent } from './ICodeChangedEvent';
import { SelectedScript } from '../../Selection/SelectedScript';
import { IScript } from '@/domain/IScript';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
export class CodeChangedEvent implements ICodeChangedEvent {
public readonly code: string;
public readonly addedScripts: ReadonlyArray<IScript>;
public readonly removedScripts: ReadonlyArray<IScript>;
public readonly changedScripts: ReadonlyArray<IScript>;
private readonly scripts: Map<IScript, ICodePosition>;
constructor(
code: string,
oldScripts: ReadonlyArray<SelectedScript>,
scripts: Map<SelectedScript, ICodePosition>) {
ensureAllPositionsExist(code, Array.from(scripts.values()));
this.code = code;
const newScripts = Array.from(scripts.keys());
this.addedScripts = selectIfNotExists(newScripts, oldScripts);
this.removedScripts = selectIfNotExists(oldScripts, newScripts);
this.changedScripts = getChangedScripts(oldScripts, newScripts);
this.scripts = new Map<IScript, ICodePosition>();
scripts.forEach((position, selection) => {
this.scripts.set(selection.script, position);
});
}
public isEmpty(): boolean {
return this.scripts.size === 0;
}
public getScriptPositionInCode(script: IScript): ICodePosition {
return this.scripts.get(script);
}
}
function ensureAllPositionsExist(script: string, positions: ReadonlyArray<ICodePosition>) {
const totalLines = script.split(/\r\n|\r|\n/).length;
for (const position of positions) {
if (position.endLine > totalLines) {
throw new Error(`script end line (${position.endLine}) is out of range.` +
`(total code lines: ${totalLines}`);
}
}
}
function getChangedScripts(
oldScripts: ReadonlyArray<SelectedScript>,
newScripts: ReadonlyArray<SelectedScript>): ReadonlyArray<IScript> {
return newScripts
.filter((newScript) => oldScripts.find((oldScript) => oldScript.id === newScript.id
&& oldScript.revert !== newScript.revert ))
.map((selection) => selection.script);
}
function selectIfNotExists(
selectableContainer: ReadonlyArray<SelectedScript>,
test: ReadonlyArray<SelectedScript>) {
return selectableContainer
.filter((script) => !test.find((oldScript) => oldScript.id === script.id))
.map((selection) => selection.script);
}

View File

@@ -0,0 +1,11 @@
import { IScript } from '@/domain/IScript';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
export interface ICodeChangedEvent {
readonly code: string;
addedScripts: ReadonlyArray<IScript>;
removedScripts: ReadonlyArray<IScript>;
changedScripts: ReadonlyArray<IScript>;
isEmpty(): boolean;
getScriptPositionInCode(script: IScript): ICodePosition;
}

View File

@@ -4,8 +4,20 @@ const TotalFunctionSeparatorChars = 58;
export class CodeBuilder {
private readonly lines = new Array<string>();
// Returns current line starting from 0 (no lines), or 1 (have single line)
public get currentLine(): number {
return this.lines.length;
}
public appendLine(code?: string): CodeBuilder {
this.lines.push(code);
if (!code) {
this.lines.push('');
return this;
}
const lines = code.match(/[^\r\n]+/g);
for (const line of lines) {
this.lines.push(line);
}
return this;
}

View File

@@ -0,0 +1,7 @@
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
export interface IUserScript {
code: string;
scriptPositions: Map<SelectedScript, ICodePosition>;
}

View File

@@ -1,5 +1,7 @@
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IUserScript } from './IUserScript';
export interface IUserScriptGenerator {
buildCode(selectedScripts: ReadonlyArray<SelectedScript>, version: string): string;
buildCode(
selectedScripts: ReadonlyArray<SelectedScript>,
version: string): IUserScript;
}

View File

@@ -1,6 +1,9 @@
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { IUserScriptGenerator } from './IUserScriptGenerator';
import { CodeBuilder } from './CodeBuilder';
import { ICodePosition } from '@/application/State/Code/Position/ICodePosition';
import { CodePosition } from '../Position/CodePosition';
import { IUserScript } from './IUserScript';
export const adminRightsScript = {
name: 'Ensure admin privileges',
@@ -13,22 +16,51 @@ export const adminRightsScript = {
};
export class UserScriptGenerator implements IUserScriptGenerator {
public buildCode(selectedScripts: ReadonlyArray<SelectedScript>, version: string): string {
public buildCode(selectedScripts: ReadonlyArray<SelectedScript>, version: string): IUserScript {
if (!selectedScripts) { throw new Error('scripts is undefined'); }
if (!selectedScripts.length) { throw new Error('scripts are empty'); }
if (!version) { throw new Error('version is undefined'); }
const builder = new CodeBuilder()
.appendLine('@echo off')
.appendCommentLine(`https://privacy.sexy — v${version}${new Date().toUTCString()}`)
.appendFunction(adminRightsScript.name, adminRightsScript.code).appendLine();
for (const selection of selectedScripts) {
const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name;
const code = selection.revert ? selection.script.revertCode : selection.script.code;
builder.appendFunction(name, code).appendLine();
let scriptPositions = new Map<SelectedScript, ICodePosition>();
if (!selectedScripts.length) {
return { code: '', scriptPositions };
}
return builder.appendLine()
.appendLine('pause')
.appendLine('exit /b 0')
.toString();
const builder = initializeCode(version);
for (const selection of selectedScripts) {
scriptPositions = appendSelection(selection, scriptPositions, builder);
}
const code = finalizeCode(builder);
return { code, scriptPositions };
}
}
function initializeCode(version: string): CodeBuilder {
return new CodeBuilder()
.appendLine('@echo off')
.appendCommentLine(`https://privacy.sexy — v${version}${new Date().toUTCString()}`)
.appendFunction(adminRightsScript.name, adminRightsScript.code)
.appendLine();
}
function finalizeCode(builder: CodeBuilder): string {
return builder.appendLine()
.appendLine('pause')
.appendLine('exit /b 0')
.toString();
}
function appendSelection(
selection: SelectedScript,
scriptPositions: Map<SelectedScript, ICodePosition>,
builder: CodeBuilder): Map<SelectedScript, ICodePosition> {
const startPosition = builder.currentLine + 1;
appendCode(selection, builder);
const endPosition = builder.currentLine - 1;
builder.appendLine();
scriptPositions.set(selection, new CodePosition(startPosition, endPosition));
return scriptPositions;
}
function appendCode(selection: SelectedScript, builder: CodeBuilder) {
const name = selection.revert ? `${selection.script.name} (revert)` : selection.script.name;
const scriptCode = selection.revert ? selection.script.revertCode : selection.script.code;
builder.appendFunction(name, scriptCode);
}

View File

@@ -1,6 +1,7 @@
import { ICodeChangedEvent } from './Event/ICodeChangedEvent';
import { ISignal } from '@/infrastructure/Events/ISignal';
export interface IApplicationCode {
readonly changed: ISignal<string>;
readonly changed: ISignal<ICodeChangedEvent>;
readonly current: string;
}

View File

@@ -0,0 +1,24 @@
import { ICodePosition } from './ICodePosition';
export class CodePosition implements ICodePosition {
public get totalLines(): number {
return this.endLine - this.startLine;
}
constructor(
public readonly startLine: number,
public readonly endLine: number) {
if (startLine < 0) {
throw new Error('Code cannot start in a negative line');
}
if (endLine < 0) {
throw new Error('Code cannot end in a negative line');
}
if (endLine === startLine) {
throw new Error('Empty code');
}
if (endLine < startLine) {
throw new Error('End line cannot be less than start line');
}
}
}

View File

@@ -0,0 +1,5 @@
export interface ICodePosition {
readonly startLine: number;
readonly endLine: number;
readonly totalLines: number;
}

View File

@@ -6,11 +6,13 @@ export interface IUserSelection {
readonly changed: ISignal<ReadonlyArray<SelectedScript>>;
readonly selectedScripts: ReadonlyArray<SelectedScript>;
readonly totalSelected: number;
removeAllInCategory(categoryId: number): void;
addAllInCategory(categoryId: number): void;
addSelectedScript(scriptId: string, revert: boolean): void;
addOrUpdateSelectedScript(scriptId: string, revert: boolean): void;
removeSelectedScript(scriptId: string): void;
selectOnly(scripts: ReadonlyArray<IScript>): void;
isSelected(script: IScript): boolean;
isSelected(scriptId: string): boolean;
selectAll(): void;
deselectAll(): void;
}

View File

@@ -8,12 +8,13 @@ import { IRepository } from '@/infrastructure/Repository/IRepository';
export class UserSelection implements IUserSelection {
public readonly changed = new Signal<ReadonlyArray<SelectedScript>>();
private readonly scripts: IRepository<string, SelectedScript> = new InMemoryRepository<string, SelectedScript>();
private readonly scripts: IRepository<string, SelectedScript>;
constructor(
private readonly app: IApplication,
/** Initially selected scripts */
selectedScripts: ReadonlyArray<IScript>) {
this.scripts = new InMemoryRepository<string, SelectedScript>();
if (selectedScripts && selectedScripts.length > 0) {
for (const script of selectedScripts) {
const selected = new SelectedScript(script, false);
@@ -22,6 +23,33 @@ export class UserSelection implements IUserSelection {
}
}
public removeAllInCategory(categoryId: number): void {
const category = this.app.findCategory(categoryId);
const scriptsToRemove = category.getAllScriptsRecursively()
.filter((script) => this.scripts.exists(script.id));
if (!scriptsToRemove.length) {
return;
}
for (const script of scriptsToRemove) {
this.scripts.removeItem(script.id);
}
this.changed.notify(this.scripts.getItems());
}
public addAllInCategory(categoryId: number): void {
const category = this.app.findCategory(categoryId);
const scriptsToAdd = category.getAllScriptsRecursively()
.filter((script) => !this.scripts.exists(script.id));
if (!scriptsToAdd.length) {
return;
}
for (const script of scriptsToAdd) {
const selectedScript = new SelectedScript(script, false);
this.scripts.addItem(selectedScript);
}
this.changed.notify(this.scripts.getItems());
}
public addSelectedScript(scriptId: string, revert: boolean): void {
const script = this.app.findScript(scriptId);
if (!script) {
@@ -44,8 +72,8 @@ export class UserSelection implements IUserSelection {
this.changed.notify(this.scripts.getItems());
}
public isSelected(script: IScript): boolean {
return this.scripts.exists(script.id);
public isSelected(scriptId: string): boolean {
return this.scripts.exists(scriptId);
}
/** Get users scripts based on his/her selections */

View File

@@ -3,15 +3,7 @@ import { IScript } from './IScript';
import { ICategory } from './ICategory';
export class Category extends BaseEntity<number> implements ICategory {
private static validate(category: ICategory) {
if (!category.name) {
throw new Error('name is null or empty');
}
if ((!category.subCategories || category.subCategories.length === 0) &&
(!category.scripts || category.scripts.length === 0)) {
throw new Error('A category must have at least one sub-category or scripts');
}
}
private allSubScripts: ReadonlyArray<IScript> = undefined;
constructor(
id: number,
@@ -20,6 +12,27 @@ export class Category extends BaseEntity<number> implements ICategory {
public readonly subCategories?: ReadonlyArray<ICategory>,
public readonly scripts?: ReadonlyArray<IScript>) {
super(id);
Category.validate(this);
validateCategory(this);
}
public getAllScriptsRecursively(): readonly IScript[] {
return this.allSubScripts || (this.allSubScripts = parseScriptsRecursively(this));
}
}
function parseScriptsRecursively(category: ICategory): ReadonlyArray<IScript> {
return [
...category.scripts,
...category.subCategories.flatMap((c) => c.getAllScriptsRecursively()),
];
}
function validateCategory(category: ICategory) {
if (!category.name) {
throw new Error('undefined or empty name');
}
if ((!category.subCategories || category.subCategories.length === 0) &&
(!category.scripts || category.scripts.length === 0)) {
throw new Error('A category must have at least one sub-category or script');
}
}

View File

@@ -7,6 +7,7 @@ export interface ICategory extends IEntity<number>, IDocumentable {
readonly name: string;
readonly subCategories?: ReadonlyArray<ICategory>;
readonly scripts?: ReadonlyArray<IScript>;
getAllScriptsRecursively(): ReadonlyArray<IScript>;
}
export { IEntity } from '../infrastructure/Entity/IEntity';

View File

@@ -1,6 +1,6 @@
import { IApplication } from './../../../domain/IApplication';
import { ICategory, IScript } from '@/domain/ICategory';
import { INode } from './SelectableTree/INode';
import { INode, NodeType } from './SelectableTree/Node/INode';
export function parseAllCategories(app: IApplication): INode[] | undefined {
const nodes = new Array<INode>();
@@ -23,9 +23,15 @@ export function parseSingleCategory(categoryId: number, app: IApplication): INod
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${category.id}`;
return `${category.id}`;
}
function parseCategoryRecursively(
@@ -64,6 +70,7 @@ function convertCategoryToNode(
category: ICategory, children: readonly INode[]): INode {
return {
id: getCategoryNodeId(category),
type: NodeType.Category,
text: category.name,
children,
documentationUrls: category.documentationUrls,
@@ -74,6 +81,7 @@ function convertCategoryToNode(
function convertScriptToNode(script: IScript): INode {
return {
id: getScriptNodeId(script),
type: NodeType.Script,
text: script.name,
children: undefined,
documentationUrls: script.documentationUrls,

View File

@@ -24,10 +24,11 @@
import { ICategory } from '@/domain/ICategory';
import { IApplicationState, IUserSelection } from '@/application/State/IApplicationState';
import { IFilterResult } from '@/application/State/Filter/IFilterResult';
import { parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId } from './ScriptNodeParser';
import SelectableTree, { FilterPredicate } from './SelectableTree/SelectableTree.vue';
import { INode } from './SelectableTree/INode';
import { parseAllCategories, parseSingleCategory, getScriptNodeId, getCategoryNodeId, getCategoryId, getScriptId } from './ScriptNodeParser';
import SelectableTree from './SelectableTree/SelectableTree.vue';
import { INode, NodeType } from './SelectableTree/Node/INode';
import { SelectedScript } from '@/application/State/Selection/SelectedScript';
import { INodeSelectedEvent } from './SelectableTree/INodeSelectedEvent';
@Component({
components: {
@@ -53,15 +54,17 @@
await this.initializeNodesAsync(this.categoryId);
}
public async toggleNodeSelectionAsync(node: INode) {
if (node.children != null && node.children.length > 0) {
return; // only interested in script nodes
}
public async toggleNodeSelectionAsync(event: INodeSelectedEvent) {
const state = await this.getCurrentStateAsync();
if (!this.selectedNodeIds.some((id) => id === node.id)) {
state.selection.addSelectedScript(node.id, false);
} else {
state.selection.removeSelectedScript(node.id);
switch (event.node.type) {
case NodeType.Category:
this.toggleCategoryNodeSelection(event, state);
break;
case NodeType.Script:
this.toggleScriptNodeSelection(event, state);
break;
default:
throw new Error(`Unknown node type: ${event.node.id}`);
}
}
@@ -97,6 +100,24 @@
this.filterText = result.query;
this.filtered = result;
}
private toggleCategoryNodeSelection(event: INodeSelectedEvent, state: IApplicationState): void {
const categoryId = getCategoryId(event.node.id);
if (event.isSelected) {
state.selection.addAllInCategory(categoryId);
} else {
state.selection.removeAllInCategory(categoryId);
}
}
private toggleScriptNodeSelection(event: INodeSelectedEvent, state: IApplicationState): void {
const scriptId = getScriptId(event.node.id);
const actualToggleState = state.selection.isSelected(scriptId);
const targetToggleState = event.isSelected;
if (targetToggleState && !actualToggleState) {
state.selection.addSelectedScript(scriptId, false);
} else if (!targetToggleState && actualToggleState) {
state.selection.removeSelectedScript(scriptId);
}
}
}
</script>

View File

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

View File

@@ -12,16 +12,25 @@ declare module 'liquor-tree' {
setModel(nodes: ReadonlyArray<ILiquorTreeNewNode>): void;
}
interface ICustomLiquorTreeData {
type: number;
documentationUrls: ReadonlyArray<string>;
isReversible: boolean;
}
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
export interface ILiquorTreeNodeState {
checked: boolean;
}
export interface ILiquorTreeNode {
id: string;
data: ICustomLiquorTreeData;
children: ReadonlyArray<ILiquorTreeNode> | undefined;
}
/**
* Returned from Node tree view events.
* See constructor in https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeExistingNode {
id: string;
export interface ILiquorTreeExistingNode extends ILiquorTreeNode {
data: ILiquorTreeNodeData;
states: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeExistingNode> | undefined;
@@ -31,12 +40,10 @@ declare module 'liquor-tree' {
* Sent to liquor tree to define of new nodes.
* https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeNewNode {
id: string;
export interface ILiquorTreeNewNode extends ILiquorTreeNode {
text: string;
state: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeNewNode> | undefined;
data: ICustomLiquorTreeData;
}
// https://amsik.github.io/liquor-tree/#Component-Options
@@ -47,13 +54,8 @@ declare module 'liquor-tree' {
autoCheckChildren: boolean;
parentSelect: boolean;
keyboardNavigation: boolean;
deletion: (node: ILiquorTreeExistingNode) => void;
filter: ILiquorTreeFilter;
}
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
interface ILiquorTreeNodeState {
checked: boolean;
deletion(node: ILiquorTreeNode): boolean;
}
interface ILiquorTreeNodeData extends ICustomLiquorTreeData {
@@ -61,15 +63,7 @@ declare module 'liquor-tree' {
}
// https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue
interface ILiquorTreeOptions {
checkbox: boolean;
checkOnSelect: boolean;
filter: ILiquorTreeFilter;
deletion(node: ILiquorTreeNewNode): boolean;
}
// https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue
interface ILiquorTreeFilter {
export interface ILiquorTreeFilter {
emptyText: string;
matcher(query: string, node: ILiquorTreeExistingNode): boolean;
}

View File

@@ -0,0 +1,16 @@
import { ILiquorTreeOptions, ILiquorTreeFilter, ILiquorTreeNode } from 'liquor-tree';
export class LiquorTreeOptions implements ILiquorTreeOptions {
public multiple = true;
public checkbox = true;
public checkOnSelect = true;
/* For checkbox mode only. Children will have the same checked state as their parent.
This is false as it's handled manually to be able to batch select for performance + highlighting */
public autoCheckChildren = false;
public parentSelect = false;
public keyboardNavigation = true;
constructor(public filter: ILiquorTreeFilter) { }
public deletion(node: ILiquorTreeNode): boolean {
return false; // no op
}
}

View File

@@ -0,0 +1,17 @@
import { ILiquorTreeFilter, ILiquorTreeExistingNode } from 'liquor-tree';
import { convertExistingToNode } from './NodeTranslator';
import { INode } from '../Node/INode';
export type FilterPredicate = (node: INode) => boolean;
export class NodePredicateFilter implements ILiquorTreeFilter {
public emptyText: string = '🕵Hmm.. Can not see one 🧐';
constructor(private readonly filterPredicate: FilterPredicate) {
if (!filterPredicate) {
throw new Error('filterPredicate is undefined');
}
}
public matcher(query: string, node: ILiquorTreeExistingNode): boolean {
return this.filterPredicate(convertExistingToNode(node));
}
}

View File

@@ -0,0 +1,66 @@
import { ILiquorTreeExistingNode, ILiquorTreeNewNode, ILiquorTreeNodeState, ILiquorTreeNode } from 'liquor-tree';
import { NodeType } from './../Node/INode';
export function updateNodesCheckedState(
oldNodes: ReadonlyArray<ILiquorTreeExistingNode>,
selectedNodeIds: ReadonlyArray<string>): ReadonlyArray<ILiquorTreeNewNode> {
const result = new Array<ILiquorTreeNewNode>();
for (const oldNode of oldNodes) {
const newState = oldNode.states;
newState.checked = getNewCheckedState(oldNode, selectedNodeIds);
const newNode: ILiquorTreeNewNode = {
id: oldNode.id,
text: oldNode.data.text,
data: {
type: oldNode.data.type,
documentationUrls: oldNode.data.documentationUrls,
isReversible: oldNode.data.isReversible,
},
children: !oldNode.children ? [] : updateNodesCheckedState(oldNode.children, selectedNodeIds),
state: newState,
};
result.push(newNode);
}
return result;
}
export function getNewCheckedState(
oldNode: ILiquorTreeNode,
selectedNodeIds: ReadonlyArray<string>): boolean {
switch (oldNode.data.type) {
case NodeType.Script:
return selectedNodeIds.some((id) => id === oldNode.id);
case NodeType.Category:
return parseAllSubScriptIds(oldNode).every((id) => selectedNodeIds.includes(id));
default:
throw new Error('Unknown node type');
}
}
function parseAllSubScriptIds(categoryNode: ILiquorTreeNode): ReadonlyArray<string> {
if (categoryNode.data.type !== NodeType.Category) {
throw new Error('Not a category node');
}
if (!categoryNode.children) {
return [];
}
const ids = new Array<string>();
for (const child of categoryNode.children) {
addNodeIds(child, ids);
}
return ids;
}
function addNodeIds(node: ILiquorTreeNode, ids: string[]) {
switch (node.data.type) {
case NodeType.Script:
ids.push(node.id);
break;
case NodeType.Category:
const subCategoryIds = parseAllSubScriptIds(node);
ids.push(...subCategoryIds);
break;
default:
throw new Error('Unknown node type');
}
}

View File

@@ -1,5 +1,5 @@
import { ILiquorTreeNewNode, ILiquorTreeExistingNode } from 'liquor-tree';
import { INode } from './INode';
import { INode } from './../Node/INode';
// Functions to translate INode to LiqourTree models and vice versa for anti-corruption
@@ -7,6 +7,7 @@ export function convertExistingToNode(liquorTreeNode: ILiquorTreeExistingNode):
if (!liquorTreeNode) { throw new Error('liquorTreeNode is undefined'); }
return {
id: liquorTreeNode.id,
type: liquorTreeNode.data.type,
text: liquorTreeNode.data.text,
// selected: liquorTreeNode.states && liquorTreeNode.states.checked,
children: convertChildren(liquorTreeNode.children, convertExistingToNode),
@@ -27,6 +28,7 @@ export function toNewLiquorTreeNode(node: INode): ILiquorTreeNewNode {
data: {
documentationUrls: node.documentationUrls,
isReversible: node.isReversible,
type: node.type,
},
};
}

View File

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

View File

@@ -19,16 +19,19 @@
<script lang="ts">
import { Component, Prop, Vue, Emit, Watch } from 'vue-property-decorator';
import LiquorTree, { ILiquorTreeNewNode, ILiquorTreeExistingNode, ILiquorTree, ILiquorTreeOptions } from 'liquor-tree';
import Node from './Node.vue';
import { INode } from './INode';
import { convertExistingToNode, toNewLiquorTreeNode } from './NodeTranslator';
export type FilterPredicate = (node: INode) => boolean;
import Node from './Node/Node.vue';
import { INode, NodeType } from './Node/INode';
import { convertExistingToNode, toNewLiquorTreeNode } from './LiquorTree/NodeTranslator';
import { INodeSelectedEvent } from './/INodeSelectedEvent';
import { updateNodesCheckedState, getNewCheckedState } from './LiquorTree/NodeStateUpdater';
import { LiquorTreeOptions } from './LiquorTree/LiquorTreeOptions';
import { FilterPredicate, NodePredicateFilter } from './LiquorTree/NodePredicateFilter';
/** Wrapper for Liquor Tree, reveals only abstracted INode for communication */
@Component({
components: {
LiquorTree,
Node,
LiquorTree,
Node,
},
})
export default class SelectableTree extends Vue {
@@ -38,7 +41,7 @@
@Prop() public initialNodes?: ReadonlyArray<INode>;
public initialLiquourTreeNodes?: ILiquorTreeNewNode[] = null;
public liquorTreeOptions = this.getDefaults();
public liquorTreeOptions = new LiquorTreeOptions(new NodePredicateFilter((node) => this.filterPredicate(node)));
public convertExistingToNode = convertExistingToNode;
public mounted() {
@@ -46,7 +49,7 @@
const initialNodes = this.initialNodes.map((node) => toNewLiquorTreeNode(node));
if (this.selectedNodeIds) {
recurseDown(initialNodes,
(node) => node.state.checked = this.selectedNodeIds.includes(node.id));
(node) => node.state.checked = getNewCheckedState(node, this.selectedNodeIds));
}
this.initialLiquourTreeNodes = initialNodes;
} else {
@@ -58,7 +61,11 @@
}
public nodeSelected(node: ILiquorTreeExistingNode) {
this.$emit('nodeSelected', convertExistingToNode(node));
const event: INodeSelectedEvent = {
node: convertExistingToNode(node),
isSelected: node.states.checked,
};
this.$emit('nodeSelected', event);
return;
}
@@ -73,11 +80,11 @@
}
@Watch('selectedNodeIds')
public setSelectedStatus(selectedNodeIds: ReadonlyArray<string>) {
public setSelectedStatusAsync(selectedNodeIds: ReadonlyArray<string>) {
if (!selectedNodeIds) {
throw new Error('Selected nodes are undefined');
}
const newNodes = updateCheckedState(this.getLiquorTreeApi().model, selectedNodeIds);
const newNodes = updateNodesCheckedState(this.getLiquorTreeApi().model, selectedNodeIds);
this.getLiquorTreeApi().setModel(newNodes);
/* Alternative:
this.getLiquorTreeApi().recurseDown((node) => {
@@ -98,27 +105,6 @@
}
return (this.$refs.treeElement as any).tree;
}
private getDefaults(): ILiquorTreeOptions {
return {
multiple: true,
checkbox: true,
checkOnSelect: true,
autoCheckChildren: true,
parentSelect: false,
keyboardNavigation: true,
deletion: (node) => !node.children || node.children.length === 0,
filter: {
matcher: (query: string, node: ILiquorTreeExistingNode) => {
if (!this.filterPredicate) {
throw new Error('Cannot filter as predicate is null');
}
return this.filterPredicate(convertExistingToNode(node));
},
emptyText: '🕵Hmm.. Can not see one 🧐',
},
};
}
}
function recurseDown(
@@ -131,27 +117,4 @@
}
}
}
function updateCheckedState(
oldNodes: ReadonlyArray<ILiquorTreeExistingNode>,
selectedNodeIds: ReadonlyArray<string>): ReadonlyArray<ILiquorTreeNewNode> {
const result = new Array<ILiquorTreeNewNode>();
for (const oldNode of oldNodes) {
const newState = oldNode.states;
newState.checked = selectedNodeIds.some((id) => id === oldNode.id);
const newNode: ILiquorTreeNewNode = {
id: oldNode.id,
text: oldNode.data.text,
data: {
documentationUrls: oldNode.data.documentationUrls,
isReversible: oldNode.data.isReversible,
},
children: oldNode.children == null ? [] :
updateCheckedState(oldNode.children, selectedNodeIds),
state: newState,
};
result.push(newNode);
}
return result;
}
</script>

View File

@@ -7,7 +7,9 @@ import { Component, Prop, Watch, Vue } from 'vue-property-decorator';
import { StatefulVue } from './StatefulVue';
import ace from 'ace-builds';
import 'ace-builds/webpack-resolver';
import { CodeBuilder } from '../application/State/Code/CodeBuilder';
import { CodeBuilder } from '@/application/State/Code/Generation/CodeBuilder';
import { ICodeChangedEvent } from '@/application/State/Code/Event/ICodeChangedEvent';
import { IScript } from '@/domain/IScript';
const NothingChosenCode =
new CodeBuilder()
@@ -28,19 +30,65 @@ const NothingChosenCode =
@Component
export default class TheCodeArea extends StatefulVue {
public readonly editorId = 'codeEditor';
private editor!: ace.Ace.Editor;
private currentMarkerId?: number;
@Prop() private theme!: string;
public async mounted() {
this.editor = initializeEditor(this.theme, this.editorId);
const state = await this.getCurrentStateAsync();
this.updateCode(state.code.current);
this.editor.setValue(state.code.current || NothingChosenCode, 1);
state.code.changed.on((code) => this.updateCode(code));
}
private updateCode(code: string) {
this.editor.setValue(code || NothingChosenCode, 1);
private updateCode(event: ICodeChangedEvent) {
this.removeCurrentHighlighting();
if (event.isEmpty()) {
this.editor.setValue(NothingChosenCode, 1);
return;
}
this.editor.setValue(event.code, 1);
if (event.addedScripts && event.addedScripts.length) {
this.reactToChanges(event, event.addedScripts);
} else if (event.changedScripts && event.changedScripts.length) {
this.reactToChanges(event, event.changedScripts);
}
}
private reactToChanges(event: ICodeChangedEvent, scripts: ReadonlyArray<IScript>) {
const positions = scripts
.map((script) => event.getScriptPositionInCode(script));
const start = Math.min(
...positions.map((position) => position.startLine),
);
const end = Math.max(
...positions.map((position) => position.endLine),
);
this.scrollToLine(end + 2);
this.highlight(start, end);
}
private highlight(startRow: number, endRow: number) {
const AceRange = ace.require('ace/range').Range;
this.currentMarkerId = this.editor.session.addMarker(
new AceRange(startRow, 0, endRow, 0), 'code-area__highlight', 'fullLine',
);
}
private scrollToLine(row: number) {
const column = this.editor.session.getLine(row).length;
this.editor.gotoLine(row, column, true);
}
private removeCurrentHighlighting() {
if (!this.currentMarkerId) {
return;
}
this.editor.session.removeMarker(this.currentMarkerId);
this.currentMarkerId = undefined;
}
}
@@ -58,12 +106,16 @@ function initializeEditor(theme: string, editorId: string): ace.Ace.Editor {
</script>
<style scoped lang="scss">
<style lang="scss">
@import "@/presentation/styles/colors.scss";
.code-area {
/* ----- Fill its parent div ------ */
width: 100%;
/* height */
max-height: 1000px;
min-height: 200px;
&__highlight {
background-color:$accent;
opacity: 20%;
position:absolute;
}
}
</style>

View File

@@ -33,7 +33,7 @@ export default class TheCodeButtons extends StatefulVue {
const state = await this.getCurrentStateAsync();
this.hasCode = state.code.current && state.code.current.length > 0;
state.code.changed.on((code) => {
this.hasCode = code && code.length > 0;
this.hasCode = code && code.code.length > 0;
});
}