Merge pull request #1264 from hgiesel/tageditor2

Svelte Tag Editor v2
This commit is contained in:
Damien Elmes 2021-09-15 20:21:16 +10:00 committed by GitHub
commit 9daf037c0b
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
58 changed files with 2088 additions and 134 deletions

View file

@ -22,6 +22,7 @@ service TagsService {
returns (collection.OpChangesWithCount);
rpc FindAndReplaceTag(FindAndReplaceTagRequest)
returns (collection.OpChangesWithCount);
rpc CompleteTag(CompleteTagRequest) returns (CompleteTagResponse);
}
message SetTagCollapsedRequest {
@ -58,3 +59,13 @@ message FindAndReplaceTagRequest {
bool regex = 4;
bool match_case = 5;
}
message CompleteTagRequest {
// a partial tag, optionally delimited with ::
string input = 1;
uint32 match_limit = 2;
}
message CompleteTagResponse {
repeated string tags = 1;
}

View file

@ -58,10 +58,11 @@ SKIP_UNROLL_INPUT = {
"UpdateDeckConfigs",
"AnswerCard",
"ChangeNotetype",
"CompleteTag",
}
SKIP_UNROLL_OUTPUT = {"GetPreferences"}
SKIP_DECODE = {"Graphs", "GetGraphPreferences", "GetChangeNotetypeInfo"}
SKIP_DECODE = {"Graphs", "GetGraphPreferences", "GetChangeNotetypeInfo", "CompleteTag"}
def python_type(field):

View file

@ -67,6 +67,11 @@ class TagManager:
"Set browser expansion state for tag, registering the tag if missing."
return self.col._backend.set_tag_collapsed(name=tag, collapsed=collapsed)
def complete_tag(self, input_bytes: bytes) -> bytes:
input = tags_pb2.CompleteTagRequest()
input.ParseFromString(input_bytes)
return self.col._backend.complete_tag(input)
# Bulk addition/removal from specific notes
#############################################################

View file

@ -226,9 +226,6 @@ class AddCards(QDialog):
self.addHistory(note)
# workaround for PyQt focus bug
self.editor.hideCompleters()
tooltip(tr.adding_added(), period=500)
av_player.stop_and_clear_queue()
self._load_new_note(sticky_fields_from=note)

View file

@ -84,6 +84,7 @@ _html = """
<a href="#" onclick="pycmd('dupes');return false;">%s</a>
</div>
<div id="cloze-hint"></div>
<div id="tag-editor-anchor" class="d-none"></div>
"""
@ -114,7 +115,6 @@ class Editor:
self.setupOuter()
self.setupWeb()
self.setupShortcuts()
self.setupTags()
gui_hooks.editor_did_init(self)
# Initial setup
@ -302,9 +302,7 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
def setupShortcuts(self) -> None:
# if a third element is provided, enable shortcut even when no field selected
cuts: List[Tuple] = [
("Ctrl+Shift+T", self.onFocusTags, True),
]
cuts: List[Tuple] = []
gui_hooks.editor_did_init_shortcuts(cuts, self)
for row in cuts:
if len(row) == 2:
@ -430,6 +428,14 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
(_, highlightColor) = cmd.split(":", 1)
self.mw.pm.profile["lastHighlightColor"] = highlightColor
elif cmd.startswith("saveTags"):
(type, tagsJson) = cmd.split(":", 1)
self.note.tags = json.loads(tagsJson)
gui_hooks.editor_did_update_tags(self.note)
if not self.addMode:
self._save_current_note()
elif cmd in self._links:
self._links[cmd](self)
@ -450,9 +456,7 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
self.currentField = None
if self.note:
self.loadNote(focusTo=focusTo)
else:
self.hideCompleters()
if hide:
elif hide:
self.widget.hide()
def loadNoteKeepingFocus(self) -> None:
@ -467,7 +471,6 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
for fld, val in self.note.items()
]
self.widget.show()
self.updateTags()
note_fields_status = self.note.fields_check()
@ -485,12 +488,13 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
text_color = self.mw.pm.profile.get("lastTextColor", "#00f")
highlight_color = self.mw.pm.profile.get("lastHighlightColor", "#00f")
js = "setFields(%s); setFonts(%s); focusField(%s); setNoteId(%s); setColorButtons(%s);" % (
js = "setFields(%s); setFonts(%s); focusField(%s); setNoteId(%s); setColorButtons(%s); setTags(%s); " % (
json.dumps(data),
json.dumps(self.fonts()),
json.dumps(focusTo),
json.dumps(self.note.id),
json.dumps([text_color, highlight_color]),
json.dumps(self.mw.col.tags.canonify(self.note.tags)),
)
if self.addMode:
@ -520,7 +524,6 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
# calling code may not expect the callback to fire immediately
self.mw.progress.timer(10, callback, False)
return
self.blur_tags_if_focused()
self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback())
saveNow = call_after_note_saved

View file

@ -351,6 +351,10 @@ def change_notetype() -> bytes:
return b""
def complete_tag() -> bytes:
return aqt.mw.col.tags.complete_tag(request.data)
post_handlers = {
"graphData": graph_data,
"graphPreferences": graph_preferences,
@ -365,6 +369,7 @@ post_handlers = {
# pylint: disable=unnecessary-lambda
"i18nResources": i18n_resources,
"congratsInfo": congrats_info,
"completeTag": complete_tag,
}

View file

@ -88,4 +88,11 @@ impl TagsService for Backend {
.map(Into::into)
})
}
fn complete_tag(&self, input: pb::CompleteTagRequest) -> Result<pb::CompleteTagResponse> {
self.with_col(|col| {
let tags = col.complete_tag(&input.input, input.match_limit as usize)?;
Ok(pb::CompleteTagResponse { tags })
})
}
}

View file

@ -66,9 +66,9 @@ impl SqliteStorage {
.map_err(Into::into)
}
pub(crate) fn get_tags_by_predicate<F>(&self, want: F) -> Result<Vec<Tag>>
pub(crate) fn get_tags_by_predicate<F>(&self, mut want: F) -> Result<Vec<Tag>>
where
F: Fn(&str) -> bool,
F: FnMut(&str) -> bool,
{
let mut query_stmt = self.db.prepare_cached(include_str!("get.sql"))?;
let mut rows = query_stmt.query([])?;

View file

@ -0,0 +1,78 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
use regex::Regex;
use crate::prelude::*;
impl Collection {
pub fn complete_tag(&self, input: &str, limit: usize) -> Result<Vec<String>> {
let filters: Vec<_> = input
.split("::")
.map(component_to_regex)
.collect::<Result<_, _>>()?;
let mut tags = vec![];
self.storage.get_tags_by_predicate(|tag| {
if tags.len() <= limit && filters_match(&filters, tag) {
tags.push(tag.to_string());
}
// we only need the tag name
false
})?;
Ok(tags)
}
}
fn component_to_regex(component: &str) -> Result<Regex> {
Regex::new(&format!("(?i){}", regex::escape(component))).map_err(Into::into)
}
fn filters_match(filters: &[Regex], tag: &str) -> bool {
let mut remaining_tag_components = tag.split("::");
'outer: for filter in filters {
loop {
if let Some(component) = remaining_tag_components.next() {
if filter.is_match(component) {
continue 'outer;
}
} else {
return false;
}
}
}
true
}
#[cfg(test)]
mod test {
use super::*;
#[test]
fn matching() -> Result<()> {
let filters = &[component_to_regex("b")?];
assert!(filters_match(filters, "ABC"));
assert!(filters_match(filters, "ABC::def"));
assert!(filters_match(filters, "def::abc"));
assert!(!filters_match(filters, "def"));
let filters = &[component_to_regex("b")?, component_to_regex("E")?];
assert!(!filters_match(filters, "ABC"));
assert!(filters_match(filters, "ABC::def"));
assert!(!filters_match(filters, "def::abc"));
assert!(!filters_match(filters, "def"));
let filters = &[
component_to_regex("a")?,
component_to_regex("c")?,
component_to_regex("e")?,
];
assert!(!filters_match(filters, "ace"));
assert!(!filters_match(filters, "a::c"));
assert!(!filters_match(filters, "c::e"));
assert!(filters_match(filters, "a::c::e"));
assert!(filters_match(filters, "a::b::c::d::e"));
assert!(filters_match(filters, "1::a::b::c::d::e::f"));
Ok(())
}
}

View file

@ -2,6 +2,7 @@
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
mod bulkadd;
mod complete;
mod findreplace;
mod matcher;
mod register;

View file

