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-superscript = Superscript
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-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'

View File

@ -17,6 +17,7 @@ Alternative to DropdownMenu that avoids Bootstrap
border-radius: 5px;
background-color: var(--frame-bg);
min-width: 1rem;
max-width: 95vw;
padding: 0.5rem 0;
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 { documentClick, documentKeyup } from "../sveltelib/event-store";
import portal from "../sveltelib/portal";
import type { PositionArgs } from "../sveltelib/position";
import position from "../sveltelib/position";
import subscribeTrigger from "../sveltelib/subscribe-trigger";
import { pageTheme } from "../sveltelib/theme";
import toggleable from "../sveltelib/toggleable";
/** TODO at the moment we only dropdowns which are placed actually below the reference */
const placement: Placement = "bottom";
export let placement: Placement = "bottom";
export let closeOnInsideClick = false;
export let keepOnKeyup = false;
/** This may be passed in for more fine-grained control */
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);
onMount(() =>
subscribeTrigger(
show,
let args: PositionArgs;
$: args = {
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, {
reference,
floating,
inside: closeOnInsideClick,
outside: true,
}),
];
if (!keepOnKeyup) {
triggers.push(
isClosingKeyup(documentKeyup, {
reference,
floating,
}),
),
);
}
subscribeTrigger(show, ...triggers);
});
</script>
<div
bind:this={reference}
class="reference"
use:position={{ floating: $show ? floating : null, placement, arrow }}
>
<slot name="reference" {show} {toggle} {on} {off} />
</div>
<slot name="reference" {show} {toggle} {on} {off} {asReference} />
<div bind:this={floating} class="floating" hidden={!$show} use:portal>
<slot name="floating" />
<div bind:this={arrow} class="arrow" class:dark={$pageTheme.isDark} />
</div>
<style lang="scss">
@use "sass/elevation" as elevation;
.reference {
/* TODO This should not be necessary */
line-height: normal;
}
.floating {
position: absolute;
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);
width: 10px;
height: 10px;
transform: rotate(45deg);
z-index: 60;
/* outer border */
@ -92,16 +107,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
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-bottom: none;
/* inner border */
box-shadow: inset 1px 1px 0 0 #eeeeee;
/* lightmode box-shadow: inset 1px 1px 0 0 #eee; */
&.dark {
box-shadow: inset 0 0 0 1px #565656;
box-shadow: inset 1px 1px 0 0 #565656;
}
}
</style>

View File

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

View File

