Skip to content
Snippets Groups Projects
Unverified Commit 89960b9b authored by Tom Robinson's avatar Tom Robinson
Browse files

More tokenized editor refinement

parent 74389fb8
No related branches found
No related tags found
No related merge requests found
......@@ -62,15 +62,19 @@ export function getSelectionPosition(element) {
}
// contenteditable
else {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const { startContainer, startOffset } = range;
range.setStart(element, 0);
const end = range.toString().length;
range.setEnd(startContainer, startOffset);
const start = range.toString().length;
try {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const { startContainer, startOffset } = range;
range.setStart(element, 0);
const end = range.toString().length;
range.setEnd(startContainer, startOffset);
const start = range.toString().length;
return [start, end];
return [start, end];
} catch (e) {
return [0, 0];
}
}
}
......
......@@ -23,6 +23,7 @@ const KEYCODE_UP = 38;
const KEYCODE_RIGHT = 39;
const KEYCODE_DOWN = 40;
const MAX_SUGGESTIONS = 30;
export default class ExpressionEditorTextfield extends Component {
constructor(props, context) {
......@@ -45,6 +46,14 @@ export default class ExpressionEditorTextfield extends Component {
placeholder: "write some math!"
}
_getParserInfo(props = this.props) {
return {
tableMetadata: props.tableMetadata,
customFields: props.customFields || {},
startRule: props.startRule
}
}
componentWillMount() {
this.componentWillReceiveProps(this.props);
}
......@@ -52,20 +61,14 @@ export default class ExpressionEditorTextfield extends Component {
componentWillReceiveProps(newProps) {
// we only refresh our state if we had no previous state OR if our expression or table has changed
if (!this.state || this.props.expression != newProps.expression || this.props.tableMetadata != newProps.tableMetadata) {
const parserInfo = this._getParserInfo(newProps);
let parsedExpression = newProps.expression;
let expressionString = format(newProps.expression, {
tableMetadata: newProps.tableMetadata,
customFields: newProps.customFields,
});
let expressionString = format(newProps.expression, parserInfo);
let expressionErrorMessage = null;
let suggestions = [];
try {
if (expressionString) {
compile(expressionString, {
tableMetadata: newProps.tableMetadata,
customFields: newProps.customFields,
startRule: newProps.startRule
});
compile(expressionString, parserInfo);
}
} catch (e) {
expressionErrorMessage = e;
......@@ -118,14 +121,16 @@ export default class ExpressionEditorTextfield extends Component {
this.setState({ highlightedSuggestion: index }, this.onSuggestionAccepted);
}
onInputKeyDown(event) {
onInputKeyDown(e) {
const { suggestions, highlightedSuggestion } = this.state;
if (event.keyCode === KEYCODE_LEFT || event.keyCode === KEYCODE_RIGHT) {
if (e.keyCode === KEYCODE_LEFT || e.keyCode === KEYCODE_RIGHT) {
setTimeout(() => this._triggerAutosuggest());
return;
}
if (event.keyCode === KEYCODE_ESC) {
if (e.keyCode === KEYCODE_ESC) {
e.stopPropagation();
e.preventDefault();
this.clearSuggestions();
return;
}
......@@ -133,19 +138,19 @@ export default class ExpressionEditorTextfield extends Component {
if (!suggestions.length) {
return;
}
if (event.keyCode === KEYCODE_ENTER) {
if (e.keyCode === KEYCODE_ENTER) {
this.onSuggestionAccepted();
event.preventDefault();
} else if (event.keyCode === KEYCODE_UP) {
e.preventDefault();
} else if (e.keyCode === KEYCODE_UP) {
this.setState({
highlightedSuggestion: (highlightedSuggestion + suggestions.length - 1) % suggestions.length
});
event.preventDefault();
} else if (event.keyCode === KEYCODE_DOWN) {
e.preventDefault();
} else if (e.keyCode === KEYCODE_DOWN) {
this.setState({
highlightedSuggestion: (highlightedSuggestion + suggestions.length + 1) % suggestions.length
});
event.preventDefault();
e.preventDefault();
}
}
......@@ -190,16 +195,14 @@ export default class ExpressionEditorTextfield extends Component {
return;
}
const parserInfo = this._getParserInfo();
let expressionErrorMessage = null;
let suggestions = [];
let parsedExpression;
try {
parsedExpression = compile(expressionString, {
tableMetadata: this.props.tableMetadata,
customFields: this.props.customFields,
startRule: this.props.startRule
})
parsedExpression = compile(expressionString, parserInfo)
} catch (e) {
expressionErrorMessage = e;
console.error("expression error:", expressionErrorMessage);
......@@ -217,9 +220,7 @@ export default class ExpressionEditorTextfield extends Component {
if (!hasSelection && !(isValid && isAtEnd && !endsWithWhitespace)) {
try {
suggestions = suggest(expressionString, {
tableMetadata: this.props.tableMetadata,
customFields: this.props.customFields,
startRule: this.props.startRule,
...parserInfo,
index: selectionEnd
})
} catch (e) {
......@@ -256,6 +257,7 @@ export default class ExpressionEditorTextfield extends Component {
onFocus={(e) => this._triggerAutosuggest()}
onClick={this.onInputClick}
autoFocus
parserInfo={this._getParserInfo()}
/>
<div className={cx(S.equalSign, "spread flex align-center h4 text-dark", { [S.placeholder]: !this.state.expressionString })}>=</div>
{ suggestions.length ?
......@@ -268,14 +270,14 @@ export default class ExpressionEditorTextfield extends Component {
}}
>
<ul style={{minWidth: 150, overflow: "hidden"}}>
{suggestions.map((suggestion, i) =>
{suggestions.slice(0,MAX_SUGGESTIONS).map((suggestion, i) =>
// insert section title. assumes they're sorted by type
[(i === 0 || suggestion.type !== suggestions[i - 1].type) &&
<li className="mx2 h6 text-uppercase text-bold text-grey-3 py1 pt2">
<li ref={"header-" + i} className="mx2 h6 text-uppercase text-bold text-grey-3 py1 pt2">
{suggestion.type}
</li>
,
<li style={{ paddingTop: 5, paddingBottom: 5 }}
<li ref={i} style={{ paddingTop: 5, paddingBottom: 5 }}
className={cx("px2 cursor-pointer text-white-hover bg-brand-hover", {"text-white bg-brand": i === this.state.highlightedSuggestion})}
onMouseDownCapture={(e) => this.onSuggestionMouseDown(e, i)}
>
......@@ -290,6 +292,9 @@ export default class ExpressionEditorTextfield extends Component {
</li>
]
)}
{ suggestions.length >= MAX_SUGGESTIONS &&
<li style={{ paddingTop: 5, paddingBottom: 5 }} className="px2 text-italic text-grey-3">and {suggestions.length - MAX_SUGGESTIONS} more</li>
}
</ul>
</Popover>
: null}
......
.TokenizedExpression .aggregation,
.TokenizedExpression .group,
.TokenizedExpression .identifier,
.TokenizedExpression .open-paren,
.TokenizedExpression .close-paren,
.TokenizedExpression .string-literal {
.Expression-node {
display: inline-block;
border-radius: 3px;
font-size: 14px;
/* display: flex;
align-items: center; */
}
.TokenizedExpression .string-literal > .open-quote,
.TokenizedExpression .string-literal > .close-quote {
/* don't use `display: none;` `visibility: hidden;` `width: 0;` or `height: 0;` as text won't appear when copied */
/* display: inline-block; */
.Expression-open-quote,
.Expression-close-quote {
opacity: 0.5;
/*width: 1px;
height: 1px; */
/* overflow: hidden; */
}
/* metric */
.TokenizedExpression .identifier {
border: 1px solid #9CC177;
background-color: #E4F7D1;
.Expression-aggregation {
padding: 3px 3px;
}
.Expression-metric,
.Expression-field {
margin: 1px 1px;
padding: 1px 3px;
}
/* field */
.TokenizedExpression .aggregation {
.Expression-aggregation,
.Expression-metric {
border: 1px solid #9CC177;
background-color: #E4F7D1;
padding: 3px 3px;
}
/* aggregation */
.TokenizedExpression .aggregation .identifier {
.Expression-field {
border: 1px solid #509EE3;
background-color: #C7E3FB;
}
.TokenizedExpression .aggregation > .identifier {
background-color: transparent;
border-color: transparent;
padding: 0 0;
}
.TokenizedExpression .identifier.selected,
.TokenizedExpression .aggregation.selected {
.Expression-selected.Expression-aggregation,
.Expression-selected.Expression-metric,
.Expression-selected .Expression-aggregation,
.Expression-selected .Expression-metric {
color: white;
background-color: #9CC177;
}
.TokenizedExpression .aggregation.selected .identifier,
.TokenizedExpression .aggregation .identifier.selected {
.Expression-selected.Expression-field,
.Expression-selected .Expression-field {
color: white;
background-color: #509EE3;
}
.TokenizedExpression .selected {
/*border: 1px solid red;*/
.Expression-selected {
outline: 1px solid rgba(255,0,0,0.5);
}
.Expression-select-skip {
outline: 1px solid rgba(255,0,255,0.5);
}
......@@ -2,59 +2,108 @@ import React, { Component, PropTypes } from "react";
import "./TokenizedExpression.css";
import { parse } from "metabase/lib/expressions/parser";
import cx from "classnames";
function nextNonWhitespace(tokens, index) {
while (index < tokens.length && /^\s+$/.test(tokens[++index])) {
}
return tokens[index];
}
export default class TokenizedExpression extends React.Component {
render() {
// TODO: use the Chevrotain parser or tokenizer
let tokens = this.props.source.match(/[a-zA-Z]\w*|"(?:[^\\"]+|\\(?:[bfnrtv"\\/]|u[0-9a-fA-F]{4}))*"|\(|\)|\d+|\s+|[*/+-]|.*/g);
let root = <span className="TokenizedExpression" children={[]} />;
let current = root;
let outsideAggregation = true;
const stack = [];
const push = (element) => {
current.props.children.push(element);
stack.push(current);
current = element;
function parsePartial(expressionString) {
// TODO: use the Chevrotain parser or tokenizer
let tokens = (expressionString || " ").match(/[a-zA-Z]\w*|"(?:[^\\"]+|\\(?:[bfnrtv"\\/]|u[0-9a-fA-F]{4}))*"|\(|\)|\d+|\s+|[*/+-]|.+/g);
let root = { type: "group", children: [] };
let current = root;
let outsideAggregation = true;
const stack = [];
const push = (element) => {
current.children.push(element);
stack.push(current);
current = element;
}
const pop = () => {
if (stack.length === 0) {
return;
}
const pop = () => {
if (stack.length === 0) {
return;
current = stack.pop();
}
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (/^[a-zA-Z]\w*$/.test(token)) {
if (nextNonWhitespace(tokens, i) === "(") {
outsideAggregation = false;
push({
type: "aggregation",
tokenized: true,
children: []
});
current.children.push({
type: "aggregation-name",
text: token
});
} else {
current.children.push({
type: outsideAggregation ? "metric" : "field",
tokenized: true,
text: token
});
}
current = stack.pop();
}
for (let i = 0; i < tokens.length; i++) {
let token = tokens[i];
if (/^[a-zA-Z]\w*$/.test(token)) {
if (nextNonWhitespace(tokens, i) === "(") {
outsideAggregation = false;
push(<span className="aggregation" children={[]} />);
current.props.children.push(<span className="aggregation-name">{token}</span>);
} else {
current.props.children.push(<span className="identifier">{token}</span>);
}
} else if (/^"(?:[^\\"]+|\\(?:[bfnrtv"\\/]|u[0-9a-fA-F]{4}))*"$/.test(token)) {
current.props.children.push(
<span className="string-literal"><span className="open-quote">"</span><span className="identifier">{JSON.parse(token)}</span><span className="close-quote">"</span></span>
);
} else if (token === "(") {
push(<span className="group" children={[]} />)
current.props.children.push(<span className="open-paren">(</span>)
} else if (token === ")") {
current.props.children.push(<span className="close-paren">)</span>)
} else if (/^"(?:[^\\"]+|\\(?:[bfnrtv"\\/]|u[0-9a-fA-F]{4}))*"$/.test(token)) {
current.children.push({
type: "string-literal",
tokenized: true,
children: [
{ type: "open-quote", text: "\"" },
{ type: outsideAggregation ? "metric" : "field", text: JSON.parse(token) },
{ type: "close-quote", text: "\"" }
]
});
} else if (token === "(") {
push({ type: "group", children: [] })
current.children.push({ type: "open-paren", text: "(" })
} else if (token === ")") {
current.children.push({ type: "close-paren", text: ")" })
pop();
if (current.type === "aggregation") {
outsideAggregation = true;
pop();
if (current.props.className === "aggregation") {
outsideAggregation = true;
pop();
}
}
} else {
// special handling for unclosed string literals
if (i === tokens.length - 1 && /^".+[^"]$/.test(token)) {
current.children.push({
type: "string-literal",
tokenized: true,
children: [
{ type: "open-quote", text: "\"" },
{ type: outsideAggregation ? "metric" : "field", text: JSON.parse(token + "\"") }
]
});
} else {
current.props.children.push(token);
current.children.push({ type: "token", text: token });
}
}
return root;
}
return root;
}
const renderSyntaxTree = (node, index) =>
<span key={index} className={cx("Expression-node", "Expression-" + node.type, { "Expression-tokenized": node.tokenized })}>
{node.text != null ?
node.text
: node.children ?
node.children.map(renderSyntaxTree)
: null }
</span>
export default class TokenizedExpression extends React.Component {
render() {
// let parsed = parse(this.props.source, this.props.parserInfo);
const parsed = parsePartial(this.props.source);
return renderSyntaxTree(parsed);
}
}
......@@ -3,7 +3,11 @@ import ReactDOM from "react-dom";
import TokenizedExpression from "./TokenizedExpression.jsx";
import { getCaretPosition, saveSelection } from "metabase/lib/dom"
import { getCaretPosition, saveSelection, getSelectionPosition } from "metabase/lib/dom"
const KEYCODE_LEFT = 37;
const KEYCODE_BACKSPACE = 8;
export default class TokenizedInput extends Component {
constructor(props) {
......@@ -41,63 +45,60 @@ export default class TokenizedInput extends Component {
onSelectionChange = (e) => {
ReactDOM.findDOMNode(this).selectionStart = getCaretPosition(ReactDOM.findDOMNode(this))
}
onClick = (e) => {
this._isTyping = false;
return this.props.onClick(e);
}
onInput = (e) => {
this._setValue(e.target.textContent);
}
onKeyDown = (e) => {
this.props.onKeyDown(e);
// isTyping signals whether the user is typing characters (keyCode >= 65) vs. deleting / navigating with arrows / clicking to select
const isTyping = this._isTyping;
// also keep isTyping same when deleting
this._isTyping = e.keyCode >= 65 || (e.keyCode === KEYCODE_BACKSPACE && isTyping);
// handle tokenized delete
if (e.keyCode !== 8) {
return;
}
const input = ReactDOM.findDOMNode(this);
var selection = window.getSelection();
var range = selection.getRangeAt(0);
let isEndOfNode = range.endContainer.length === range.endOffset;
let hasSelection = range.startContainer !== range.endContainer || range.startOffset !== range.endOffset;
let el = selection.focusNode;
let path = [];
while (el && el != input) {
path.unshift(el.className);
el = el.parentNode;
}
/*
e.stopPropagation();
e.preventDefault();
return;
/**/
if (!isEndOfNode || hasSelection) {
let [start, end] = getSelectionPosition(input);
if (start !== end) {
return;
}
let parent = selection.focusNode.parentNode;
let group;
if (parent.classList.contains("close-paren") && parent.parentNode.classList.contains("group") && parent.parentNode.parentNode.classList.contains("aggregation")) {
group = parent.parentNode.parentNode;
} else if (parent.classList.contains("identifier")) {
if (parent.parentNode.classList.contains("string-literal")) {
group = parent.parentNode;
} else {
group = parent;
let element = window.getSelection().focusNode;
while (element && element !== input) {
// check ancestors of the focused node for "Expression-tokenized"
// if the element is marked as "tokenized" we might want to intercept keypresses
if (element.classList && element.classList.contains("Expression-tokenized") && getCaretPosition(element) === element.textContent.length) {
const isSelected = element.classList.contains("Expression-selected");
if (e.keyCode === KEYCODE_BACKSPACE && !isSelected && !isTyping) {
// not selected, not "typging", and hit backspace, so mark as "selected"
element.classList.add("Expression-selected");
e.stopPropagation();
e.preventDefault();
return;
} else if (e.keyCode === KEYCODE_BACKSPACE && isSelected) {
// selected and hit backspace, so delete it
element.parentNode.removeChild(element);
this._setValue(input.textContent);
e.stopPropagation();
e.preventDefault();
return;
} else if (e.keyCode === KEYCODE_LEFT && isSelected) {
// selected and hit left arrow, so enter "typing" mode and unselect it
element.classList.remove("Expression-selected");
this._isTyping = true;
e.stopPropagation();
e.preventDefault();
return;
}
}
// nada, try the next ancestor
element = element.parentNode;
}
if (group) {
e.stopPropagation();
e.preventDefault();
if (group.classList.contains("selected")) {
group.parentNode.removeChild(group);
this._setValue(input.textContent);
} else {
group.classList.add("selected");
}
}
// if we haven't handled the event yet, pass it on to our parent
this.props.onKeyDown(e);
}
componentDidUpdate() {
......@@ -108,14 +109,15 @@ export default class TokenizedInput extends Component {
while (inputNode.firstChild) {
inputNode.removeChild(inputNode.firstChild);
}
ReactDOM.render(<TokenizedExpression source={this._getValue()} />, inputNode);
ReactDOM.render(<TokenizedExpression source={this._getValue()} parserInfo={this.props.parserInfo} />, inputNode);
if (document.activeElement === inputNode) {
restore();
}
}
render() {
const { className, onFocus, onBlur, onClick } = this.props;
const { className, onFocus, onBlur } = this.props;
return (
<div
className={className}
......@@ -125,7 +127,7 @@ export default class TokenizedInput extends Component {
onInput={this.onInput}
onFocus={onFocus}
onBlur={onBlur}
onClick={onClick}
onClick={this.onClick}
/>
);
}
......
0% Loading or .
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment