Fix outstanding tag editor issues (#1717)

* Start using WithFloating for SelectedTagBadge

* Adjust arrow on WithFloating for all directions

* Move TagOptionsBadge to its own sub directory

* Show autocomplete menu via WithFloating

* Have WithFloating return asReference instead of initializing its own reference element

* Add html: overflow: hidden for editor

* Replace ButtonToolbar with generic div

* Move scroll logic into autocomplete item + restrict Popover width to 95vw

* Fix autocomplete menu after pressing enter after selecting

- should not trigger an autocomplete choose

* Overlap TagInput perfectly with Tag

* Satisfy formatter

* Fix autocompletion item scrolling too much

* Remove unused Tag.svelte focusable prop

* Remove console.log

* Fix floating arrow is a diamond in dark mode

* Set autocompletion menu to 80vw
This commit is contained in:
Henrik Giesel 2022-03-11 06:48:49 +01:00 committed by GitHub
parent 2d6dd0630f
commit 1ec3741934
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
23 changed files with 433 additions and 401 deletions

View File

@ -41,6 +41,10 @@ editing-show-duplicates = Show Duplicates
editing-subscript = Subscript editing-subscript = Subscript
editing-superscript = Superscript editing-superscript = Superscript
editing-tags = Tags editing-tags = Tags
editing-tags-add = Add tag
editing-tags-copy = Copy tags
editing-tags-remove = Remove tags
editing-tags-select-all = Select all tags
editing-text-color = Text color editing-text-color = Text color
editing-text-highlight-color = Text highlight color editing-text-highlight-color = Text highlight color
editing-to-make-a-cloze-deletion-on = To make a cloze deletion on an existing note, you need to change it to a cloze type first, via 'Notes>Change Note Type' editing-to-make-a-cloze-deletion-on = To make a cloze deletion on an existing note, you need to change it to a cloze type first, via 'Notes>Change Note Type'

View File

@ -17,6 +17,7 @@ Alternative to DropdownMenu that avoids Bootstrap
border-radius: 5px; border-radius: 5px;
background-color: var(--frame-bg); background-color: var(--frame-bg);
min-width: 1rem; min-width: 1rem;
max-width: 95vw;
padding: 0.5rem 0; padding: 0.5rem 0;
font-size: 1rem; font-size: 1rem;

View File

@ -11,15 +11,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import isClosingKeyup from "../sveltelib/closing-keyup"; import isClosingKeyup from "../sveltelib/closing-keyup";
import { documentClick, documentKeyup } from "../sveltelib/event-store"; import { documentClick, documentKeyup } from "../sveltelib/event-store";
import portal from "../sveltelib/portal"; import portal from "../sveltelib/portal";
import type { PositionArgs } from "../sveltelib/position";
import position from "../sveltelib/position"; import position from "../sveltelib/position";
import subscribeTrigger from "../sveltelib/subscribe-trigger"; import subscribeTrigger from "../sveltelib/subscribe-trigger";
import { pageTheme } from "../sveltelib/theme"; import { pageTheme } from "../sveltelib/theme";
import toggleable from "../sveltelib/toggleable"; import toggleable from "../sveltelib/toggleable";
/** TODO at the moment we only dropdowns which are placed actually below the reference */ export let placement: Placement = "bottom";
const placement: Placement = "bottom";
export let closeOnInsideClick = false; export let closeOnInsideClick = false;
export let keepOnKeyup = false;
/** This may be passed in for more fine-grained control */ /** This may be passed in for more fine-grained control */
export let show = writable(false); export let show = writable(false);
@ -30,45 +30,61 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const { toggle, on, off } = toggleable(show); const { toggle, on, off } = toggleable(show);
onMount(() => let args: PositionArgs;
subscribeTrigger( $: args = {
show, floating: $show ? floating : null,
placement,
arrow,
};
let update: (args: PositionArgs) => void;
$: update?.(args);
function asReference(element: HTMLElement) {
const pos = position(element, args);
reference = element;
update = pos.update;
return {
destroy() {
pos.destroy();
},
};
}
onMount(() => {
const triggers = [
isClosingClick(documentClick, { isClosingClick(documentClick, {
reference, reference,
floating, floating,
inside: closeOnInsideClick, inside: closeOnInsideClick,
outside: true, outside: true,
}), }),
isClosingKeyup(documentKeyup, { ];
reference,
floating, if (!keepOnKeyup) {
}), triggers.push(
), isClosingKeyup(documentKeyup, {
); reference,
floating,
}),
);
}
subscribeTrigger(show, ...triggers);
});
</script> </script>
<div <slot name="reference" {show} {toggle} {on} {off} {asReference} />
bind:this={reference}
class="reference"
use:position={{ floating: $show ? floating : null, placement, arrow }}
>
<slot name="reference" {show} {toggle} {on} {off} />
</div>
<div bind:this={floating} class="floating" hidden={!$show} use:portal> <div bind:this={floating} class="floating" hidden={!$show} use:portal>
<slot name="floating" /> <slot name="floating" />
<div bind:this={arrow} class="arrow" class:dark={$pageTheme.isDark} /> <div bind:this={arrow} class="arrow" class:dark={$pageTheme.isDark} />
</div> </div>
<style lang="scss"> <style lang="scss">
@use "sass/elevation" as elevation; @use "sass/elevation" as elevation;
.reference {
/* TODO This should not be necessary */
line-height: normal;
}
.floating { .floating {
position: absolute; position: absolute;
border-radius: 5px; border-radius: 5px;
@ -82,7 +98,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
background-color: var(--frame-bg); background-color: var(--frame-bg);
width: 10px; width: 10px;
height: 10px; height: 10px;
transform: rotate(45deg);
z-index: 60; z-index: 60;
/* outer border */ /* outer border */
@ -92,16 +107,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
border-color: #060606; border-color: #060606;
} }
/* These are dependant on which edge the arrow is supposed to be */ /* Rotate the box to indicate the different directions */
border-right: none; border-right: none;
border-bottom: none; border-bottom: none;
/* inner border */ /* inner border */
box-shadow: inset 1px 1px 0 0 #eeeeee; box-shadow: inset 1px 1px 0 0 #eeeeee;
/* lightmode box-shadow: inset 1px 1px 0 0 #eee; */
&.dark { &.dark {
box-shadow: inset 0 0 0 1px #565656; box-shadow: inset 1px 1px 0 0 #565656;
} }
} }
</style> </style>

