mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(core) Add our very own implementation of tokenfield (aka pillbox, tag list)
Summary: - Supports auto-completion - Supports various keyboard interactions to delete/select/copy-paste tokens - Supports various mouse interactions to select and move tokens. - Supports undo/redo while editing tokens. Test Plan: stand-alone fixture and browser test verify the supported behaviors Reviewers: paulfitz Reviewed By: paulfitz Differential Revision: https://phab.getgrist.com/D2789
This commit is contained in:
parent
5baae7437a
commit
2f26f140c8
608
app/client/lib/TokenField.ts
Normal file
608
app/client/lib/TokenField.ts
Normal file
@ -0,0 +1,608 @@
|
||||
/**
|
||||
* A full-featured implementation of tokenfield (aka "pillbox", "tag list", etc).
|
||||
*
|
||||
* Supported features:
|
||||
* - Each token includes an "x" button to delete it.
|
||||
* - Click on a token to select;
|
||||
* Shift+click to extend selection;
|
||||
* Ctrl+click for non-contigous selection.
|
||||
* - Arrow keys to move selection.
|
||||
* Shift + arrow keys to extend selection.
|
||||
* - Cmd+A to select all options.
|
||||
* - Delete/Backspace delete selection. If no selection, Backspace deletes the last item.
|
||||
* - Copy-cut is supported for a selection. By default CSV-encodes token labels.
|
||||
* - Paste is supported into input textbox, or to replace a selection.
|
||||
* - Tokens or a selection of tokens may be dragged to move within the tokenfield.
|
||||
* - Supports undo/redo for token changes.
|
||||
*/
|
||||
import { ACItem } from 'app/client/lib/ACIndex';
|
||||
import { modKeyProp } from 'app/client/lib/browserInfo';
|
||||
import { Autocomplete, IAutocompleteOptions } from 'app/client/lib/autocomplete';
|
||||
import { colors, testId } from 'app/client/ui2018/cssVars';
|
||||
import { icon } from 'app/client/ui2018/icons';
|
||||
import { csvDecodeRow, csvEncodeRow } from 'app/common/csvFormat';
|
||||
import { computedArray, IObsArraySplice, ObsArray, obsArray, Observable } from 'grainjs';
|
||||
import { Disposable, dom, DomContents, Holder, styled } from 'grainjs';
|
||||
|
||||
export interface IToken {
|
||||
label: string;
|
||||
}
|
||||
|
||||
export interface ITokenFieldOptions {
|
||||
initialValue: IToken[];
|
||||
renderToken: (token: IToken) => DomContents;
|
||||
createToken: (inputText: string) => IToken|undefined;
|
||||
acOptions?: IAutocompleteOptions<IToken & ACItem>;
|
||||
openAutocompleteOnFocus?: boolean;
|
||||
|
||||
// Allows overriding how tokens are copied to the clipboard, or retrieved from it.
|
||||
// By default, tokens are placed into clipboard as text/plain comma-separated token labels, with
|
||||
// CSV escaping, and pasted from clipboard by applying createToken() to parsed CSV text.
|
||||
tokensToClipboard?: (tokens: IToken[], clipboard: DataTransfer) => void;
|
||||
clipboardToTokens?: (clipboard: DataTransfer) => IToken[];
|
||||
}
|
||||
|
||||
// TokenWrap serves to distinguish multiple instances of the same token in the list.
|
||||
class TokenWrap {
|
||||
constructor(public token: IToken) {}
|
||||
}
|
||||
|
||||
class UndoItem {
|
||||
constructor(public redo: () => void, public undo: () => void) {}
|
||||
}
|
||||
|
||||
export class TokenField extends Disposable {
|
||||
public tokensObs: ObsArray<IToken>;
|
||||
|
||||
private _acHolder = Holder.create<Autocomplete<IToken & ACItem>>(this);
|
||||
private _acOptions: IAutocompleteOptions<IToken & ACItem>|undefined;
|
||||
private _rootElem: HTMLElement;
|
||||
private _textInput: HTMLInputElement;
|
||||
|
||||
// ClipboardAPI events work as expected only when the focus is in an actual input.
|
||||
// This is where we place focus when we have some tokens selected.
|
||||
private _hiddenInput: HTMLInputElement;
|
||||
|
||||
// Keys to navigate tokens. In a vertical list, these would be changed to Up/Down.
|
||||
// TODO Support a vertical list.
|
||||
private _keyPrev = 'ArrowLeft';
|
||||
private _keyNext = 'ArrowRight';
|
||||
|
||||
private _tokens = this.autoDispose(obsArray<TokenWrap>());
|
||||
private _selection = Observable.create(this, new Set<TokenWrap>());
|
||||
private _selectionAnchor: TokenWrap|null = null;
|
||||
private _undoStack: UndoItem[] = [];
|
||||
private _undoIndex = 0; // The last action done; next to undo.
|
||||
private _inUndoRedo = false;
|
||||
|
||||
constructor(private _options: ITokenFieldOptions) {
|
||||
super();
|
||||
const addSelectedItem = this._addSelectedItem.bind(this);
|
||||
const openAutocomplete = this._openAutocomplete.bind(this);
|
||||
this._acOptions = _options.acOptions && {..._options.acOptions, onClick: addSelectedItem};
|
||||
this._tokens.set(_options.initialValue.map(t => new TokenWrap(t)));
|
||||
this.tokensObs = this.autoDispose(computedArray(this._tokens, t => t.token));
|
||||
|
||||
// We can capture undo info in a consistent way as long as we change _tokens using its
|
||||
// obsArray interface, by listening to the splice events.
|
||||
this.autoDispose(this._tokens.addListener(this._recordUndo.bind(this)));
|
||||
|
||||
this._rootElem = cssTokenField(
|
||||
{tabIndex: '-1'},
|
||||
dom.forEach(this._tokens, (t) =>
|
||||
cssToken(this._options.renderToken(t.token),
|
||||
cssDeleteIcon('CrossSmall', testId('tokenfield-delete')),
|
||||
dom.cls('selected', (use) => use(this._selection).has(t)),
|
||||
dom.on('click', (ev) => this._onTokenClick(ev, t)),
|
||||
dom.on('mousedown', (ev) => this._onMouseDown(ev, t)),
|
||||
testId('tokenfield-token')
|
||||
),
|
||||
),
|
||||
cssInputWrapper(
|
||||
this._textInput = cssTokenInput(
|
||||
dom.on('focus', this._onInputFocus.bind(this)),
|
||||
dom.on('blur', () => { this._acHolder.clear(); }),
|
||||
dom.onKeyDown({
|
||||
Escape: () => { this._acHolder.clear(); },
|
||||
Enter: addSelectedItem,
|
||||
ArrowDown$: openAutocomplete,
|
||||
Tab$: (ev) => {
|
||||
// Only treat tab specially if there is some token-adding in progress.
|
||||
if (this._textInput.value !== '' || !this._acHolder.isEmpty()) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
addSelectedItem();
|
||||
}
|
||||
},
|
||||
}),
|
||||
dom.on('input', openAutocomplete),
|
||||
testId('tokenfield-input'),
|
||||
),
|
||||
),
|
||||
dom.onKeyDown({
|
||||
a$: this._maybeSelectAllTokens.bind(this),
|
||||
Backspace$: this._maybeBackspace.bind(this),
|
||||
Delete$: this._maybeDelete.bind(this),
|
||||
[this._keyPrev + '$']: (ev) => this._maybeAdvance(ev, -1),
|
||||
[this._keyNext + '$']: (ev) => this._maybeAdvance(ev, +1),
|
||||
// ['Mod+z'] triggers undo; ['Mod+Shift+Z', 'Ctrl+y' ] trigger redo
|
||||
z$: (ev) => { if (ev[modKeyProp()]) { ev.shiftKey ? this._redo(ev) : this._undo(ev); } },
|
||||
y$: (ev) => { if (ev.ctrlKey && !ev.shiftKey) { this._redo(ev); } },
|
||||
}),
|
||||
this._hiddenInput = cssHiddenInput({type: 'text', tabIndex: '-1'},
|
||||
dom.on('blur', (ev) => {
|
||||
if (ev.relatedTarget && ev.relatedTarget !== this._rootElem) {
|
||||
this._selectionAnchor = null;
|
||||
this._selection.set(new Set());
|
||||
}
|
||||
}),
|
||||
),
|
||||
dom.on('focus', () => this._hiddenInput.focus()),
|
||||
dom.on('copy', this._onCopyEvent.bind(this)),
|
||||
dom.on('cut', this._onCutEvent.bind(this)),
|
||||
dom.on('paste', this._onPasteEvent.bind(this)),
|
||||
testId('tokenfield'),
|
||||
);
|
||||
}
|
||||
|
||||
public attach(elem: HTMLElement): void {
|
||||
elem.appendChild(this._rootElem);
|
||||
}
|
||||
|
||||
// Open the autocomplete dropdown, if autocomplete was configured in the options.
|
||||
private _openAutocomplete() {
|
||||
if (this._acOptions && this._acHolder.isEmpty()) {
|
||||
Autocomplete.create(this._acHolder, this._textInput, this._acOptions);
|
||||
}
|
||||
}
|
||||
|
||||
// Adds the typed-in or selected item. If an item is selected in autocomplete dropdown, adds
|
||||
// that; otherwise if options.createToken is present, creates a token from text input value.
|
||||
private _addSelectedItem() {
|
||||
let item: IToken|undefined = this._acHolder.get()?.getSelectedItem();
|
||||
if (!item && this._options.createToken && this._textInput.value) {
|
||||
item = this._options.createToken(this._textInput.value);
|
||||
}
|
||||
if (item) {
|
||||
this._tokens.push(new TokenWrap(item));
|
||||
this._textInput.value = '';
|
||||
this._acHolder.clear();
|
||||
}
|
||||
}
|
||||
|
||||
// Handler for when text input is focused: clears selection, optionally opens dropdown.
|
||||
private _onInputFocus() {
|
||||
this._selectionAnchor = null;
|
||||
this._selection.set(new Set());
|
||||
if (this._options.openAutocompleteOnFocus) {
|
||||
this._openAutocomplete();
|
||||
}
|
||||
}
|
||||
|
||||
// Handle for a click on a token or the token's delete button. This handles selection, including
|
||||
// Shift+Click and Ctrl+Click.
|
||||
private _onTokenClick(ev: MouseEvent, t: TokenWrap) {
|
||||
const idx = this._tokens.get().indexOf(t);
|
||||
if (idx < 0) { return; }
|
||||
if (ev.target && (ev.target as HTMLElement).matches('.' + cssDeleteIcon.className)) {
|
||||
// Delete token.
|
||||
this._tokens.splice(idx, 1);
|
||||
} else {
|
||||
const fromIdx = this._selectionAnchor ? this._tokens.get().indexOf(this._selectionAnchor) : -1;
|
||||
if (ev.shiftKey && fromIdx >= 0) {
|
||||
// Shift+Click selects range from selectionAnchor to the clicked token.
|
||||
const [first, last] = fromIdx <= idx ? [fromIdx, idx] : [idx, fromIdx];
|
||||
this._selection.set(new Set(this._tokens.get().slice(first, last + 1)));
|
||||
} else if (ev[modKeyProp()] && fromIdx >= 0) {
|
||||
// Ctrl+Click (or Command+Click on mac) toggles the clicked token.
|
||||
this._toggleTokenSelection(t);
|
||||
} else {
|
||||
// Plain click, or any click in the absence of an anchor element, sets the anchor and
|
||||
// selects just this element.
|
||||
this._resetTokenSelection(t);
|
||||
}
|
||||
}
|
||||
this._setFocus();
|
||||
}
|
||||
|
||||
private _maybeSelectAllTokens(ev: KeyboardEvent) {
|
||||
if (ev[modKeyProp()] && this._textInput.value === '') {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
const tokens = this._tokens.get();
|
||||
this._selection.set(new Set(tokens));
|
||||
this._selectionAnchor = tokens ? tokens[0] : null;
|
||||
this._setFocus();
|
||||
}
|
||||
}
|
||||
|
||||
// Set focus appropriately to the textInput or to the outer container.
|
||||
private _setFocus() {
|
||||
if (this._selection.get().size === 0) {
|
||||
this._textInput.focus();
|
||||
} else {
|
||||
this._hiddenInput.focus();
|
||||
}
|
||||
}
|
||||
|
||||
private _maybeBackspace(ev: KeyboardEvent) {
|
||||
if (this._textInput.value === '') {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
if (this._selection.get().size === 0) {
|
||||
this._tokens.pop();
|
||||
} else {
|
||||
this._deleteTokens(this._selection.get(), -1);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _maybeDelete(ev: KeyboardEvent) {
|
||||
if (this._textInput.value === '' && this._selection.get().size > 0) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this._deleteTokens(this._selection.get(), 1);
|
||||
}
|
||||
}
|
||||
|
||||
// Handle arrow and shift+arrow keys, when the text input is empty.
|
||||
private _maybeAdvance(ev: KeyboardEvent, advance: 1|-1): void {
|
||||
if (this._textInput.value !== '') {
|
||||
return;
|
||||
}
|
||||
const tokens = this._tokens.get();
|
||||
const anchorIdx = this._selectionAnchor ? tokens.indexOf(this._selectionAnchor) : -1;
|
||||
|
||||
if (ev.shiftKey && this._selection.get().size > 0 && anchorIdx >= 0) {
|
||||
// For shift+arrows, we either extend or reduce the selection, depending on whether we are
|
||||
// walking away from the anchor or back towards it.
|
||||
const [first, last] = this._getSelectedIndexRange(this._selection.get());
|
||||
if (last < 0) { return; }
|
||||
const toggleIdx = (advance > 0) ?
|
||||
(last === anchorIdx && first < anchorIdx ? first : last + 1) :
|
||||
(first === anchorIdx && last > anchorIdx ? last : first - 1);
|
||||
const t = tokens[toggleIdx];
|
||||
if (t) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this._toggleTokenSelection(t);
|
||||
this._setFocus();
|
||||
}
|
||||
} else {
|
||||
// For arrow keys, move to the next token after the selection.
|
||||
let next: TokenWrap|null = null;
|
||||
if (this._selection.get().size > 0) {
|
||||
next = this._getNextToken(this._selection.get(), advance);
|
||||
} else if (advance < 0 && tokens.length > 0) {
|
||||
next = tokens[tokens.length - 1];
|
||||
}
|
||||
// If no next token and we are moving to the right, we should end up back in the text input.
|
||||
if (next || advance > 0) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this._resetTokenSelection(next);
|
||||
this._setFocus();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _toggleTokenSelection(token: TokenWrap) {
|
||||
const selection = this._selection.get();
|
||||
if (selection.has(token)) {
|
||||
selection.delete(token);
|
||||
} else {
|
||||
selection.add(token);
|
||||
}
|
||||
// We use .setAndTrigger() to set a value that's identical (by reference) to the previous one.
|
||||
this._selection.setAndTrigger(selection);
|
||||
}
|
||||
|
||||
private _resetTokenSelection(token: TokenWrap|null) {
|
||||
this._selectionAnchor = token;
|
||||
this._selection.set(token ? new Set([token]) : new Set());
|
||||
}
|
||||
|
||||
// Delete the given set of tokens, and select either the following or the preceding one.
|
||||
private _deleteTokens(toDelete: Set<TokenWrap>, advance: 1|-1|0) {
|
||||
if (this._selection.get().size === 0) { return; }
|
||||
const selectAfter = advance ? this._getNextToken(toDelete, advance) : null;
|
||||
this._tokens.set(this._tokens.get().filter(t => !toDelete.has(t)));
|
||||
this._resetTokenSelection(selectAfter);
|
||||
this._setFocus();
|
||||
}
|
||||
|
||||
private _getNextToken(selection: Set<TokenWrap>, advance: 1|-1): TokenWrap|null {
|
||||
const [first, last] = this._getSelectedIndexRange(selection);
|
||||
if (last < 0) { return null; }
|
||||
return this._tokens.get()[advance > 0 ? last + 1 : first - 1] || null;
|
||||
}
|
||||
|
||||
private _getSelectedIndexRange(selection: Set<TokenWrap>): [number, number] {
|
||||
const tokens = this._tokens.get();
|
||||
let first = -1, last = -1;
|
||||
for (let i = 0; i < tokens.length; i++) {
|
||||
if (selection.has(tokens[i])) {
|
||||
if (first === -1) { first = i; }
|
||||
last = i;
|
||||
}
|
||||
}
|
||||
return [first, last];
|
||||
}
|
||||
|
||||
private _onCopyEvent(ev: ClipboardEvent): boolean {
|
||||
if (!ev.clipboardData || !this._selection.get().size) { return false; }
|
||||
ev.preventDefault(); // Required for overriding: https://www.w3.org/TR/clipboard-apis/#override-copy
|
||||
|
||||
const selected = this._selection.get();
|
||||
const tokens = this._tokens.get().filter(t => selected.has(t));
|
||||
if (this._options.tokensToClipboard) {
|
||||
this._options.tokensToClipboard(tokens.map(t => t.token), ev.clipboardData);
|
||||
} else {
|
||||
const values = tokens.map(t => t.token.label);
|
||||
ev.clipboardData.setData('text/plain', csvEncodeRow(values, {prettier: true}));
|
||||
}
|
||||
return true;
|
||||
}
|
||||
|
||||
private _onCutEvent(ev: ClipboardEvent) {
|
||||
if (this._onCopyEvent(ev)) {
|
||||
this._deleteTokens(this._selection.get(), 0);
|
||||
}
|
||||
}
|
||||
|
||||
private _onPasteEvent(ev: ClipboardEvent) {
|
||||
if (!ev.clipboardData) { return; }
|
||||
ev.preventDefault();
|
||||
let tokens: IToken[];
|
||||
if (this._options.clipboardToTokens) {
|
||||
tokens = this._options.clipboardToTokens(ev.clipboardData);
|
||||
} else {
|
||||
const text = ev.clipboardData.getData('text/plain');
|
||||
const values = csvDecodeRow(text);
|
||||
tokens = values.map(v => this._options.createToken(v)).filter((t): t is IToken => Boolean(t));
|
||||
}
|
||||
if (!tokens.length) { return; }
|
||||
const wrappedTokens = tokens.map(t => new TokenWrap(t));
|
||||
this._combineUndo(() => {
|
||||
this._deleteTokens(this._selection.get(), 1);
|
||||
const anchorIdx = this._selectionAnchor ? this._tokens.get().indexOf(this._selectionAnchor) : -1;
|
||||
if (anchorIdx >= 0) {
|
||||
this._tokens.splice(anchorIdx, 0, ...wrappedTokens);
|
||||
this._selectionAnchor = wrappedTokens[0];
|
||||
this._selection.set(new Set(wrappedTokens));
|
||||
} else {
|
||||
this._tokens.push(...wrappedTokens);
|
||||
this._resetTokenSelection(null);
|
||||
}
|
||||
});
|
||||
this._setFocus();
|
||||
}
|
||||
|
||||
// For a mousedown on a token, register events for mousemove/mouseup, and start dragging as soon
|
||||
// as mousemove occurs.
|
||||
private _onMouseDown(startEvent: MouseEvent, t: TokenWrap) {
|
||||
const xInitial = startEvent.clientX;
|
||||
const yInitial = startEvent.clientY;
|
||||
const dragTargetSelector = `.${cssToken.className}, .${cssInputWrapper.className}`;
|
||||
|
||||
let started = false;
|
||||
let allTargets: HTMLElement[];
|
||||
let tokenList: HTMLElement[];
|
||||
|
||||
const onMove = (ev: MouseEvent) => {
|
||||
if (!started) {
|
||||
started = true;
|
||||
// If we started dragging an element that's not part of the selection, reset the selection
|
||||
// to just that element. After this, we are always dragging the active selection.
|
||||
if (!this._selection.get().has(t)) {
|
||||
this._resetTokenSelection(t);
|
||||
}
|
||||
|
||||
this._rootElem.classList.add('token-dragactive');
|
||||
|
||||
// Get a list of all drag targets, and add a CSS class that shows drop location on hover.
|
||||
allTargets = Array.prototype.filter.call(this._rootElem.children, el => el.matches(dragTargetSelector));
|
||||
allTargets.forEach(el => el.classList.add(cssDragTarget.className));
|
||||
|
||||
// Get a list of element we are dragging, and add a CSS class to show them as dragged.
|
||||
tokenList = allTargets.filter(el => el.matches('.selected'));
|
||||
tokenList.forEach(el => el.classList.add('token-dragging'));
|
||||
}
|
||||
const xOffset = ev.clientX - xInitial;
|
||||
const yOffset = ev.clientY - yInitial;
|
||||
const transform = `translate(${xOffset}px, ${yOffset}px)`;
|
||||
tokenList.forEach(el => { el.style.transform = transform; });
|
||||
};
|
||||
|
||||
const onStop = (ev: MouseEvent) => {
|
||||
moveLis.dispose();
|
||||
stopLis.dispose();
|
||||
|
||||
// Stop here if dragging never started.
|
||||
if (!started) { return; }
|
||||
|
||||
// Restore all style changes.
|
||||
this._rootElem.classList.remove('token-dragactive');
|
||||
allTargets.forEach(el => el.classList.remove(cssDragTarget.className));
|
||||
tokenList.forEach(el => el.classList.remove('token-dragging'));
|
||||
tokenList.forEach(el => { el.style.transform = ''; });
|
||||
|
||||
// Find the token before which we are inserting the dragged elements. If inserting at the
|
||||
// end (just before or over the input box), destToken will be undefined.
|
||||
const index = allTargets.indexOf(ev.target as HTMLElement);
|
||||
if (index < 0) { return; }
|
||||
const destToken: TokenWrap|undefined = this._tokens.get()[index];
|
||||
|
||||
const selection = this._selection.get();
|
||||
if (selection.has(destToken)) { return; } // Not actually moving anywhere new.
|
||||
|
||||
const movedTokens = this._tokens.get().filter(tok => selection.has(tok));
|
||||
if (!movedTokens.length) { return; } // Didn't find any tokens to move.
|
||||
|
||||
this._combineUndo(() => {
|
||||
this._deleteTokens(selection, 0);
|
||||
// Find destination again after the deletion (it's likely to have changed).
|
||||
const destIndex = destToken ? this._tokens.get().indexOf(destToken) : this._tokens.get().length;
|
||||
// Move the tokens and mark them as selected.
|
||||
this._tokens.splice(destIndex, 0, ...movedTokens);
|
||||
this._selectionAnchor = movedTokens[0];
|
||||
this._selection.set(new Set(movedTokens));
|
||||
});
|
||||
};
|
||||
|
||||
const moveLis = dom.onElem(document, 'mousemove', onMove, {useCapture: true});
|
||||
const stopLis = dom.onElem(document, 'mouseup', onStop, {useCapture: true});
|
||||
}
|
||||
|
||||
private _recordUndo(val: TokenWrap[], prev: TokenWrap[], change?: IObsArraySplice<TokenWrap>) {
|
||||
if (this._inUndoRedo) { return; }
|
||||
const splice = change || {start: 0, numAdded: val.length, deleted: [...prev]};
|
||||
const newTokens = val.slice(splice.start, splice.start + splice.numAdded);
|
||||
const redo = () => this._tokens.splice(splice.start, splice.deleted.length, ...newTokens);
|
||||
const undo = () => this._tokens.splice(splice.start, splice.numAdded, ...splice.deleted);
|
||||
this._undoIndex = Math.min(this._undoIndex + 1, this._undoStack.length);
|
||||
this._undoStack.splice(this._undoIndex, this._undoStack.length, new UndoItem(redo, undo));
|
||||
}
|
||||
|
||||
private _combineUndo(callback: () => void) {
|
||||
const nextAction = this._undoIndex + 1;
|
||||
try {
|
||||
callback();
|
||||
} finally {
|
||||
if (this._undoStack.length > nextAction + 1) {
|
||||
// If multiple actions were added, combine them into one.
|
||||
const actions = this._undoStack.slice(nextAction);
|
||||
const redo = () => actions.forEach(a => a.redo());
|
||||
const undo = () => actions.slice().reverse().forEach(a => a.undo());
|
||||
this._undoIndex = nextAction;
|
||||
this._undoStack.splice(this._undoIndex, actions.length, new UndoItem(redo, undo));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _undo(ev: KeyboardEvent): void {
|
||||
if (this._textInput.value === '' && this._undoIndex >= 0 && this._undoIndex < this._undoStack.length) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this._inUndoRedo = true;
|
||||
try {
|
||||
this._undoStack[this._undoIndex].undo();
|
||||
this._undoIndex--;
|
||||
} finally {
|
||||
this._inUndoRedo = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private _redo(ev: KeyboardEvent): void {
|
||||
if (this._undoIndex + 1 < this._undoStack.length) {
|
||||
ev.stopPropagation();
|
||||
ev.preventDefault();
|
||||
this._inUndoRedo = true;
|
||||
try {
|
||||
this._undoIndex += 1;
|
||||
this._undoStack[this._undoIndex].redo();
|
||||
} finally {
|
||||
this._inUndoRedo = false;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const cssTokenField = styled('div', `
|
||||
display: flex;
|
||||
border: 1px solid ${colors.darkGrey};
|
||||
border-radius: 3px;
|
||||
padding: 0 4px;
|
||||
|
||||
&.token-dragactive {
|
||||
cursor: grabbing;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssToken = styled('div', `
|
||||
position: relative;
|
||||
flex: none;
|
||||
border-radius: 3px;
|
||||
background-color: ${colors.mediumGreyOpaque};
|
||||
padding: 4px;
|
||||
margin: 3px 2px;
|
||||
line-height: 16px;
|
||||
user-select: none;
|
||||
cursor: grab;
|
||||
|
||||
&.selected {
|
||||
background-color: ${colors.darkGrey};
|
||||
}
|
||||
&.token-dragging {
|
||||
pointer-events: none;
|
||||
z-index: 1;
|
||||
opacity: 0.7;
|
||||
}
|
||||
.${cssTokenField.className}.token-dragactive & {
|
||||
cursor: unset;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssInputWrapper = styled('div', `
|
||||
position: relative;
|
||||
flex: auto;
|
||||
margin: 3px 2px;
|
||||
display: flex;
|
||||
`);
|
||||
|
||||
const cssTokenInput = styled('input', `
|
||||
flex: auto;
|
||||
-webkit-appearance: none;
|
||||
-moz-appearance: none;
|
||||
padding: 0;
|
||||
border: none;
|
||||
outline: none;
|
||||
`);
|
||||
|
||||
// This class is applied to tokens and the input box on start of dragging, to use them as drag
|
||||
// targets. Insertion point will always be to the left of them. While dragging, these include a
|
||||
// transparent pseudo-element to cover some area to the left, to know when it's a suitable drop
|
||||
// position. While the drag is over the element (or its extension), it gets shifted to show
|
||||
// the user the location of the drop using another pseudo-element.
|
||||
const cssDragTarget = styled('div', `
|
||||
&::before {
|
||||
content: "";
|
||||
position: absolute;
|
||||
left: -8px;
|
||||
right: 50%;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
}
|
||||
&:hover {
|
||||
transform: translateX(2px);
|
||||
}
|
||||
&:hover::after {
|
||||
content: "";
|
||||
position: absolute;
|
||||
background-color: ${colors.lightGreen};
|
||||
width: 2px;
|
||||
top: 0px;
|
||||
bottom: 0px;
|
||||
left: -4px;
|
||||
}
|
||||
`);
|
||||
|
||||
const cssHiddenInput = styled('input', `
|
||||
left: -10000px;
|
||||
width: 1px;
|
||||
position: absolute;
|
||||
`);
|
||||
|
||||
const cssDeleteIcon = styled(icon, `
|
||||
vertical-align: bottom;
|
||||
margin-left: 4px;
|
||||
cursor: pointer;
|
||||
--icon-color: ${colors.slate};
|
||||
&:hover {
|
||||
--icon-color: ${colors.dark};
|
||||
}
|
||||
.${cssTokenField.className}.token-dragactive & {
|
||||
cursor: unset;
|
||||
}
|
||||
`);
|
@ -18,3 +18,11 @@ export function isDesktop() {
|
||||
export function isIOS() {
|
||||
return navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform);
|
||||
}
|
||||
|
||||
// Returns 'metaKey' on Mac / iOS, and 'ctrlKey' elsewhere. This is useful for various keyboard
|
||||
// interactions that use Control on Windows and Linux, and Command key on Mac for the same
|
||||
// purpose. Suitable to use with KeyboardEvent and MouseEvent.
|
||||
// (Note: Mousetrap.js uses the same logic to interpret its "mod" key alias.)
|
||||
export function modKeyProp(): 'metaKey'|'ctrlKey' {
|
||||
return /Mac|iPod|iPhone|iPad/.test(navigator.platform) ? 'metaKey' : 'ctrlKey';
|
||||
}
|
||||
|
@ -17,7 +17,7 @@ export interface MouseDragHandler {
|
||||
onStop(endEv: MouseEvent): void;
|
||||
}
|
||||
|
||||
export type MouseDragStart = (startEv: MouseEvent, elem: Element) => MouseDragHandler|null;
|
||||
export type MouseDragStart = (startEv: MouseEvent, elem: HTMLElement) => MouseDragHandler|null;
|
||||
|
||||
export function mouseDragElem(elem: HTMLElement, onStart: MouseDragStart): IDisposable {
|
||||
|
||||
@ -30,7 +30,7 @@ export function mouseDrag(onStart: MouseDragStart): DomElementMethod {
|
||||
return (elem) => { mouseDragElem(elem, onStart); };
|
||||
}
|
||||
|
||||
function _startDragging(startEv: MouseEvent, elem: Element, onStart: MouseDragStart) {
|
||||
function _startDragging(startEv: MouseEvent, elem: HTMLElement, onStart: MouseDragStart) {
|
||||
const dragHandler = onStart(startEv, elem);
|
||||
if (dragHandler) {
|
||||
|
||||
|
38
app/common/csvFormat.ts
Normal file
38
app/common/csvFormat.ts
Normal file
@ -0,0 +1,38 @@
|
||||
/**
|
||||
* Simple utilities for escaping/quoting/parsing CSV data.
|
||||
*
|
||||
* This only supports the default Excel-like encoding, in which fields containing any separators
|
||||
* or quotes get quoted (using '"'), and quotes get doubled.
|
||||
*
|
||||
* Quoting is also applied when values contain leading or trailing whitespace, and on parsing,
|
||||
* leading or trailing whitespace in unquoted values is trimmed, so that "," or ", " may be used
|
||||
* as a separator.
|
||||
*
|
||||
* This is intended for copy-pasting multi-choice values, where plain comma-separated text is the
|
||||
* most user-friendly, and CSV encoding is used to ensure we can handle arbitrary values.
|
||||
*/
|
||||
|
||||
// Encode a row. If {prettier: true} is set, separate output with ", ". Leading whitespace gets
|
||||
// encoded in any case.
|
||||
export function csvEncodeRow(values: string[], options: {prettier?: boolean} = {}): string {
|
||||
return values.map(csvEncodeCell).join(options.prettier ? ", " : ",");
|
||||
}
|
||||
|
||||
export function csvDecodeRow(text: string): string[] {
|
||||
// Clever regexp from https://github.com/micnews/csv-line
|
||||
const parts = text.split(/((?:(?:"[^"]*")|[^,])*)/);
|
||||
const main = parts.filter((v, idx) => idx % 2).map(csvDecodeCell);
|
||||
// The "delimiter" (odd-numbered parts) is our content. If it's not at the start/end, it means
|
||||
// we have commas, and should include empty fields at those ends.
|
||||
if (parts[0]) { main.unshift(""); }
|
||||
if (parts[parts.length - 1]) { main.push(""); }
|
||||
return main;
|
||||
}
|
||||
|
||||
export function csvEncodeCell(value: string): string {
|
||||
return /[,\r\n"]|^\s|\s$/.test(value) ? '"' + value.replace(/"/g, '""') + '"' : value;
|
||||
}
|
||||
|
||||
export function csvDecodeCell(value: string): string {
|
||||
return value.trim().replace(/^"|"$/g, '').replace(/""/g, '"');
|
||||
}
|
@ -964,6 +964,10 @@ export async function copyKey() {
|
||||
return await isMac() ? Key.chord(Key.CONTROL, Key.INSERT) : Key.chord(Key.CONTROL, 'c');
|
||||
}
|
||||
|
||||
export async function cutKey() {
|
||||
return await isMac() ? Key.chord(Key.CONTROL, Key.DELETE) : Key.chord(Key.CONTROL, 'x');
|
||||
}
|
||||
|
||||
export async function pasteKey() {
|
||||
return await isMac() ? Key.chord(Key.SHIFT, Key.INSERT) : Key.chord(Key.CONTROL, 'v');
|
||||
}
|
||||
|
Loading…
Reference in New Issue
Block a user