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,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

@@ -0,0 +1,73 @@
// Two ways of typing other libraries: https://stackoverflow.com/a/53070501
declare module 'liquor-tree' {
import { PluginObject } from 'vue';
import { VueClass } from 'vue-class-component/lib/declarations';
// https://github.com/amsik/liquor-tree/blob/master/src/lib/Tree.js
export interface ILiquorTree {
readonly model: ReadonlyArray<ILiquorTreeExistingNode>;
filter(query: string): void;
clearFilter(): void;
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 extends ILiquorTreeNode {
data: ILiquorTreeNodeData;
states: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeExistingNode> | undefined;
}
/**
* Sent to liquor tree to define of new nodes.
* https://github.com/amsik/liquor-tree/blob/master/src/lib/Node.js
*/
export interface ILiquorTreeNewNode extends ILiquorTreeNode {
text: string;
state: ILiquorTreeNodeState | undefined;
children: ReadonlyArray<ILiquorTreeNewNode> | undefined;
}
// https://amsik.github.io/liquor-tree/#Component-Options
export interface ILiquorTreeOptions {
multiple: boolean;
checkbox: boolean;
checkOnSelect: boolean;
autoCheckChildren: boolean;
parentSelect: boolean;
keyboardNavigation: boolean;
filter: ILiquorTreeFilter;
deletion(node: ILiquorTreeNode): boolean;
}
interface ILiquorTreeNodeData extends ICustomLiquorTreeData {
text: string;
}
// https://github.com/amsik/liquor-tree/blob/master/src/components/TreeRoot.vue
export interface ILiquorTreeFilter {
emptyText: string;
matcher(query: string, node: ILiquorTreeExistingNode): boolean;
}
const LiquorTree: PluginObject<any> & VueClass<any>;
export default LiquorTree;
}

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