@ -5,6 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="ts">
import * as tr from "lib/i18n";
import type { ChangeNotetypeState } from "./lib";
import { withButton } from "components/helpers";
import ButtonGroup from "components/ButtonGroup.svelte";
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
@ -29,8 +30,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
theme="primary"
on:click={() => save()}
tooltip={shortcutLabel}
on:mount={(event) => createShortcut(event.detail.button)}
>{tr.actionsSave()}</LabelButton
on:mount={withButton(createShortcut)}>{tr.actionsSave()}</LabelButton
>
</WithShortcut>
</ButtonGroupItem>

View file

@ -19,7 +19,10 @@ compile_svelte(
srcs = svelte_files,
visibility = ["//visibility:public"],
deps = [
"//ts/lib",
"//ts/sass:base_lib",
"//ts/sass:button_mixins_lib",
"//ts/sass:scrollbar_lib",
"//ts/sass/bootstrap",
],
)
@ -69,7 +72,9 @@ svelte_check(
"*.ts",
"*.svelte",
]) + [
"//ts/sass:base_lib",
"//ts/sass:button_mixins_lib",
"//ts/sass:scrollbar_lib",
"//ts/sass/bootstrap",
"@npm//@types/bootstrap",
],

View file

@ -3,12 +3,13 @@ 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 { DropdownProps } from "components/dropdown";
import { dropdownKey } from "components/context-keys";
import type { DropdownProps } from "./dropdown";
import { dropdownKey } from "./context-keys";
import { onMount, createEventDispatcher, getContext } from "svelte";
let className = "";
export { className as class };
export let tooltip: string | undefined = undefined;
const dispatch = createEventDispatcher();
@ -23,10 +24,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<span
bind:this={spanRef}
title={tooltip}
class={`badge ${className}`}
class:dropdown-toggle={dropdownProps.dropdown}
{...dropdownProps}
on:click
on:mouseenter
on:mouseleave
>
<slot />
</span>
@ -41,6 +45,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
}
span :global(svg) {
vertical-align: -0.125rem;
border-radius: inherit;
vertical-align: var(--badge-align, -0.125rem);
}
</style>

View file

@ -3,8 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import WithTheming from "components/WithTheming.svelte";
import Detachable from "components/Detachable.svelte";
import WithTheming from "./WithTheming.svelte";
import Detachable from "./Detachable.svelte";
import type { ButtonRegistration } from "./buttons";
import { ButtonPosition } from "./buttons";

View file

@ -3,10 +3,10 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import { setContext } from "svelte";
import { getContext, setContext } from "svelte";
import { writable } from "svelte/store";
import Item from "./Item.svelte";
import { sectionKey } from "./context-keys";
import { sectionKey, nightModeKey } from "./context-keys";
import type { Identifier } from "./identifier";
import { insertElement, appendElement } from "./identifier";
import type { SvelteComponent, Registration } from "./registration";
@ -73,14 +73,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
toggleGroup,
});
}
const nightMode = getContext<boolean>(nightModeKey);
</script>
<div
bind:this={buttonToolbarRef}
{id}
class={`btn-toolbar container wrap-variable ${className}`}
class="btn-toolbar container wrap-variable {className}"
class:nightMode
{style}
role="toolbar"
on:focusout
>
<slot />
{#each $dynamicItems as item}
@ -91,6 +95,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</div>
<style lang="scss">
@use 'scrollbar';
.nightMode {
@include scrollbar.night-mode;
}
.wrap-variable {
flex-wrap: var(--buttons-wrap);
}

View file

@ -11,6 +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 tabbable: boolean = false;
let buttonRef: HTMLButtonElement;
@ -22,12 +23,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<button
{id}
tabindex={tabbable ? 0 : -1}
bind:this={buttonRef}
class={`btn dropdown-item ${className}`}
class:btn-day={!nightMode}
class:btn-night={nightMode}
title={tooltip}
on:click
on:mouseenter
on:focus
on:keydown
on:mousedown|preventDefault
>
<slot />
@ -43,17 +48,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
font-size: calc(var(--buttons-size) / 2.3);
background: none;
box-shadow: none;
box-shadow: none !important;
border: none;
&:active,
&.active {
background-color: button.$focus-color;
color: white;
}
}
.btn-day {
color: black;
&:active {
background-color: button.$focus-color;
color: white;
}
}
.btn-night {
@ -63,10 +69,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
&:focus {
@include button.btn-night-base;
}
&:active {
background-color: button.$focus-color;
color: white;
}
}
</style>

View file