View File

@ -354,7 +354,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</Fields> </Fields>
</FieldsEditor> </FieldsEditor>
<TagEditor {size} {wrap} {tags} on:tagsupdate={saveTags} /> <TagEditor {tags} on:tagsupdate={saveTags} />
</div> </div>
<style lang="scss"> <style lang="scss">

View File

@ -8,3 +8,7 @@ $btn-disabled-opacity: 0.4;
@import "sass/bootstrap/scss/button-group"; @import "sass/bootstrap/scss/button-group";
@import "sass/bootstrap/scss/dropdown"; @import "sass/bootstrap/scss/dropdown";
@import "sass/bootstrap-tooltip"; @import "sass/bootstrap-tooltip";
html {
overflow: hidden;
}

View File

@ -70,10 +70,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
</script> </script>
<WithFloating show={showDropdown} closeOnInsideClick let:toggle> <WithFloating show={showDropdown} closeOnInsideClick>
<IconButton slot="reference" {disabled} on:click={toggle}> <span
{@html functionIcon} class="latex-button"
</IconButton> slot="reference"
let:asReference
use:asReference
let:toggle
>
<IconButton slot="reference" {disabled} on:click={toggle}>
{@html functionIcon}
</IconButton>
</span>
<Popover slot="floating"> <Popover slot="floating">
{#each dropdownItems as [callback, keyCombination, label]} {#each dropdownItems as [callback, keyCombination, label]}
@ -89,6 +97,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</WithFloating> </WithFloating>
<style lang="scss"> <style lang="scss">
.latex-button {
line-height: 1;
}
.shortcut { .shortcut {
font: Verdana; font: Verdana;
} }

View File

@ -3,35 +3,27 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
import { pageTheme } from "../../sveltelib/theme"; import { pageTheme } from "../../sveltelib/theme";
export let id: string | undefined = undefined;
let className = "";
export { className as class };
export let selected = false; export let selected = false;
export let active = false; export let active = false;
const dispatch = createEventDispatcher();
let buttonRef: HTMLButtonElement; let buttonRef: HTMLButtonElement;
export function scroll() { $: if (selected && buttonRef) {
/* TODO will not work on Gecko */
(buttonRef as any)?.scrollIntoViewIfNeeded(false);
/* buttonRef.scrollIntoView({ behavior: "smooth", block: "start" }); */ /* buttonRef.scrollIntoView({ behavior: "smooth", block: "start" }); */
/* TODO will not work on Gecko */
(buttonRef as any).scrollIntoViewIfNeeded({
behavior: "smooth",
block: "start",
});
} }
onMount(() => dispatch("mount", { button: buttonRef }));
</script> </script>
<button <button
bind:this={buttonRef} bind:this={buttonRef}
{id}
tabindex="-1" tabindex="-1"
class="autocomplete-item btn {className}" class="autocomplete-item btn"
class:btn-day={!$pageTheme.isDark} class:btn-day={!$pageTheme.isDark}
class:btn-night={$pageTheme.isDark} class:btn-night={$pageTheme.isDark}
class:selected class:selected
@ -52,6 +44,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
text-align: start; text-align: start;
white-space: nowrap; white-space: nowrap;
flex-grow: 1;
border-radius: 0;
} }
button { button {

View File

@ -1,80 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { createEventDispatcher } from "svelte";
import Badge from "../../components/Badge.svelte";
import DropdownItem from "../../components/DropdownItem.svelte";
import DropdownMenu from "../../components/DropdownMenu.svelte";
import { withSpan } from "../../components/helpers";
import Shortcut from "../../components/Shortcut.svelte";
import WithDropdown from "../../components/WithDropdown.svelte";
import { getPlatformString } from "../../lib/shortcuts";
import { dotsIcon } from "./icons";
const dispatch = createEventDispatcher();
const allLabel = "Select all tags";
const allShortcut = "Control+A";
const copyLabel = "Copy tags";
const copyShortcut = "Control+C";
const removeLabel = "Remove tags";
const removeShortcut = "Backspace";
</script>
<WithDropdown let:createDropdown>
<div class="more-icon">
<Badge class="me-1" on:mount={withSpan(createDropdown)}>{@html dotsIcon}</Badge>
<DropdownMenu>
<DropdownItem
on:click={(event) => {
dispatch("tagselectall");
event.stopImmediatePropagation();
}}>{allLabel} ({getPlatformString(allShortcut)})</DropdownItem
>
<Shortcut
keyCombination={allShortcut}
on:action={(event) => {
dispatch("tagselectall");
event.stopImmediatePropagation();
}}
/>
<DropdownItem on:click={() => dispatch("tagcopy")}
>{copyLabel} ({getPlatformString(copyShortcut)})</DropdownItem
>
<Shortcut
keyCombination={copyShortcut}
on:action={() => dispatch("tagcopy")}
/>
<DropdownItem on:click={() => dispatch("tagdelete")}
>{removeLabel} ({getPlatformString(removeShortcut)})</DropdownItem
>
<Shortcut
keyCombination={removeShortcut}
on:action={() => dispatch("tagdelete")}
/>
</DropdownMenu>
</div>
</WithDropdown>
<style lang="scss">
.more-icon {
line-height: 1;
:global(svg) {
padding-bottom: 2px;
cursor: pointer;
fill: currentColor;
opacity: 0.6;
}
:global(svg:hover) {
opacity: 1;
}
}
</style>

View File

@ -11,7 +11,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { className as class }; export { className as class };
export let tooltip: string | undefined = undefined; export let tooltip: string | undefined = undefined;
export let selected: boolean = false; export let selected = false;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();

View File

@ -5,15 +5,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, tick } from "svelte"; import { createEventDispatcher, tick } from "svelte";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
import { writable } from "svelte/store";
import ButtonToolbar from "../../components/ButtonToolbar.svelte";
import StickyContainer from "../../components/StickyContainer.svelte"; import StickyContainer from "../../components/StickyContainer.svelte";
import { Tags, tags as tagsService } from "../../lib/proto"; import { Tags, tags as tagsService } from "../../lib/proto";
import { execCommand } from "../helpers"; import { execCommand } from "../helpers";
import Tag from "./Tag.svelte"; import { TagOptionsButton } from "./tag-options-button";
import TagEditMode from "./TagEditMode.svelte"; import TagEditMode from "./TagEditMode.svelte";
import TagInput from "./TagInput.svelte"; import TagInput from "./TagInput.svelte";
import TagOptionsBadge from "./TagOptionsBadge.svelte";
import type { Tag as TagType } from "./tags"; import type { Tag as TagType } from "./tags";
import { import {
attachId, attachId,
@ -21,10 +20,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
replaceWithColons, replaceWithColons,
replaceWithUnicodeSeparator, replaceWithUnicodeSeparator,
} from "./tags"; } from "./tags";
import TagSpacer from "./TagSpacer.svelte";
import WithAutocomplete from "./WithAutocomplete.svelte"; import WithAutocomplete from "./WithAutocomplete.svelte";
export let size: number;
export let wrap: boolean;
export let tags: Writable<string[]>; export let tags: Writable<string[]>;
let tagTypes: TagType[]; let tagTypes: TagType[];
@ -36,6 +34,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: tagsToTagTypes($tags); $: tagsToTagTypes($tags);
const show = writable(false);
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
const noSuggestions = Promise.resolve([]); const noSuggestions = Promise.resolve([]);
let suggestionsPromise: Promise<string[]> = noSuggestions; let suggestionsPromise: Promise<string[]> = noSuggestions;
@ -145,19 +144,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return true; return true;
} }
async function enterBehavior(
index: number,
start: number,
end: number,
): Promise<void> {
if (autocomplete.hasSelected()) {
autocomplete.chooseSelected();
await tick();
}
splitTag(index, start, end);
}
async function splitTag(index: number, start: number, end: number): Promise<void> { async function splitTag(index: number, start: number, end: number): Promise<void> {
const current = activeName.slice(0, start); const current = activeName.slice(0, start);
const splitOff = activeName.slice(end); const splitOff = activeName.slice(end);
@ -279,7 +265,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
break; break;
case "Tab": case "Tab":
if (event.shiftKey) { if (!$show) {
break;
} else if (event.shiftKey) {
autocomplete.selectPrevious(); autocomplete.selectPrevious();
} else { } else {
autocomplete.selectNext(); autocomplete.selectNext();
@ -294,12 +282,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
function onKeyup(): void {
if (activeName.length === 0) {
autocomplete.hide();
}
}
let selectionAnchor: number | null = null; let selectionAnchor: number | null = null;
let selectionFocus: number | null = null; let selectionFocus: number | null = null;
@ -389,6 +371,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
// typically correct for rows < 7 // typically correct for rows < 7
$: assumedRows = Math.floor(height / badgeHeight); $: assumedRows = Math.floor(height / badgeHeight);
$: shortenTags = shortenTags || assumedRows > 2; $: shortenTags = shortenTags || assumedRows > 2;
$: anyTagsSelected = tagTypes.some((tag) => tag.selected);
</script> </script>
<StickyContainer <StickyContainer
@ -397,36 +380,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bind:height bind:height
class="d-flex" class="d-flex"
> >
{#if !wrap} <div class="tag-editor-area" on:focusout={deselectIfLeave}>
<TagOptionsBadge <TagOptionsButton
showSelectionsOptions={tagTypes.some((tag) => tag.selected)}
bind:badgeHeight bind:badgeHeight
tagsSelected={anyTagsSelected}
on:tagselectall={selectAllTags} on:tagselectall={selectAllTags}
on:tagcopy={copySelectedTags} on:tagcopy={copySelectedTags}
on:tagdelete={deleteSelectedTags} on:tagdelete={deleteSelectedTags}
on:tagappend={appendEmptyTag} on:tagappend={appendEmptyTag}
/> />
{/if}
<ButtonToolbar
class="d-flex align-items-center w-100 px-1"
{size}
{wrap}
on:focusout={deselectIfLeave}
>
{#if wrap}
<TagOptionsBadge
showSelectionsOptions={tagTypes.some((tag) => tag.selected)}
bind:badgeHeight
on:tagselectall={selectAllTags}
on:tagcopy={copySelectedTags}
on:tagdelete={deleteSelectedTags}
on:tagappend={appendEmptyTag}
/>
{/if}
{#each tagTypes as tag, index (tag.id)} {#each tagTypes as tag, index (tag.id)}
<div class="position-relative" class:hide-tag={index === active}> <div class="tag-relative" class:hide-tag={index === active}>
<TagEditMode <TagEditMode
class="ms-0" class="ms-0"
name={index === active ? activeName : tag.name} name={index === active ? activeName : tag.name}
@ -449,90 +414,76 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/> />
{#if index === active} {#if index === active}
<div class="adjust-position"> <WithAutocomplete
<WithAutocomplete {suggestionsPromise}
drop="up" {show}
class="d-flex flex-column cap-items" on:update={updateSuggestions}
{suggestionsPromise} on:select={({ detail }) => onAutocomplete(detail.selected)}
on:update={updateSuggestions} on:choose={({ detail }) => {
on:select={({ detail }) => onAutocomplete(detail.selected)} onAutocomplete(detail.chosen);
on:choose={({ detail }) => onAutocomplete(detail.chosen)} splitTag(index, detail.chosen.length, detail.chosen.length);
let:createAutocomplete }}
> let:createAutocomplete
<TagInput let:hide
id={tag.id} >
class="position-absolute start-0 top-0 bottom-0 ps-2 py-0" <TagInput
disabled={autocompleteDisabled} id={tag.id}
bind:name={activeName} class="position-absolute start-0 top-0 bottom-0 ps-2 py-0"
bind:input={activeInput} disabled={autocompleteDisabled}
on:focus={() => { bind:name={activeName}
activeName = tag.name; bind:input={activeInput}
autocomplete = createAutocomplete(activeInput); on:focus={() => {
}} activeName = tag.name;
on:keydown={onKeydown} autocomplete = createAutocomplete();
on:keyup={onKeyup} }}
on:taginput={() => updateTagName(tag)} on:keydown={onKeydown}
on:tagsplit={({ detail }) => on:keyup={() => {
enterBehavior(index, detail.start, detail.end)} if (activeName.length === 0) {
on:tagadd={() => insertTagKeepFocus(index)} hide?.();
on:tagdelete={() => deleteTagAt(index)} }
on:tagjoinprevious={() => joinWithPreviousTag(index)} }}
on:tagjoinnext={() => joinWithNextTag(index)} on:taginput={() => updateTagName(tag)}
on:tagmoveprevious={() => moveToPreviousTag(index)} on:tagsplit={({ detail }) =>
on:tagmovenext={() => moveToNextTag(index)} splitTag(index, detail.start, detail.end)}
on:tagaccept={() => { on:tagadd={() => insertTagKeepFocus(index)}
deleteTagIfNotUnique(tag, index); on:tagdelete={() => deleteTagAt(index)}
if (tag) { on:tagjoinprevious={() => joinWithPreviousTag(index)}
updateTagName(tag); on:tagjoinnext={() => joinWithNextTag(index)}
} on:tagmoveprevious={() => moveToPreviousTag(index)}
saveTags(); on:tagmovenext={() => moveToNextTag(index)}
decideNextActive(); on:tagaccept={() => {
}} deleteTagIfNotUnique(tag, index);
/> if (tag) {
</WithAutocomplete> updateTagName(tag);
</div> }
saveTags();
decideNextActive();
}}
/>
</WithAutocomplete>
{/if} {/if}
</div> </div>
{/each} {/each}
<div <TagSpacer on:click={appendEmptyTag} />
class="tag-spacer flex-grow-1 align-self-stretch" </div>
on:click={appendEmptyTag}
/>
<div class="position-relative hide-tag zero-width-tag">
<!-- makes sure footer does not resize when adding first tag -->
<Tag>SPACER</Tag>
</div>
</ButtonToolbar>
</StickyContainer> </StickyContainer>
<style lang="scss"> <style lang="scss">
.tag-spacer { .tag-editor-area {
cursor: text; display: flex;
flex-flow: row wrap;
padding: 0 1px 1px;
overflow: hidden;
margin-bottom: 3px;
}
.tag-relative {
position: relative;
padding: 0 1px;
} }
.hide-tag :global(.tag) { .hide-tag :global(.tag) {
opacity: 0; opacity: 0;
} }
.zero-width-tag :global(.tag) {
width: 0;
pointer-events: none;
padding-left: 0 !important;
padding-right: 0 !important;
}
.adjust-position {
:global(.tag-input) {
/* recreates positioning of Tag component
* so that the text does not move when accepting */
border-left: 1px solid transparent;
}
:global(.cap-items) {
max-height: 7rem;
overflow-y: auto;
}
}
</style> </style>

View File

@ -265,4 +265,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
border: none; border: none;
margin: 0; margin: 0;
} }
.tag-input {
/* recreates positioning of Tag component
* so that the text does not move when accepting */
border-left: 1px solid transparent;
}
</style> </style>

View File

@ -1,24 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import AddTagBadge from "./AddTagBadge.svelte";
import SelectedTagBadge from "./SelectedTagBadge.svelte";
export let badgeHeight: number;
export let showSelectionsOptions: boolean;
</script>
<div class="gap" bind:offsetHeight={badgeHeight} on:mousedown|preventDefault>
{#if showSelectionsOptions}
<SelectedTagBadge
--badge-align="-webkit-baseline-middle"
on:tagselectall
on:tagcopy
on:tagdelete
/>
{:else}
<AddTagBadge --badge-align="-webkit-baseline-middle" on:tagappend />
{/if}
</div>

View File

@ -0,0 +1,13 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<div class="tag-spacer" on:click />
<style lang="scss">
.tag-spacer {
cursor: text;
flex-grow: 1;
align-self: stretch;
}
</style>

View File

@ -1,15 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import Badge from "../../components/Badge.svelte";
import { deleteIcon } from "./icons";
let className: string = "";
export { className as class };
</script>
<Badge class="d-flex align-items-center ms-1 {className}" on:click iconSize={80}
>{@html deleteIcon}</Badge
>

View File

@ -3,42 +3,35 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import type Dropdown from "bootstrap/js/dist/dropdown";
import { createEventDispatcher, tick } from "svelte"; import { createEventDispatcher, tick } from "svelte";
import type { Writable } from "svelte/store";
import DropdownMenu from "../../components/DropdownMenu.svelte"; import Popover from "../../components/Popover.svelte";
import WithDropdown from "../../components/WithDropdown.svelte"; import WithFloating from "../../components/WithFloating.svelte";
import AutocompleteItem from "./AutocompleteItem.svelte"; import AutocompleteItem from "./AutocompleteItem.svelte";
let className: string = "";
export { className as class };
export let drop: "down" | "up" = "down";
export let suggestionsPromise: Promise<string[]>; export let suggestionsPromise: Promise<string[]>;
export let show: Writable<boolean>;
let dropdown: Dropdown;
let show = false;
let suggestionsItems: string[] = []; let suggestionsItems: string[] = [];
$: suggestionsPromise.then((items) => { $: suggestionsPromise.then((items) => {
show = items.length > 0; show.set(items.length > 0);
if (show) {
dropdown.show();
} else {
dropdown.hide();
}
suggestionsItems = items; suggestionsItems = items;
}); });
let selected: number | null = null; let selected: number | null = null;
let active: boolean = false; let active: boolean = false;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher<{
update: void;
/* Selected should be displayed to the user, but it is not accepted */
select: { selected: string };
/* Autocompletion action should finish with "chosen" */
choose: { chosen: string };
}>();
/** /**
* select as currently highlighted item * Select as currently highlighted item
*/ */
function incrementSelected(): void { function incrementSelected(): void {
if (selected === null) { if (selected === null) {
@ -62,8 +55,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
async function updateSelected(): Promise<void> { async function updateSelected(): Promise<void> {
dispatch("select", { selected: suggestionsItems[selected ?? -1] }); dispatch("select", { selected: suggestionsItems[selected ?? -1] });
await tick();
dropdown.update();
} }
async function selectNext(): Promise<void> { async function selectNext(): Promise<void> {
@ -77,20 +68,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
/** /**
* choose as accepted suggestion * Choose as accepted suggestion
*/ */
async function chooseSelected() { async function chooseSelected() {
active = true; active = true;
dispatch("choose", { chosen: suggestionsItems[selected ?? -1] }); dispatch("choose", { chosen: suggestionsItems[selected ?? -1] });
await tick(); await tick();
show = false; show.set(false);
} }
async function update() { async function update() {
dropdown.update();
await tick(); await tick();
dispatch("update"); dispatch("update");
} }
@ -98,26 +87,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return selected !== null; return selected !== null;
} }
const createAutocomplete = function createAutocomplete() {
(createDropdown: (element: HTMLElement) => Dropdown) => const api = {
(element: HTMLElement): any => { selectPrevious,
dropdown = createDropdown(element); selectNext,
chooseSelected,
const api = { update,
hide: dropdown.hide, hasSelected,
show: dropdown.show,
toggle: dropdown.toggle,
isVisible: (dropdown as any).isVisible,
selectPrevious,
selectNext,
chooseSelected,
update,
hasSelected,
};
return api;
}; };
return api;
}
function setSelected(index: number): void { function setSelected(index: number): void {
selected = index; selected = index;
active = true; active = true;
@ -137,44 +118,73 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setSelected(index); setSelected(index);
} }
} }
let scroll: () => void;
$: if (scroll) {
scroll();
}
</script> </script>
<WithDropdown {drop} toggleOpen={false} let:createDropdown align="start"> <WithFloating keepOnKeyup {show} placement="top-start" let:toggle let:hide let:show>
<slot createAutocomplete={createAutocomplete(createDropdown)} /> <span
class="autocomplete-reference"
slot="reference"
let:asReference
use:asReference
>
<slot {createAutocomplete} {toggle} {hide} {show} />
</span>
<DropdownMenu class={className} {show}> <Popover slot="floating">
{#each suggestionsItems as suggestion, index} <div class="autocomplete-menu">
{#if index === selected} {#each suggestionsItems as suggestion, index}
<AutocompleteItem {#if index === selected}
bind:scroll <AutocompleteItem
selected selected
{active} {active}
on:mousedown={() => setSelectedAndActive(index)} on:mousedown={() => setSelectedAndActive(index)}
on:mouseup={() => { on:mouseup={() => {
selectIndex(index); selectIndex(index);
chooseSelected(); chooseSelected();
}} }}
on:mouseenter={(event) => selectIfMousedown(event, index)} on:mouseenter={(event) => selectIfMousedown(event, index)}
on:mouseleave={() => (active = false)} on:mouseleave={() => (active = false)}
>{suggestion}</AutocompleteItem >{suggestion}</AutocompleteItem
> >
{:else} {:else}
<AutocompleteItem <AutocompleteItem
on:mousedown={() => setSelectedAndActive(index)} on:mousedown={() => setSelectedAndActive(index)}
on:mouseup={() => { on:mouseup={() => {
selectIndex(index); selectIndex(index);
chooseSelected(); chooseSelected();
}} }}
on:mouseenter={(event) => selectIfMousedown(event, index)} on:mouseenter={(event) => selectIfMousedown(event, index)}
>{suggestion}</AutocompleteItem >{suggestion}</AutocompleteItem
> >
{/if} {/if}
{/each} {/each}
</DropdownMenu> </div>
</WithDropdown> </Popover>
</WithFloating>
<style lang="scss">
.autocomplete-reference {
position: absolute;
top: 0;
left: 0;
right: 0;
bottom: 0;
/* Make sure that text in TagInput perfectly overlaps with Tag */
border-left: 1px solid transparent;
border-top: 2px solid transparent;
}
.autocomplete-menu {
display: flex;
flex-flow: column nowrap;
width: 80vw;
max-height: 7rem;
font-size: 11px;
overflow-x: hidden;
text-overflow: ellipsis;
overflow-y: auto;
}
</style>

View File

@ -3,7 +3,4 @@
/// <reference types="../../lib/image-import" /> /// <reference types="../../lib/image-import" />
export { default as dotsIcon } from "@mdi/svg/svg/dots-vertical.svg";
export { default as tagIcon } from "@mdi/svg/svg/tag.svg";
export { default as addTagIcon } from "@mdi/svg/svg/tag-plus.svg";
export { default as deleteIcon } from "bootstrap-icons/icons/x.svg"; export { default as deleteIcon } from "bootstrap-icons/icons/x.svg";

View File

@ -5,35 +5,32 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
import Badge from "../../components/Badge.svelte"; import IconConstrain from "../../../components/IconConstrain.svelte";
import Shortcut from "../../components/Shortcut.svelte"; import Shortcut from "../../../components/Shortcut.svelte";
import { getPlatformString } from "../../lib/shortcuts"; import * as tr from "../../../lib/ftl";
import { getPlatformString } from "../../../lib/shortcuts";
import { addTagIcon, tagIcon } from "./icons"; import { addTagIcon, tagIcon } from "./icons";
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher<{ tagappend: CustomEvent<void> }>();
const tooltip = "Add tag";
const keyCombination = "Control+Shift+T";
function appendTag(): void { const keyCombination = "Control+Shift+T";
dispatch("tagappend");
}
</script> </script>
<div class="add-icon"> <div
<Badge class="tag-add-button"
class="d-flex me-1" title="{tr.editingTagsAdd()} ({getPlatformString(keyCombination)})"
tooltip="{tooltip} ({getPlatformString(keyCombination)})" on:click={() => dispatch("tagappend")}
on:click={appendTag} >
> <IconConstrain>
{@html tagIcon} {@html tagIcon}
{@html addTagIcon} {@html addTagIcon}
</Badge> </IconConstrain>
<Shortcut {keyCombination} on:action={appendTag} />
</div> </div>
<Shortcut {keyCombination} on:action={() => dispatch("tagappend")} />
<style lang="scss"> <style lang="scss">
.add-icon { .tag-add-button {
line-height: 1; line-height: 1;
:global(svg:last-child) { :global(svg:last-child) {

View File

@ -0,0 +1,29 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import TagAddButton from "./TagAddButton.svelte";
import TagsSelectedButton from "./TagsSelectedButton.svelte";
export let badgeHeight: number;
export let tagsSelected: boolean;
</script>
<div
class="tag-options-button gap"
bind:offsetHeight={badgeHeight}
on:mousedown|preventDefault
>
{#if tagsSelected}
<TagsSelectedButton on:tagselectall on:tagcopy on:tagdelete />
{:else}
<TagAddButton on:tagappend />
{/if}
</div>
<style lang="scss">
.tag-options-button {
padding: 3px;
}
</style>

View File

@ -0,0 +1,77 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { createEventDispatcher } from "svelte";
import DropdownItem from "../../../components/DropdownItem.svelte";
import IconConstrain from "../../../components/IconConstrain.svelte";
import Popover from "../../../components/Popover.svelte";
import Shortcut from "../../../components/Shortcut.svelte";
import WithFloating from "../../../components/WithFloating.svelte";
import * as tr from "../../../lib/ftl";
import { getPlatformString } from "../../../lib/shortcuts";
import { dotsIcon } from "./icons";
const dispatch = createEventDispatcher();
const allShortcut = "Control+A";
const copyShortcut = "Control+C";
const removeShortcut = "Backspace";
</script>
<WithFloating placement="top">
<div
class="tags-selected-button"
slot="reference"
let:asReference
use:asReference
let:toggle
on:click={toggle}
>
<IconConstrain>{@html dotsIcon}</IconConstrain>
</div>
<Popover slot="floating">
<DropdownItem on:click={() => dispatch("tagselectall")}>
{tr.editingTagsSelectAll()} ({getPlatformString(allShortcut)})
</DropdownItem>
<Shortcut
keyCombination={allShortcut}
on:action={() => dispatch("tagselectall")}
/>
<DropdownItem on:click={() => dispatch("tagcopy")}
>{tr.editingTagsCopy()} ({getPlatformString(copyShortcut)})</DropdownItem
>
<Shortcut keyCombination={copyShortcut} on:action={() => dispatch("tagcopy")} />
<DropdownItem on:click={() => dispatch("tagdelete")}
>{tr.editingTagsRemove()} ({getPlatformString(
removeShortcut,
)})</DropdownItem
>
<Shortcut
keyCombination={removeShortcut}
on:action={() => dispatch("tagdelete")}
/>
</Popover>
</WithFloating>
<style lang="scss">
.tags-selected-button {
line-height: 1;
:global(svg) {
padding-bottom: 2px;
cursor: pointer;
fill: currentColor;
opacity: 0.6;
}
:global(svg:hover) {
opacity: 1;
}
}
</style>

View File

@ -0,0 +1,8 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/// <reference types="../../../lib/image-import" />
export { default as dotsIcon } from "@mdi/svg/svg/dots-vertical.svg";
export { default as tagIcon } from "@mdi/svg/svg/tag.svg";
export { default as addTagIcon } from "@mdi/svg/svg/tag-plus.svg";

View File

@ -0,0 +1,4 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { default as TagOptionsButton } from "./TagOptionsButton.svelte";

View File

@ -7,7 +7,8 @@
"plain-text-input/*", "plain-text-input/*",
"rich-text-input/*", "rich-text-input/*",
"editor-toolbar/*", "editor-toolbar/*",
"tag-editor/*" "tag-editor/*",
"tag-editor/tag-options-button/*"
], ],
"references": [ "references": [
{ "path": "../components" }, { "path": "../components" },

View File

@ -2,9 +2,16 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Placement } from "@floating-ui/dom"; import type { Placement } from "@floating-ui/dom";
import { arrow, autoUpdate, computePosition, offset, shift } from "@floating-ui/dom"; import {
arrow,
autoUpdate,
computePosition,
inline,
offset,
shift,
} from "@floating-ui/dom";
interface PositionArgs { export interface PositionArgs {
/** /**
* The floating element which is positioned relative to `reference`. * The floating element which is positioned relative to `reference`.
*/ */
@ -25,19 +32,41 @@ function position(
args.floating!, args.floating!,
{ {
middleware: [ middleware: [
inline(),
offset(5), offset(5),
shift({ padding: 5 }), shift({ padding: 5 }),
arrow({ element: args.arrow }), arrow({ element: args.arrow, padding: 5 }),
], ],
placement: args.placement, placement: args.placement,
}, },
); );
const arrowX = middlewareData.arrow?.x ?? ""; let rotation: number;
let arrowX: number | undefined;
let arrowY: number | undefined;
if (args.placement.startsWith("bottom")) {
rotation = 45;
arrowX = middlewareData.arrow?.x;
arrowY = -5;
} else if (args.placement.startsWith("left")) {
rotation = 135;
arrowX = args.floating!.offsetWidth - 5;
arrowY = middlewareData.arrow?.y;
} else if (args.placement.startsWith("top")) {
rotation = 225;
arrowX = middlewareData.arrow?.x;
arrowY = args.floating!.offsetHeight - 5;
} /* if (args.placement.startsWith("right")) */ else {
rotation = 315;
arrowX = -5;
arrowY = middlewareData.arrow?.y;
}
Object.assign(args.arrow.style, { Object.assign(args.arrow.style, {
left: `${arrowX}px`, left: arrowX ? `${arrowX}px` : "",
top: `-5px`, top: arrowY ? `${arrowY}px` : "",
transform: `rotate(${rotation}deg)`,
}); });
Object.assign(args.floating!.style, { Object.assign(args.floating!.style, {