(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:
Dmitry S 2021-04-30 18:34:09 -04:00
parent 5baae7437a
commit 2f26f140c8
5 changed files with 660 additions and 2 deletions

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

View File

@ -18,3 +18,11 @@ export function isDesktop() {
export function isIOS() { export function isIOS() {
return navigator.platform && /iPad|iPhone|iPod/.test(navigator.platform); 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';
}

View File

@ -17,7 +17,7 @@ export interface MouseDragHandler {
onStop(endEv: MouseEvent): void; 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 { export function mouseDragElem(elem: HTMLElement, onStart: MouseDragStart): IDisposable {
@ -30,7 +30,7 @@ export function mouseDrag(onStart: MouseDragStart): DomElementMethod {
return (elem) => { mouseDragElem(elem, onStart); }; 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); const dragHandler = onStart(startEv, elem);
if (dragHandler) { if (dragHandler) {

38
app/common/csvFormat.ts Normal file
View 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, '"');
}

View File

@ -964,6 +964,10 @@ export async function copyKey() {
return await isMac() ? Key.chord(Key.CONTROL, Key.INSERT) : Key.chord(Key.CONTROL, 'c'); 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() { export async function pasteKey() {
return await isMac() ? Key.chord(Key.SHIFT, Key.INSERT) : Key.chord(Key.CONTROL, 'v'); return await isMac() ? Key.chord(Key.SHIFT, Key.INSERT) : Key.chord(Key.CONTROL, 'v');
} }