@ -7,16 +7,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { dropdownKey } from "./context-keys";
export let id: string | undefined = undefined;
let className: string = "";
export { className as class };
export let labelledby: string | undefined = undefined;
export let show = false;
setContext(dropdownKey, null);
</script>
<div {id} class="dropdown-menu">
<div {id} class="dropdown-menu" class:show aria-labelledby={labelledby}>
<div class="dropdown-content {className}">
<slot />
</div>
</div>
<style lang="scss">
div {
.dropdown-menu {
background-color: var(--frame-bg);
border-color: var(--medium-border);
}

View file

@ -3,7 +3,7 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import Detachable from "components/Detachable.svelte";
import Detachable from "./Detachable.svelte";
import type { Register, Registration } from "./registration";

View file

@ -0,0 +1,12 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<div />
<style lang="scss">
div {
width: var(--width, auto);
height: var(--height, auto);
}
</style>

View file

@ -8,7 +8,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export { className as class };
</script>
<nav {id} class={`container-fluid pb-1 pt-1 ${className}`}>
<nav {id} class={`container-fluid py-1 ${className}`}>
<slot />
</nav>

View file

@ -0,0 +1,28 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
export let id: string | undefined = undefined;
let className: string = "";
export { className as class };
export let height: number;
</script>
<footer {id} bind:offsetHeight={height} class={`container-fluid pt-1 ${className}`}>
<slot />
</footer>
<style lang="scss">
footer {
position: fixed;
bottom: 0;
left: 0;
right: 0;
z-index: 10;
background: var(--window-bg);
border-top: 1px solid var(--medium-border);
}
</style>

View file

@ -11,7 +11,40 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let autoOpen = false;
export let autoClose: boolean | "inside" | "outside" = true;
export let placement = "bottom-start";
export let toggleOpen = true;
export let drop: "down" | "up" = "down";
export let align: "start" | "end" | "auto" = "auto";
let placement: string;
$: {
let blockPlacement: string;
switch (drop) {
case "down":
blockPlacement = "bottom";
break;
case "up":
blockPlacement = "top";
break;
}
let inlinePlacement: string;
switch (align) {
case "start":
case "end":
inlinePlacement = `-${align}`;
break;
default:
inlinePlacement = "";
break;
}
placement = `${blockPlacement}${inlinePlacement}`;
}
$: dropClass = `drop${drop}`;
setContext(dropdownKey, {
dropdown: true,
@ -19,40 +52,50 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
});
let dropdown: Dropdown;
let dropdownObject: Dropdown;
let api: Dropdown & { isVisible: () => boolean };
function isVisible() {
return (dropdown as any)._menu
? (dropdown as any)._menu.classList.contains("show")
: false;
}
const noop = () => {};
function createDropdown(toggle: HTMLElement): Dropdown {
/* avoid focusing element toggle on menu activation */
toggle.focus = noop;
if (!toggleOpen) {
/* do not open on clicking toggle */
toggle.addEventListener = noop;
}
dropdown = new Dropdown(toggle, {
autoClose,
popperConfig: (defaultConfig: Record<string, any>) => ({
...defaultConfig,
placement,
}),
popperConfig: { placement },
} as any);
if (autoOpen) {
dropdown.show();
}
dropdownObject = {
let api = {
show: dropdown.show.bind(dropdown),
toggle: dropdown.toggle.bind(dropdown),
hide: dropdown.hide.bind(dropdown),
update: dropdown.update.bind(dropdown),
dispose: dropdown.dispose.bind(dropdown),
isVisible,
};
return dropdownObject;
return api;
}
onDestroy(() => dropdown?.dispose());
</script>
<div class="dropdown">
<slot {createDropdown} {dropdownObject} />
<div class={dropClass}>
<slot {createDropdown} dropdownObject={api} />
</div>
<style lang="scss">

View file

@ -12,9 +12,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let deregister: () => void;
function createShortcut(mounted: HTMLElement): void {
function createShortcut(element: HTMLElement): void {
deregister = registerShortcut((event: KeyboardEvent) => {
mounted.dispatchEvent(new MouseEvent("click", event));
element.dispatchEvent(new MouseEvent("click", event));
event.preventDefault();
}, shortcut);
}

View file

@ -19,24 +19,34 @@
export let tooltip: string;
export let trigger: TriggerType = "hover focus";
export let placement: "auto" | "top" | "bottom" | "left" | "right" = "top";
export let html = true;
export let offset: Tooltip.Offset = [0, 0];
export let showDelay = 0;
export let hideDelay = 0;
let tooltipObject: Tooltip;
function createTooltip(element: HTMLElement): void {
element.title = tooltip;
tooltipObject = new Tooltip(element, {
placement: "bottom",
html: true,
offset: [0, 20],
delay: { show: 250, hide: 0 },
placement,
html,
offset,
delay: { show: showDelay, hide: hideDelay },
trigger,
});
}
onDestroy(() => {
if (tooltipObject) {
tooltipObject.dispose();
}
});
onDestroy(() => tooltipObject?.dispose());
</script>
<slot {createTooltip} {tooltipObject} />
<style lang="scss">
/* tooltip is inserted under the body tag
/* long tooltips can cause x-overflow */
:global(body) {
overflow-x: hidden;
}
</style>

View file

@ -14,3 +14,15 @@ export function mergeTooltipAndShortcut(
}
return buf;
}
export const withButton =
(f: (button: HTMLButtonElement) => void) =>
({ detail }: CustomEvent): void => {
f(detail.button);
};
export const withSpan =
(f: (span: HTMLSpanElement) => void) =>
({ detail }: CustomEvent): void => {
f(detail.span);
};

View file

@ -8,7 +8,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import WithDropdown from "components/WithDropdown.svelte";
import DropdownMenu from "components/DropdownMenu.svelte";
import DropdownItem from "components/DropdownItem.svelte";
import Badge from "./Badge.svelte";
import Badge from "components/Badge.svelte";
import { revertIcon } from "./icons";
import { isEqual as isEqualLodash, cloneDeep } from "lodash-es";
import { touchDeviceKey } from "components/context-keys";

View file

@ -7,6 +7,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { createEventDispatcher } from "svelte";
import type { DeckOptionsState } from "./lib";
import type Dropdown from "bootstrap/js/dist/dropdown";
import { withButton } from "components/helpers";
import ButtonGroup from "components/ButtonGroup.svelte";
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
@ -65,7 +66,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
theme="primary"
on:click={() => save(false)}
tooltip={shortcutLabel}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>{tr.deckConfigSaveButton()}</LabelButton
>
</WithShortcut>

View file

@ -5,9 +5,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="typescript">
import marked from "marked";
import { infoCircle } from "./icons";
import WithTooltip from "./WithTooltip.svelte";
import WithTooltip from "components/WithTooltip.svelte";
import Label from "./Label.svelte";
import Badge from "./Badge.svelte";
import Badge from "components/Badge.svelte";
export let markdownTooltip: string;
let forId: string;
@ -16,7 +16,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<span>
<Label for={forId}><slot /></Label>
<WithTooltip tooltip={marked(markdownTooltip)} let:createTooltip>
<WithTooltip
tooltip={marked(markdownTooltip)}
showDelay={250}
offset={[0, 20]}
placement="bottom"
let:createTooltip
>
<Badge
class="opacity-50"
on:mount={(event) => createTooltip(event.detail.span)}

View file

@ -0,0 +1,59 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import WithShortcut from "components/WithShortcut.svelte";
import Badge from "components/Badge.svelte";
import { withSpan } from "components/helpers";
import { appendInParentheses } from "./helpers";
import { tagIcon, addTagIcon } from "./icons";
const tooltip = "Add tag";
</script>
<WithShortcut shortcut="Control+Shift+T" let:createShortcut let:shortcutLabel>
<div class="add-icon">
<Badge
class="d-flex me-1"
tooltip={appendInParentheses(tooltip, shortcutLabel)}
on:click
on:mount={withSpan(createShortcut)}
>
{@html tagIcon}
{@html addTagIcon}
</Badge>
</div>
</WithShortcut>
<style lang="scss">
.add-icon {
line-height: 1;
:global(svg:last-child) {
display: none;
}
&:hover {
:global(svg:first-child) {
display: none;
}
:global(svg:last-child) {
display: block;
}
}
:global(svg) {
padding-bottom: 2px;
cursor: pointer;
fill: currentColor;
opacity: 0.6;
}
:global(svg:hover) {
opacity: 1;
}
}
</style>

View file

@ -0,0 +1,96 @@
<!--
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, createEventDispatcher, getContext } from "svelte";
import { nightModeKey } from "components/context-keys";
export let id: string | undefined = undefined;
let className = "";
export { className as class };
export let selected = false;
export let active = false;
const nightMode = getContext<boolean>(nightModeKey);
const dispatch = createEventDispatcher();
let buttonRef: HTMLButtonElement;
export function scroll() {
/* TODO will not work on Gecko */
(buttonRef as any)?.scrollIntoViewIfNeeded(false);
/* buttonRef.scrollIntoView({ behavior: "smooth", block: "start" }); */
}
onMount(() => dispatch("mount", { button: buttonRef }));
</script>
<button
bind:this={buttonRef}
{id}
tabindex="-1"
class="autocomplete-item btn {className}"
class:btn-day={!nightMode}
class:btn-night={nightMode}
class:selected
class:active
on:mousedown|preventDefault
on:mouseup
on:mouseenter
on:mouseleave
>
<slot />
</button>
<style lang="scss">
@use 'button-mixins' as button;
.autocomplete-item {
padding: 1px 7px 2px;
text-align: start;
white-space: nowrap;
}
button {
display: flex;
justify-content: space-between;
font-size: calc(var(--buttons-size) / 2.3);
background: none;
box-shadow: none !important;
border: none;
&.active {
background-color: button.$focus-color !important;
color: white !important;
}
}
/* reset global CSS from buttons.scss */
:global(.nightMode) button:hover {
background-color: inherit;
}
/* extra specificity bc of global CSS reset above */
button.btn-day {
color: black;
&.selected {
background-color: #e9ecef;
border-color: #e9ecef;
}
}
button.btn-night {
color: white;
&.selected {
@include button.btn-night-base;
}
}
</style>

View file

@ -103,6 +103,9 @@ copy_bootstrap_icons(
"text-center.svg",
"text-indent-left.svg",
"text-indent-right.svg",
# tag editor
"x.svg",
],
visibility = ["//visibility:public"],
)
@ -126,6 +129,12 @@ copy_mdi_icons(
"image-size-select-large.svg",
"image-size-select-actual.svg",
# tag editor
"tag-outline.svg",
"tag.svg",
"tag-plus.svg",
"dots-vertical.svg",
],
visibility = ["//visibility:public"],
)

View file

