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

Misc token editor improvements

parent 9ea995ef
No related branches found
No related tags found
No related merge requests found
......@@ -55,42 +55,68 @@ export function elementIsInView(element, percentX = 1, percentY = 1) {
});
}
export function getCaretPosition(element) {
if (element.nodeName.toLowerCase() === "input" || element.nodeName.toLowerCase() === "textarea") {
return element.selectionStart;
} else {
// contenteditable
export function getSelectionPosition(element) {
// input, textarea, IE
if (element.setSelectionRange || element.createTextRange) {
return [element.selectionStart, element.selectionEnd];
}
// contenteditable
else {
const selection = window.getSelection();
const range = selection.getRangeAt(0);
const { startContainer, startOffset } = range;
range.setStart(element, 0);
return range.toString().length;
const end = range.toString().length;
range.setEnd(startContainer, startOffset);
const start = range.toString().length;
return [start, end];
}
}
export function setCaretPosition(element, position) {
export function setSelectionPosition(element, [start, end]) {
// input, textarea
if (element.setSelectionRange) {
element.focus();
element.setSelectionRange(position, position);
} else if (element.createTextRange) {
element.setSelectionRange(start, end);
}
// IE
else if (element.createTextRange) {
const range = element.createTextRange();
range.collapse(true);
range.moveEnd("character", position);
range.moveStart("character", position);
range.moveEnd("character", end);
range.moveStart("character", start);
range.select();
} else {
// contenteditable
}
// contenteditable
else {
const selection = window.getSelection();
const pos = getTextNodeAtPosition(element, position);
const startPos = getTextNodeAtPosition(element, start);
const endPos = getTextNodeAtPosition(element, end);
selection.removeAllRanges();
const range = new Range();
range.setStart(pos.node ,pos.position);
range.setStart(startPos.node, startPos.position);
range.setEnd(endPos.node, endPos.position);
selection.addRange(range);
}
}
export function saveCaretPosition(context) {
let position = getCaretPosition(context);
return () => setCaretPosition(context, position);
export function saveSelection(element) {
let range = getSelectionPosition(element);
return () => setSelectionPosition(element, range);
}
export function getCaretPosition(element) {
return getSelectionPosition(element)[1];
}
export function setCaretPosition(element, position) {
setSelectionPosition(element, [position, position]);
}
export function saveCaretPosition(element) {
let position = getCaretPosition(element);
return () => setCaretPosition(element, position);
}
function getTextNodeAtPosition(root, index) {
......
......@@ -168,7 +168,7 @@ export default class AggregationPopover extends Component {
if (editingAggregation) {
return (
<div style={{width: 300}}>
<div style={{width: editingAggregation ? 500 : 300}}>
<div className="text-grey-3 p1 py2 border-bottom flex align-center">
<a className="cursor-pointer flex align-center" onClick={this.onClearAggregation}>
<Icon name="chevronleft" size={18}/>
......@@ -205,7 +205,7 @@ export default class AggregationPopover extends Component {
NamedClause.setName(aggregation, e.target.value) :
aggregation
})}
placeholder="Aggregation name (optional)"
placeholder="Name (optional)"
/>
<Button className="full" primary disabled={this.state.error} onClick={() => this.commitAggregation(this.state.aggregation)}>
{isNew ? "Add" : "Update"} Aggregation
......
......@@ -7,7 +7,7 @@ import cx from "classnames";
import { compile, suggest } from "metabase/lib/expressions/parser";
import { format } from "metabase/lib/expressions/formatter";
import { setCaretPosition, getCaretPosition } from "metabase/lib/dom";
import { setCaretPosition, getSelectionPosition } from "metabase/lib/dom";
import Popover from "metabase/components/Popover.jsx";
......@@ -16,7 +16,6 @@ import TokenizedInput from "./TokenizedInput.jsx";
import { isExpression } from "metabase/lib/expressions";
const KEYCODE_TAB = 9;
const KEYCODE_ENTER = 13;
const KEYCODE_ESC = 27;
const KEYCODE_LEFT = 37;
......@@ -134,7 +133,7 @@ export default class ExpressionEditorTextfield extends Component {
if (!suggestions.length) {
return;
}
if (event.keyCode === KEYCODE_ENTER || event.keyCode === KEYCODE_TAB) {
if (event.keyCode === KEYCODE_ENTER) {
this.onSuggestionAccepted();
event.preventDefault();
} else if (event.keyCode === KEYCODE_UP) {
......@@ -205,15 +204,27 @@ export default class ExpressionEditorTextfield extends Component {
expressionErrorMessage = e;
console.error("expression error:", expressionErrorMessage);
}
try {
suggestions = suggest(expressionString, {
tableMetadata: this.props.tableMetadata,
customFields: this.props.customFields,
startRule: this.props.startRule,
index: getCaretPosition(inputElement)
})
} catch (e) {
console.error("suggest error:", e);
const isValid = parsedExpression && parsedExpression.length > 0;
const [selectionStart, selectionEnd] = getSelectionPosition(inputElement);
const hasSelection = selectionStart !== selectionEnd;
const isAtEnd = selectionEnd === expressionString.length;
const endsWithWhitespace = /\s$/.test(expressionString);
// don't show suggestions if
// * there's a section
// * we're at the end of a valid expression, unless the user has typed another space
if (!hasSelection && !(isValid && isAtEnd && !endsWithWhitespace)) {
try {
suggestions = suggest(expressionString, {
tableMetadata: this.props.tableMetadata,
customFields: this.props.customFields,
startRule: this.props.startRule,
index: selectionEnd
})
} catch (e) {
console.error("suggest error:", e);
}
}
this.setState({
......@@ -249,7 +260,7 @@ export default class ExpressionEditorTextfield extends Component {
<div className={cx(S.equalSign, "spread flex align-center h4 text-dark", { [S.placeholder]: !this.state.expressionString })}>=</div>
{ suggestions.length ?
<Popover
className="px2 pb1 not-rounded border-dark"
className="pb1 not-rounded border-dark"
hasArrow={false}
tetherOptions={{
attachment: 'top left',
......@@ -260,17 +271,17 @@ export default class ExpressionEditorTextfield extends Component {
{suggestions.map((suggestion, i) =>
// insert section title. assumes they're sorted by type
[(i === 0 || suggestion.type !== suggestions[i - 1].type) &&
<li className="h6 text-uppercase text-bold text-grey-3 py1 pt2">
<li className="mx2 h6 text-uppercase text-bold text-grey-3 py1 pt2">
{suggestion.type}
</li>
,
<li style={{ paddingTop: "2px", paddingBottom: "2px" }}
className={cx("cursor-pointer text-brand-hover", {"text-bold text-brand": i === this.state.highlightedSuggestion})}
<li 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)}
>
{ suggestion.prefixLength ?
<span>
<span className="text-brand text-bold">{suggestion.name.slice(0, suggestion.prefixLength)}</span>
<span className={cx("text-brand text-bold", {"text-white bg-brand": i === this.state.highlightedSuggestion})}>{suggestion.name.slice(0, suggestion.prefixLength)}</span>
<span>{suggestion.name.slice(suggestion.prefixLength)}</span>
</span>
:
......
......@@ -42,7 +42,7 @@ export default class ExpressionWidget extends Component {
const { expression } = this.state;
return (
<div style={{maxWidth: "500px"}}>
<div style={{maxWidth: "600px"}}>
<div className="p2">
<div className="h5 text-uppercase text-grey-3 text-bold">Field formula</div>
<div>
......
/*
.TokenizedExpression .aggregation { border: 3px solid rgb(255,0,0) !important; }
.TokenizedExpression .group { border: 3px solid rgb(0,255,0) !important; }
.TokenizedExpression .identifier { border: 3px solid rgb(0,0,255) !important; }
.TokenizedExpression .string-literal { border: 3px solid rgb(0,255,255) !important; }
.TokenizedExpression .open-paren { border: 3px solid rgb(255,255,0) !important; }
.TokenizedExpression .close-paren { border: 3px solid rgb(0,255,255) !important; } /**/
.TokenizedExpression .aggregation,
.TokenizedExpression .group,
.TokenizedExpression .identifier,
......@@ -14,7 +5,8 @@
.TokenizedExpression .close-paren,
.TokenizedExpression .string-literal {
display: inline-block;
border-radius: 4px;
border-radius: 3px;
font-size: 14px;
/* display: flex;
align-items: center; */
}
......@@ -29,28 +21,45 @@
/* overflow: hidden; */
}
/* metric */
.TokenizedExpression .identifier {
border: 1px solid #84BB4C;
background-color: #bfe49b;
padding: 0px 2px;
border: 1px solid #9CC177;
background-color: #E4F7D1;
margin: 1px 1px;
padding: 1px 3px;
}
.TokenizedExpression .aggregation .identifier {
border: 1px solid #2D86D4;
background-color: #6ab9ff;
/* field */
.TokenizedExpression .aggregation {
border: 1px solid #9CC177;
background-color: #E4F7D1;
padding: 3px 3px;
}
.TokenizedExpression .aggregation {
border: 1px solid #84BB4C;
background-color: #bfe49b;
padding: 0px 2px;
/* height: 25px; */
/* aggregation */
.TokenizedExpression .aggregation .identifier {
border: 1px solid #509EE3;
background-color: #C7E3FB;
}
.TokenizedExpression .aggregation > .identifier {
border: none;
background-color: transparent;
padding: 0;
margin: 0;
border-color: transparent;
padding: 0 0;
}
.TokenizedExpression .identifier.selected,
.TokenizedExpression .aggregation.selected {
color: white;
background-color: #9CC177;
}
.TokenizedExpression .aggregation.selected .identifier,
.TokenizedExpression .aggregation .identifier.selected {
color: white;
background-color: #509EE3;
}
.TokenizedExpression .selected {
/*border: 1px solid red;*/
}
......@@ -10,9 +10,11 @@ function nextNonWhitespace(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);
......@@ -29,9 +31,12 @@ export default class TokenizedExpression extends React.Component {
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>);
}
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>
......@@ -43,6 +48,7 @@ export default class TokenizedExpression extends React.Component {
current.props.children.push(<span className="close-paren">)</span>)
pop();
if (current.props.className === "aggregation") {
outsideAggregation = true;
pop();
}
} else {
......
......@@ -3,7 +3,7 @@ import ReactDOM from "react-dom";
import TokenizedExpression from "./TokenizedExpression.jsx";
import { getCaretPosition, saveCaretPosition } from "metabase/lib/dom"
import { getCaretPosition, saveSelection } from "metabase/lib/dom"
export default class TokenizedInput extends Component {
constructor(props) {
......@@ -77,26 +77,32 @@ export default class TokenizedInput extends Component {
}
let parent = selection.focusNode.parentNode;
if (parent.className === "close-paren" && parent.parentNode.className === "group" && parent.parentNode.parentNode.className === "aggregation") {
parent.parentNode.parentNode.parentNode.removeChild(parent.parentNode.parentNode);
e.stopPropagation();
e.preventDefault();
this._setValue(input.textContent);
} else if (parent.className === "identifier") {
if (parent.parentNode.className === "string-literal") {
parent.parentNode.parentNode.removeChild(parent.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 {
parent.parentNode.removeChild(parent);
group = parent;
}
}
if (group) {
e.stopPropagation();
e.preventDefault();
this._setValue(input.textContent);
if (group.classList.contains("selected")) {
group.parentNode.removeChild(group);
this._setValue(input.textContent);
} else {
group.classList.add("selected");
}
}
}
componentDidUpdate() {
const inputNode = ReactDOM.findDOMNode(this);
const restore = saveCaretPosition(inputNode);
const restore = saveSelection(inputNode);
ReactDOM.unmountComponentAtNode(inputNode);
while (inputNode.firstChild) {
......@@ -113,6 +119,7 @@ export default class TokenizedInput extends Component {
return (
<div
className={className}
style={{ whiteSpace: "pre" }}
contentEditable
onKeyDown={this.onKeyDown}
onInput={this.onInput}
......
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