Port most components from first tageditor PR
This commit is contained in:
parent
1026d26793
commit
392bdf6184
@ -4,20 +4,22 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||||||
-->
|
-->
|
||||||
<script lang="typescript">
|
<script lang="typescript">
|
||||||
import Badge from "components/Badge.svelte";
|
import Badge from "components/Badge.svelte";
|
||||||
|
import TagInputEdit from "./TagInputEdit.svelte";
|
||||||
import { deleteIcon } from "./icons";
|
import { deleteIcon } from "./icons";
|
||||||
|
|
||||||
let active = false;
|
export let name: string;
|
||||||
|
|
||||||
function activate(): void {
|
let active = false;
|
||||||
active = true;
|
|
||||||
}
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
{#if active}
|
{#if active}
|
||||||
<span>active!</span>
|
<TagInputEdit bind:name on:focusout={() => (active = false)} />
|
||||||
{:else}
|
{:else}
|
||||||
<span class="tag text-nowrap bg-secondary rounded p-1 me-2" on:click={activate}>
|
<span
|
||||||
<slot />
|
class="tag text-nowrap bg-secondary rounded p-1 me-2"
|
||||||
|
on:click|stopPropagation={() => (active = true)}
|
||||||
|
>
|
||||||
|
<span>{name}</span>
|
||||||
<Badge class="delete-icon">{@html deleteIcon}</Badge>
|
<Badge class="delete-icon">{@html deleteIcon}</Badge>
|
||||||
</span>
|
</span>
|
||||||
{/if}
|
{/if}
|
||||||
|
98
ts/editor/TagAutocomplete.svelte
Normal file
98
ts/editor/TagAutocomplete.svelte
Normal file
@ -0,0 +1,98 @@
|
|||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="typescript">
|
||||||
|
import { createEventDispatcher, onMount, onDestroy } from "svelte";
|
||||||
|
import Dropdown from "bootstrap/js/dist/dropdown";
|
||||||
|
|
||||||
|
export let name: string;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
const triggerId = "tagLabel" + String(Math.random()).slice(2);
|
||||||
|
const triggerClass = "dropdown-toggle";
|
||||||
|
|
||||||
|
let originalName = name;
|
||||||
|
let menu: HTMLDivElement;
|
||||||
|
let dropdown;
|
||||||
|
let activeItem = -1;
|
||||||
|
|
||||||
|
const tagSuggestions = ["en::idioms", "anki::functionality", "math"];
|
||||||
|
$: tagValues = [...tagSuggestions, originalName];
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
const toggle = menu.querySelector(`#${triggerId}`)!;
|
||||||
|
dropdown = new Dropdown(toggle, {
|
||||||
|
reference: "parent",
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
function onItemClick(event: Event) {
|
||||||
|
dispatch("nameChosen", { name: event.currentTarget!.innerText });
|
||||||
|
event.stopPropagation();
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent) {
|
||||||
|
if (event.code === "ArrowUp") {
|
||||||
|
activeItem = activeItem === tagValues.length - 1 ? 0 : ++activeItem;
|
||||||
|
name = tagValues[activeItem];
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.code === "ArrowDown") {
|
||||||
|
activeItem = activeItem === 0 ? tagValues.length - 1 : --activeItem;
|
||||||
|
name = tagValues[activeItem];
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.code === "Enter") {
|
||||||
|
const dropdownActive = dropdown._element.classList.contains("show");
|
||||||
|
if (dropdownActive) {
|
||||||
|
if (typeof activeItem === "number") {
|
||||||
|
name = tagValues[activeItem];
|
||||||
|
activeItem = null;
|
||||||
|
}
|
||||||
|
dropdown.hide();
|
||||||
|
} else {
|
||||||
|
dispatch("accept");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="dropdown" bind:this={menu} on:keydown={onKeydown}>
|
||||||
|
<slot {triggerId} {triggerClass} {dropdown} />
|
||||||
|
|
||||||
|
<ul class="dropdown-menu" aria-labelledby={triggerId}>
|
||||||
|
{#each tagSuggestions as tag, index}
|
||||||
|
<li>
|
||||||
|
<a
|
||||||
|
href="#/"
|
||||||
|
class="dropdown-item"
|
||||||
|
class:dropdown-item-active={activeItem === index}
|
||||||
|
on:click={onItemClick}
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
{/each}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
:global(.show).dropdown-menu {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column-reverse;
|
||||||
|
}
|
||||||
|
.dropdown-item {
|
||||||
|
padding: 0rem 0.3rem;
|
||||||
|
font-size: smaller;
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.dropdown-item:hover {
|
||||||
|
background-color: #c3c5c7;
|
||||||
|
}
|
||||||
|
.dropdown-item-active {
|
||||||
|
color: #1e2125;
|
||||||
|
background-color: #c3c5c7;
|
||||||
|
}
|
||||||
|
</style>
|
@ -6,24 +6,37 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||||||
import StickyBottom from "components/StickyBottom.svelte";
|
import StickyBottom from "components/StickyBottom.svelte";
|
||||||
import Badge from "components/Badge.svelte";
|
import Badge from "components/Badge.svelte";
|
||||||
import Tag from "./Tag.svelte";
|
import Tag from "./Tag.svelte";
|
||||||
|
import TagInputNew from "./TagInputNew.svelte";
|
||||||
import { tagIcon } from "./icons";
|
import { tagIcon } from "./icons";
|
||||||
|
|
||||||
|
export let tags = ["en::foobar", "zh::あっちこっち"];
|
||||||
|
|
||||||
|
let tagInputNew: HTMLInputElement;
|
||||||
|
let inputNew = false;
|
||||||
|
|
||||||
|
function focusTagInputNew(): void {
|
||||||
|
inputNew = true;
|
||||||
|
tagInputNew.focus();
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<StickyBottom>
|
<StickyBottom>
|
||||||
<div>
|
<div class="d-flex flex-wrap" on:click={focusTagInputNew}>
|
||||||
<Badge class="me-1">{@html tagIcon}</Badge>
|
<Badge class="me-1">{@html tagIcon}</Badge>
|
||||||
<Tag>en::foobar</Tag>
|
|
||||||
<Tag>zh::あちこ</Tag>
|
{#each tags as tag}
|
||||||
|
<Tag name={tag} />
|
||||||
|
{/each}
|
||||||
|
|
||||||
|
{#if inputNew}
|
||||||
|
<TagInputNew bind:input={tagInputNew} on:blur={() => (inputNew = false)} />
|
||||||
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
</StickyBottom>
|
</StickyBottom>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
div {
|
:global(#mdi-tag-outline) {
|
||||||
display: flex;
|
|
||||||
fill: currentColor;
|
fill: currentColor;
|
||||||
|
height: 100%;
|
||||||
:global(#mdi-tag-outline) {
|
|
||||||
height: 100%;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
108
ts/editor/TagInput.svelte
Normal file
108
ts/editor/TagInput.svelte
Normal file
@ -0,0 +1,108 @@
|
|||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="typescript">
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
import TagAutocomplete from "./TagAutocomplete.svelte";
|
||||||
|
import { normalizeTagname } from "./tags";
|
||||||
|
|
||||||
|
import type Dropdown from "bootstrap/js/dist/dropdown";
|
||||||
|
|
||||||
|
export let name: string;
|
||||||
|
export let input: HTMLInputElement;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function onFocus(event: FocusEvent, dropdown: Dropdown): void {
|
||||||
|
dropdown.show();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onAccept(event: Event): void {
|
||||||
|
dispatch("update", { tagname: normalizeTagname(name) });
|
||||||
|
}
|
||||||
|
|
||||||
|
function dropdownBlur(event: Event, dropdown: Dropdown): void {
|
||||||
|
onAccept(event);
|
||||||
|
dropdown.hide();
|
||||||
|
}
|
||||||
|
|
||||||
|
function onKeydown(event: KeyboardEvent, dropdown: Dropdown): void {
|
||||||
|
if (event.code === "Space") {
|
||||||
|
name += "::";
|
||||||
|
event.preventDefault();
|
||||||
|
} else if (event.code === "Backspace" && name.endsWith("::")) {
|
||||||
|
name = name.slice(0, -2);
|
||||||
|
event.preventDefault();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPaste({ clipboardData }: ClipboardEvent): void {
|
||||||
|
const pasted = name + clipboardData!.getData("text/plain");
|
||||||
|
const splitted = pasted.split(" ");
|
||||||
|
const last = splitted.pop();
|
||||||
|
for (const token of splitted) {
|
||||||
|
const tagname = normalizeTagname(token);
|
||||||
|
if (tagname) {
|
||||||
|
dispatch("add", { tagname });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
name = last!;
|
||||||
|
}
|
||||||
|
|
||||||
|
function setTagname({ detail }: CustomEvent): void {
|
||||||
|
name = detail.name;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TagAutocomplete
|
||||||
|
bind:name
|
||||||
|
let:triggerId
|
||||||
|
let:triggerClass
|
||||||
|
let:dropdown
|
||||||
|
on:nameChosen={setTagname}
|
||||||
|
on:accept={onAccept}
|
||||||
|
>
|
||||||
|
<label data-value={name} id={triggerId} class={triggerClass}>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
size="1"
|
||||||
|
bind:this={input}
|
||||||
|
bind:value={name}
|
||||||
|
on:focus={(event) => onFocus(event, dropdown)}
|
||||||
|
on:blur={(event) => dropdownBlur(event, dropdown)}
|
||||||
|
on:focusout
|
||||||
|
on:keydown={(event) => onKeydown(event, dropdown)}
|
||||||
|
on:paste={onPaste}
|
||||||
|
on:click
|
||||||
|
/>
|
||||||
|
</label>
|
||||||
|
</TagAutocomplete>
|
||||||
|
|
||||||
|
<style lang="scss">
|
||||||
|
label {
|
||||||
|
display: inline-grid;
|
||||||
|
&::after,
|
||||||
|
input {
|
||||||
|
color: var(--text-fg);
|
||||||
|
background: none;
|
||||||
|
resize: none;
|
||||||
|
appearance: none;
|
||||||
|
width: auto;
|
||||||
|
grid-area: 1 / 1;
|
||||||
|
font: inherit;
|
||||||
|
outline: none;
|
||||||
|
border: none;
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
&::after {
|
||||||
|
/* 8 spaces to minimize reflow on clicking tag */
|
||||||
|
content: attr(data-value) " ";
|
||||||
|
visibility: hidden;
|
||||||
|
white-space: pre-wrap;
|
||||||
|
position: relative;
|
||||||
|
top: -2rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</style>
|
42
ts/editor/TagInputEdit.svelte
Normal file
42
ts/editor/TagInputEdit.svelte
Normal file
@ -0,0 +1,42 @@
|
|||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="typescript">
|
||||||
|
import { onMount } from "svelte";
|
||||||
|
import TagInput from "./TagInput.svelte";
|
||||||
|
|
||||||
|
export let name: string;
|
||||||
|
|
||||||
|
let input: HTMLInputElement;
|
||||||
|
|
||||||
|
function moveCursorToEnd(element: HTMLInputElement): void {
|
||||||
|
element.selectionStart = element.selectionEnd = element.value.length;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
// Make sure Autocomplete was fully mounted
|
||||||
|
setTimeout(() => {
|
||||||
|
moveCursorToEnd(input);
|
||||||
|
input.focus();
|
||||||
|
}, 0);
|
||||||
|
});
|
||||||
|
|
||||||
|
function onKeydown(): void {
|
||||||
|
console.log("onkeydown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPropagation(event: Event): void {
|
||||||
|
event.stopPropagation();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TagInput
|
||||||
|
bind:name
|
||||||
|
bind:input
|
||||||
|
on:keydown={onKeydown}
|
||||||
|
on:click={stopPropagation}
|
||||||
|
on:focusout
|
||||||
|
on:update
|
||||||
|
on:add
|
||||||
|
/>
|
35
ts/editor/TagInputNew.svelte
Normal file
35
ts/editor/TagInputNew.svelte
Normal file
@ -0,0 +1,35 @@
|
|||||||
|
<!--
|
||||||
|
Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
-->
|
||||||
|
<script lang="typescript">
|
||||||
|
import { createEventDispatcher } from "svelte";
|
||||||
|
import TagInput from "./TagInput.svelte";
|
||||||
|
|
||||||
|
export let input: HTMLInputElement;
|
||||||
|
|
||||||
|
let name = "";
|
||||||
|
let active = true;
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
function onKeydown(): void {
|
||||||
|
console.log("onkeydown");
|
||||||
|
}
|
||||||
|
|
||||||
|
function translateToAdd({ detail }: CustomEvent): void {
|
||||||
|
if (name) {
|
||||||
|
dispatch("add", detail);
|
||||||
|
name = "";
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<TagInput
|
||||||
|
bind:name
|
||||||
|
bind:input
|
||||||
|
on:keydown={onKeydown}
|
||||||
|
on:add
|
||||||
|
on:update={translateToAdd}
|
||||||
|
on:blur
|
||||||
|
/>
|
16
ts/editor/tags.ts
Normal file
16
ts/editor/tags.ts
Normal file
@ -0,0 +1,16 @@
|
|||||||
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
|
export function normalizeTagname(tagname: string): string {
|
||||||
|
let trimmed = tagname.trim();
|
||||||
|
|
||||||
|
while (true) {
|
||||||
|
if (trimmed.startsWith("::")) {
|
||||||
|
trimmed = trimmed.slice(2).trimStart();
|
||||||
|
} else if (trimmed.endsWith("::")) {
|
||||||
|
trimmed = trimmed.slice(0, -2).trimEnd();
|
||||||
|
} else {
|
||||||
|
return trimmed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
@ -8,6 +8,7 @@
|
|||||||
"es2019.array",
|
"es2019.array",
|
||||||
"es2018.promise",
|
"es2018.promise",
|
||||||
"es2020.promise",
|
"es2020.promise",
|
||||||
|
"es2019.string",
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable"
|
"dom.iterable"
|
||||||
],
|
],
|
||||||
|
Loading…
Reference in New Issue
Block a user