mirror of
https://github.com/gristlabs/grist-core.git
synced 2024-10-27 20:44:07 +00:00
(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:
parent
4b258ae0fa
commit
3e49fe9a50
@ -44,6 +44,9 @@ export interface ITokenFieldOptions<Token extends IToken> {
|
||||
// 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<ITokenFieldKeyBindings> = {
|
||||
previous: 'ArrowLeft',
|
||||
next: 'ArrowRight'
|
||||
@ -96,6 +101,7 @@ export class TokenField<Token extends IToken = IToken> 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<Token>) {
|
||||
super();
|
||||
@ -480,10 +486,12 @@ export class TokenField<Token extends IToken = IToken> 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<Token extends IToken = IToken> 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<Token extends IToken = IToken> 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<Token>|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;
|
||||
|
@ -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),
|
||||
],
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
@ -64,12 +64,20 @@ enum Status { NORMAL, EDITING, SAVING }
|
||||
|
||||
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
|
||||
* 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<string>, save: SaveFunc, ...args: Array<DomArg<HTMLInputElement>>) {
|
||||
export function editableLabel(label: Observable<string>, options: EditableLabelOptions) {
|
||||
const {save, args, inputArgs} = options;
|
||||
|
||||
let input: HTMLInputElement;
|
||||
let sizer: HTMLSpanElement;
|
||||
|
||||
@ -81,8 +89,9 @@ export function editableLabel(label: Observable<string>, save: SaveFunc, ...args
|
||||
sizer = cssSizer(label.get()),
|
||||
input = rawTextInput(label, save, updateSizer, dom.cls(cssLabelText.className),
|
||||
dom.on('focus', () => input.select()),
|
||||
...args
|
||||
...inputArgs ?? [],
|
||||
),
|
||||
...args ?? [],
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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(
|
||||
|
@ -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;
|
||||
|
Loading…
Reference in New Issue
Block a user