@ -8,3 +8,7 @@ $btn-disabled-opacity: 0.4;
@import "sass/bootstrap/scss/button-group";
@import "sass/bootstrap/scss/dropdown";
@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>
<WithFloating show={showDropdown} closeOnInsideClick let:toggle>
<WithFloating show={showDropdown} closeOnInsideClick>
<span
class="latex-button"
slot="reference"
let:asReference
use:asReference
let:toggle
>
<IconButton slot="reference" {disabled} on:click={toggle}>
{@html functionIcon}
</IconButton>
</span>
<Popover slot="floating">
{#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>
<style lang="scss">
.latex-button {
line-height: 1;
}
.shortcut {
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
-->
<script lang="ts">
import { createEventDispatcher, onMount } from "svelte";
import { pageTheme } from "../../sveltelib/theme";
export let id: string | undefined = undefined;
let className = "";
export { className as class };
export let selected = false;
export let active = false;
const dispatch = createEventDispatcher();
let buttonRef: HTMLButtonElement;
export function scroll() {
/* TODO will not work on Gecko */
(buttonRef as any)?.scrollIntoViewIfNeeded(false);
$: if (selected && buttonRef) {
/* 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>
<button
bind:this={buttonRef}
{id}
tabindex="-1"
class="autocomplete-item btn {className}"
class="autocomplete-item btn"
class:btn-day={!$pageTheme.isDark}
class:btn-night={$pageTheme.isDark}
class:selected
@ -52,6 +44,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
text-align: start;
white-space: nowrap;
flex-grow: 1;
border-radius: 0;
}
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 let tooltip: string | undefined = undefined;
export let selected: boolean = false;
export let selected = false;
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">
import { createEventDispatcher, tick } from "svelte";
import type { Writable } from "svelte/store";
import { writable } from "svelte/store";
import ButtonToolbar from "../../components/ButtonToolbar.svelte";
import StickyContainer from "../../components/StickyContainer.svelte";
import { Tags, tags as tagsService } from "../../lib/proto";
import { execCommand } from "../helpers";
import Tag from "./Tag.svelte";
import { TagOptionsButton } from "./tag-options-button";
import TagEditMode from "./TagEditMode.svelte";
import TagInput from "./TagInput.svelte";
import TagOptionsBadge from "./TagOptionsBadge.svelte";
import type { Tag as TagType } from "./tags";
import {
attachId,
@ -21,10 +20,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
replaceWithColons,
replaceWithUnicodeSeparator,
} from "./tags";
import TagSpacer from "./TagSpacer.svelte";
import WithAutocomplete from "./WithAutocomplete.svelte";
export let size: number;
export let wrap: boolean;
export let tags: Writable<string[]>;
let tagTypes: TagType[];
@ -36,6 +34,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: tagsToTagTypes($tags);
const show = writable(false);
const dispatch = createEventDispatcher();
const noSuggestions = Promise.resolve([]);
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;
}
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> {
const current = activeName.slice(0, start);
const splitOff = activeName.slice(end);
@ -279,7 +265,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
break;
case "Tab":
if (event.shiftKey) {
if (!$show) {
break;
} else if (event.shiftKey) {
autocomplete.selectPrevious();
} else {
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 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
$: assumedRows = Math.floor(height / badgeHeight);
$: shortenTags = shortenTags || assumedRows > 2;
$: anyTagsSelected = tagTypes.some((tag) => tag.selected);
</script>
<StickyContainer
@ -397,36 +380,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bind:height
class="d-flex"
>
{#if !wrap}
<TagOptionsBadge
showSelectionsOptions={tagTypes.some((tag) => tag.selected)}
<div class="tag-editor-area" on:focusout={deselectIfLeave}>
<TagOptionsButton
bind:badgeHeight
tagsSelected={anyTagsSelected}
on:tagselectall={selectAllTags}
on:tagcopy={copySelectedTags}
on:tagdelete={deleteSelectedTags}
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)}
<div class="position-relative" class:hide-tag={index === active}>
<div class="tag-relative" class:hide-tag={index === active}>
<TagEditMode
class="ms-0"
name={index === active ? activeName : tag.name}
@ -449,15 +414,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/>
{#if index === active}
<div class="adjust-position">
<WithAutocomplete
drop="up"
class="d-flex flex-column cap-items"
{suggestionsPromise}
{show}
on:update={updateSuggestions}
on:select={({ detail }) => onAutocomplete(detail.selected)}
on:choose={({ detail }) => onAutocomplete(detail.chosen)}
on:choose={({ detail }) => {
onAutocomplete(detail.chosen);
splitTag(index, detail.chosen.length, detail.chosen.length);
}}
let:createAutocomplete
let:hide
>
<TagInput
id={tag.id}
@ -467,13 +434,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bind:input={activeInput}
on:focus={() => {
activeName = tag.name;
autocomplete = createAutocomplete(activeInput);
autocomplete = createAutocomplete();
}}
on:keydown={onKeydown}
on:keyup={onKeyup}
on:keyup={() => {
if (activeName.length === 0) {
hide?.();
}
}}
on:taginput={() => updateTagName(tag)}
on:tagsplit={({ detail }) =>
enterBehavior(index, detail.start, detail.end)}
splitTag(index, detail.start, detail.end)}
on:tagadd={() => insertTagKeepFocus(index)}
on:tagdelete={() => deleteTagAt(index)}
on:tagjoinprevious={() => joinWithPreviousTag(index)}
@ -490,49 +461,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}}
/>
</WithAutocomplete>
</div>
{/if}
</div>
{/each}
<div
class="tag-spacer flex-grow-1 align-self-stretch"
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>
<TagSpacer on:click={appendEmptyTag} />
</div>
</ButtonToolbar>
</StickyContainer>
<style lang="scss">
.tag-spacer {
cursor: text;
.tag-editor-area {
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) {
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>

View File

@ -265,4 +265,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
border: none;
margin: 0;
}
.tag-input {
/* recreates positioning of Tag component
* so that the text does not move when accepting */
border-left: 1px solid transparent;
}
</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
-->
<script lang="ts">
import type Dropdown from "bootstrap/js/dist/dropdown";
import { createEventDispatcher, tick } from "svelte";
import type { Writable } from "svelte/store";
import DropdownMenu from "../../components/DropdownMenu.svelte";
import WithDropdown from "../../components/WithDropdown.svelte";
import Popover from "../../components/Popover.svelte";
import WithFloating from "../../components/WithFloating.svelte";
import AutocompleteItem from "./AutocompleteItem.svelte";
let className: string = "";
export { className as class };
export let drop: "down" | "up" = "down";
export let suggestionsPromise: Promise<string[]>;
let dropdown: Dropdown;
let show = false;
export let show: Writable<boolean>;
let suggestionsItems: string[] = [];
$: suggestionsPromise.then((items) => {
show = items.length > 0;
if (show) {
dropdown.show();
} else {
dropdown.hide();
}
show.set(items.length > 0);
suggestionsItems = items;
});
let selected: number | null = null;
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 {
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> {
dispatch("select", { selected: suggestionsItems[selected ?? -1] });
await tick();
dropdown.update();
}
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() {
active = true;
dispatch("choose", { chosen: suggestionsItems[selected ?? -1] });
await tick();
show = false;
show.set(false);
}
async function update() {
dropdown.update();
await tick();
dispatch("update");
}
@ -98,16 +87,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return selected !== null;
}
const createAutocomplete =
(createDropdown: (element: HTMLElement) => Dropdown) =>
(element: HTMLElement): any => {
dropdown = createDropdown(element);
function createAutocomplete() {
const api = {
hide: dropdown.hide,
show: dropdown.show,
toggle: dropdown.toggle,
isVisible: (dropdown as any).isVisible,
selectPrevious,
selectNext,
chooseSelected,
@ -116,7 +97,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
};
return api;
};
}
function setSelected(index: number): void {
selected = index;
@ -137,22 +118,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setSelected(index);
}
}
let scroll: () => void;
$: if (scroll) {
scroll();
}
</script>
<WithDropdown {drop} toggleOpen={false} let:createDropdown align="start">
<slot createAutocomplete={createAutocomplete(createDropdown)} />
<WithFloating keepOnKeyup {show} placement="top-start" let:toggle let:hide let:show>
<span
class="autocomplete-reference"
slot="reference"
let:asReference
use:asReference
>
<slot {createAutocomplete} {toggle} {hide} {show} />
</span>
<DropdownMenu class={className} {show}>
<Popover slot="floating">
<div class="autocomplete-menu">
{#each suggestionsItems as suggestion, index}
{#if index === selected}
<AutocompleteItem
bind:scroll
selected
{active}
on:mousedown={() => setSelectedAndActive(index)}
@ -176,5 +158,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
{/if}
{/each}
</DropdownMenu>
</WithDropdown>
</div>
</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" />
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";

View File

@ -5,35 +5,32 @@ 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 Shortcut from "../../components/Shortcut.svelte";
import { getPlatformString } from "../../lib/shortcuts";
import IconConstrain from "../../../components/IconConstrain.svelte";
import Shortcut from "../../../components/Shortcut.svelte";
import * as tr from "../../../lib/ftl";
import { getPlatformString } from "../../../lib/shortcuts";
import { addTagIcon, tagIcon } from "./icons";
const dispatch = createEventDispatcher();
const tooltip = "Add tag";
const keyCombination = "Control+Shift+T";
const dispatch = createEventDispatcher<{ tagappend: CustomEvent<void> }>();
function appendTag(): void {
dispatch("tagappend");
}
const keyCombination = "Control+Shift+T";
</script>
<div class="add-icon">
<Badge
class="d-flex me-1"
tooltip="{tooltip} ({getPlatformString(keyCombination)})"
on:click={appendTag}
>
<div
class="tag-add-button"
title="{tr.editingTagsAdd()} ({getPlatformString(keyCombination)})"
on:click={() => dispatch("tagappend")}
>
<IconConstrain>
{@html tagIcon}
{@html addTagIcon}
</Badge>
<Shortcut {keyCombination} on:action={appendTag} />
</IconConstrain>
</div>
<Shortcut {keyCombination} on:action={() => dispatch("tagappend")} />
<style lang="scss">
.add-icon {
.tag-add-button {
line-height: 1;
: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/*",
"rich-text-input/*",
"editor-toolbar/*",
"tag-editor/*"
"tag-editor/*",
"tag-editor/tag-options-button/*"
],
"references": [
{ "path": "../components" },

View File

@ -2,9 +2,16 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
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`.
*/
@ -25,19 +32,41 @@ function position(
args.floating!,
{
middleware: [
inline(),
offset(5),
shift({ padding: 5 }),
arrow({ element: args.arrow }),
arrow({ element: args.arrow, padding: 5 }),
],
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, {
left: `${arrowX}px`,
top: `-5px`,
left: arrowX ? `${arrowX}px` : "",
top: arrowY ? `${arrowY}px` : "",
transform: `rotate(${rotation}deg)`,
});
Object.assign(args.floating!.style, {