(core) Polish ChoiceListEntry drag and drop

Summary:
A green line indicating the insertion point is now shown in the
ChoiceListEntry component when dragging and dropping choices, similar
to the one shown in the choice list cell editor.

Test Plan: Tested manually.

Reviewers: jarek

Reviewed By: jarek

Differential Revision: https://phab.getgrist.com/D3529
This commit is contained in:
George Gevoian 2022-07-18 10:05:35 -07:00
parent 4b258ae0fa
commit 3e49fe9a50
5 changed files with 122 additions and 44 deletions

View File

@ -44,6 +44,9 @@ export interface ITokenFieldOptions<Token extends IToken> {
// CSV escaping, and pasted from clipboard by applying createToken() to parsed CSV text. // CSV escaping, and pasted from clipboard by applying createToken() to parsed CSV text.
tokensToClipboard?: (tokens: Token[], clipboard: DataTransfer) => void; tokensToClipboard?: (tokens: Token[], clipboard: DataTransfer) => void;
clipboardToTokens?: (clipboard: DataTransfer) => Token[]; clipboardToTokens?: (clipboard: DataTransfer) => Token[];
// Defaults to horizontal.
variant?: ITokenFieldVariant;
} }
/** /**
@ -56,6 +59,8 @@ export interface ITokenFieldKeyBindings {
next?: string; next?: string;
} }
export type ITokenFieldVariant = 'horizontal' | 'vertical';
const defaultKeyBindings: Required<ITokenFieldKeyBindings> = { const defaultKeyBindings: Required<ITokenFieldKeyBindings> = {
previous: 'ArrowLeft', previous: 'ArrowLeft',
next: 'ArrowRight' next: 'ArrowRight'
@ -96,6 +101,7 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
private _undoStack: UndoItem[] = []; private _undoStack: UndoItem[] = [];
private _undoIndex = 0; // The last action done; next to undo. private _undoIndex = 0; // The last action done; next to undo.
private _inUndoRedo = false; private _inUndoRedo = false;
private _variant: ITokenFieldVariant = this._options.variant ?? 'horizontal';
constructor(private _options: ITokenFieldOptions<Token>) { constructor(private _options: ITokenFieldOptions<Token>) {
super(); super();
@ -480,10 +486,12 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
const xInitial = startEvent.clientX; const xInitial = startEvent.clientX;
const yInitial = startEvent.clientY; const yInitial = startEvent.clientY;
const dragTargetSelector = `.${this._styles.cssToken.className}, .${this._styles.cssInputWrapper.className}`; const dragTargetSelector = `.${this._styles.cssToken.className}, .${this._styles.cssInputWrapper.className}`;
const dragTargetStyle = this._variant === 'horizontal' ? cssDragTarget : cssVerticalDragTarget;
let started = false; let started = false;
let allTargets: HTMLElement[]; let allTargets: HTMLElement[];
let tokenList: HTMLElement[]; let tokenList: HTMLElement[];
let nextUnselectedToken: HTMLElement|undefined;
const onMove = (ev: MouseEvent) => { const onMove = (ev: MouseEvent) => {
if (!started) { if (!started) {
@ -498,11 +506,17 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
// Get a list of all drag targets, and add a CSS class that shows drop location on hover. // 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 = Array.prototype.filter.call(this._rootElem.children, el => el.matches(dragTargetSelector));
allTargets.forEach(el => el.classList.add(cssDragTarget.className)); allTargets.forEach(el => el.classList.add(dragTargetStyle.className));
// Get a list of element we are dragging, and add a CSS class to show them as dragged. // 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 = allTargets.filter(el => el.matches('.selected'));
tokenList.forEach(el => el.classList.add('token-dragging')); tokenList.forEach(el => el.classList.add('token-dragging'));
// Add a CSS class to the first unselected token after the current selection; we use it for showing
// the drag/drop markers when hovering over a token.
nextUnselectedToken = allTargets.find(el => el.previousElementSibling === tokenList[tokenList.length - 1]);
nextUnselectedToken?.classList.add(dragTargetStyle.className + "-next");
nextUnselectedToken?.style.setProperty('--count', String(tokenList.length));
} }
const xOffset = ev.clientX - xInitial; const xOffset = ev.clientX - xInitial;
const yOffset = ev.clientY - yInitial; const yOffset = ev.clientY - yInitial;
@ -519,21 +533,16 @@ export class TokenField<Token extends IToken = IToken> extends Disposable {
// Restore all style changes. // Restore all style changes.
this._rootElem.classList.remove('token-dragactive'); this._rootElem.classList.remove('token-dragactive');
allTargets.forEach(el => el.classList.remove(cssDragTarget.className)); allTargets.forEach(el => el.classList.remove(dragTargetStyle.className));
tokenList.forEach(el => el.classList.remove('token-dragging')); tokenList.forEach(el => el.classList.remove('token-dragging'));
tokenList.forEach(el => { el.style.transform = ''; }); tokenList.forEach(el => { el.style.transform = ''; });
nextUnselectedToken?.classList.remove(dragTargetStyle.className + "-next");
// Find the token before which we are inserting the dragged elements. If inserting at the // 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. // end (just before or over the input box), destToken will be undefined.
let index = allTargets.indexOf(ev.target as HTMLElement); const index = allTargets.findIndex((target) => target.contains(ev.target as Node));
if (index < 0) {
// Sometimes we are at inner input element of the target (when dragging past the last element).
// In this case we need to test the parent.
if (ev.target instanceof HTMLInputElement && ev.target.parentElement) {
index = allTargets.indexOf(ev.target.parentElement);
}
if (index < 0) { return; } if (index < 0) { return; }
}
const destToken: TokenWrap<Token>|undefined = this._tokens.get()[index]; const destToken: TokenWrap<Token>|undefined = this._tokens.get()[index];
const selection = this._selection.get(); const selection = this._selection.get();
@ -707,6 +716,37 @@ const cssDragTarget = styled('div', `
} }
`); `);
const cssVerticalDragTarget = styled('div', `
/* This pseudo-element prevents small, flickering height changes when
* dragging the selection over targets. */
&::before {
content: "";
position: absolute;
top: -8px;
bottom: 0px;
left: 0px;
right: 0px;
}
&-next::before {
/* 27.75px is the height of a token. */
top: calc(-27.75px * var(--count, 1) - 8px);
}
&:hover {
transform: translateY(4px);
margin-bottom: 8px;
}
&:hover::after {
content: "";
position: absolute;
background-color: ${colors.lightGreen};
height: 2px;
top: -5px;
bottom: 0px;
left: 0px;
right: 0px;
}
`);
const cssHiddenInput = styled('input', ` const cssHiddenInput = styled('input', `
left: -10000px; left: -10000px;
width: 1px; width: 1px;

View File

@ -140,10 +140,14 @@ export function docBreadcrumbs(
]; ];
} }
), ),
editableLabel( editableLabel(docName, {
docName, options.docNameSave, testId('bc-doc'), cssEditableName.cls(''), save: options.docNameSave,
inputArgs: [
testId('bc-doc'),
cssEditableName.cls(''),
dom.boolAttr('disabled', options.isDocNameReadOnly || false), dom.boolAttr('disabled', options.isDocNameReadOnly || false),
), ],
}),
dom.maybe(options.isPublic, () => cssPublicIcon('PublicFilled', testId('bc-is-public'))), dom.maybe(options.isPublic, () => cssPublicIcon('PublicFilled', testId('bc-is-public'))),
dom.domComputed((use) => { dom.domComputed((use) => {
if (options.isSnapshot && use(options.isSnapshot)) { if (options.isSnapshot && use(options.isSnapshot)) {
@ -175,10 +179,14 @@ export function docBreadcrumbs(
separator(' / ', separator(' / ',
testId('bc-separator'), testId('bc-separator'),
cssHideForNarrowScreen.cls('')), cssHideForNarrowScreen.cls('')),
editableLabel( editableLabel(pageName, {
pageName, options.pageNameSave, testId('bc-page'), cssEditableName.cls(''), save: options.pageNameSave,
inputArgs: [
testId('bc-page'),
cssEditableName.cls(''),
dom.boolAttr('disabled', options.isPageNameReadOnly || false), dom.boolAttr('disabled', options.isPageNameReadOnly || false),
dom.cls(cssHideForNarrowScreen.className), dom.cls(cssHideForNarrowScreen.className),
), ],
}),
); );
} }

View File

@ -64,12 +64,20 @@ enum Status { NORMAL, EDITING, SAVING }
type SaveFunc = (value: string) => Promise<void>; type SaveFunc = (value: string) => Promise<void>;
export interface EditableLabelOptions {
save: SaveFunc;
args?: Array<DomArg<HTMLDivElement>>;
inputArgs?: Array<DomArg<HTMLInputElement>>;
}
/** /**
* Provides a label that takes in an observable that is set on Enter or loss of focus. Escape * Provides a label that takes in an observable that is set on Enter or loss of focus. Escape
* cancels editing. Label grows in size with typed input. Validation logic (if any) should happen in * cancels editing. Label grows in size with typed input. Validation logic (if any) should happen in
* the save function, to reject a value simply throw an error, this will revert to the saved one . * the save function, to reject a value simply throw an error, this will revert to the saved one .
*/ */
export function editableLabel(label: Observable<string>, save: SaveFunc, ...args: Array<DomArg<HTMLInputElement>>) { export function editableLabel(label: Observable<string>, options: EditableLabelOptions) {
const {save, args, inputArgs} = options;
let input: HTMLInputElement; let input: HTMLInputElement;
let sizer: HTMLSpanElement; let sizer: HTMLSpanElement;
@ -81,8 +89,9 @@ export function editableLabel(label: Observable<string>, save: SaveFunc, ...args
sizer = cssSizer(label.get()), sizer = cssSizer(label.get()),
input = rawTextInput(label, save, updateSizer, dom.cls(cssLabelText.className), input = rawTextInput(label, save, updateSizer, dom.cls(cssLabelText.className),
dom.on('focus', () => input.select()), dom.on('focus', () => input.select()),
...args ...inputArgs ?? [],
), ),
...args ?? [],
); );
} }

View File

@ -141,8 +141,10 @@ export class AttachmentsEditor extends NewBaseEditor {
), ),
dom.maybe(this._selected, selected => dom.maybe(this._selected, selected =>
cssTitle( cssTitle(
cssEditableLabel(selected.filename, (val) => this._renameAttachment(selected, val), cssEditableLabel(selected.filename, {
testId('pw-name')) save: (val) => this._renameAttachment(selected, val),
inputArgs: [testId('pw-name')],
}),
) )
), ),
cssFlexExpand( cssFlexExpand(

View File

@ -129,7 +129,8 @@ export class ChoiceListEntry extends Disposable {
keyBindings: { keyBindings: {
previous: 'ArrowUp', previous: 'ArrowUp',
next: 'ArrowDown' next: 'ArrowDown'
} },
variant: 'vertical',
}); });
return cssVerticalFlex( return cssVerticalFlex(
@ -324,8 +325,9 @@ export class ChoiceListEntry extends Disposable {
})); }));
} }
), ),
editableLabel(choiceText, editableLabel(choiceText, {
rename, save: rename,
inputArgs: [
testId('token-label'), testId('token-label'),
// Don't bubble up keyboard events, use them for editing the text. // Don't bubble up keyboard events, use them for editing the text.
// Without this keys like Backspace, or Mod+a will propagate and modify all tokens. // Without this keys like Backspace, or Mod+a will propagate and modify all tokens.
@ -339,7 +341,10 @@ export class ChoiceListEntry extends Disposable {
}), }),
// Don't bubble up click, as it would change focus. // Don't bubble up click, as it would change focus.
dom.on('click', stopPropagation), dom.on('click', stopPropagation),
dom.cls(cssTokenLabel.className)), dom.cls(cssEditableLabelInput.className),
],
args: [dom.cls(cssEditableLabel.className)],
}),
); );
} }
} }
@ -432,7 +437,6 @@ const cssListRow = styled('div', `
color: ${colors.dark}; color: ${colors.dark};
background-color: ${colors.mediumGrey}; background-color: ${colors.mediumGrey};
border-radius: 3px; border-radius: 3px;
overflow: hidden;
text-overflow: ellipsis; text-overflow: ellipsis;
`); `);
@ -478,6 +482,20 @@ const cssTokenLabel = styled('span', `
overflow: hidden; overflow: hidden;
`); `);
const cssEditableLabelInput = styled('input', `
display: inline-block;
text-overflow: ellipsis;
white-space: pre;
overflow: hidden;
`);
const cssEditableLabel = styled('div', `
margin-left: 6px;
text-overflow: ellipsis;
white-space: pre;
overflow: hidden;
`);
const cssTokenInput = styled('input', ` const cssTokenInput = styled('input', `
padding-top: 4px; padding-top: 4px;
padding-bottom: 4px; padding-bottom: 4px;
@ -503,7 +521,7 @@ const cssFlex = styled('div', `
`); `);
const cssColorAndLabel = styled(cssFlex, ` const cssColorAndLabel = styled(cssFlex, `
max-width: calc(100% - 16px); max-width: calc(100% - 20px);
`); `);
const cssVerticalFlex = styled('div', ` const cssVerticalFlex = styled('div', `
@ -522,6 +540,7 @@ const cssButtonRow = styled('div', `
const cssDeleteButton = styled('div', ` const cssDeleteButton = styled('div', `
display: inline; display: inline;
float: right; float: right;
margin-left: 4px;
cursor: pointer; cursor: pointer;
.${cssTokenField.className}.token-dragactive & { .${cssTokenField.className}.token-dragactive & {
cursor: unset; cursor: unset;