From 3e49fe9a50ace99bbe063170f0919e564b137896 Mon Sep 17 00:00:00 2001 From: George Gevoian Date: Mon, 18 Jul 2022 10:05:35 -0700 Subject: [PATCH] (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 --- app/client/lib/TokenField.ts | 62 ++++++++++++++++++++----- app/client/ui2018/breadcrumbs.ts | 26 +++++++---- app/client/ui2018/editableLabel.ts | 13 +++++- app/client/widgets/AttachmentsEditor.ts | 6 ++- app/client/widgets/ChoiceListEntry.ts | 59 +++++++++++++++-------- 5 files changed, 122 insertions(+), 44 deletions(-) diff --git a/app/client/lib/TokenField.ts b/app/client/lib/TokenField.ts index bdd06804..0a561f0b 100644 --- a/app/client/lib/TokenField.ts +++ b/app/client/lib/TokenField.ts @@ -44,6 +44,9 @@ export interface ITokenFieldOptions { // CSV escaping, and pasted from clipboard by applying createToken() to parsed CSV text. tokensToClipboard?: (tokens: Token[], clipboard: DataTransfer) => void; clipboardToTokens?: (clipboard: DataTransfer) => Token[]; + + // Defaults to horizontal. + variant?: ITokenFieldVariant; } /** @@ -56,6 +59,8 @@ export interface ITokenFieldKeyBindings { next?: string; } +export type ITokenFieldVariant = 'horizontal' | 'vertical'; + const defaultKeyBindings: Required = { previous: 'ArrowLeft', next: 'ArrowRight' @@ -96,6 +101,7 @@ export class TokenField extends Disposable { private _undoStack: UndoItem[] = []; private _undoIndex = 0; // The last action done; next to undo. private _inUndoRedo = false; + private _variant: ITokenFieldVariant = this._options.variant ?? 'horizontal'; constructor(private _options: ITokenFieldOptions) { super(); @@ -480,10 +486,12 @@ export class TokenField extends Disposable { const xInitial = startEvent.clientX; const yInitial = startEvent.clientY; const dragTargetSelector = `.${this._styles.cssToken.className}, .${this._styles.cssInputWrapper.className}`; + const dragTargetStyle = this._variant === 'horizontal' ? cssDragTarget : cssVerticalDragTarget; let started = false; let allTargets: HTMLElement[]; let tokenList: HTMLElement[]; + let nextUnselectedToken: HTMLElement|undefined; const onMove = (ev: MouseEvent) => { if (!started) { @@ -498,11 +506,17 @@ export class TokenField extends Disposable { // 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)); + 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. tokenList = allTargets.filter(el => el.matches('.selected')); 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 yOffset = ev.clientY - yInitial; @@ -519,21 +533,16 @@ export class TokenField extends Disposable { // Restore all style changes. 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.style.transform = ''; }); + nextUnselectedToken?.classList.remove(dragTargetStyle.className + "-next"); // 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. - let index = allTargets.indexOf(ev.target as HTMLElement); - 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; } - } + const index = allTargets.findIndex((target) => target.contains(ev.target as Node)); + if (index < 0) { return; } + const destToken: TokenWrap|undefined = this._tokens.get()[index]; 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', ` left: -10000px; width: 1px; diff --git a/app/client/ui2018/breadcrumbs.ts b/app/client/ui2018/breadcrumbs.ts index df046a1e..028476f4 100644 --- a/app/client/ui2018/breadcrumbs.ts +++ b/app/client/ui2018/breadcrumbs.ts @@ -140,10 +140,14 @@ export function docBreadcrumbs( ]; } ), - editableLabel( - docName, options.docNameSave, testId('bc-doc'), cssEditableName.cls(''), - dom.boolAttr('disabled', options.isDocNameReadOnly || false), - ), + editableLabel(docName, { + save: options.docNameSave, + inputArgs: [ + testId('bc-doc'), + cssEditableName.cls(''), + dom.boolAttr('disabled', options.isDocNameReadOnly || false), + ], + }), dom.maybe(options.isPublic, () => cssPublicIcon('PublicFilled', testId('bc-is-public'))), dom.domComputed((use) => { if (options.isSnapshot && use(options.isSnapshot)) { @@ -175,10 +179,14 @@ export function docBreadcrumbs( separator(' / ', testId('bc-separator'), cssHideForNarrowScreen.cls('')), - editableLabel( - pageName, options.pageNameSave, testId('bc-page'), cssEditableName.cls(''), - dom.boolAttr('disabled', options.isPageNameReadOnly || false), - dom.cls(cssHideForNarrowScreen.className), - ), + editableLabel(pageName, { + save: options.pageNameSave, + inputArgs: [ + testId('bc-page'), + cssEditableName.cls(''), + dom.boolAttr('disabled', options.isPageNameReadOnly || false), + dom.cls(cssHideForNarrowScreen.className), + ], + }), ); } diff --git a/app/client/ui2018/editableLabel.ts b/app/client/ui2018/editableLabel.ts index 2b12d54e..c7ed2496 100644 --- a/app/client/ui2018/editableLabel.ts +++ b/app/client/ui2018/editableLabel.ts @@ -64,12 +64,20 @@ enum Status { NORMAL, EDITING, SAVING } type SaveFunc = (value: string) => Promise; +export interface EditableLabelOptions { + save: SaveFunc; + args?: Array>; + inputArgs?: Array>; +} + /** * 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 * the save function, to reject a value simply throw an error, this will revert to the saved one . */ -export function editableLabel(label: Observable, save: SaveFunc, ...args: Array>) { +export function editableLabel(label: Observable, options: EditableLabelOptions) { + const {save, args, inputArgs} = options; + let input: HTMLInputElement; let sizer: HTMLSpanElement; @@ -81,8 +89,9 @@ export function editableLabel(label: Observable, save: SaveFunc, ...args sizer = cssSizer(label.get()), input = rawTextInput(label, save, updateSizer, dom.cls(cssLabelText.className), dom.on('focus', () => input.select()), - ...args + ...inputArgs ?? [], ), + ...args ?? [], ); } diff --git a/app/client/widgets/AttachmentsEditor.ts b/app/client/widgets/AttachmentsEditor.ts index 03412374..684a8df4 100644 --- a/app/client/widgets/AttachmentsEditor.ts +++ b/app/client/widgets/AttachmentsEditor.ts @@ -141,8 +141,10 @@ export class AttachmentsEditor extends NewBaseEditor { ), dom.maybe(this._selected, selected => cssTitle( - cssEditableLabel(selected.filename, (val) => this._renameAttachment(selected, val), - testId('pw-name')) + cssEditableLabel(selected.filename, { + save: (val) => this._renameAttachment(selected, val), + inputArgs: [testId('pw-name')], + }), ) ), cssFlexExpand( diff --git a/app/client/widgets/ChoiceListEntry.ts b/app/client/widgets/ChoiceListEntry.ts index 69f30ddb..c3296a18 100644 --- a/app/client/widgets/ChoiceListEntry.ts +++ b/app/client/widgets/ChoiceListEntry.ts @@ -129,7 +129,8 @@ export class ChoiceListEntry extends Disposable { keyBindings: { previous: 'ArrowUp', next: 'ArrowDown' - } + }, + variant: 'vertical', }); return cssVerticalFlex( @@ -324,22 +325,26 @@ export class ChoiceListEntry extends Disposable { })); } ), - editableLabel(choiceText, - rename, - testId('token-label'), - // 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. - dom.on('keydown', stopPropagation), - dom.on('copy', stopPropagation), - dom.on('cut', stopPropagation), - dom.on('paste', stopPropagation), - // On enter, focus on the input element. - dom.onKeyDown({ - Enter : focusOnNew - }), - // Don't bubble up click, as it would change focus. - dom.on('click', stopPropagation), - dom.cls(cssTokenLabel.className)), + editableLabel(choiceText, { + save: rename, + inputArgs: [ + testId('token-label'), + // 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. + dom.on('keydown', stopPropagation), + dom.on('copy', stopPropagation), + dom.on('cut', stopPropagation), + dom.on('paste', stopPropagation), + // On enter, focus on the input element. + dom.onKeyDown({ + Enter : focusOnNew + }), + // Don't bubble up click, as it would change focus. + dom.on('click', stopPropagation), + dom.cls(cssEditableLabelInput.className), + ], + args: [dom.cls(cssEditableLabel.className)], + }), ); } } @@ -432,7 +437,6 @@ const cssListRow = styled('div', ` color: ${colors.dark}; background-color: ${colors.mediumGrey}; border-radius: 3px; - overflow: hidden; text-overflow: ellipsis; `); @@ -478,6 +482,20 @@ const cssTokenLabel = styled('span', ` 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', ` padding-top: 4px; padding-bottom: 4px; @@ -503,7 +521,7 @@ const cssFlex = styled('div', ` `); const cssColorAndLabel = styled(cssFlex, ` - max-width: calc(100% - 16px); + max-width: calc(100% - 20px); `); const cssVerticalFlex = styled('div', ` @@ -521,7 +539,8 @@ const cssButtonRow = styled('div', ` const cssDeleteButton = styled('div', ` display: inline; - float:right; + float: right; + margin-left: 4px; cursor: pointer; .${cssTokenField.className}.token-dragactive & { cursor: unset;