mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00
commit
9daf037c0b
58 changed files with 2088 additions and 134 deletions
|
@ -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;
|
||||
}
|
||||
|
|
|
@ -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):
|
||||
|
|
|
@ -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
|
||||
#############################################################
|
||||
|
||||
|
|
|
@ -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)
|
||||
|
|
|
@ -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,10 +456,8 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
|
|||
self.currentField = None
|
||||
if self.note:
|
||||
self.loadNote(focusTo=focusTo)
|
||||
else:
|
||||
self.hideCompleters()
|
||||
if hide:
|
||||
self.widget.hide()
|
||||
elif hide:
|
||||
self.widget.hide()
|
||||
|
||||
def loadNoteKeepingFocus(self) -> None:
|
||||
self.loadNote(self.currentField)
|
||||
|
@ -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
|
||||
|
|
|
@ -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,
|
||||
}
|
||||
|
||||
|
||||
|
|
|
@ -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 })
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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([])?;
|
||||
|
|
78
rslib/src/tags/complete.rs
Normal file
78
rslib/src/tags/complete.rs
Normal 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(())
|
||||
}
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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",
|
||||
],
|
||||
|
|
|
@ -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>
|
|
@ -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";
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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">
|
||||
<slot />
|
||||
<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);
|
||||
}
|
||||
|
|
|
@ -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";
|
||||
|
||||
|
|
12
ts/components/Spacer.svelte
Normal file
12
ts/components/Spacer.svelte
Normal 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>
|
|
@ -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>
|
||||
|
||||
|
|
28
ts/components/StickyBottom.svelte
Normal file
28
ts/components/StickyBottom.svelte
Normal 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>
|
|
@ -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">
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
|
|
|
@ -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>
|
|
@ -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);
|
||||
};
|
||||
|
|
|
@ -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";
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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)}
|
||||
|
|
59
ts/editor/AddTagBadge.svelte
Normal file
59
ts/editor/AddTagBadge.svelte
Normal 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>
|
96
ts/editor/AutocompleteItem.svelte
Normal file
96
ts/editor/AutocompleteItem.svelte
Normal 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>
|
|
@ -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"],
|
||||
)
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
|
|
73
ts/editor/SelectedTagBadge.svelte
Normal file
73
ts/editor/SelectedTagBadge.svelte
Normal 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
99
ts/editor/Tag.svelte
Normal 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>
|
15
ts/editor/TagDeleteBadge.svelte
Normal file
15
ts/editor/TagDeleteBadge.svelte
Normal 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
|
||||
>
|
52
ts/editor/TagEditMode.svelte
Normal file
52
ts/editor/TagEditMode.svelte
Normal 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
545
ts/editor/TagEditor.svelte
Normal 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
269
ts/editor/TagInput.svelte
Normal 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>
|
24
ts/editor/TagOptionsBadge.svelte
Normal file
24
ts/editor/TagOptionsBadge.svelte
Normal 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>
|
124
ts/editor/TagWithTooltip.svelte
Normal file
124
ts/editor/TagWithTooltip.svelte
Normal 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>
|
|
@ -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>
|
||||
|
|
181
ts/editor/WithAutocomplete.svelte
Normal file
181
ts/editor/WithAutocomplete.svelte
Normal 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>
|
4
ts/editor/bootstrap.scss
vendored
4
ts/editor/bootstrap.scss
vendored
|
@ -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";
|
||||
|
|
|
@ -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;
|
||||
|
|
|
@ -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>';
|
||||
|
||||
|
|
|
@ -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
38
ts/editor/tag-editor.ts
Normal 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
48
ts/editor/tags.ts
Normal 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
75
ts/lib/keys.ts
Normal 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;
|
||||
}
|
||||
}
|
|
@ -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 };
|
||||
|
|
|
@ -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 {
|
||||
|
|
|
@ -4,6 +4,7 @@ sass_library(
|
|||
name = "base_lib",
|
||||
srcs = [
|
||||
"_vars.scss",
|
||||
"_fusion-vars.scss",
|
||||
"base.scss",
|
||||
"bootstrap-dark.scss",
|
||||
],
|
||||
|
|
|
@ -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%);
|
||||
}
|
||||
|
|
|
@ -8,6 +8,7 @@
|
|||
"es2019.array",
|
||||
"es2018.promise",
|
||||
"es2020.promise",
|
||||
"es2019.string",
|
||||
"dom",
|
||||
"dom.iterable"
|
||||
],
|
||||
|
|
Loading…
Reference in a new issue