add auto-highlighting of selected/updated code
This commit is contained in:
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
64
src/application/State/Code/Event/CodeChangedEvent.ts
Normal file
64
src/application/State/Code/Event/CodeChangedEvent.ts
Normal 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);
|
||||
}
|
||||
11
src/application/State/Code/Event/ICodeChangedEvent.ts
Normal file
11
src/application/State/Code/Event/ICodeChangedEvent.ts
Normal 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;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
7
src/application/State/Code/Generation/IUserScript.ts
Normal file
7
src/application/State/Code/Generation/IUserScript.ts
Normal 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>;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
24
src/application/State/Code/Position/CodePosition.ts
Normal file
24
src/application/State/Code/Position/CodePosition.ts
Normal 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');
|
||||
}
|
||||
}
|
||||
}
|
||||
5
src/application/State/Code/Position/ICodePosition.ts
Normal file
5
src/application/State/Code/Position/ICodePosition.ts
Normal file
@@ -0,0 +1,5 @@
|
||||
export interface ICodePosition {
|
||||
readonly startLine: number;
|
||||
readonly endLine: number;
|
||||
readonly totalLines: number;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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 */
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -0,0 +1,6 @@
|
||||
import { INode } from './Node/INode';
|
||||
|
||||
export interface INodeSelectedEvent {
|
||||
isSelected: boolean;
|
||||
node: INode;
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -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');
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
},
|
||||
};
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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;
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user