@ -9,6 +9,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import WithShortcut from "components/WithShortcut.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import { withButton } from "components/helpers";
import { ellipseIcon } from "./icons";
import { forEditorField } from ".";
import { wrapCurrent } from "./wrap";
@ -48,7 +49,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`}
{disabled}
on:click={onCloze}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{@html ellipseIcon}
</IconButton>

View file

@ -14,6 +14,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import OnlyEditable from "./OnlyEditable.svelte";
import { bridgeCommand } from "lib/bridgecommand";
import { withButton } from "components/helpers";
import { textColorIcon, highlightColorIcon, arrowIcon } from "./icons";
import { appendInParentheses } from "./helpers";
@ -45,7 +46,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
)}
{disabled}
on:click={forecolorWrap}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{@html textColorIcon}
{@html colorHelperIcon}
@ -71,7 +72,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
forecolorWrap = wrapWithForecolor(setColor(event));
forecolorWrap();
}}
on:mount={(event) => createShortcut(event.detail.input)}
on:mount={withButton(createShortcut)}
/>
</IconButton>
</WithShortcut>

View file

@ -8,6 +8,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import WithState from "components/WithState.svelte";
import OnlyEditable from "./OnlyEditable.svelte";
import { withButton } from "components/helpers";
import { appendInParentheses } from "./helpers";
export let key: string;
@ -48,7 +49,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
tooltip={appendInParentheses(tooltip, shortcutLabel)}
{disabled}
on:click={() => document.execCommand(key)}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
<slot />
</IconButton>
@ -69,7 +70,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
document.execCommand(key);
updateState(event);
}}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
<slot />
</IconButton>

View file

@ -5,6 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="typescript">
import { bridgeCommand } from "lib/bridgecommand";
import * as tr from "lib/i18n";
import { withButton } from "components/helpers";
import ButtonGroup from "components/ButtonGroup.svelte";
import ButtonGroupItem from "components/ButtonGroupItem.svelte";
@ -29,7 +30,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<LabelButton
tooltip={`${tr.editingCustomizeCardTemplates()} (${shortcutLabel})`}
on:click={() => bridgeCommand("cards")}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{tr.editingCards()}...
</LabelButton>

View file

@ -5,6 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<script lang="typescript">
import { bridgeCommand } from "lib/bridgecommand";
import * as tr from "lib/i18n";
import { withButton } from "components/helpers";
import WithShortcut from "components/WithShortcut.svelte";
import LabelButton from "components/LabelButton.svelte";
@ -14,7 +15,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<LabelButton
tooltip={tr.browsingPreviewSelectedCard({ val: shortcutLabel })}
on:click={() => bridgeCommand("preview")}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{tr.actionsPreview()}
</LabelButton>

View file

@ -0,0 +1,73 @@
<!--
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 WithDropdown from "components/WithDropdown.svelte";
import WithShortcut from "components/WithShortcut.svelte";
import DropdownMenu from "components/DropdownMenu.svelte";
import DropdownItem from "components/DropdownItem.svelte";
import { withSpan, withButton } from "components/helpers";
import { appendInParentheses } from "./helpers";
import { dotsIcon } from "./icons";
const dispatch = createEventDispatcher();
const allLabel = "Select all tags";
const copyLabel = "Copy tags";
const removeLabel = "Remove tags";
</script>
<WithDropdown let:createDropdown>
<div class="more-icon">
<Badge class="me-1" on:mount={withSpan(createDropdown)}>{@html dotsIcon}</Badge>
<DropdownMenu>
<WithShortcut shortcut="Control+A" let:createShortcut let:shortcutLabel>
<DropdownItem
on:click={(event) => {
dispatch("tagselectall");
event.stopImmediatePropagation();
}}
on:mount={withButton(createShortcut)}
>{appendInParentheses(allLabel, shortcutLabel)}</DropdownItem
>
</WithShortcut>
<WithShortcut shortcut="Control+C" let:createShortcut let:shortcutLabel>
<DropdownItem
on:click={() => dispatch("tagcopy")}
on:mount={withButton(createShortcut)}
>{appendInParentheses(copyLabel, shortcutLabel)}</DropdownItem
>
</WithShortcut>
<WithShortcut shortcut="Backspace" let:createShortcut let:shortcutLabel>
<DropdownItem
on:click={() => dispatch("tagdelete")}
on:mount={withButton(createShortcut)}
>{appendInParentheses(removeLabel, shortcutLabel)}</DropdownItem
>
</WithShortcut>
</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>

99
ts/editor/Tag.svelte Normal file
View file

@ -0,0 +1,99 @@
<!--
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, getContext, createEventDispatcher } from "svelte";
import { nightModeKey } from "components/context-keys";
let className: string = "";
export { className as class };
export let tooltip: string | undefined = undefined;
export let selected: boolean = false;
const dispatch = createEventDispatcher();
let flashing: boolean = false;
export function flash(): void {
flashing = true;
setTimeout(() => (flashing = false), 300);
}
const nightMode = getContext<boolean>(nightModeKey);
let button: HTMLButtonElement;
onMount(() => dispatch("mount", { button }));
</script>
<button
bind:this={button}
class="tag btn d-inline-flex align-items-center text-nowrap ps-2 pe-1 {className}"
class:selected
class:flashing
class:btn-day={!nightMode}
class:btn-night={nightMode}
tabindex="-1"
title={tooltip}
on:mousemove
on:click
>
<slot />
</button>
<style lang="scss">
@use "button-mixins" as button;
@keyframes flash {
0% {
filter: invert(0);
}
50% {
filter: invert(0.4);
}
100% {
filter: invert(0);
}
}
button {
padding-top: 0.1rem;
padding-bottom: 0.1rem;
&:focus,
&:active {
outline: none;
box-shadow: none;
}
&.tag {
--border-color: var(--medium-border);
border: 1px solid var(--border-color) !important;
border-radius: 5px;
}
&.flashing {
animation: flash 0.3s linear;
}
&.selected {
box-shadow: 0 0 0 2px var(--focus-shadow);
--border-color: var(--focus-border);
}
}
@include button.btn-day(
$with-active: false,
$with-disabled: false,
$with-hover: false
);
@include button.btn-night(
$with-active: false,
$with-disabled: false,
$with-hover: false
);
</style>

View file

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

View file

@ -0,0 +1,52 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import TagWithTooltip from "./TagWithTooltip.svelte";
import TagDeleteBadge from "./TagDeleteBadge.svelte";
import { createEventDispatcher } from "svelte";
export let name: string;
let className: string = "";
export { className as class };
export let tooltip: string;
export let selected: boolean;
export let active: boolean;
export let shorten: boolean;
export let flash: () => void;
const dispatch = createEventDispatcher();
function deleteTag(): void {
dispatch("tagdelete");
}
</script>
<TagWithTooltip
{name}
class={className}
{tooltip}
{selected}
{active}
{shorten}
{flash}
on:tagrange
on:tagselect
on:tagclick={() => dispatch("tagedit")}
let:selectMode
let:hoverClass
>
<TagDeleteBadge
class={hoverClass}
on:click={() => {
if (!selectMode) {
deleteTag();
}
}}
/>
</TagWithTooltip>

545
ts/editor/TagEditor.svelte Normal file
View file

@ -0,0 +1,545 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import { tick } from "svelte";
import { isApplePlatform } from "lib/platform";
import { bridgeCommand } from "lib/bridgecommand";
import Spacer from "components/Spacer.svelte";
import StickyBottom from "components/StickyBottom.svelte";
import TagOptionsBadge from "./TagOptionsBadge.svelte";
import TagEditMode from "./TagEditMode.svelte";
import TagInput from "./TagInput.svelte";
import Tag from "./Tag.svelte";
import WithAutocomplete from "./WithAutocomplete.svelte";
import ButtonToolbar from "components/ButtonToolbar.svelte";
import type { Tag as TagType } from "./tags";
import {
attachId,
getName,
replaceWithUnicodeSeparator,
replaceWithColons,
} from "./tags";
import { Tags } from "lib/proto";
import { postRequest } from "lib/postrequest";
export let tags: TagType[] = [];
export let size = isApplePlatform() ? 1.6 : 2.0;
export let wrap = true;
export function resetTags(names: string[]): void {
tags = names.map(replaceWithUnicodeSeparator).map(attachId);
}
const noSuggestions = Promise.resolve([]);
let suggestionsPromise: Promise<string[]> = noSuggestions;
function saveTags(): void {
bridgeCommand(
`saveTags:${JSON.stringify(
tags.map((tag) => tag.name).map(replaceWithColons)
)}`
);
suggestionsPromise = noSuggestions;
}
let active: number | null = null;
let activeAfterBlur: number | null = null;
let activeName = "";
let activeInput: HTMLInputElement;
let autocomplete: any;
let autocompleteDisabled: boolean = false;
async function fetchSuggestions(input: string): Promise<string[]> {
const data = await postRequest(
"/_anki/completeTag",
Tags.CompleteTagRequest.encode(
Tags.CompleteTagRequest.create({ input, matchLimit: 500 })
).finish()
);
const response = Tags.CompleteTagResponse.decode(data);
return response.tags;
}
const colonAtStartOrEnd = /^:?|:?$/g;
function updateSuggestions(): void {
const activeTag = tags[active!];
const activeName = activeTag.name;
autocompleteDisabled = activeName.length === 0;
if (autocompleteDisabled) {
suggestionsPromise = noSuggestions;
} else {
const cleanedName = replaceWithColons(activeName).replace(
colonAtStartOrEnd,
""
);
suggestionsPromise = fetchSuggestions(cleanedName).then(
(names: string[]): string[] => {
autocompleteDisabled = names.length === 0;
return names.map(replaceWithUnicodeSeparator);
}
);
}
}
function onAutocomplete(selected: string): void {
const activeTag = tags[active!];
activeName = selected ?? activeTag.name;
activeInput.setSelectionRange(Infinity, Infinity);
}
async function updateTagName(tag: TagType): Promise<void> {
tag.name = activeName;
tags = tags;
await tick();
if (activeInput) {
autocomplete.update();
}
}
function setActiveAfterBlur(value: number): void {
if (activeAfterBlur === null) {
activeAfterBlur = value;
}
}
function appendEmptyTag(): void {
// used by tag badge and tag spacer
const lastTag = tags[tags.length - 1];
if (!lastTag || lastTag.name.length > 0) {
appendTagAndFocusAt(tags.length - 1, "");
}
const tagsHadFocus = active === null;
active = null;
if (tagsHadFocus) {
decideNextActive();
}
}
function appendTagAndFocusAt(index: number, name: string): void {
tags.splice(index + 1, 0, attachId(name));
tags = tags;
setActiveAfterBlur(index + 1);
}
function isActiveNameUniqueAt(index: number): boolean {
const names = tags.map(getName);
names.splice(index, 1);
const contained = names.indexOf(activeName);
if (contained >= 0) {
tags[contained >= index ? contained + 1 : contained].flash();
return false;
}
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);
activeName = current;
// await tag to update its name, so it can normalize correctly
await tick();
appendTagAndFocusAt(index, splitOff);
active = null;
await tick();
if (index === active) {
// splitOff tag was rejected
return;
}
activeInput.setSelectionRange(0, 0);
}
function insertTagKeepFocus(index: number): void {
if (isActiveNameUniqueAt(index)) {
tags.splice(index, 0, attachId(activeName));
active!++;
tags = tags;
}
}
function deleteTagAt(index: number): TagType {
const deleted = tags.splice(index, 1)[0];
tags = tags;
if (activeAfterBlur !== null && activeAfterBlur > index) {
activeAfterBlur--;
}
return deleted;
}
function isFirst(index: number): boolean {
return index === 0;
}
function isLast(index: number): boolean {
return index === tags.length - 1;
}
function joinWithPreviousTag(index: number): void {
if (isFirst(index)) {
return;
}
const deleted = deleteTagAt(index - 1);
activeName = deleted.name + activeName;
active!--;
updateTagName(tags[active!]);
}
function joinWithNextTag(index: number): void {
if (isLast(index)) {
return;
}
const deleted = deleteTagAt(index + 1);
activeName = activeName + deleted.name;
updateTagName(tags[active!]);
}
function moveToPreviousTag(index: number): void {
if (isFirst(index)) {
return;
}
activeAfterBlur = index - 1;
active = null;
}
async function moveToNextTag(index: number): Promise<void> {
if (isLast(index)) {
if (activeName.length !== 0) {
appendTagAndFocusAt(index, "");
active = null;
}
return;
}
activeAfterBlur = index + 1;
active = null;
await tick();
activeInput.setSelectionRange(0, 0);
}
function deleteTagIfNotUnique(tag: TagType, index: number): void {
if (!tags.includes(tag)) {
// already deleted
return;
}
if (!isActiveNameUniqueAt(index)) {
deleteTagAt(index);
}
}
function decideNextActive() {
active = activeAfterBlur;
activeAfterBlur = null;
}
function onKeydown(event: KeyboardEvent): void {
switch (event.code) {
case "ArrowUp":
autocomplete.selectPrevious();
event.preventDefault();
break;
case "ArrowDown":
autocomplete.selectNext();
event.preventDefault();
break;
case "Tab":
if (event.shiftKey) {
autocomplete.selectPrevious();
} else {
autocomplete.selectNext();
}
event.preventDefault();
break;
case "Enter":
autocomplete.chooseSelected();
event.preventDefault();
break;
}
}
function onKeyup(): void {
if (activeName.length === 0) {
autocomplete.hide();
}
}
let selectionAnchor: number | null = null;
let selectionFocus: number | null = null;
function select(index: number) {
tags[index].selected = !tags[index].selected;
tags = tags;
selectionAnchor = index;
}
function selectRange(index: number) {
if (selectionAnchor === null) {
select(index);
return;
}
selectionFocus = index;
const from = Math.min(selectionAnchor, selectionFocus);
const to = Math.max(selectionAnchor, selectionFocus);
for (let index = from; index <= to; index++) {
tags[index].selected = true;
}
tags = tags;
}
function deselect() {
tags = tags.map((tag: TagType): TagType => ({ ...tag, selected: false }));
selectionAnchor = null;
selectionFocus = null;
}
function deselectIfLeave(event: FocusEvent) {
const toolbar = event.currentTarget as HTMLDivElement;
if (
event.relatedTarget === null ||
!toolbar.contains(event.relatedTarget as Node)
) {
deselect();
}
}
/* TODO replace with navigator.clipboard once available */
function copyToClipboard(content: string): void {
const textarea = document.createElement("textarea");
textarea.value = content;
textarea.setAttribute("readonly", "");
textarea.style.position = "absolute";
textarea.style.left = "-9999px";
document.body.appendChild(textarea);
textarea.select();
document.execCommand("copy");
document.body.removeChild(textarea);
}
function selectAllTags() {
tags.forEach((tag) => (tag.selected = true));
tags = tags;
}
function copySelectedTags() {
const content = tags
.filter((tag) => tag.selected)
.map((tag) => replaceWithColons(tag.name))
.join("\n");
copyToClipboard(content);
deselect();
}
function deleteSelectedTags() {
tags.map((tag, index) => [tag.selected, index])
.filter(([selected]) => selected)
.reverse()
.forEach(([, index]) => deleteTagAt(index as number));
deselect();
saveTags();
}
let height: number;
let badgeHeight: number;
// typically correct for rows < 7
$: assumedRows = Math.floor(height / badgeHeight);
$: shortenTags = shortenTags || assumedRows > 2;
</script>
<Spacer --height="{height}px" />
<StickyBottom class="d-flex" bind:height>
{#if !wrap}
<TagOptionsBadge
--buttons-size="{size}rem"
showSelectionsOptions={tags.some((tag) => tag.selected)}
bind:badgeHeight
on:tagselectall={selectAllTags}
on:tagcopy={copySelectedTags}
on:tagdelete={deleteSelectedTags}
on:click={appendEmptyTag}
/>
{/if}
<ButtonToolbar
class="d-flex align-items-center w-100 px-1"
{size}
{wrap}
on:focusout={deselectIfLeave}
>
{#if wrap}
<TagOptionsBadge
showSelectionsOptions={tags.some((tag) => tag.selected)}
bind:badgeHeight
on:tagselectall={selectAllTags}
on:tagcopy={copySelectedTags}
on:tagdelete={deleteSelectedTags}
on:click={appendEmptyTag}
/>
{/if}
{#each tags as tag, index (tag.id)}
<div
class="position-relative tag-margins"
class:hide-tag={index === active}
>
<TagEditMode
class="ms-0 tag-margins-inner"
name={index === active ? activeName : tag.name}
tooltip={tag.name}
active={index === active}
shorten={shortenTags}
bind:flash={tag.flash}
bind:selected={tag.selected}
on:tagedit={() => {
active = index;
deselect();
}}
on:tagselect={() => select(index)}
on:tagrange={() => selectRange(index)}
on:tagdelete={() => {
deselect();
deleteTagAt(index);
saveTags();
}}
/>
{#if index === active}
<div class="adjust-position">
<WithAutocomplete
drop="up"
class="d-flex flex-column cap-items"
{suggestionsPromise}
on:update={updateSuggestions}
on:select={({ detail }) => onAutocomplete(detail.selected)}
on:choose={({ detail }) => onAutocomplete(detail.chosen)}
let:createAutocomplete
>
<TagInput
id={tag.id}
class="tag-input position-absolute start-0 top-0 ps-2 py-0"
disabled={autocompleteDisabled}
bind:name={activeName}
bind:input={activeInput}
on:focus={() => {
activeName = tag.name;
autocomplete = createAutocomplete(activeInput);
}}
on:keydown={onKeydown}
on:keyup={onKeyup}
on:taginput={() => updateTagName(tag)}
on:tagsplit={({ detail }) =>
enterBehavior(index, detail.start, detail.end)}
on:tagadd={() => insertTagKeepFocus(index)}
on:tagdelete={() => deleteTagAt(index)}
on:tagjoinprevious={() => joinWithPreviousTag(index)}
on:tagjoinnext={() => joinWithNextTag(index)}
on:tagmoveprevious={() => moveToPreviousTag(index)}
on:tagmovenext={() => moveToNextTag(index)}
on:tagaccept={() => {
deleteTagIfNotUnique(tag, index);
if (tag) {
updateTagName(tag);
}
saveTags();
decideNextActive();
}}
/>
</WithAutocomplete>
</div>
{/if}
</div>
{/each}
<div
class="tag-spacer flex-grow-1 align-self-stretch"
on:click={appendEmptyTag}
/>
<div class="position-relative tag-margins hide-tag zero-width-tag">
<!-- makes sure footer does not resize when adding first tag -->
<Tag>SPACER</Tag>
</div>
</ButtonToolbar>
</StickyBottom>
<style lang="scss">
.tag-spacer {
cursor: text;
}
.hide-tag :global(.tag) {
opacity: 0;
}
.zero-width-tag :global(.tag) {
width: 0;
pointer-events: none;
padding-left: 0 !important;
padding-right: 0 !important;
}
.tag-margins {
margin-bottom: 0.15rem;
:global(.tag-margins-inner) {
margin-right: 2px;
}
}
.adjust-position {
:global(.tag-input) {
/* recreates positioning of Tag component */
border-left: 1px solid transparent;
}
:global(.cap-items) {
max-height: 7rem;
overflow-y: scroll;
}
}
</style>

269
ts/editor/TagInput.svelte Normal file
View file

@ -0,0 +1,269 @@
<!--
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, createEventDispatcher, tick } from "svelte";
import {
normalizeTagname,
delimChar,
replaceWithUnicodeSeparator,
replaceWithColons,
} from "./tags";
export let id: string | undefined = undefined;
let className: string = "";
export { className as class };
export let name: string;
export let input: HTMLInputElement;
export let disabled: boolean;
const dispatch = createEventDispatcher();
function isCollapsed(): boolean {
return input.selectionStart === input.selectionEnd;
}
function caretAtStart(): boolean {
return input.selectionStart === 0 && input.selectionEnd === 0;
}
function caretAtEnd(): boolean {
return (
input.selectionStart === input.value.length &&
input.selectionEnd === input.value.length
);
}
function setPosition(position: number): void {
input.setSelectionRange(position, position);
}
function isEmpty(): boolean {
return name.length === 0;
}
async function joinWithPreviousTag(event: Event): Promise<void> {
const length = input.value.length;
dispatch("tagjoinprevious");
await tick();
setPosition(input.value.length - length);
event.preventDefault();
}
async function maybeDeleteDelimiter(event: Event, position: number): Promise<void> {
if (position > name.length) {
return;
}
const nameUptoCaret = name.slice(0, position);
if (nameUptoCaret.endsWith(delimChar)) {
name = name.slice(0, position - 1) + name.slice(position, name.length);
await tick();
event.preventDefault();
setPosition(position - 1);
dispatch("taginput");
}
}
function onBackspace(event: KeyboardEvent): void {
if (caretAtStart()) {
joinWithPreviousTag(event);
} else {
maybeDeleteDelimiter(event, input.selectionStart!);
}
}
async function joinWithNextTag(event: Event): Promise<void> {
const length = input.value.length;
dispatch("tagjoinnext");
await tick();
setPosition(length);
event.preventDefault();
}
function onDelete(event: KeyboardEvent): void {
if (caretAtEnd()) {
joinWithNextTag(event);
} else {
maybeDeleteDelimiter(event, input.selectionStart! + 2);
}
}
function onBlur(): void {
name = normalizeTagname(name);
if (name.length === 0) {
dispatch("tagdelete");
}
dispatch("tagaccept");
}
function onEnter(event: Event): void {
dispatch("tagsplit", { start: input.selectionStart, end: input.selectionEnd });
event.preventDefault();
}
async function onDelimiter(event: Event, single: boolean = false): Promise<void> {
const positionStart = input.selectionStart!;
const positionEnd = input.selectionEnd!;
const before = name.slice(0, positionStart);
const after = name.slice(positionEnd, name.length);
if (before.endsWith(delimChar)) {
event.preventDefault();
event.stopPropagation();
return;
} else if (before.endsWith(":")) {
event.preventDefault();
name = `${before.slice(0, -1)}${delimChar}${name.slice(
positionEnd,
name.length
)}`;
await tick();
setPosition(positionStart);
return;
} else if (after.startsWith(":")) {
event.preventDefault();
name = `${before}${delimChar}${name.slice(positionEnd + 1, name.length)}`;
} else if (single) {
return;
} else {
event.preventDefault();
name = `${before}${delimChar}${after}`;
}
await tick();
setPosition(positionStart + 1);
dispatch("taginput");
}
function onKeydown(event: KeyboardEvent): void {
switch (event.code) {
case "Enter":
onEnter(event);
break;
case "Space":
onDelimiter(event);
break;
case "Backspace":
if (isCollapsed()) {
onBackspace(event);
}
break;
case "Delete":
if (isCollapsed()) {
onDelete(event);
}
break;
case "ArrowLeft":
if (isEmpty()) {
joinWithPreviousTag(event);
} else if (caretAtStart()) {
dispatch("tagmoveprevious");
event.preventDefault();
}
break;
case "ArrowRight":
if (isEmpty()) {
joinWithNextTag(event);
} else if (caretAtEnd()) {
dispatch("tagmovenext");
event.preventDefault();
}
break;
}
if (event.key === ":") {
onDelimiter(event, true);
}
}
function onCopy(event: ClipboardEvent): void {
const selection = document.getSelection();
event.clipboardData!.setData(
"text/plain",
replaceWithColons(selection!.toString())
);
}
function onPaste(event: ClipboardEvent): void {
if (!event.clipboardData) {
return;
}
const pasted = name + event.clipboardData.getData("text/plain");
const splitted = pasted
.split(/\s+/)
.map(normalizeTagname)
.filter((name: string) => name.length > 0)
.map(replaceWithUnicodeSeparator);
if (splitted.length === 0) {
return;
}
const last = splitted.pop()!;
for (const pastedName of splitted.reverse()) {
name = pastedName;
dispatch("tagadd");
}
name = last;
}
onMount(() => input.focus());
</script>
<input
{id}
class={className}
class:disabled
bind:this={input}
bind:value={name}
type="text"
tabindex="-1"
size="1"
on:focus
on:blur|preventDefault={onBlur}
on:keydown={onKeydown}
on:keydown
on:keyup
on:input={() => dispatch("taginput")}
on:copy|preventDefault={onCopy}
on:paste|preventDefault={onPaste}
/>
<style lang="scss">
input {
width: 100%;
height: 100%;
color: var(--text-fg);
background: none;
resize: none;
appearance: none;
font: inherit;
/* TODO we need something like --base-font-size for buttons' 13px */
font-size: 13px;
outline: none;
border: none;
margin: 0;
}
</style>

View file

@ -0,0 +1,24 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import SelectedTagBadge from "./SelectedTagBadge.svelte";
import AddTagBadge from "./AddTagBadge.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:click />
{/if}
</div>

View file

@ -0,0 +1,124 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
import Tag from "./Tag.svelte";
import WithTooltip from "components/WithTooltip.svelte";
import { createEventDispatcher, getContext } from "svelte";
import { nightModeKey } from "components/context-keys";
import { controlPressed, shiftPressed } from "lib/keys";
import { delimChar } from "./tags";
export let name: string;
let className: string = "";
export { className as class };
export let tooltip: string;
export let selected: boolean;
export let active: boolean;
export let shorten: boolean;
export let flash: () => void;
const dispatch = createEventDispatcher();
let control = false;
let shift = false;
$: selectMode = control || shift;
function setControlShift(event: KeyboardEvent | MouseEvent): void {
control = controlPressed(event);
shift = shiftPressed(event);
}
function onClick(): void {
if (shift) {
dispatch("tagrange");
} else if (control) {
dispatch("tagselect");
} else {
dispatch("tagclick");
}
}
function processTagName(name: string): string {
const parts = name.split(delimChar);
if (parts.length === 1) {
return name;
}
return `…${delimChar}` + parts[parts.length - 1];
}
function hasMultipleParts(name: string): boolean {
return name.split(delimChar).length > 1;
}
const nightMode = getContext<boolean>(nightModeKey);
const hoverClass = "tag-icon-hover";
</script>
<svelte:body on:keydown={setControlShift} on:keyup={setControlShift} />
<div class:select-mode={selectMode} class:night-mode={nightMode}>
{#if active}
<Tag class={className} on:mousemove={setControlShift} on:click={onClick}>
{name}
<slot {selectMode} {hoverClass} />
</Tag>
{:else if shorten && hasMultipleParts(name)}
<WithTooltip {tooltip} trigger="hover" placement="auto" let:createTooltip>
<Tag
class={className}
bind:flash
bind:selected
on:mousemove={setControlShift}
on:click={onClick}
on:mount={(event) => createTooltip(event.detail.button)}
>
<span>{processTagName(name)}</span>
<slot {selectMode} {hoverClass} />
</Tag>
</WithTooltip>
{:else}
<Tag
class={className}
bind:flash
bind:selected
on:mousemove={setControlShift}
on:click={onClick}
>
<span>{name}</span>
<slot {selectMode} {hoverClass} />
</Tag>
{/if}
</div>
<style lang="scss">
.select-mode :global(button:hover) {
display: contents;
cursor: crosshair;
:global(.tag-icon-hover) {
opacity: 0;
}
}
:global(.tag-icon-hover):hover {
$white-translucent: rgba(255 255 255 / 0.5);
$dark-translucent: rgba(0 0 0 / 0.2);
div & {
background-color: $dark-translucent;
}
.night-mode & {
background-color: $white-translucent;
}
}
</style>

View file

@ -19,6 +19,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ClozeButton from "./ClozeButton.svelte";
import { getCurrentField, appendInParentheses } from "./helpers";
import { withButton } from "components/helpers";
import { wrapCurrent } from "./wrap";
import { paperclipIcon, micIcon, functionIcon, xmlIcon } from "./icons";
@ -52,7 +53,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
iconSize={70}
{disabled}
on:click={onAttachment}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{@html paperclipIcon}
</IconButton>
@ -71,7 +72,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
iconSize={70}
{disabled}
on:click={onRecord}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{@html micIcon}
</IconButton>
@ -88,10 +89,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem>
<WithDropdown let:createDropdown>
<OnlyEditable let:disabled>
<IconButton
{disabled}
on:mount={(event) => createDropdown(event.detail.button)}
>
<IconButton {disabled} on:mount={withButton(createDropdown)}>
{@html functionIcon}
</IconButton>
</OnlyEditable>
@ -104,7 +102,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
<DropdownItem
on:click={() => wrapCurrent("\\(", "\\)")}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{tr.editingMathjaxInline()}
<span class="ps-1 float-end">{shortcutLabel}</span>
@ -118,7 +116,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
<DropdownItem
on:click={() => wrapCurrent("\\[", "\\]")}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{tr.editingMathjaxBlock()}
<span class="ps-1 float-end">{shortcutLabel}</span>
@ -132,7 +130,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
<DropdownItem
on:click={() => wrapCurrent("\\(\\ce{", "}\\)")}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{tr.editingMathjaxChemistry()}
<span class="ps-1 float-end">{shortcutLabel}</span>
@ -146,7 +144,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
<DropdownItem
on:click={() => wrapCurrent("[latex]", "[/latex]")}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{tr.editingLatex()}
<span class="ps-1 float-end">{shortcutLabel}</span>
@ -160,7 +158,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
<DropdownItem
on:click={() => wrapCurrent("[$]", "[/$]")}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{tr.editingLatexEquation()}
<span class="ps-1 float-end">{shortcutLabel}</span>
@ -174,7 +172,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
>
<DropdownItem
on:click={() => wrapCurrent("[$$]", "[/$$]")}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{tr.editingLatexMathEnv()}
<span class="ps-1 float-end">{shortcutLabel}</span>
@ -201,7 +199,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
active={inCodable}
disabled={!fieldFocused}
on:click={onHtmlEdit}
on:mount={(event) => createShortcut(event.detail.button)}
on:mount={withButton(createShortcut)}
>
{@html xmlIcon}
</IconButton>

View file

@ -0,0 +1,181 @@
<!--
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, tick } from "svelte";
import type Dropdown from "bootstrap/js/dist/dropdown";
import WithDropdown from "components/WithDropdown.svelte";
import DropdownMenu from "components/DropdownMenu.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;
let suggestionsItems: string[] = [];
$: suggestionsPromise.then((items) => {
show = items.length > 0;
if (show) {
dropdown.show();
} else {
dropdown.hide();
}
suggestionsItems = items;
});
let selected: number | null = null;
let active: boolean = false;
const dispatch = createEventDispatcher();
/**
* select as currently highlighted item
*/
function incrementSelected(): void {
if (selected === null) {
selected = 0;
} else if (selected >= suggestionsItems.length - 1) {
selected = null;
} else {
selected++;
}
}
function decrementSelected(): void {
if (selected === null) {
selected = suggestionsItems.length - 1;
} else if (selected === 0) {
selected = null;
} else {
selected--;
}
}
async function updateSelected(): Promise<void> {
dispatch("select", { selected: suggestionsItems[selected ?? -1] });
await tick();
dropdown.update();
}
async function selectNext(): Promise<void> {
incrementSelected();
await updateSelected();
}
async function selectPrevious(): Promise<void> {
decrementSelected();
await updateSelected();
}
/**
* choose as accepted suggestion
*/
async function chooseSelected() {
active = true;
dispatch("choose", { chosen: suggestionsItems[selected ?? -1] });
await tick();
show = false;
}
async function update() {
dropdown.update();
await tick();
dispatch("update");
}
function hasSelected(): boolean {
return selected !== null;
}
const createAutocomplete =
(createDropdown: (element: HTMLElement) => Dropdown) =>
(element: HTMLElement): any => {
dropdown = createDropdown(element);
const api = {
hide: dropdown.hide,
show: dropdown.show,
toggle: dropdown.toggle,
isVisible: (dropdown as any).isVisible,
selectPrevious,
selectNext,
chooseSelected,
update,
hasSelected,
};
return api;
};
function setSelected(index: number): void {
selected = index;
active = true;
}
function setSelectedAndActive(index: number): void {
setSelected(index);
}
async function selectIndex(index: number): Promise<void> {
active = false;
dispatch("select", { selected: suggestionsItems[index] });
}
function selectIfMousedown(event: MouseEvent, index: number): void {
if (event.buttons === 1) {
setSelected(index);
}
}
let scroll: () => void;
$: if (scroll) {
scroll();
}
</script>
<WithDropdown {drop} toggleOpen={false} let:createDropdown align="start">
<slot createAutocomplete={createAutocomplete(createDropdown)} />
<DropdownMenu class={className} {show}>
{#each suggestionsItems as suggestion, index}
{#if index === selected}
<AutocompleteItem
bind:scroll
selected
{active}
on:mousedown={() => setSelectedAndActive(index)}
on:mouseup={() => {
selectIndex(index);
chooseSelected();
}}
on:mouseenter={(event) => selectIfMousedown(event, index)}
on:mouseleave={() => (active = false)}
>{suggestion}</AutocompleteItem
>
{:else}
<AutocompleteItem
on:mousedown={() => setSelectedAndActive(index)}
on:mouseup={() => {
selectIndex(index);
chooseSelected();
}}
on:mouseenter={(event) => selectIfMousedown(event, index)}
>{suggestion}</AutocompleteItem
>
{/if}
{/each}
</DropdownMenu>
</WithDropdown>

View file

@ -7,3 +7,7 @@ $btn-disabled-opacity: 0.4;
@import "bootstrap/scss/buttons";
@import "bootstrap/scss/button-group";
@import "bootstrap/scss/dropdown";
$tooltip-max-width: 600px;
@import "bootstrap/scss/tooltip";

View file

@ -88,9 +88,9 @@
}
}
@import "ts/sass/codemirror/lib/codemirror";
@import "ts/sass/codemirror/theme/monokai";
@import "ts/sass/codemirror/addon/fold/foldgutter";
@import "codemirror/lib/codemirror";
@import "codemirror/theme/monokai";
@import "codemirror/addon/fold/foldgutter";
.CodeMirror {
height: auto;

View file

@ -31,6 +31,11 @@ export { default as ellipseIcon } from "./contain.svg";
export { default as functionIcon } from "./function-variant.svg";
export { default as xmlIcon } from "./xml.svg";
export { default as tagIcon } from "./tag.svg";
export { default as addTagIcon } from "./tag-plus.svg";
export { default as dotsIcon } from "./dots-vertical.svg";
export { default as deleteIcon } from "./x.svg";
export const arrowIcon =
'<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill="transparent" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M2 5l6 6 6-6"/></svg>';

View file

@ -9,6 +9,9 @@
import "sveltelib/export-runtime";
import "lib/register-package";
import type EditorToolbar from "./EditorToolbar.svelte";
import type TagEditor from "./TagEditor.svelte";
import { filterHTML } from "html-filter";
import { updateActiveButtons } from "./toolbar";
import { setupI18n, ModuleName } from "lib/i18n";
@ -27,6 +30,7 @@ import { EditableContainer } from "./editable-container";
import { Editable } from "./editable";
import { Codable } from "./codable";
import { initToolbar, fieldFocused } from "./toolbar";
import { initTagEditor } from "./tag-editor";
import { getCurrentField } from "./helpers";
export { setNoteId, getNoteId } from "./note-id";
@ -192,6 +196,10 @@ export function setFormat(cmd: string, arg?: string, nosave = false): void {
}
}
export function setTags(tags: string[]): void {
$tagEditor.then((tagEditor: TagEditor): void => tagEditor.resetTags(tags));
}
export const i18n = setupI18n({
modules: [
ModuleName.EDITING,
@ -201,6 +209,5 @@ export const i18n = setupI18n({
],
});
import type EditorToolbar from "./EditorToolbar.svelte";
export const $editorToolbar: Promise<EditorToolbar> = initToolbar(i18n);
export const $tagEditor: Promise<TagEditor> = initTagEditor(i18n);

38
ts/editor/tag-editor.ts Normal file
View file

@ -0,0 +1,38 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* eslint
@typescript-eslint/no-non-null-assertion: "off",
@typescript-eslint/no-explicit-any: "off",
*/
import { nightModeKey } from "components/context-keys";
import TagEditor from "./TagEditor.svelte";
import "./bootstrap.css";
export function initTagEditor(i18n: Promise<void>): Promise<TagEditor> {
let tagEditorResolve: (value: TagEditor) => void;
const tagEditorPromise = new Promise<TagEditor>((resolve) => {
tagEditorResolve = resolve;
});
document.addEventListener("DOMContentLoaded", () =>
i18n.then(() => {
const target = document.body;
const anchor = document.getElementById("tag-editor-anchor")!;
const context = new Map();
context.set(
nightModeKey,
document.documentElement.classList.contains("night-mode")
);
tagEditorResolve(new TagEditor({ target, anchor, context } as any));
})
);
return tagEditorPromise;
}
export {} from "./TagEditor.svelte";

48
ts/editor/tags.ts Normal file
View file

@ -0,0 +1,48 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const delimChar = "\u2237";
export function replaceWithUnicodeSeparator(name: string): string {
return name.replace(/::/g, delimChar);
}
export function replaceWithColons(name: string): string {
return name.replace(/\u2237/gu, "::");
}
export function normalizeTagname(tagname: string): string {
let trimmed = tagname.trim();
while (trimmed.startsWith(":") || trimmed.startsWith(delimChar)) {
trimmed = trimmed.slice(1).trimStart();
}
while (trimmed.endsWith(":") || trimmed.endsWith(delimChar)) {
trimmed = trimmed.slice(0, -1).trimEnd();
}
return trimmed;
}
export interface Tag {
id: string;
name: string;
selected: boolean;
flash: () => void;
}
export function attachId(name: string): Tag {
return {
id: Math.random().toString(36).substring(2),
name,
selected: false,
flash: () => {
/* noop */
},
};
}
export function getName(tag: Tag): string {
return tag.name;
}

75
ts/lib/keys.ts Normal file
View file

@ -0,0 +1,75 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "./i18n";
import { isApplePlatform } from "./platform";
// those are the modifiers that Anki works with
export type Modifier = "Control" | "Alt" | "Shift" | "Meta";
const allModifiers: Modifier[] = ["Control", "Alt", "Shift", "Meta"];
const platformModifiers: string[] = isApplePlatform()
? ["Meta", "Alt", "Shift", "Control"]
: ["Control", "Alt", "Shift", "OS"];
function translateModifierToPlatform(modifier: Modifier): string {
return platformModifiers[allModifiers.indexOf(modifier)];
}
export const checkModifiers =
(required: Modifier[], optional: Modifier[] = []) =>
(event: KeyboardEvent): boolean => {
return allModifiers.reduce(
(
matches: boolean,
currentModifier: Modifier,
currentIndex: number
): boolean =>
matches &&
(optional.includes(currentModifier as Modifier) ||
event.getModifierState(platformModifiers[currentIndex]) ===
required.includes(currentModifier)),
true
);
};
const modifierPressed =
(modifier: Modifier) =>
(event: MouseEvent | KeyboardEvent): boolean => {
const translated = translateModifierToPlatform(modifier);
const state = event.getModifierState(translated);
return event.type === "keyup"
? state && (event as KeyboardEvent).key !== translated
: state;
};
export const controlPressed = modifierPressed("Control");
export const shiftPressed = modifierPressed("Shift");
export function modifiersToPlatformString(modifiers: string[]): string {
const displayModifiers = isApplePlatform()
? ["^", "⌥", "⇧", "⌘"]
: [`${tr.keyboardCtrl()}+`, "Alt+", `${tr.keyboardShift()}+`, "Win+"];
let result = "";
for (const modifier of modifiers) {
result += displayModifiers[platformModifiers.indexOf(modifier)];
}
return result;
}
export function keyToPlatformString(key: string): string {
switch (key) {
case "Backspace":
return "⌫";
case "Delete":
return "⌦";
case "Escape":
return "⎋";
default:
return key;
}
}

View file

@ -7,4 +7,5 @@ import DeckConfig = anki.deckconfig;
import Notetypes = anki.notetypes;
import Scheduler = anki.scheduler;
import Stats = anki.stats;
export { Stats, Cards, DeckConfig, Notetypes, Scheduler };
import Tags = anki.tags;
export { Stats, Cards, DeckConfig, Notetypes, Scheduler, Tags };

View file

@ -1,30 +1,10 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "./i18n";
import { isApplePlatform } from "./platform";
import type { Modifier } from "./keys";
import { registerPackage } from "./register-package";
export type Modifier = "Control" | "Alt" | "Shift" | "Meta";
// how modifiers are mapped
const platformModifiers = isApplePlatform()
? ["Meta", "Alt", "Shift", "Control"]
: ["Control", "Alt", "Shift", "OS"];
function modifiersToPlatformString(modifiers: string[]): string {
const displayModifiers = isApplePlatform()
? ["^", "⌥", "⇧", "⌘"]
: [`${tr.keyboardCtrl()}+`, "Alt+", `${tr.keyboardShift()}+`, "Win+"];
let result = "";
for (const modifier of modifiers) {
result += displayModifiers[platformModifiers.indexOf(modifier)];
}
return result;
}
import { modifiersToPlatformString, keyToPlatformString, checkModifiers } from "./keys";
const keyCodeLookup = {
Backspace: 8,
@ -68,7 +48,7 @@ function toPlatformString(keyCombination: string[]): string {
return (
modifiersToPlatformString(
keyCombination.slice(0, -1).filter(isRequiredModifier)
) + keyCombination[keyCombination.length - 1]
) + keyToPlatformString(keyCombination[keyCombination.length - 1])
);
}
@ -82,8 +62,6 @@ function checkKey(event: KeyboardEvent, key: number): boolean {
return event.which === key;
}
const allModifiers: Modifier[] = ["Control", "Alt", "Shift", "Meta"];
function partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] {
const trueItems: T[] = [];
const falseItems: T[] = [];
@ -100,28 +78,25 @@ function removeTrailing(modifier: string): string {
return modifier.substring(0, modifier.length - 1);
}
function checkModifiers(event: KeyboardEvent, modifiers: string[]): boolean {
// function checkModifiers(event: KeyboardEvent, modifiers: string[]): boolean {
function separateRequiredOptionalModifiers(
modifiers: string[]
): [Modifier[], Modifier[]] {
const [requiredModifiers, otherModifiers] = partition(
isRequiredModifier,
modifiers
);
const optionalModifiers = otherModifiers.map(removeTrailing);
return allModifiers.reduce(
(matches: boolean, currentModifier: string, currentIndex: number): boolean =>
matches &&
(optionalModifiers.includes(currentModifier as Modifier) ||
event.getModifierState(platformModifiers[currentIndex]) ===
requiredModifiers.includes(currentModifier)),
true
);
return [requiredModifiers as Modifier[], optionalModifiers as Modifier[]];
}
const check =
(keyCode: number, modifiers: string[]) =>
(event: KeyboardEvent): boolean => {
return checkKey(event, keyCode) && checkModifiers(event, modifiers);
const [required, optional] = separateRequiredOptionalModifiers(modifiers);
return checkKey(event, keyCode) && checkModifiers(required, optional)(event);
};
function keyToCode(key: string): number {

View file

@ -4,6 +4,7 @@ sass_library(
name = "base_lib",
srcs = [
"_vars.scss",
"_fusion-vars.scss",
"base.scss",
"bootstrap-dark.scss",
],

View file

@ -28,7 +28,8 @@ $btn-base-color-day: white;
@content ($btn-base-color-day);
@if ($with-hover) {
&:hover {
&:hover,
&.hover {
background-color: darken($btn-base-color-day, 8%);
}
}
@ -76,7 +77,8 @@ $btn-base-color-night: #666;
@content ($btn-base-color-night);
@if ($with-hover) {
&:hover {
&:hover,
&.hover {
background-color: lighten($btn-base-color-night, 8%);
border-color: lighten($btn-base-color-night, 8%);
}

View file

@ -8,6 +8,7 @@
"es2019.array",
"es2018.promise",
"es2020.promise",
"es2019.string",
"dom",
"dom.iterable"
],