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);
|
returns (collection.OpChangesWithCount);
|
||||||
rpc FindAndReplaceTag(FindAndReplaceTagRequest)
|
rpc FindAndReplaceTag(FindAndReplaceTagRequest)
|
||||||
returns (collection.OpChangesWithCount);
|
returns (collection.OpChangesWithCount);
|
||||||
|
rpc CompleteTag(CompleteTagRequest) returns (CompleteTagResponse);
|
||||||
}
|
}
|
||||||
|
|
||||||
message SetTagCollapsedRequest {
|
message SetTagCollapsedRequest {
|
||||||
|
@ -58,3 +59,13 @@ message FindAndReplaceTagRequest {
|
||||||
bool regex = 4;
|
bool regex = 4;
|
||||||
bool match_case = 5;
|
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",
|
"UpdateDeckConfigs",
|
||||||
"AnswerCard",
|
"AnswerCard",
|
||||||
"ChangeNotetype",
|
"ChangeNotetype",
|
||||||
|
"CompleteTag",
|
||||||
}
|
}
|
||||||
SKIP_UNROLL_OUTPUT = {"GetPreferences"}
|
SKIP_UNROLL_OUTPUT = {"GetPreferences"}
|
||||||
|
|
||||||
SKIP_DECODE = {"Graphs", "GetGraphPreferences", "GetChangeNotetypeInfo"}
|
SKIP_DECODE = {"Graphs", "GetGraphPreferences", "GetChangeNotetypeInfo", "CompleteTag"}
|
||||||
|
|
||||||
|
|
||||||
def python_type(field):
|
def python_type(field):
|
||||||
|
|
|
@ -67,6 +67,11 @@ class TagManager:
|
||||||
"Set browser expansion state for tag, registering the tag if missing."
|
"Set browser expansion state for tag, registering the tag if missing."
|
||||||
return self.col._backend.set_tag_collapsed(name=tag, collapsed=collapsed)
|
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
|
# Bulk addition/removal from specific notes
|
||||||
#############################################################
|
#############################################################
|
||||||
|
|
||||||
|
|
|
@ -226,9 +226,6 @@ class AddCards(QDialog):
|
||||||
|
|
||||||
self.addHistory(note)
|
self.addHistory(note)
|
||||||
|
|
||||||
# workaround for PyQt focus bug
|
|
||||||
self.editor.hideCompleters()
|
|
||||||
|
|
||||||
tooltip(tr.adding_added(), period=500)
|
tooltip(tr.adding_added(), period=500)
|
||||||
av_player.stop_and_clear_queue()
|
av_player.stop_and_clear_queue()
|
||||||
self._load_new_note(sticky_fields_from=note)
|
self._load_new_note(sticky_fields_from=note)
|
||||||
|
|
|
@ -84,6 +84,7 @@ _html = """
|
||||||
<a href="#" onclick="pycmd('dupes');return false;">%s</a>
|
<a href="#" onclick="pycmd('dupes');return false;">%s</a>
|
||||||
</div>
|
</div>
|
||||||
<div id="cloze-hint"></div>
|
<div id="cloze-hint"></div>
|
||||||
|
<div id="tag-editor-anchor" class="d-none"></div>
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
@ -114,7 +115,6 @@ class Editor:
|
||||||
self.setupOuter()
|
self.setupOuter()
|
||||||
self.setupWeb()
|
self.setupWeb()
|
||||||
self.setupShortcuts()
|
self.setupShortcuts()
|
||||||
self.setupTags()
|
|
||||||
gui_hooks.editor_did_init(self)
|
gui_hooks.editor_did_init(self)
|
||||||
|
|
||||||
# Initial setup
|
# Initial setup
|
||||||
|
@ -302,9 +302,7 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
|
||||||
|
|
||||||
def setupShortcuts(self) -> None:
|
def setupShortcuts(self) -> None:
|
||||||
# if a third element is provided, enable shortcut even when no field selected
|
# if a third element is provided, enable shortcut even when no field selected
|
||||||
cuts: List[Tuple] = [
|
cuts: List[Tuple] = []
|
||||||
("Ctrl+Shift+T", self.onFocusTags, True),
|
|
||||||
]
|
|
||||||
gui_hooks.editor_did_init_shortcuts(cuts, self)
|
gui_hooks.editor_did_init_shortcuts(cuts, self)
|
||||||
for row in cuts:
|
for row in cuts:
|
||||||
if len(row) == 2:
|
if len(row) == 2:
|
||||||
|
@ -430,6 +428,14 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
|
||||||
(_, highlightColor) = cmd.split(":", 1)
|
(_, highlightColor) = cmd.split(":", 1)
|
||||||
self.mw.pm.profile["lastHighlightColor"] = highlightColor
|
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:
|
elif cmd in self._links:
|
||||||
self._links[cmd](self)
|
self._links[cmd](self)
|
||||||
|
|
||||||
|
@ -450,10 +456,8 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
|
||||||
self.currentField = None
|
self.currentField = None
|
||||||
if self.note:
|
if self.note:
|
||||||
self.loadNote(focusTo=focusTo)
|
self.loadNote(focusTo=focusTo)
|
||||||
else:
|
elif hide:
|
||||||
self.hideCompleters()
|
self.widget.hide()
|
||||||
if hide:
|
|
||||||
self.widget.hide()
|
|
||||||
|
|
||||||
def loadNoteKeepingFocus(self) -> None:
|
def loadNoteKeepingFocus(self) -> None:
|
||||||
self.loadNote(self.currentField)
|
self.loadNote(self.currentField)
|
||||||
|
@ -467,7 +471,6 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
|
||||||
for fld, val in self.note.items()
|
for fld, val in self.note.items()
|
||||||
]
|
]
|
||||||
self.widget.show()
|
self.widget.show()
|
||||||
self.updateTags()
|
|
||||||
|
|
||||||
note_fields_status = self.note.fields_check()
|
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")
|
text_color = self.mw.pm.profile.get("lastTextColor", "#00f")
|
||||||
highlight_color = self.mw.pm.profile.get("lastHighlightColor", "#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(data),
|
||||||
json.dumps(self.fonts()),
|
json.dumps(self.fonts()),
|
||||||
json.dumps(focusTo),
|
json.dumps(focusTo),
|
||||||
json.dumps(self.note.id),
|
json.dumps(self.note.id),
|
||||||
json.dumps([text_color, highlight_color]),
|
json.dumps([text_color, highlight_color]),
|
||||||
|
json.dumps(self.mw.col.tags.canonify(self.note.tags)),
|
||||||
)
|
)
|
||||||
|
|
||||||
if self.addMode:
|
if self.addMode:
|
||||||
|
@ -520,7 +524,6 @@ $editorToolbar.then(({{ toolbar }}) => toolbar.appendGroup({{
|
||||||
# calling code may not expect the callback to fire immediately
|
# calling code may not expect the callback to fire immediately
|
||||||
self.mw.progress.timer(10, callback, False)
|
self.mw.progress.timer(10, callback, False)
|
||||||
return
|
return
|
||||||
self.blur_tags_if_focused()
|
|
||||||
self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback())
|
self.web.evalWithCallback("saveNow(%d)" % keepFocus, lambda res: callback())
|
||||||
|
|
||||||
saveNow = call_after_note_saved
|
saveNow = call_after_note_saved
|
||||||
|
|
|
@ -351,6 +351,10 @@ def change_notetype() -> bytes:
|
||||||
return b""
|
return b""
|
||||||
|
|
||||||
|
|
||||||
|
def complete_tag() -> bytes:
|
||||||
|
return aqt.mw.col.tags.complete_tag(request.data)
|
||||||
|
|
||||||
|
|
||||||
post_handlers = {
|
post_handlers = {
|
||||||
"graphData": graph_data,
|
"graphData": graph_data,
|
||||||
"graphPreferences": graph_preferences,
|
"graphPreferences": graph_preferences,
|
||||||
|
@ -365,6 +369,7 @@ post_handlers = {
|
||||||
# pylint: disable=unnecessary-lambda
|
# pylint: disable=unnecessary-lambda
|
||||||
"i18nResources": i18n_resources,
|
"i18nResources": i18n_resources,
|
||||||
"congratsInfo": congrats_info,
|
"congratsInfo": congrats_info,
|
||||||
|
"completeTag": complete_tag,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
|
|
@ -88,4 +88,11 @@ impl TagsService for Backend {
|
||||||
.map(Into::into)
|
.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)
|
.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
|
where
|
||||||
F: Fn(&str) -> bool,
|
F: FnMut(&str) -> bool,
|
||||||
{
|
{
|
||||||
let mut query_stmt = self.db.prepare_cached(include_str!("get.sql"))?;
|
let mut query_stmt = self.db.prepare_cached(include_str!("get.sql"))?;
|
||||||
let mut rows = query_stmt.query([])?;
|
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
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
mod bulkadd;
|
mod bulkadd;
|
||||||
|
mod complete;
|
||||||
mod findreplace;
|
mod findreplace;
|
||||||
mod matcher;
|
mod matcher;
|
||||||
mod register;
|
mod register;
|
||||||
|
|
|
@ -5,6 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import * as tr from "lib/i18n";
|
import * as tr from "lib/i18n";
|
||||||
import type { ChangeNotetypeState } from "./lib";
|
import type { ChangeNotetypeState } from "./lib";
|
||||||
|
import { withButton } from "components/helpers";
|
||||||
|
|
||||||
import ButtonGroup from "components/ButtonGroup.svelte";
|
import ButtonGroup from "components/ButtonGroup.svelte";
|
||||||
import ButtonGroupItem from "components/ButtonGroupItem.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"
|
theme="primary"
|
||||||
on:click={() => save()}
|
on:click={() => save()}
|
||||||
tooltip={shortcutLabel}
|
tooltip={shortcutLabel}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}>{tr.actionsSave()}</LabelButton
|
||||||
>{tr.actionsSave()}</LabelButton
|
|
||||||
>
|
>
|
||||||
</WithShortcut>
|
</WithShortcut>
|
||||||
</ButtonGroupItem>
|
</ButtonGroupItem>
|
||||||
|
|
|
@ -19,7 +19,10 @@ compile_svelte(
|
||||||
srcs = svelte_files,
|
srcs = svelte_files,
|
||||||
visibility = ["//visibility:public"],
|
visibility = ["//visibility:public"],
|
||||||
deps = [
|
deps = [
|
||||||
|
"//ts/lib",
|
||||||
|
"//ts/sass:base_lib",
|
||||||
"//ts/sass:button_mixins_lib",
|
"//ts/sass:button_mixins_lib",
|
||||||
|
"//ts/sass:scrollbar_lib",
|
||||||
"//ts/sass/bootstrap",
|
"//ts/sass/bootstrap",
|
||||||
],
|
],
|
||||||
)
|
)
|
||||||
|
@ -69,7 +72,9 @@ svelte_check(
|
||||||
"*.ts",
|
"*.ts",
|
||||||
"*.svelte",
|
"*.svelte",
|
||||||
]) + [
|
]) + [
|
||||||
|
"//ts/sass:base_lib",
|
||||||
"//ts/sass:button_mixins_lib",
|
"//ts/sass:button_mixins_lib",
|
||||||
|
"//ts/sass:scrollbar_lib",
|
||||||
"//ts/sass/bootstrap",
|
"//ts/sass/bootstrap",
|
||||||
"@npm//@types/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
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
-->
|
-->
|
||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import type { DropdownProps } from "components/dropdown";
|
import type { DropdownProps } from "./dropdown";
|
||||||
import { dropdownKey } from "components/context-keys";
|
import { dropdownKey } from "./context-keys";
|
||||||
import { onMount, createEventDispatcher, getContext } from "svelte";
|
import { onMount, createEventDispatcher, getContext } from "svelte";
|
||||||
|
|
||||||
let className = "";
|
let className = "";
|
||||||
export { className as class };
|
export { className as class };
|
||||||
|
export let tooltip: string | undefined = undefined;
|
||||||
|
|
||||||
const dispatch = createEventDispatcher();
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
@ -23,10 +24,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
<span
|
<span
|
||||||
bind:this={spanRef}
|
bind:this={spanRef}
|
||||||
|
title={tooltip}
|
||||||
class={`badge ${className}`}
|
class={`badge ${className}`}
|
||||||
class:dropdown-toggle={dropdownProps.dropdown}
|
class:dropdown-toggle={dropdownProps.dropdown}
|
||||||
{...dropdownProps}
|
{...dropdownProps}
|
||||||
on:click
|
on:click
|
||||||
|
on:mouseenter
|
||||||
|
on:mouseleave
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</span>
|
</span>
|
||||||
|
@ -41,6 +45,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
}
|
}
|
||||||
|
|
||||||
span :global(svg) {
|
span :global(svg) {
|
||||||
vertical-align: -0.125rem;
|
border-radius: inherit;
|
||||||
|
vertical-align: var(--badge-align, -0.125rem);
|
||||||
}
|
}
|
||||||
</style>
|
</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
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
-->
|
-->
|
||||||
<script lang="typescript">
|
<script lang="typescript">
|
||||||
import WithTheming from "components/WithTheming.svelte";
|
import WithTheming from "./WithTheming.svelte";
|
||||||
import Detachable from "components/Detachable.svelte";
|
import Detachable from "./Detachable.svelte";
|
||||||
|
|
||||||
import type { ButtonRegistration } from "./buttons";
|
import type { ButtonRegistration } from "./buttons";
|
||||||
import { ButtonPosition } 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
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
-->
|
-->
|
||||||
<script lang="typescript">
|
<script lang="typescript">
|
||||||
import { setContext } from "svelte";
|
import { getContext, setContext } from "svelte";
|
||||||
import { writable } from "svelte/store";
|
import { writable } from "svelte/store";
|
||||||
import Item from "./Item.svelte";
|
import Item from "./Item.svelte";
|
||||||
import { sectionKey } from "./context-keys";
|
import { sectionKey, nightModeKey } from "./context-keys";
|
||||||
import type { Identifier } from "./identifier";
|
import type { Identifier } from "./identifier";
|
||||||
import { insertElement, appendElement } from "./identifier";
|
import { insertElement, appendElement } from "./identifier";
|
||||||
import type { SvelteComponent, Registration } from "./registration";
|
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,
|
toggleGroup,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const nightMode = getContext<boolean>(nightModeKey);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
bind:this={buttonToolbarRef}
|
bind:this={buttonToolbarRef}
|
||||||
{id}
|
{id}
|
||||||
class={`btn-toolbar container wrap-variable ${className}`}
|
class="btn-toolbar container wrap-variable {className}"
|
||||||
|
class:nightMode
|
||||||
{style}
|
{style}
|
||||||
role="toolbar"
|
role="toolbar"
|
||||||
|
on:focusout
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
{#each $dynamicItems as item}
|
{#each $dynamicItems as item}
|
||||||
|
@ -91,6 +95,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
@use 'scrollbar';
|
||||||
|
|
||||||
|
.nightMode {
|
||||||
|
@include scrollbar.night-mode;
|
||||||
|
}
|
||||||
|
|
||||||
.wrap-variable {
|
.wrap-variable {
|
||||||
flex-wrap: var(--buttons-wrap);
|
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 { className as class };
|
||||||
|
|
||||||
export let tooltip: string | undefined = undefined;
|
export let tooltip: string | undefined = undefined;
|
||||||
|
export let tabbable: boolean = false;
|
||||||
|
|
||||||
let buttonRef: HTMLButtonElement;
|
let buttonRef: HTMLButtonElement;
|
||||||
|
|
||||||
|
@ -22,12 +23,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
<button
|
<button
|
||||||
{id}
|
{id}
|
||||||
|
tabindex={tabbable ? 0 : -1}
|
||||||
bind:this={buttonRef}
|
bind:this={buttonRef}
|
||||||
class={`btn dropdown-item ${className}`}
|
class={`btn dropdown-item ${className}`}
|
||||||
class:btn-day={!nightMode}
|
class:btn-day={!nightMode}
|
||||||
class:btn-night={nightMode}
|
class:btn-night={nightMode}
|
||||||
title={tooltip}
|
title={tooltip}
|
||||||
on:click
|
on:click
|
||||||
|
on:mouseenter
|
||||||
|
on:focus
|
||||||
|
on:keydown
|
||||||
on:mousedown|preventDefault
|
on:mousedown|preventDefault
|
||||||
>
|
>
|
||||||
<slot />
|
<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);
|
font-size: calc(var(--buttons-size) / 2.3);
|
||||||
|
|
||||||
background: none;
|
background: none;
|
||||||
box-shadow: none;
|
box-shadow: none !important;
|
||||||
border: none;
|
border: none;
|
||||||
|
|
||||||
|
&:active,
|
||||||
|
&.active {
|
||||||
|
background-color: button.$focus-color;
|
||||||
|
color: white;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-day {
|
.btn-day {
|
||||||
color: black;
|
color: black;
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: button.$focus-color;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.btn-night {
|
.btn-night {
|
||||||
|
@ -63,10 +69,5 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
&:focus {
|
&:focus {
|
||||||
@include button.btn-night-base;
|
@include button.btn-night-base;
|
||||||
}
|
}
|
||||||
|
|
||||||
&:active {
|
|
||||||
background-color: button.$focus-color;
|
|
||||||
color: white;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|
|
@ -7,16 +7,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
import { dropdownKey } from "./context-keys";
|
import { dropdownKey } from "./context-keys";
|
||||||
|
|
||||||
export let id: string | undefined = undefined;
|
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);
|
setContext(dropdownKey, null);
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div {id} class="dropdown-menu">
|
<div {id} class="dropdown-menu" class:show aria-labelledby={labelledby}>
|
||||||
<slot />
|
<div class="dropdown-content {className}">
|
||||||
|
<slot />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
div {
|
.dropdown-menu {
|
||||||
background-color: var(--frame-bg);
|
background-color: var(--frame-bg);
|
||||||
border-color: var(--medium-border);
|
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
|
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
-->
|
-->
|
||||||
<script lang="typescript">
|
<script lang="typescript">
|
||||||
import Detachable from "components/Detachable.svelte";
|
import Detachable from "./Detachable.svelte";
|
||||||
|
|
||||||
import type { Register, Registration } from "./registration";
|
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 };
|
export { className as class };
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<nav {id} class={`container-fluid pb-1 pt-1 ${className}`}>
|
<nav {id} class={`container-fluid py-1 ${className}`}>
|
||||||
<slot />
|
<slot />
|
||||||
</nav>
|
</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 autoOpen = false;
|
||||||
export let autoClose: boolean | "inside" | "outside" = true;
|
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, {
|
setContext(dropdownKey, {
|
||||||
dropdown: true,
|
dropdown: true,
|
||||||
|
@ -19,40 +52,50 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
});
|
});
|
||||||
|
|
||||||
let dropdown: Dropdown;
|
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 = () => {};
|
const noop = () => {};
|
||||||
function createDropdown(toggle: HTMLElement): Dropdown {
|
function createDropdown(toggle: HTMLElement): Dropdown {
|
||||||
/* avoid focusing element toggle on menu activation */
|
/* avoid focusing element toggle on menu activation */
|
||||||
toggle.focus = noop;
|
toggle.focus = noop;
|
||||||
|
|
||||||
|
if (!toggleOpen) {
|
||||||
|
/* do not open on clicking toggle */
|
||||||
|
toggle.addEventListener = noop;
|
||||||
|
}
|
||||||
|
|
||||||
dropdown = new Dropdown(toggle, {
|
dropdown = new Dropdown(toggle, {
|
||||||
autoClose,
|
autoClose,
|
||||||
popperConfig: (defaultConfig: Record<string, any>) => ({
|
popperConfig: { placement },
|
||||||
...defaultConfig,
|
|
||||||
placement,
|
|
||||||
}),
|
|
||||||
} as any);
|
} as any);
|
||||||
|
|
||||||
if (autoOpen) {
|
if (autoOpen) {
|
||||||
dropdown.show();
|
dropdown.show();
|
||||||
}
|
}
|
||||||
|
|
||||||
dropdownObject = {
|
let api = {
|
||||||
show: dropdown.show.bind(dropdown),
|
show: dropdown.show.bind(dropdown),
|
||||||
toggle: dropdown.toggle.bind(dropdown),
|
toggle: dropdown.toggle.bind(dropdown),
|
||||||
hide: dropdown.hide.bind(dropdown),
|
hide: dropdown.hide.bind(dropdown),
|
||||||
update: dropdown.update.bind(dropdown),
|
update: dropdown.update.bind(dropdown),
|
||||||
dispose: dropdown.dispose.bind(dropdown),
|
dispose: dropdown.dispose.bind(dropdown),
|
||||||
|
isVisible,
|
||||||
};
|
};
|
||||||
|
|
||||||
return dropdownObject;
|
return api;
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => dropdown?.dispose());
|
onDestroy(() => dropdown?.dispose());
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="dropdown">
|
<div class={dropClass}>
|
||||||
<slot {createDropdown} {dropdownObject} />
|
<slot {createDropdown} dropdownObject={api} />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<style lang="scss">
|
<style lang="scss">
|
||||||
|
|
|
@ -12,9 +12,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
let deregister: () => void;
|
let deregister: () => void;
|
||||||
|
|
||||||
function createShortcut(mounted: HTMLElement): void {
|
function createShortcut(element: HTMLElement): void {
|
||||||
deregister = registerShortcut((event: KeyboardEvent) => {
|
deregister = registerShortcut((event: KeyboardEvent) => {
|
||||||
mounted.dispatchEvent(new MouseEvent("click", event));
|
element.dispatchEvent(new MouseEvent("click", event));
|
||||||
event.preventDefault();
|
event.preventDefault();
|
||||||
}, shortcut);
|
}, shortcut);
|
||||||
}
|
}
|
||||||
|
|
|
@ -19,24 +19,34 @@
|
||||||
export let tooltip: string;
|
export let tooltip: string;
|
||||||
export let trigger: TriggerType = "hover focus";
|
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;
|
let tooltipObject: Tooltip;
|
||||||
|
|
||||||
function createTooltip(element: HTMLElement): void {
|
function createTooltip(element: HTMLElement): void {
|
||||||
element.title = tooltip;
|
element.title = tooltip;
|
||||||
tooltipObject = new Tooltip(element, {
|
tooltipObject = new Tooltip(element, {
|
||||||
placement: "bottom",
|
placement,
|
||||||
html: true,
|
html,
|
||||||
offset: [0, 20],
|
offset,
|
||||||
delay: { show: 250, hide: 0 },
|
delay: { show: showDelay, hide: hideDelay },
|
||||||
trigger,
|
trigger,
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
onDestroy(() => {
|
onDestroy(() => tooltipObject?.dispose());
|
||||||
if (tooltipObject) {
|
|
||||||
tooltipObject.dispose();
|
|
||||||
}
|
|
||||||
});
|
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<slot {createTooltip} {tooltipObject} />
|
<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;
|
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 WithDropdown from "components/WithDropdown.svelte";
|
||||||
import DropdownMenu from "components/DropdownMenu.svelte";
|
import DropdownMenu from "components/DropdownMenu.svelte";
|
||||||
import DropdownItem from "components/DropdownItem.svelte";
|
import DropdownItem from "components/DropdownItem.svelte";
|
||||||
import Badge from "./Badge.svelte";
|
import Badge from "components/Badge.svelte";
|
||||||
import { revertIcon } from "./icons";
|
import { revertIcon } from "./icons";
|
||||||
import { isEqual as isEqualLodash, cloneDeep } from "lodash-es";
|
import { isEqual as isEqualLodash, cloneDeep } from "lodash-es";
|
||||||
import { touchDeviceKey } from "components/context-keys";
|
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 { createEventDispatcher } from "svelte";
|
||||||
import type { DeckOptionsState } from "./lib";
|
import type { DeckOptionsState } from "./lib";
|
||||||
import type Dropdown from "bootstrap/js/dist/dropdown";
|
import type Dropdown from "bootstrap/js/dist/dropdown";
|
||||||
|
import { withButton } from "components/helpers";
|
||||||
|
|
||||||
import ButtonGroup from "components/ButtonGroup.svelte";
|
import ButtonGroup from "components/ButtonGroup.svelte";
|
||||||
import ButtonGroupItem from "components/ButtonGroupItem.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"
|
theme="primary"
|
||||||
on:click={() => save(false)}
|
on:click={() => save(false)}
|
||||||
tooltip={shortcutLabel}
|
tooltip={shortcutLabel}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>{tr.deckConfigSaveButton()}</LabelButton
|
>{tr.deckConfigSaveButton()}</LabelButton
|
||||||
>
|
>
|
||||||
</WithShortcut>
|
</WithShortcut>
|
||||||
|
|
|
@ -5,9 +5,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<script lang="typescript">
|
<script lang="typescript">
|
||||||
import marked from "marked";
|
import marked from "marked";
|
||||||
import { infoCircle } from "./icons";
|
import { infoCircle } from "./icons";
|
||||||
import WithTooltip from "./WithTooltip.svelte";
|
import WithTooltip from "components/WithTooltip.svelte";
|
||||||
import Label from "./Label.svelte";
|
import Label from "./Label.svelte";
|
||||||
import Badge from "./Badge.svelte";
|
import Badge from "components/Badge.svelte";
|
||||||
|
|
||||||
export let markdownTooltip: string;
|
export let markdownTooltip: string;
|
||||||
let forId: string;
|
let forId: string;
|
||||||
|
@ -16,7 +16,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
<span>
|
<span>
|
||||||
<Label for={forId}><slot /></Label>
|
<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
|
<Badge
|
||||||
class="opacity-50"
|
class="opacity-50"
|
||||||
on:mount={(event) => createTooltip(event.detail.span)}
|
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-center.svg",
|
||||||
"text-indent-left.svg",
|
"text-indent-left.svg",
|
||||||
"text-indent-right.svg",
|
"text-indent-right.svg",
|
||||||
|
|
||||||
|
# tag editor
|
||||||
|
"x.svg",
|
||||||
],
|
],
|
||||||
visibility = ["//visibility:public"],
|
visibility = ["//visibility:public"],
|
||||||
)
|
)
|
||||||
|
@ -126,6 +129,12 @@ copy_mdi_icons(
|
||||||
|
|
||||||
"image-size-select-large.svg",
|
"image-size-select-large.svg",
|
||||||
"image-size-select-actual.svg",
|
"image-size-select-actual.svg",
|
||||||
|
|
||||||
|
# tag editor
|
||||||
|
"tag-outline.svg",
|
||||||
|
"tag.svg",
|
||||||
|
"tag-plus.svg",
|
||||||
|
"dots-vertical.svg",
|
||||||
],
|
],
|
||||||
visibility = ["//visibility:public"],
|
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 WithShortcut from "components/WithShortcut.svelte";
|
||||||
import OnlyEditable from "./OnlyEditable.svelte";
|
import OnlyEditable from "./OnlyEditable.svelte";
|
||||||
|
|
||||||
|
import { withButton } from "components/helpers";
|
||||||
import { ellipseIcon } from "./icons";
|
import { ellipseIcon } from "./icons";
|
||||||
import { forEditorField } from ".";
|
import { forEditorField } from ".";
|
||||||
import { wrapCurrent } from "./wrap";
|
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})`}
|
tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`}
|
||||||
{disabled}
|
{disabled}
|
||||||
on:click={onCloze}
|
on:click={onCloze}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{@html ellipseIcon}
|
{@html ellipseIcon}
|
||||||
</IconButton>
|
</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 OnlyEditable from "./OnlyEditable.svelte";
|
||||||
|
|
||||||
import { bridgeCommand } from "lib/bridgecommand";
|
import { bridgeCommand } from "lib/bridgecommand";
|
||||||
|
import { withButton } from "components/helpers";
|
||||||
import { textColorIcon, highlightColorIcon, arrowIcon } from "./icons";
|
import { textColorIcon, highlightColorIcon, arrowIcon } from "./icons";
|
||||||
import { appendInParentheses } from "./helpers";
|
import { appendInParentheses } from "./helpers";
|
||||||
|
|
||||||
|
@ -45,7 +46,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
)}
|
)}
|
||||||
{disabled}
|
{disabled}
|
||||||
on:click={forecolorWrap}
|
on:click={forecolorWrap}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{@html textColorIcon}
|
{@html textColorIcon}
|
||||||
{@html colorHelperIcon}
|
{@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 = wrapWithForecolor(setColor(event));
|
||||||
forecolorWrap();
|
forecolorWrap();
|
||||||
}}
|
}}
|
||||||
on:mount={(event) => createShortcut(event.detail.input)}
|
on:mount={withButton(createShortcut)}
|
||||||
/>
|
/>
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</WithShortcut>
|
</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 WithState from "components/WithState.svelte";
|
||||||
import OnlyEditable from "./OnlyEditable.svelte";
|
import OnlyEditable from "./OnlyEditable.svelte";
|
||||||
|
|
||||||
|
import { withButton } from "components/helpers";
|
||||||
import { appendInParentheses } from "./helpers";
|
import { appendInParentheses } from "./helpers";
|
||||||
|
|
||||||
export let key: string;
|
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)}
|
tooltip={appendInParentheses(tooltip, shortcutLabel)}
|
||||||
{disabled}
|
{disabled}
|
||||||
on:click={() => document.execCommand(key)}
|
on:click={() => document.execCommand(key)}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -69,7 +70,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
document.execCommand(key);
|
document.execCommand(key);
|
||||||
updateState(event);
|
updateState(event);
|
||||||
}}
|
}}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
<slot />
|
<slot />
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
|
|
@ -5,6 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<script lang="typescript">
|
<script lang="typescript">
|
||||||
import { bridgeCommand } from "lib/bridgecommand";
|
import { bridgeCommand } from "lib/bridgecommand";
|
||||||
import * as tr from "lib/i18n";
|
import * as tr from "lib/i18n";
|
||||||
|
import { withButton } from "components/helpers";
|
||||||
|
|
||||||
import ButtonGroup from "components/ButtonGroup.svelte";
|
import ButtonGroup from "components/ButtonGroup.svelte";
|
||||||
import ButtonGroupItem from "components/ButtonGroupItem.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
|
<LabelButton
|
||||||
tooltip={`${tr.editingCustomizeCardTemplates()} (${shortcutLabel})`}
|
tooltip={`${tr.editingCustomizeCardTemplates()} (${shortcutLabel})`}
|
||||||
on:click={() => bridgeCommand("cards")}
|
on:click={() => bridgeCommand("cards")}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{tr.editingCards()}...
|
{tr.editingCards()}...
|
||||||
</LabelButton>
|
</LabelButton>
|
||||||
|
|
|
@ -5,6 +5,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<script lang="typescript">
|
<script lang="typescript">
|
||||||
import { bridgeCommand } from "lib/bridgecommand";
|
import { bridgeCommand } from "lib/bridgecommand";
|
||||||
import * as tr from "lib/i18n";
|
import * as tr from "lib/i18n";
|
||||||
|
import { withButton } from "components/helpers";
|
||||||
|
|
||||||
import WithShortcut from "components/WithShortcut.svelte";
|
import WithShortcut from "components/WithShortcut.svelte";
|
||||||
import LabelButton from "components/LabelButton.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
|
<LabelButton
|
||||||
tooltip={tr.browsingPreviewSelectedCard({ val: shortcutLabel })}
|
tooltip={tr.browsingPreviewSelectedCard({ val: shortcutLabel })}
|
||||||
on:click={() => bridgeCommand("preview")}
|
on:click={() => bridgeCommand("preview")}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{tr.actionsPreview()}
|
{tr.actionsPreview()}
|
||||||
</LabelButton>
|
</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 ClozeButton from "./ClozeButton.svelte";
|
||||||
|
|
||||||
import { getCurrentField, appendInParentheses } from "./helpers";
|
import { getCurrentField, appendInParentheses } from "./helpers";
|
||||||
|
import { withButton } from "components/helpers";
|
||||||
import { wrapCurrent } from "./wrap";
|
import { wrapCurrent } from "./wrap";
|
||||||
import { paperclipIcon, micIcon, functionIcon, xmlIcon } from "./icons";
|
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}
|
iconSize={70}
|
||||||
{disabled}
|
{disabled}
|
||||||
on:click={onAttachment}
|
on:click={onAttachment}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{@html paperclipIcon}
|
{@html paperclipIcon}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -71,7 +72,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
iconSize={70}
|
iconSize={70}
|
||||||
{disabled}
|
{disabled}
|
||||||
on:click={onRecord}
|
on:click={onRecord}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{@html micIcon}
|
{@html micIcon}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
|
@ -88,10 +89,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
<ButtonGroupItem>
|
<ButtonGroupItem>
|
||||||
<WithDropdown let:createDropdown>
|
<WithDropdown let:createDropdown>
|
||||||
<OnlyEditable let:disabled>
|
<OnlyEditable let:disabled>
|
||||||
<IconButton
|
<IconButton {disabled} on:mount={withButton(createDropdown)}>
|
||||||
{disabled}
|
|
||||||
on:mount={(event) => createDropdown(event.detail.button)}
|
|
||||||
>
|
|
||||||
{@html functionIcon}
|
{@html functionIcon}
|
||||||
</IconButton>
|
</IconButton>
|
||||||
</OnlyEditable>
|
</OnlyEditable>
|
||||||
|
@ -104,7 +102,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
>
|
>
|
||||||
<DropdownItem
|
<DropdownItem
|
||||||
on:click={() => wrapCurrent("\\(", "\\)")}
|
on:click={() => wrapCurrent("\\(", "\\)")}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{tr.editingMathjaxInline()}
|
{tr.editingMathjaxInline()}
|
||||||
<span class="ps-1 float-end">{shortcutLabel}</span>
|
<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
|
<DropdownItem
|
||||||
on:click={() => wrapCurrent("\\[", "\\]")}
|
on:click={() => wrapCurrent("\\[", "\\]")}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{tr.editingMathjaxBlock()}
|
{tr.editingMathjaxBlock()}
|
||||||
<span class="ps-1 float-end">{shortcutLabel}</span>
|
<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
|
<DropdownItem
|
||||||
on:click={() => wrapCurrent("\\(\\ce{", "}\\)")}
|
on:click={() => wrapCurrent("\\(\\ce{", "}\\)")}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{tr.editingMathjaxChemistry()}
|
{tr.editingMathjaxChemistry()}
|
||||||
<span class="ps-1 float-end">{shortcutLabel}</span>
|
<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
|
<DropdownItem
|
||||||
on:click={() => wrapCurrent("[latex]", "[/latex]")}
|
on:click={() => wrapCurrent("[latex]", "[/latex]")}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{tr.editingLatex()}
|
{tr.editingLatex()}
|
||||||
<span class="ps-1 float-end">{shortcutLabel}</span>
|
<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
|
<DropdownItem
|
||||||
on:click={() => wrapCurrent("[$]", "[/$]")}
|
on:click={() => wrapCurrent("[$]", "[/$]")}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{tr.editingLatexEquation()}
|
{tr.editingLatexEquation()}
|
||||||
<span class="ps-1 float-end">{shortcutLabel}</span>
|
<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
|
<DropdownItem
|
||||||
on:click={() => wrapCurrent("[$$]", "[/$$]")}
|
on:click={() => wrapCurrent("[$$]", "[/$$]")}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{tr.editingLatexMathEnv()}
|
{tr.editingLatexMathEnv()}
|
||||||
<span class="ps-1 float-end">{shortcutLabel}</span>
|
<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}
|
active={inCodable}
|
||||||
disabled={!fieldFocused}
|
disabled={!fieldFocused}
|
||||||
on:click={onHtmlEdit}
|
on:click={onHtmlEdit}
|
||||||
on:mount={(event) => createShortcut(event.detail.button)}
|
on:mount={withButton(createShortcut)}
|
||||||
>
|
>
|
||||||
{@html xmlIcon}
|
{@html xmlIcon}
|
||||||
</IconButton>
|
</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/buttons";
|
||||||
@import "bootstrap/scss/button-group";
|
@import "bootstrap/scss/button-group";
|
||||||
@import "bootstrap/scss/dropdown";
|
@import "bootstrap/scss/dropdown";
|
||||||
|
|
||||||
|
$tooltip-max-width: 600px;
|
||||||
|
|
||||||
|
@import "bootstrap/scss/tooltip";
|
||||||
|
|
|
@ -88,9 +88,9 @@
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@import "ts/sass/codemirror/lib/codemirror";
|
@import "codemirror/lib/codemirror";
|
||||||
@import "ts/sass/codemirror/theme/monokai";
|
@import "codemirror/theme/monokai";
|
||||||
@import "ts/sass/codemirror/addon/fold/foldgutter";
|
@import "codemirror/addon/fold/foldgutter";
|
||||||
|
|
||||||
.CodeMirror {
|
.CodeMirror {
|
||||||
height: auto;
|
height: auto;
|
||||||
|
|
|
@ -31,6 +31,11 @@ export { default as ellipseIcon } from "./contain.svg";
|
||||||
export { default as functionIcon } from "./function-variant.svg";
|
export { default as functionIcon } from "./function-variant.svg";
|
||||||
export { default as xmlIcon } from "./xml.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 =
|
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>';
|
'<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 "sveltelib/export-runtime";
|
||||||
import "lib/register-package";
|
import "lib/register-package";
|
||||||
|
|
||||||
|
import type EditorToolbar from "./EditorToolbar.svelte";
|
||||||
|
import type TagEditor from "./TagEditor.svelte";
|
||||||
|
|
||||||
import { filterHTML } from "html-filter";
|
import { filterHTML } from "html-filter";
|
||||||
import { updateActiveButtons } from "./toolbar";
|
import { updateActiveButtons } from "./toolbar";
|
||||||
import { setupI18n, ModuleName } from "lib/i18n";
|
import { setupI18n, ModuleName } from "lib/i18n";
|
||||||
|
@ -27,6 +30,7 @@ import { EditableContainer } from "./editable-container";
|
||||||
import { Editable } from "./editable";
|
import { Editable } from "./editable";
|
||||||
import { Codable } from "./codable";
|
import { Codable } from "./codable";
|
||||||
import { initToolbar, fieldFocused } from "./toolbar";
|
import { initToolbar, fieldFocused } from "./toolbar";
|
||||||
|
import { initTagEditor } from "./tag-editor";
|
||||||
import { getCurrentField } from "./helpers";
|
import { getCurrentField } from "./helpers";
|
||||||
|
|
||||||
export { setNoteId, getNoteId } from "./note-id";
|
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({
|
export const i18n = setupI18n({
|
||||||
modules: [
|
modules: [
|
||||||
ModuleName.EDITING,
|
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 $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 Notetypes = anki.notetypes;
|
||||||
import Scheduler = anki.scheduler;
|
import Scheduler = anki.scheduler;
|
||||||
import Stats = anki.stats;
|
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
|
// Copyright: Ankitects Pty Ltd and contributors
|
||||||
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||||
|
|
||||||
import * as tr from "./i18n";
|
import type { Modifier } from "./keys";
|
||||||
import { isApplePlatform } from "./platform";
|
|
||||||
import { registerPackage } from "./register-package";
|
import { registerPackage } from "./register-package";
|
||||||
|
import { modifiersToPlatformString, keyToPlatformString, checkModifiers } from "./keys";
|
||||||
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;
|
|
||||||
}
|
|
||||||
|
|
||||||
const keyCodeLookup = {
|
const keyCodeLookup = {
|
||||||
Backspace: 8,
|
Backspace: 8,
|
||||||
|
@ -68,7 +48,7 @@ function toPlatformString(keyCombination: string[]): string {
|
||||||
return (
|
return (
|
||||||
modifiersToPlatformString(
|
modifiersToPlatformString(
|
||||||
keyCombination.slice(0, -1).filter(isRequiredModifier)
|
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;
|
return event.which === key;
|
||||||
}
|
}
|
||||||
|
|
||||||
const allModifiers: Modifier[] = ["Control", "Alt", "Shift", "Meta"];
|
|
||||||
|
|
||||||
function partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] {
|
function partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] {
|
||||||
const trueItems: T[] = [];
|
const trueItems: T[] = [];
|
||||||
const falseItems: T[] = [];
|
const falseItems: T[] = [];
|
||||||
|
@ -100,28 +78,25 @@ function removeTrailing(modifier: string): string {
|
||||||
return modifier.substring(0, modifier.length - 1);
|
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(
|
const [requiredModifiers, otherModifiers] = partition(
|
||||||
isRequiredModifier,
|
isRequiredModifier,
|
||||||
modifiers
|
modifiers
|
||||||
);
|
);
|
||||||
|
|
||||||
const optionalModifiers = otherModifiers.map(removeTrailing);
|
const optionalModifiers = otherModifiers.map(removeTrailing);
|
||||||
|
return [requiredModifiers as Modifier[], optionalModifiers as Modifier[]];
|
||||||
return allModifiers.reduce(
|
|
||||||
(matches: boolean, currentModifier: string, currentIndex: number): boolean =>
|
|
||||||
matches &&
|
|
||||||
(optionalModifiers.includes(currentModifier as Modifier) ||
|
|
||||||
event.getModifierState(platformModifiers[currentIndex]) ===
|
|
||||||
requiredModifiers.includes(currentModifier)),
|
|
||||||
true
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const check =
|
const check =
|
||||||
(keyCode: number, modifiers: string[]) =>
|
(keyCode: number, modifiers: string[]) =>
|
||||||
(event: KeyboardEvent): boolean => {
|
(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 {
|
function keyToCode(key: string): number {
|
||||||
|
|
|
@ -4,6 +4,7 @@ sass_library(
|
||||||
name = "base_lib",
|
name = "base_lib",
|
||||||
srcs = [
|
srcs = [
|
||||||
"_vars.scss",
|
"_vars.scss",
|
||||||
|
"_fusion-vars.scss",
|
||||||
"base.scss",
|
"base.scss",
|
||||||
"bootstrap-dark.scss",
|
"bootstrap-dark.scss",
|
||||||
],
|
],
|
||||||
|
|
|
@ -28,7 +28,8 @@ $btn-base-color-day: white;
|
||||||
@content ($btn-base-color-day);
|
@content ($btn-base-color-day);
|
||||||
|
|
||||||
@if ($with-hover) {
|
@if ($with-hover) {
|
||||||
&:hover {
|
&:hover,
|
||||||
|
&.hover {
|
||||||
background-color: darken($btn-base-color-day, 8%);
|
background-color: darken($btn-base-color-day, 8%);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -76,7 +77,8 @@ $btn-base-color-night: #666;
|
||||||
@content ($btn-base-color-night);
|
@content ($btn-base-color-night);
|
||||||
|
|
||||||
@if ($with-hover) {
|
@if ($with-hover) {
|
||||||
&:hover {
|
&:hover,
|
||||||
|
&.hover {
|
||||||
background-color: lighten($btn-base-color-night, 8%);
|
background-color: lighten($btn-base-color-night, 8%);
|
||||||
border-color: lighten($btn-base-color-night, 8%);
|
border-color: lighten($btn-base-color-night, 8%);
|
||||||
}
|
}
|
||||||
|
|
|
@ -8,6 +8,7 @@
|
||||||
"es2019.array",
|
"es2019.array",
|
||||||
"es2018.promise",
|
"es2018.promise",
|
||||||
"es2020.promise",
|
"es2020.promise",
|
||||||
|
"es2019.string",
|
||||||
"dom",
|
"dom",
|
||||||
"dom.iterable"
|
"dom.iterable"
|
||||||
],
|
],
|
||||||
|
|
Loading…
Reference in a new issue