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;