mirror of
https://github.com/ankitects/anki.git
synced 2025-09-21 15:32:23 -04:00
Merge branch 'ankitects:main' into main
This commit is contained in:
commit
12e69cb668
39 changed files with 623 additions and 372 deletions
|
@ -23,10 +23,13 @@ deck-config-new-limit-tooltip =
|
|||
deck-config-review-limit-tooltip =
|
||||
The maximum number of review cards to show in a day,
|
||||
if cards are ready for review.
|
||||
deck-config-limit-deck-v3 =
|
||||
When studying a deck that has subdecks inside it, the limits set on each
|
||||
subdeck control the maximum number of cards drawn from that particular deck.
|
||||
The selected deck's limits control the total cards that will be shown.
|
||||
|
||||
## Learning section
|
||||
## New Cards section
|
||||
|
||||
deck-config-learning-title = Learning
|
||||
deck-config-learning-steps = Learning steps
|
||||
# Please don't translate `5m` or `2d`
|
||||
-deck-config-delay-hint = Delays can be in minutes (eg `5m`), or days (eg `2d`).
|
||||
|
@ -36,14 +39,6 @@ deck-config-learning-steps-tooltip =
|
|||
The `Good` button will advance to the next step, which is 10 minutes by default.
|
||||
Once all steps have been passed, the card will become a review card, and
|
||||
will appear on a different day. { -deck-config-delay-hint }
|
||||
deck-config-interday-step-priority = Interday step priority
|
||||
deck-config-interday-step-priority-tooltip = When to show (re)learning cards that cross a day boundary.
|
||||
deck-config-review-mix-mix-with-reviews = Mix with reviews
|
||||
deck-config-review-mix-show-after-reviews = Show after reviews
|
||||
deck-config-review-mix-show-before-reviews = Show before reviews
|
||||
|
||||
## New Cards section
|
||||
|
||||
deck-config-graduating-interval-tooltip =
|
||||
The number of days to wait before showing a card again, after the `Good` button
|
||||
is pressed on the final learning step.
|
||||
|
@ -53,48 +48,10 @@ deck-config-easy-interval-tooltip =
|
|||
deck-config-new-insertion-order = Insertion order
|
||||
deck-config-new-insertion-order-tooltip =
|
||||
Controls the position (due #) new cards are assigned when you add new cards.
|
||||
Cards with a lower due # will be shown first when studying. Changing this
|
||||
option will automatically update the existing position of new cards.
|
||||
Cards with a lower due number will be shown first when studying. Changing
|
||||
this option will automatically update the existing position of new cards.
|
||||
deck-config-new-insertion-order-sequential = Sequential (oldest cards first)
|
||||
deck-config-new-insertion-order-random = Random
|
||||
deck-config-new-gather-priority = Gather priority
|
||||
deck-config-new-gather-priority-tooltip =
|
||||
`Deck`: gathers cards from each subdeck in order, and stops when the
|
||||
limit of the selected deck has been exceeded. This is faster, and allows you
|
||||
to prioritize subdecks that are closer to the top.
|
||||
|
||||
`Position`: gathers cards from all decks before they are sorted. This
|
||||
ensures the oldest cards will be shown first, even if the parent limit is
|
||||
not high enough to see cards from all decks.
|
||||
deck-config-new-gather-priority-deck = Deck
|
||||
deck-config-new-gather-priority-position = Position
|
||||
deck-config-sort-order = Sort order
|
||||
deck-config-sort-order-tooltip =
|
||||
This option controls how cards are sorted after they have been gathered.
|
||||
By default, Anki sorts by card template first, to avoid multiple cards of
|
||||
the same note from being shown in succession.
|
||||
deck-config-sort-order-card-template-then-position = Card template, then position
|
||||
deck-config-sort-order-card-template-then-random = Card template, then random
|
||||
deck-config-sort-order-position = Position (siblings together)
|
||||
deck-config-sort-order-random = Random
|
||||
deck-config-review-priority = Review priority
|
||||
deck-config-review-priority-tooltip = When to show these cards in relation to review cards.
|
||||
|
||||
## Review section
|
||||
|
||||
deck-config-review-sort-order-tooltip =
|
||||
The default order fetches cards from each subdeck in turn, stopping when the limit
|
||||
of the selected deck has been reached. The gathered cards are then shuffled together,
|
||||
and shown in due date order. Because gathering stops when the parent limit has been
|
||||
reached, your child decks should have smaller limits if you wish to see cards from
|
||||
multiple decks at once.
|
||||
|
||||
The other sort options are mainly useful when catching up from a large backlog.
|
||||
Because they have to sort all the cards first, they can be considerably slower
|
||||
than the default sort order when many cards are due.
|
||||
deck-config-sort-order-due-date-then-random = Due date, then random
|
||||
deck-config-sort-order-ascending-intervals = Ascending intervals
|
||||
deck-config-sort-order-descending-intervals = Descending intervals
|
||||
|
||||
## Lapses section
|
||||
|
||||
|
@ -118,12 +75,53 @@ deck-config-leech-action-tooltip =
|
|||
|
||||
## Burying section
|
||||
|
||||
deck-config-bury-title = Burying
|
||||
deck-config-bury-new-siblings = Bury new siblings until the next day
|
||||
deck-config-bury-review-siblings = Bury review siblings until the next day
|
||||
deck-config-bury-tooltip =
|
||||
Whether other cards of the same note (eg reverse cards, adjacent
|
||||
cloze deletions) will be delayed until the next day.
|
||||
|
||||
## Ordering section
|
||||
|
||||
deck-config-ordering-title = Display Order
|
||||
deck-config-new-gather-priority = New card gather priority
|
||||
deck-config-new-gather-priority-tooltip =
|
||||
`Deck`: gathers cards from each subdeck in order, and stops when the
|
||||
limit of the selected deck has been exceeded. This is faster, and allows you
|
||||
to prioritize subdecks that are closer to the top.
|
||||
|
||||
`Position`: gathers cards from all decks before they are sorted. This
|
||||
ensures the oldest cards will be shown first, even if the parent limit is
|
||||
not high enough to see cards from all decks.
|
||||
deck-config-new-gather-priority-deck = Deck
|
||||
deck-config-new-gather-priority-position = Position
|
||||
deck-config-new-card-sort-order = New card sort order
|
||||
deck-config-new-card-sort-order-tooltip =
|
||||
How cards are sorted after they have been gathered. By default, Anki sorts
|
||||
by card template first, to avoid multiple cards of the same note from being
|
||||
shown in succession.
|
||||
deck-config-sort-order-card-template-then-position = Card template, then position
|
||||
deck-config-sort-order-card-template-then-random = Card template, then random
|
||||
deck-config-sort-order-position = Position (siblings together)
|
||||
deck-config-sort-order-random = Random
|
||||
deck-config-new-review-priority = New/review priority
|
||||
deck-config-new-review-priority-tooltip = When to show new cards in relation to review cards.
|
||||
deck-config-interday-step-priority = Interday learning/review priority
|
||||
deck-config-interday-step-priority-tooltip = When to show (re)learning cards that cross a day boundary.
|
||||
deck-config-review-mix-mix-with-reviews = Mix with reviews
|
||||
deck-config-review-mix-show-after-reviews = Show after reviews
|
||||
deck-config-review-mix-show-before-reviews = Show before reviews
|
||||
deck-config-review-sort-order = Review sort order
|
||||
deck-config-review-sort-order-tooltip =
|
||||
The default order prioritizes cards that have been waiting longest, so that
|
||||
if you have a backlog of reviews, the longest-waiting ones will appear
|
||||
first. If you have a large backlog that will take more than a few days to
|
||||
clear, you may find the alternate sort orders preferable.
|
||||
deck-config-sort-order-due-date-then-random = Due date, then random
|
||||
deck-config-sort-order-ascending-intervals = Ascending intervals
|
||||
deck-config-sort-order-descending-intervals = Descending intervals
|
||||
|
||||
## Timer section
|
||||
|
||||
deck-config-timer-title = Timer
|
||||
|
|
|
@ -789,8 +789,17 @@ class Collection:
|
|||
except KeyError:
|
||||
return default
|
||||
|
||||
def set_config(self, key: str, val: Any) -> OpChanges:
|
||||
return self._backend.set_config_json(key=key, value_json=to_json_bytes(val))
|
||||
def set_config(self, key: str, val: Any, *, undoable: bool = False) -> OpChanges:
|
||||
"""Set a single config variable to any JSON-serializable value. The config
|
||||
is currently sent on every sync, so please don't store more than a few
|
||||
kilobytes in it.
|
||||
|
||||
By default, no undo entry will be created, but the existing undo history
|
||||
will be preserved. Set `undoable=True` to allow the change to be undone;
|
||||
see undo code for how you can merge multiple undo entries."""
|
||||
return self._backend.set_config_json(
|
||||
key=key, value_json=to_json_bytes(val), undoable=undoable
|
||||
)
|
||||
|
||||
def remove_config(self, key: str) -> OpChanges:
|
||||
return self.conf.remove(key)
|
||||
|
@ -802,14 +811,18 @@ class Collection:
|
|||
def get_config_bool(self, key: Config.Bool.Key.V) -> bool:
|
||||
return self._backend.get_config_bool(key)
|
||||
|
||||
def set_config_bool(self, key: Config.Bool.Key.V, value: bool) -> OpChanges:
|
||||
return self._backend.set_config_bool(key=key, value=value)
|
||||
def set_config_bool(
|
||||
self, key: Config.Bool.Key.V, value: bool, *, undoable: bool = False
|
||||
) -> OpChanges:
|
||||
return self._backend.set_config_bool(key=key, value=value, undoable=undoable)
|
||||
|
||||
def get_config_string(self, key: Config.String.Key.V) -> str:
|
||||
return self._backend.get_config_string(key)
|
||||
|
||||
def set_config_string(self, key: Config.String.Key.V, value: str) -> OpChanges:
|
||||
return self._backend.set_config_string(key=key, value=value)
|
||||
def set_config_string(
|
||||
self, key: Config.String.Key.V, value: str, undoable: bool = False
|
||||
) -> OpChanges:
|
||||
return self._backend.set_config_string(key=key, value=value, undoable=undoable)
|
||||
|
||||
# Stats
|
||||
##########################################################################
|
||||
|
|
|
@ -45,7 +45,10 @@ class ConfigManager:
|
|||
|
||||
def set(self, key: str, val: Any) -> None:
|
||||
self.col._backend.set_config_json_no_undo(
|
||||
key=key, value_json=to_json_bytes(val)
|
||||
key=key,
|
||||
value_json=to_json_bytes(val),
|
||||
# this argument is ignored
|
||||
undoable=True,
|
||||
)
|
||||
|
||||
def remove(self, key: str) -> OpChanges:
|
||||
|
|
|
@ -170,7 +170,8 @@ class ModelManager:
|
|||
return self.get(NotetypeId(self.all_names_and_ids()[0].id))
|
||||
|
||||
def setCurrent(self, m: NotetypeDict) -> None:
|
||||
self.col.conf["curModel"] = m["id"]
|
||||
"""Legacy. The current notetype is now updated on note add."""
|
||||
self.col.set_config("curModel", m["id"])
|
||||
|
||||
# Retrieving and creating models
|
||||
#############################################################
|
||||
|
|
|
@ -3,7 +3,7 @@
|
|||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, List, Tuple
|
||||
from typing import Any, Callable, List, Tuple
|
||||
|
||||
import anki
|
||||
import anki._backend.backend_pb2 as _pb
|
||||
|
@ -40,11 +40,11 @@ def get_stock_notetypes(
|
|||
m = from_json_bytes(col._backend.get_stock_notetype_legacy(kind))
|
||||
|
||||
def instance_getter(
|
||||
col: anki.collection.Collection,
|
||||
) -> anki.models.NotetypeDict:
|
||||
return m # pylint:disable=cell-var-from-loop
|
||||
model: Any,
|
||||
) -> Callable[[anki.collection.Collection], anki.models.NotetypeDict]:
|
||||
return lambda col: model
|
||||
|
||||
out.append((m["name"], instance_getter))
|
||||
out.append((m["name"], instance_getter(m)))
|
||||
# add extras from add-ons
|
||||
for (name_or_func, func) in models:
|
||||
if not isinstance(name_or_func, str):
|
||||
|
|
|
@ -132,6 +132,7 @@ class Browser(QMainWindow):
|
|||
|
||||
if changes.browser_table and changes.card:
|
||||
self.card = self.table.get_current_card()
|
||||
self._update_context_actions()
|
||||
|
||||
def on_focus_change(self, new: Optional[QWidget], old: Optional[QWidget]) -> None:
|
||||
if current_top_level_widget() == self:
|
||||
|
@ -498,21 +499,6 @@ class Browser(QMainWindow):
|
|||
def selectedNotesAsCards(self) -> Sequence[CardId]:
|
||||
return self.table.get_card_ids_from_selected_note_ids()
|
||||
|
||||
def oneModelNotes(self) -> Sequence[NoteId]:
|
||||
sf = self.selected_notes()
|
||||
if not sf:
|
||||
return []
|
||||
mods = self.col.db.scalar(
|
||||
"""
|
||||
select count(distinct mid) from notes
|
||||
where id in %s"""
|
||||
% ids2str(sf)
|
||||
)
|
||||
if mods > 1:
|
||||
showInfo(tr.browsing_please_select_cards_from_only_one())
|
||||
return []
|
||||
return sf
|
||||
|
||||
def onHelp(self) -> None:
|
||||
openHelp(HelpPage.BROWSING)
|
||||
|
||||
|
@ -528,7 +514,17 @@ where id in %s"""
|
|||
@skip_if_selection_is_empty
|
||||
@ensure_editor_saved
|
||||
def onChangeModel(self) -> None:
|
||||
ChangeModel(self, self.oneModelNotes())
|
||||
ids = self.selected_notes()
|
||||
if self._is_one_notetype(ids):
|
||||
ChangeModel(self, ids)
|
||||
else:
|
||||
showInfo(tr.browsing_please_select_cards_from_only_one())
|
||||
|
||||
def _is_one_notetype(self, ids: Sequence[NoteId]) -> bool:
|
||||
query = f"select count(distinct mid) from notes where id in {ids2str(ids)}"
|
||||
if self.col.db.scalar(query) == 1:
|
||||
return True
|
||||
return False
|
||||
|
||||
def createFilteredDeck(self) -> None:
|
||||
search = self.current_search()
|
||||
|
|
|
@ -40,12 +40,11 @@ class SidebarToolbar(QToolBar):
|
|||
action.setCheckable(True)
|
||||
action.setShortcut(f"Alt+{row + 1}")
|
||||
self._action_group.addAction(action)
|
||||
saved = self.sidebar.col.get_config("sidebarTool", 0)
|
||||
active = saved if saved < len(self._tools) else 0
|
||||
# always start with first tool
|
||||
active = 0
|
||||
self._action_group.actions()[active].setChecked(True)
|
||||
self.sidebar.tool = self._tools[active][0]
|
||||
|
||||
def _on_action_group_triggered(self, action: QAction) -> None:
|
||||
index = self._action_group.actions().index(action)
|
||||
self.sidebar.col.set_config("sidebarTool", index)
|
||||
self.sidebar.tool = self._tools[index][0]
|
||||
|
|
|
@ -180,7 +180,8 @@ class Table:
|
|||
SearchContext(search=last_search, browser=self.browser)
|
||||
)
|
||||
self.col.set_config_bool(
|
||||
Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE, self.is_notes_mode()
|
||||
Config.Bool.BROWSER_TABLE_SHOW_NOTES_MODE,
|
||||
self.is_notes_mode(),
|
||||
)
|
||||
self._restore_header()
|
||||
self._restore_selection(self._toggled_selection)
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
# -*- coding: utf-8 -*-
|
||||
# Copyright: Ankitects Pty Ltd and contributors
|
||||
# License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
from typing import Tuple
|
||||
|
||||
from aqt import colors
|
||||
from aqt.qt import *
|
||||
|
@ -21,6 +22,8 @@ class Switch(QAbstractButton):
|
|||
radius: int = 10,
|
||||
left_label: str = "",
|
||||
right_label: str = "",
|
||||
left_color: Tuple[str, str] = colors.FLAG4_BG,
|
||||
right_color: Tuple[str, str] = colors.FLAG3_BG,
|
||||
parent: QWidget = None,
|
||||
) -> None:
|
||||
super().__init__(parent=parent)
|
||||
|
@ -29,9 +32,10 @@ class Switch(QAbstractButton):
|
|||
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Fixed)
|
||||
self._left_label = left_label
|
||||
self._right_label = right_label
|
||||
self._path_radius = radius
|
||||
self._knob_radius = radius - self._margin
|
||||
self._left_position = self._position = self._path_radius + self._margin
|
||||
self._left_color = left_color
|
||||
self._right_color = right_color
|
||||
self._path_radius = radius - self._margin
|
||||
self._knob_radius = self._left_position = self._position = radius
|
||||
self._right_position = 3 * self._path_radius + self._margin
|
||||
|
||||
@pyqtProperty(int) # type: ignore
|
||||
|
@ -55,6 +59,11 @@ class Switch(QAbstractButton):
|
|||
def label(self) -> str:
|
||||
return self._right_label if self.isChecked() else self._left_label
|
||||
|
||||
@property
|
||||
def path_color(self) -> QColor:
|
||||
color = self._right_color if self.isChecked() else self._left_color
|
||||
return theme_manager.qcolor(color)
|
||||
|
||||
def sizeHint(self) -> QSize:
|
||||
return QSize(
|
||||
4 * self._path_radius + 2 * self._margin,
|
||||
|
@ -75,7 +84,7 @@ class Switch(QAbstractButton):
|
|||
self._paint_label(painter)
|
||||
|
||||
def _paint_path(self, painter: QPainter) -> None:
|
||||
painter.setBrush(QBrush(theme_manager.qcolor(colors.FRAME_BG)))
|
||||
painter.setBrush(QBrush(self.path_color))
|
||||
rectangle = QRectF(
|
||||
self._margin,
|
||||
self._margin,
|
||||
|
@ -87,19 +96,23 @@ class Switch(QAbstractButton):
|
|||
def _current_knob_rectangle(self) -> QRectF:
|
||||
return QRectF(
|
||||
self.position - self._knob_radius, # type: ignore
|
||||
2 * self._margin,
|
||||
0,
|
||||
2 * self._knob_radius,
|
||||
2 * self._knob_radius,
|
||||
)
|
||||
|
||||
def _paint_knob(self, painter: QPainter) -> None:
|
||||
painter.setBrush(QBrush(theme_manager.qcolor(colors.LINK)))
|
||||
if theme_manager.night_mode:
|
||||
color = QColor(theme_manager.DARK_MODE_BUTTON_BG_MIDPOINT)
|
||||
else:
|
||||
color = theme_manager.qcolor(colors.FRAME_BG)
|
||||
painter.setBrush(QBrush(color))
|
||||
painter.drawEllipse(self._current_knob_rectangle())
|
||||
|
||||
def _paint_label(self, painter: QPainter) -> None:
|
||||
painter.setPen(QColor("white"))
|
||||
painter.setPen(theme_manager.qcolor(colors.SLIGHTLY_GREY_TEXT))
|
||||
font = painter.font()
|
||||
font.setPixelSize(int(1.5 * self._knob_radius))
|
||||
font.setPixelSize(int(1.2 * self._knob_radius))
|
||||
painter.setFont(font)
|
||||
painter.drawText(self._current_knob_rectangle(), Qt.AlignCenter, self.label)
|
||||
|
||||
|
|
|
@ -36,6 +36,10 @@ class ThemeManager:
|
|||
_icon_size = 128
|
||||
_dark_mode_available: Optional[bool] = None
|
||||
|
||||
# Qt applies a gradient to the buttons in dark mode
|
||||
# from about #505050 to #606060.
|
||||
DARK_MODE_BUTTON_BG_MIDPOINT = "#555555"
|
||||
|
||||
def macos_dark_mode(self) -> bool:
|
||||
"True if the user has night mode on, and has forced native widgets."
|
||||
if not isMac:
|
||||
|
|
|
@ -988,6 +988,7 @@ message RenameTagsIn {
|
|||
message SetConfigJsonIn {
|
||||
string key = 1;
|
||||
bytes value_json = 2;
|
||||
bool undoable = 3;
|
||||
}
|
||||
|
||||
message StockNotetype {
|
||||
|
@ -1441,11 +1442,13 @@ message Config {
|
|||
message SetConfigBoolIn {
|
||||
Config.Bool.Key key = 1;
|
||||
bool value = 2;
|
||||
bool undoable = 3;
|
||||
}
|
||||
|
||||
message SetConfigStringIn {
|
||||
Config.String.Key key = 1;
|
||||
string value = 2;
|
||||
bool undoable = 3;
|
||||
}
|
||||
|
||||
message RenderMarkdownIn {
|
||||
|
|
|
@ -65,7 +65,7 @@ impl ConfigService for Backend {
|
|||
fn set_config_json(&self, input: pb::SetConfigJsonIn) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| {
|
||||
let val: Value = serde_json::from_slice(&input.value_json)?;
|
||||
col.set_config_json(input.key.as_str(), &val)
|
||||
col.set_config_json(input.key.as_str(), &val, input.undoable)
|
||||
})
|
||||
.map(Into::into)
|
||||
}
|
||||
|
@ -100,7 +100,7 @@ impl ConfigService for Backend {
|
|||
}
|
||||
|
||||
fn set_config_bool(&self, input: pb::SetConfigBoolIn) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| col.set_config_bool(input.key().into(), input.value))
|
||||
self.with_col(|col| col.set_config_bool(input.key().into(), input.value, input.undoable))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
|
@ -113,7 +113,7 @@ impl ConfigService for Backend {
|
|||
}
|
||||
|
||||
fn set_config_string(&self, input: pb::SetConfigStringIn) -> Result<pb::OpChanges> {
|
||||
self.with_col(|col| col.set_config_string(input.key().into(), &input.value))
|
||||
self.with_col(|col| col.set_config_string(input.key().into(), &input.value, input.undoable))
|
||||
.map(Into::into)
|
||||
}
|
||||
|
||||
|
|
|
@ -70,9 +70,18 @@ impl Collection {
|
|||
}
|
||||
}
|
||||
|
||||
pub fn set_config_bool(&mut self, key: BoolKey, value: bool) -> Result<OpOutput<()>> {
|
||||
pub fn set_config_bool(
|
||||
&mut self,
|
||||
key: BoolKey,
|
||||
value: bool,
|
||||
undoable: bool,
|
||||
) -> Result<OpOutput<()>> {
|
||||
self.transact(Op::UpdateConfig, |col| {
|
||||
col.set_config(key, &value).map(|_| ())
|
||||
col.set_config(key, &value)?;
|
||||
if !undoable {
|
||||
col.clear_current_undo_step_changes();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -70,8 +70,19 @@ pub enum SchedulerVersion {
|
|||
}
|
||||
|
||||
impl Collection {
|
||||
pub fn set_config_json<T: Serialize>(&mut self, key: &str, val: &T) -> Result<OpOutput<()>> {
|
||||
self.transact(Op::UpdateConfig, |col| col.set_config(key, val).map(|_| ()))
|
||||
pub fn set_config_json<T: Serialize>(
|
||||
&mut self,
|
||||
key: &str,
|
||||
val: &T,
|
||||
undoable: bool,
|
||||
) -> Result<OpOutput<()>> {
|
||||
self.transact(Op::UpdateConfig, |col| {
|
||||
col.set_config(key, val)?;
|
||||
if !undoable {
|
||||
col.clear_current_undo_step_changes();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
|
||||
pub fn remove_config(&mut self, key: &str) -> Result<OpOutput<()>> {
|
||||
|
|
|
@ -23,9 +23,18 @@ impl Collection {
|
|||
.unwrap_or_else(|| default.to_string())
|
||||
}
|
||||
|
||||
pub fn set_config_string(&mut self, key: StringKey, val: &str) -> Result<OpOutput<()>> {
|
||||
pub fn set_config_string(
|
||||
&mut self,
|
||||
key: StringKey,
|
||||
val: &str,
|
||||
undoable: bool,
|
||||
) -> Result<OpOutput<()>> {
|
||||
self.transact(Op::UpdateConfig, |col| {
|
||||
col.set_config_string_inner(key, val).map(|_| ())
|
||||
col.set_config_string_inner(key, val)?;
|
||||
if !undoable {
|
||||
col.clear_current_undo_step_changes();
|
||||
}
|
||||
Ok(())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
|
|
@ -370,40 +370,52 @@ impl Collection {
|
|||
}
|
||||
}
|
||||
|
||||
// test helpers
|
||||
#[cfg(test)]
|
||||
impl Collection {
|
||||
pub(crate) fn answer_again(&mut self) {
|
||||
pub mod test_helpers {
|
||||
use super::*;
|
||||
|
||||
pub struct PostAnswerState {
|
||||
pub card_id: CardId,
|
||||
pub new_state: CardState,
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
pub(crate) fn answer_again(&mut self) -> PostAnswerState {
|
||||
self.answer(|states| states.again, Rating::Again).unwrap()
|
||||
}
|
||||
|
||||
#[allow(dead_code)]
|
||||
pub(crate) fn answer_hard(&mut self) {
|
||||
pub(crate) fn answer_hard(&mut self) -> PostAnswerState {
|
||||
self.answer(|states| states.hard, Rating::Hard).unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn answer_good(&mut self) {
|
||||
pub(crate) fn answer_good(&mut self) -> PostAnswerState {
|
||||
self.answer(|states| states.good, Rating::Good).unwrap()
|
||||
}
|
||||
|
||||
pub(crate) fn answer_easy(&mut self) {
|
||||
pub(crate) fn answer_easy(&mut self) -> PostAnswerState {
|
||||
self.answer(|states| states.easy, Rating::Easy).unwrap()
|
||||
}
|
||||
|
||||
fn answer<F>(&mut self, get_state: F, rating: Rating) -> Result<()>
|
||||
fn answer<F>(&mut self, get_state: F, rating: Rating) -> Result<PostAnswerState>
|
||||
where
|
||||
F: FnOnce(&NextCardStates) -> CardState,
|
||||
{
|
||||
let queued = self.next_card()?.unwrap();
|
||||
let new_state = get_state(&queued.next_states);
|
||||
self.answer_card(&CardAnswer {
|
||||
card_id: queued.card.id,
|
||||
current_state: queued.next_states.current,
|
||||
new_state: get_state(&queued.next_states),
|
||||
new_state,
|
||||
rating,
|
||||
answered_at: TimestampMillis::now(),
|
||||
milliseconds_taken: 0,
|
||||
})?;
|
||||
Ok(())
|
||||
Ok(PostAnswerState {
|
||||
card_id: queued.card.id,
|
||||
new_state,
|
||||
})
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -416,3 +428,131 @@ fn get_fuzz_seed(card: &Card) -> Option<u64> {
|
|||
Some((card.id.0 as u64).wrapping_add(card.reps as u64))
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod test {
|
||||
use super::*;
|
||||
use crate::{card::CardType, collection::open_test_collection};
|
||||
|
||||
fn current_state(col: &mut Collection, card_id: CardId) -> CardState {
|
||||
col.get_next_card_states(card_id).unwrap().current
|
||||
}
|
||||
|
||||
// make sure the 'current' state for a card matches the
|
||||
// state we applied to it
|
||||
#[test]
|
||||
fn state_application() -> Result<()> {
|
||||
let mut col = open_test_collection();
|
||||
if col.timing_today()?.near_cutoff() {
|
||||
return Ok(());
|
||||
}
|
||||
let nt = col.get_notetype_by_name("Basic")?.unwrap();
|
||||
let mut note = nt.new_note();
|
||||
col.add_note(&mut note, DeckId(1))?;
|
||||
|
||||
// new->learning
|
||||
let post_answer = col.answer_again();
|
||||
assert_eq!(
|
||||
post_answer.new_state,
|
||||
current_state(&mut col, post_answer.card_id)
|
||||
);
|
||||
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
|
||||
assert_eq!(card.queue, CardQueue::Learn);
|
||||
assert_eq!(card.remaining_steps, 2);
|
||||
|
||||
// learning step
|
||||
col.storage.db.execute_batch("update cards set due=0")?;
|
||||
col.clear_study_queues();
|
||||
let post_answer = col.answer_good();
|
||||
assert_eq!(
|
||||
post_answer.new_state,
|
||||
current_state(&mut col, post_answer.card_id)
|
||||
);
|
||||
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
|
||||
assert_eq!(card.queue, CardQueue::Learn);
|
||||
assert_eq!(card.remaining_steps, 1);
|
||||
|
||||
// graduation
|
||||
col.storage.db.execute_batch("update cards set due=0")?;
|
||||
col.clear_study_queues();
|
||||
let mut post_answer = col.answer_good();
|
||||
// compensate for shifting the due date
|
||||
if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {
|
||||
state.elapsed_days = 1;
|
||||
};
|
||||
assert_eq!(
|
||||
post_answer.new_state,
|
||||
current_state(&mut col, post_answer.card_id)
|
||||
);
|
||||
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
|
||||
assert_eq!(card.queue, CardQueue::Review);
|
||||
assert_eq!(card.interval, 1);
|
||||
assert_eq!(card.remaining_steps, 0);
|
||||
|
||||
// answering a review card again; easy boost
|
||||
col.storage.db.execute_batch("update cards set due=0")?;
|
||||
col.clear_study_queues();
|
||||
let mut post_answer = col.answer_easy();
|
||||
if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {
|
||||
state.elapsed_days = 4;
|
||||
};
|
||||
assert_eq!(
|
||||
post_answer.new_state,
|
||||
current_state(&mut col, post_answer.card_id)
|
||||
);
|
||||
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
|
||||
assert_eq!(card.queue, CardQueue::Review);
|
||||
assert_eq!(card.interval, 4);
|
||||
assert_eq!(card.ease_factor, 2650);
|
||||
|
||||
// lapsing it
|
||||
col.storage.db.execute_batch("update cards set due=0")?;
|
||||
col.clear_study_queues();
|
||||
let mut post_answer = col.answer_again();
|
||||
if let CardState::Normal(NormalState::Relearning(state)) = &mut post_answer.new_state {
|
||||
state.review.elapsed_days = 1;
|
||||
};
|
||||
assert_eq!(
|
||||
post_answer.new_state,
|
||||
current_state(&mut col, post_answer.card_id)
|
||||
);
|
||||
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
|
||||
assert_eq!(card.queue, CardQueue::Learn);
|
||||
assert_eq!(card.ctype, CardType::Relearn);
|
||||
assert_eq!(card.interval, 1);
|
||||
assert_eq!(card.ease_factor, 2450);
|
||||
assert_eq!(card.lapses, 1);
|
||||
|
||||
// failed in relearning
|
||||
col.storage.db.execute_batch("update cards set due=0")?;
|
||||
col.clear_study_queues();
|
||||
let mut post_answer = col.answer_again();
|
||||
if let CardState::Normal(NormalState::Relearning(state)) = &mut post_answer.new_state {
|
||||
state.review.elapsed_days = 1;
|
||||
};
|
||||
assert_eq!(
|
||||
post_answer.new_state,
|
||||
current_state(&mut col, post_answer.card_id)
|
||||
);
|
||||
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
|
||||
assert_eq!(card.queue, CardQueue::Learn);
|
||||
assert_eq!(card.lapses, 1);
|
||||
|
||||
// re-graduating
|
||||
col.storage.db.execute_batch("update cards set due=0")?;
|
||||
col.clear_study_queues();
|
||||
let mut post_answer = col.answer_good();
|
||||
if let CardState::Normal(NormalState::Review(state)) = &mut post_answer.new_state {
|
||||
state.elapsed_days = 1;
|
||||
};
|
||||
assert_eq!(
|
||||
post_answer.new_state,
|
||||
current_state(&mut col, post_answer.card_id)
|
||||
);
|
||||
let card = col.storage.get_card(post_answer.card_id)?.unwrap();
|
||||
assert_eq!(card.queue, CardQueue::Review);
|
||||
assert_eq!(card.interval, 1);
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
|
|
@ -17,6 +17,7 @@ impl CardStateUpdater {
|
|||
self.card.remaining_steps = next.learning.remaining_steps;
|
||||
self.card.ctype = CardType::Relearn;
|
||||
self.card.lapses = next.review.lapses;
|
||||
self.card.ease_factor = (next.review.ease_factor * 1000.0).round() as u16;
|
||||
|
||||
let interval = next
|
||||
.interval_kind()
|
||||
|
|
|
@ -19,6 +19,7 @@ impl CardStateUpdater {
|
|||
self.card.due = (self.timing.days_elapsed + next.scheduled_days) as i32;
|
||||
self.card.ease_factor = (next.ease_factor * 1000.0).round() as u16;
|
||||
self.card.lapses = next.lapses;
|
||||
self.card.remaining_steps = 0;
|
||||
|
||||
RevlogEntryPartial::maybe_new(
|
||||
current,
|
||||
|
|
|
@ -200,6 +200,9 @@ mod test {
|
|||
#[test]
|
||||
fn undo_counts() -> Result<()> {
|
||||
let mut col = open_test_collection();
|
||||
if col.timing_today()?.near_cutoff() {
|
||||
return Ok(());
|
||||
}
|
||||
|
||||
assert_eq!(col.counts(), [0, 0, 0]);
|
||||
add_note(&mut col, true)?;
|
||||
|
|
|
@ -197,6 +197,18 @@ mod test {
|
|||
|
||||
use super::*;
|
||||
|
||||
// test helper
|
||||
impl SchedTimingToday {
|
||||
/// Check if less than 25 minutes until the rollover
|
||||
pub fn near_cutoff(&self) -> bool {
|
||||
let near = TimestampSecs::now().adding_secs(60 * 25) > self.next_day_at;
|
||||
if near {
|
||||
println!("this would fail near the rollover time");
|
||||
}
|
||||
near
|
||||
}
|
||||
}
|
||||
|
||||
// static timezone for tests
|
||||
const AEST_MINS_WEST: i32 = -600;
|
||||
|
||||
|
|
|
@ -182,6 +182,12 @@ impl UndoManager {
|
|||
self.end_step();
|
||||
self.counter
|
||||
}
|
||||
|
||||
fn clear_current_changes(&mut self) {
|
||||
if let Some(op) = &mut self.current_step {
|
||||
op.changes.clear();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl Collection {
|
||||
|
@ -255,6 +261,12 @@ impl Collection {
|
|||
self.state.undo.save(item.into());
|
||||
}
|
||||
|
||||
/// Forget any recorded changes in the current transaction, allowing
|
||||
/// a minor change like a config update to bypass undo.
|
||||
pub(crate) fn clear_current_undo_step_changes(&mut self) {
|
||||
self.state.undo.clear_current_changes()
|
||||
}
|
||||
|
||||
pub(crate) fn current_undo_op(&self) -> Option<&UndoableOp> {
|
||||
self.state.undo.current_op()
|
||||
}
|
||||
|
|
|
@ -3,13 +3,10 @@ Copyright: Ankitects Pty Ltd and contributors
|
|||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="typescript">
|
||||
import type { Modifier } from "lib/shortcuts";
|
||||
|
||||
import { onDestroy } from "svelte";
|
||||
import { registerShortcut, getPlatformString } from "lib/shortcuts";
|
||||
|
||||
export let shortcut: string;
|
||||
export let optionalModifiers: Modifier[] | undefined = [];
|
||||
|
||||
const shortcutLabel = getPlatformString(shortcut);
|
||||
|
||||
|
@ -17,14 +14,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
function createShortcut({ detail }: CustomEvent): void {
|
||||
const mounted: HTMLButtonElement = detail.button;
|
||||
deregister = registerShortcut(
|
||||
(event: KeyboardEvent) => {
|
||||
deregister = registerShortcut((event: KeyboardEvent) => {
|
||||
mounted.dispatchEvent(new MouseEvent("click", event));
|
||||
event.preventDefault();
|
||||
},
|
||||
shortcut,
|
||||
optionalModifiers
|
||||
);
|
||||
}, shortcut);
|
||||
}
|
||||
|
||||
onDestroy(() => deregister());
|
||||
|
|
27
ts/deckoptions/BuryOptions.svelte
Normal file
27
ts/deckoptions/BuryOptions.svelte
Normal file
|
@ -0,0 +1,27 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import * as tr from "lib/i18n";
|
||||
import CheckBox from "./CheckBox.svelte";
|
||||
import type { DeckOptionsState } from "./lib";
|
||||
|
||||
export let state: DeckOptionsState;
|
||||
let config = state.currentConfig;
|
||||
let defaults = state.defaults;
|
||||
</script>
|
||||
|
||||
<h2>{tr.deckConfigBuryTitle()}</h2>
|
||||
|
||||
<CheckBox
|
||||
label={tr.deckConfigBuryNewSiblings()}
|
||||
tooltip={tr.deckConfigBuryTooltip()}
|
||||
defaultValue={defaults.buryNew}
|
||||
bind:value={$config.buryNew} />
|
||||
|
||||
<CheckBox
|
||||
label={tr.deckConfigBuryReviewSiblings()}
|
||||
tooltip={tr.deckConfigBuryTooltip()}
|
||||
defaultValue={defaults.buryReviews}
|
||||
bind:value={$config.buryReviews} />
|
|
@ -4,10 +4,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
-->
|
||||
<script lang="ts">
|
||||
import DailyLimits from "./DailyLimits.svelte";
|
||||
import LearningOptions from "./LearningOptions.svelte";
|
||||
import DisplayOrder from "./DisplayOrder.svelte";
|
||||
import NewOptions from "./NewOptions.svelte";
|
||||
import AdvancedOptions from "./AdvancedOptions.svelte";
|
||||
import ReviewOptions from "./ReviewOptions.svelte";
|
||||
import BuryOptions from "./BuryOptions.svelte";
|
||||
import LapseOptions from "./LapseOptions.svelte";
|
||||
import GeneralOptions from "./GeneralOptions.svelte";
|
||||
import Addons from "./Addons.svelte";
|
||||
|
@ -36,10 +36,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<div class="outer">
|
||||
<DailyLimits {state} />
|
||||
<LearningOptions {state} />
|
||||
<NewOptions {state} />
|
||||
<ReviewOptions {state} />
|
||||
<LapseOptions {state} />
|
||||
<BuryOptions {state} />
|
||||
{#if state.v3Scheduler}
|
||||
<DisplayOrder {state} />
|
||||
{/if}
|
||||
<GeneralOptions {state} />
|
||||
<Addons {state} />
|
||||
<AdvancedOptions {state} />
|
||||
|
|
|
@ -12,6 +12,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
let defaults = state.defaults;
|
||||
let parentLimits = state.parentLimits;
|
||||
|
||||
const v3Extra = state.v3Scheduler ? "\n\n" + tr.deckConfigLimitDeckV3() : "";
|
||||
|
||||
$: newCardsGreaterThanParent =
|
||||
!state.v3Scheduler && $config.newPerDay > $parentLimits.newCards
|
||||
? tr.deckConfigDailyLimitWillBeCapped({ cards: $parentLimits.newCards })
|
||||
|
@ -30,7 +32,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<SpinBox
|
||||
label={tr.schedulingNewCardsday()}
|
||||
tooltip={tr.deckConfigNewLimitTooltip()}
|
||||
tooltip={tr.deckConfigNewLimitTooltip() + v3Extra}
|
||||
min={0}
|
||||
warnings={[newCardsGreaterThanParent]}
|
||||
defaultValue={defaults.newPerDay}
|
||||
|
@ -38,7 +40,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<SpinBox
|
||||
label={tr.schedulingMaximumReviewsday()}
|
||||
tooltip={tr.deckConfigReviewLimitTooltip()}
|
||||
tooltip={tr.deckConfigReviewLimitTooltip() + v3Extra}
|
||||
min={0}
|
||||
warnings={[reviewsTooLow]}
|
||||
defaultValue={defaults.reviewsPerDay}
|
||||
|
|
68
ts/deckoptions/DisplayOrder.svelte
Normal file
68
ts/deckoptions/DisplayOrder.svelte
Normal file
|
@ -0,0 +1,68 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import * as tr from "lib/i18n";
|
||||
import EnumSelector from "./EnumSelector.svelte";
|
||||
|
||||
import type { DeckOptionsState } from "./lib";
|
||||
import { reviewMixChoices } from "./strings";
|
||||
|
||||
export let state: DeckOptionsState;
|
||||
let config = state.currentConfig;
|
||||
let defaults = state.defaults;
|
||||
|
||||
const newGatherPriorityChoices = [
|
||||
tr.deckConfigNewGatherPriorityDeck(),
|
||||
tr.deckConfigNewGatherPriorityPosition(),
|
||||
];
|
||||
const newSortOrderChoices = [
|
||||
tr.deckConfigSortOrderCardTemplateThenPosition(),
|
||||
tr.deckConfigSortOrderCardTemplateThenRandom(),
|
||||
tr.deckConfigSortOrderPosition(),
|
||||
tr.deckConfigSortOrderRandom(),
|
||||
];
|
||||
const reviewOrderChoices = [
|
||||
tr.deckConfigSortOrderDueDateThenRandom(),
|
||||
tr.deckConfigSortOrderAscendingIntervals(),
|
||||
tr.deckConfigSortOrderDescendingIntervals(),
|
||||
];
|
||||
</script>
|
||||
|
||||
<h2>{tr.deckConfigOrderingTitle()}</h2>
|
||||
|
||||
<EnumSelector
|
||||
label={tr.deckConfigNewGatherPriority()}
|
||||
tooltip={tr.deckConfigNewGatherPriorityTooltip()}
|
||||
choices={newGatherPriorityChoices}
|
||||
defaultValue={defaults.newCardGatherPriority}
|
||||
bind:value={$config.newCardGatherPriority} />
|
||||
|
||||
<EnumSelector
|
||||
label={tr.deckConfigNewCardSortOrder()}
|
||||
tooltip={tr.deckConfigNewCardSortOrderTooltip()}
|
||||
choices={newSortOrderChoices}
|
||||
defaultValue={defaults.newCardSortOrder}
|
||||
bind:value={$config.newCardSortOrder} />
|
||||
|
||||
<EnumSelector
|
||||
label={tr.deckConfigNewReviewPriority()}
|
||||
tooltip={tr.deckConfigNewReviewPriorityTooltip()}
|
||||
choices={reviewMixChoices()}
|
||||
defaultValue={defaults.newMix}
|
||||
bind:value={$config.newMix} />
|
||||
|
||||
<EnumSelector
|
||||
label={tr.deckConfigInterdayStepPriority()}
|
||||
tooltip={tr.deckConfigInterdayStepPriorityTooltip()}
|
||||
choices={reviewMixChoices()}
|
||||
defaultValue={defaults.interdayLearningMix}
|
||||
bind:value={$config.interdayLearningMix} />
|
||||
|
||||
<EnumSelector
|
||||
label={tr.deckConfigReviewSortOrder()}
|
||||
tooltip={tr.deckConfigReviewSortOrderTooltip()}
|
||||
choices={reviewOrderChoices}
|
||||
defaultValue={defaults.reviewOrder}
|
||||
bind:value={$config.reviewOrder} />
|
|
@ -1,33 +0,0 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import * as tr from "lib/i18n";
|
||||
import StepsInput from "./StepsInput.svelte";
|
||||
import EnumSelector from "./EnumSelector.svelte";
|
||||
import type { DeckOptionsState } from "./lib";
|
||||
import { reviewMixChoices } from "./strings";
|
||||
|
||||
export let state: DeckOptionsState;
|
||||
let config = state.currentConfig;
|
||||
let defaults = state.defaults;
|
||||
</script>
|
||||
|
||||
<h2>{tr.deckConfigLearningTitle()}</h2>
|
||||
|
||||
<StepsInput
|
||||
label={tr.deckConfigLearningSteps()}
|
||||
tooltip={tr.deckConfigLearningStepsTooltip()}
|
||||
defaultValue={defaults.learnSteps}
|
||||
value={$config.learnSteps}
|
||||
on:changed={(evt) => ($config.learnSteps = evt.detail.value)} />
|
||||
|
||||
{#if state.v3Scheduler}
|
||||
<EnumSelector
|
||||
label={tr.deckConfigInterdayStepPriority()}
|
||||
tooltip={tr.deckConfigInterdayStepPriorityTooltip()}
|
||||
choices={reviewMixChoices()}
|
||||
defaultValue={defaults.interdayLearningMix}
|
||||
bind:value={$config.interdayLearningMix} />
|
||||
{/if}
|
|
@ -5,10 +5,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
<script lang="ts">
|
||||
import * as tr from "lib/i18n";
|
||||
import SpinBox from "./SpinBox.svelte";
|
||||
import CheckBox from "./CheckBox.svelte";
|
||||
import StepsInput from "./StepsInput.svelte";
|
||||
import EnumSelector from "./EnumSelector.svelte";
|
||||
import type { DeckOptionsState } from "./lib";
|
||||
import { reviewMixChoices } from "./strings";
|
||||
|
||||
export let state: DeckOptionsState;
|
||||
let config = state.currentConfig;
|
||||
|
@ -18,16 +17,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
tr.deckConfigNewInsertionOrderSequential(),
|
||||
tr.deckConfigNewInsertionOrderRandom(),
|
||||
];
|
||||
const newGatherPriorityChoices = [
|
||||
tr.deckConfigNewGatherPriorityDeck(),
|
||||
tr.deckConfigNewGatherPriorityPosition(),
|
||||
];
|
||||
const newSortOrderChoices = [
|
||||
tr.deckConfigSortOrderCardTemplateThenPosition(),
|
||||
tr.deckConfigSortOrderCardTemplateThenRandom(),
|
||||
tr.deckConfigSortOrderPosition(),
|
||||
tr.deckConfigSortOrderRandom(),
|
||||
];
|
||||
|
||||
let stepsExceedGraduatingInterval: string;
|
||||
$: {
|
||||
|
@ -48,6 +37,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<h2>{tr.schedulingNewCards()}</h2>
|
||||
|
||||
<StepsInput
|
||||
label={tr.deckConfigLearningSteps()}
|
||||
tooltip={tr.deckConfigLearningStepsTooltip()}
|
||||
defaultValue={defaults.learnSteps}
|
||||
value={$config.learnSteps}
|
||||
on:changed={(evt) => ($config.learnSteps = evt.detail.value)} />
|
||||
|
||||
<SpinBox
|
||||
label={tr.schedulingGraduatingInterval()}
|
||||
tooltip={tr.deckConfigGraduatingIntervalTooltip()}
|
||||
|
@ -62,38 +58,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
defaultValue={defaults.graduatingIntervalEasy}
|
||||
bind:value={$config.graduatingIntervalEasy} />
|
||||
|
||||
{#if state.v3Scheduler}
|
||||
<EnumSelector
|
||||
<EnumSelector
|
||||
label={tr.deckConfigNewInsertionOrder()}
|
||||
tooltip={tr.deckConfigNewInsertionOrderTooltip()}
|
||||
choices={newInsertOrderChoices}
|
||||
defaultValue={defaults.newCardInsertOrder}
|
||||
bind:value={$config.newCardInsertOrder} />
|
||||
|
||||
<EnumSelector
|
||||
label={tr.deckConfigNewGatherPriority()}
|
||||
tooltip={tr.deckConfigNewGatherPriorityTooltip()}
|
||||
choices={newGatherPriorityChoices}
|
||||
defaultValue={defaults.newCardGatherPriority}
|
||||
bind:value={$config.newCardGatherPriority} />
|
||||
|
||||
<EnumSelector
|
||||
label={tr.deckConfigSortOrder()}
|
||||
tooltip={tr.deckConfigSortOrderTooltip()}
|
||||
choices={newSortOrderChoices}
|
||||
defaultValue={defaults.newCardSortOrder}
|
||||
bind:value={$config.newCardSortOrder} />
|
||||
|
||||
<EnumSelector
|
||||
label={tr.deckConfigReviewPriority()}
|
||||
tooltip={tr.deckConfigReviewPriorityTooltip()}
|
||||
choices={reviewMixChoices()}
|
||||
defaultValue={defaults.newMix}
|
||||
bind:value={$config.newMix} />
|
||||
{/if}
|
||||
|
||||
<CheckBox
|
||||
label={tr.deckConfigBuryNewSiblings()}
|
||||
tooltip={tr.deckConfigBuryTooltip()}
|
||||
defaultValue={defaults.buryNew}
|
||||
bind:value={$config.buryNew} />
|
||||
|
|
|
@ -6,7 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
import * as tr from "lib/i18n";
|
||||
import { revertIcon } from "./icons";
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { isEqual, cloneDeep } from "lodash-es";
|
||||
import { isEqual as isEqualLodash, cloneDeep } from "lodash-es";
|
||||
// import { onMount } from "svelte";
|
||||
// import Tooltip from "bootstrap/js/dist/tooltip";
|
||||
|
||||
|
@ -26,6 +26,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
function isEqual(a: unknown, b: unknown): boolean {
|
||||
if (typeof a === "number" && typeof b === "number") {
|
||||
// round to .01 precision before comparing,
|
||||
// so the values coming out of the UI match
|
||||
// the originals
|
||||
return isEqualLodash(Math.round(a * 100) / 100, Math.round(b * 100) / 100);
|
||||
} else {
|
||||
return isEqualLodash(a, b);
|
||||
}
|
||||
}
|
||||
|
||||
let modified: boolean;
|
||||
$: modified = !isEqual(value, defaultValue);
|
||||
|
||||
|
|
|
@ -1,39 +0,0 @@
|
|||
<!--
|
||||
Copyright: Ankitects Pty Ltd and contributors
|
||||
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
||||
-->
|
||||
<script lang="ts">
|
||||
import * as tr from "lib/i18n";
|
||||
import CheckBox from "./CheckBox.svelte";
|
||||
import EnumSelector from "./EnumSelector.svelte";
|
||||
import type { DeckOptionsState } from "./lib";
|
||||
|
||||
export let state: DeckOptionsState;
|
||||
let config = state.currentConfig;
|
||||
let defaults = state.defaults;
|
||||
|
||||
const reviewOrderChoices = [
|
||||
tr.deckConfigSortOrderDueDateThenRandom(),
|
||||
tr.deckConfigSortOrderAscendingIntervals(),
|
||||
tr.deckConfigSortOrderDescendingIntervals(),
|
||||
];
|
||||
</script>
|
||||
|
||||
<div>
|
||||
<h2>{tr.schedulingReviews()}</h2>
|
||||
|
||||
{#if state.v3Scheduler}
|
||||
<EnumSelector
|
||||
label={tr.deckConfigSortOrder()}
|
||||
tooltip={tr.deckConfigReviewSortOrderTooltip()}
|
||||
choices={reviewOrderChoices}
|
||||
defaultValue={defaults.reviewOrder}
|
||||
bind:value={$config.reviewOrder} />
|
||||
{/if}
|
||||
|
||||
<CheckBox
|
||||
label={tr.deckConfigBuryReviewSiblings()}
|
||||
tooltip={tr.deckConfigBuryTooltip()}
|
||||
defaultValue={defaults.buryReviews}
|
||||
bind:value={$config.buryReviews} />
|
||||
</div>
|
|
@ -46,10 +46,10 @@ html {
|
|||
// the default code color in tooltips is difficult to read; we'll probably
|
||||
// want to add more of our own styling in the future
|
||||
code {
|
||||
color: var(--flag1-bg);
|
||||
color: #ffaaaa;
|
||||
}
|
||||
|
||||
// override the default down arrow colour in <select> elements
|
||||
.night-mode select {
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23FFFFFF' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e")
|
||||
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 16 16'%3e%3cpath fill='none' stroke='%23FFFFFF' stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M2 5l6 6 6-6'/%3e%3c/svg%3e");
|
||||
}
|
||||
|
|
|
@ -41,11 +41,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
}
|
||||
</script>
|
||||
|
||||
<WithShortcut
|
||||
shortcut="Control+Shift+KeyC"
|
||||
optionalModifiers={['Alt']}
|
||||
let:createShortcut
|
||||
let:shortcutLabel>
|
||||
<WithShortcut shortcut={'Control+Alt?+Shift+C'} let:createShortcut let:shortcutLabel>
|
||||
<IconButton
|
||||
tooltip={`${tr.editingClozeDeletion()} (${shortcutLabel})`}
|
||||
on:click={onCloze}
|
||||
|
|
|
@ -36,7 +36,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<ButtonGroup {api}>
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut="F7" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'F7'} let:createShortcut let:shortcutLabel>
|
||||
<IconButton
|
||||
class="forecolor"
|
||||
tooltip={appendInParentheses(tr.editingSetForegroundColor(), shortcutLabel)}
|
||||
|
@ -48,7 +48,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut="F8" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'F8'} let:createShortcut let:shortcutLabel>
|
||||
<ColorPicker
|
||||
tooltip={appendInParentheses(tr.editingChangeColor(), shortcutLabel)}
|
||||
on:change={setWithCurrentColor}
|
||||
|
|
|
@ -26,7 +26,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<ButtonGroup {api}>
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut="Control+KeyB" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'Control+B'} let:createShortcut let:shortcutLabel>
|
||||
<WithState
|
||||
key="bold"
|
||||
update={() => document.queryCommandState('bold')}
|
||||
|
@ -47,7 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut="Control+KeyI" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'Control+I'} let:createShortcut let:shortcutLabel>
|
||||
<WithState
|
||||
key="italic"
|
||||
update={() => document.queryCommandState('italic')}
|
||||
|
@ -68,7 +68,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut="Control+KeyU" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'Control+U'} let:createShortcut let:shortcutLabel>
|
||||
<WithState
|
||||
key="underline"
|
||||
update={() => document.queryCommandState('underline')}
|
||||
|
@ -89,10 +89,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut
|
||||
shortcut="Control+Shift+Equal"
|
||||
let:createShortcut
|
||||
let:shortcutLabel>
|
||||
<WithShortcut shortcut={'Control+='} let:createShortcut let:shortcutLabel>
|
||||
<WithState
|
||||
key="superscript"
|
||||
update={() => document.queryCommandState('superscript')}
|
||||
|
@ -113,7 +110,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut="Control+Equal" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'Control+Shift+='} let:createShortcut let:shortcutLabel>
|
||||
<WithState
|
||||
key="subscript"
|
||||
update={() => document.queryCommandState('subscript')}
|
||||
|
@ -134,7 +131,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut="Control+KeyR" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'Control+R'} let:createShortcut let:shortcutLabel>
|
||||
<IconButton
|
||||
tooltip={appendInParentheses(tr.editingRemoveFormatting(), shortcutLabel)}
|
||||
on:click={() => {
|
||||
|
|
|
@ -25,7 +25,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut="Control+KeyL" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'Control+L'} let:createShortcut let:shortcutLabel>
|
||||
<LabelButton
|
||||
disables={false}
|
||||
tooltip={`${tr.editingCustomizeCardTemplates()} (${shortcutLabel})`}
|
||||
|
|
|
@ -10,7 +10,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
import LabelButton from "components/LabelButton.svelte";
|
||||
</script>
|
||||
|
||||
<WithShortcut shortcut="Control+Shift+KeyP" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'Control+Shift+P'} let:createShortcut let:shortcutLabel>
|
||||
<LabelButton
|
||||
tooltip={tr.browsingPreviewSelectedCard({ val: shortcutLabel })}
|
||||
disables={false}
|
||||
|
|
|
@ -36,7 +36,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<ButtonGroup {api}>
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut="F3" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'F3'} let:createShortcut let:shortcutLabel>
|
||||
<IconButton
|
||||
tooltip={appendInParentheses(tr.editingAttachPicturesaudiovideo(), shortcutLabel)}
|
||||
on:click={onAttachment}
|
||||
|
@ -47,7 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut shortcut="F5" let:createShortcut let:shortcutLabel>
|
||||
<WithShortcut shortcut={'F5'} let:createShortcut let:shortcutLabel>
|
||||
<IconButton
|
||||
tooltip={appendInParentheses(tr.editingRecordAudio(), shortcutLabel)}
|
||||
on:click={onRecord}
|
||||
|
@ -69,7 +69,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<DropdownMenu id={menuId}>
|
||||
<WithShortcut
|
||||
shortcut="Control+KeyM, KeyM"
|
||||
shortcut={'Control+M, M'}
|
||||
let:createShortcut
|
||||
let:shortcutLabel>
|
||||
<DropdownItem
|
||||
|
@ -81,7 +81,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</WithShortcut>
|
||||
|
||||
<WithShortcut
|
||||
shortcut="Control+KeyM, KeyE"
|
||||
shortcut={'Control+M, E'}
|
||||
let:createShortcut
|
||||
let:shortcutLabel>
|
||||
<DropdownItem
|
||||
|
@ -93,7 +93,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</WithShortcut>
|
||||
|
||||
<WithShortcut
|
||||
shortcut="Control+KeyM, KeyC"
|
||||
shortcut={'Control+M, C'}
|
||||
let:createShortcut
|
||||
let:shortcutLabel>
|
||||
<DropdownItem
|
||||
|
@ -105,7 +105,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</WithShortcut>
|
||||
|
||||
<WithShortcut
|
||||
shortcut="Control+KeyT, KeyT"
|
||||
shortcut={'Control+T, T'}
|
||||
let:createShortcut
|
||||
let:shortcutLabel>
|
||||
<DropdownItem
|
||||
|
@ -117,7 +117,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</WithShortcut>
|
||||
|
||||
<WithShortcut
|
||||
shortcut="Control+KeyT, KeyE"
|
||||
shortcut={'Control+T, E'}
|
||||
let:createShortcut
|
||||
let:shortcutLabel>
|
||||
<DropdownItem
|
||||
|
@ -129,7 +129,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</WithShortcut>
|
||||
|
||||
<WithShortcut
|
||||
shortcut="Control+KeyT, KeyM"
|
||||
shortcut={'Control+T, M'}
|
||||
let:createShortcut
|
||||
let:shortcutLabel>
|
||||
<DropdownItem
|
||||
|
@ -144,10 +144,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</ButtonGroupItem>
|
||||
|
||||
<ButtonGroupItem>
|
||||
<WithShortcut
|
||||
shortcut="Control+Shift+KeyX"
|
||||
let:createShortcut
|
||||
let:shortcutLabel>
|
||||
<WithShortcut shortcut={'Control+Shift+X'} let:createShortcut let:shortcutLabel>
|
||||
<IconButton
|
||||
tooltip={appendInParentheses(tr.editingHtmlEditor(), shortcutLabel)}
|
||||
on:click={onHtmlEdit}
|
||||
|
|
|
@ -66,8 +66,7 @@ function updateFocus(evt: FocusEvent) {
|
|||
|
||||
registerShortcut(
|
||||
() => document.addEventListener("focusin", updateFocus, { once: true }),
|
||||
"Tab",
|
||||
["Shift"]
|
||||
"Shift?+Tab"
|
||||
);
|
||||
|
||||
export function onKeyUp(evt: KeyboardEvent): void {
|
||||
|
|
|
@ -4,8 +4,6 @@ import * as tr from "./i18n";
|
|||
|
||||
export type Modifier = "Control" | "Alt" | "Shift" | "Meta";
|
||||
|
||||
const modifiers: Modifier[] = ["Control", "Alt", "Shift", "Meta"];
|
||||
|
||||
function isApplePlatform(): boolean {
|
||||
return (
|
||||
window.navigator.platform.startsWith("Mac") ||
|
||||
|
@ -18,10 +16,6 @@ const platformModifiers = isApplePlatform()
|
|||
? ["Meta", "Alt", "Shift", "Control"]
|
||||
: ["Control", "Alt", "Shift", "OS"];
|
||||
|
||||
function splitKeyCombinationString(keyCombinationString: string): string[][] {
|
||||
return keyCombinationString.split(", ").map((segment) => segment.split("+"));
|
||||
}
|
||||
|
||||
function modifiersToPlatformString(modifiers: string[]): string {
|
||||
const displayModifiers = isApplePlatform()
|
||||
? ["^", "⌥", "⇧", "⌘"]
|
||||
|
@ -36,38 +30,50 @@ function modifiersToPlatformString(modifiers: string[]): string {
|
|||
return result;
|
||||
}
|
||||
|
||||
const alphabeticPrefix = "Key";
|
||||
const numericPrefix = "Digit";
|
||||
const keyToCharacterMap = {
|
||||
Backslash: "\\",
|
||||
Backquote: "`",
|
||||
BracketLeft: "[",
|
||||
BrackerRight: "]",
|
||||
Quote: "'",
|
||||
Semicolon: ";",
|
||||
Minus: "-",
|
||||
Equal: "=",
|
||||
Comma: ",",
|
||||
Period: ".",
|
||||
Slash: "/",
|
||||
const keyCodeLookup = {
|
||||
Backspace: 8,
|
||||
Delete: 46,
|
||||
Tab: 9,
|
||||
Enter: 13,
|
||||
F1: 112,
|
||||
F2: 113,
|
||||
F3: 114,
|
||||
F4: 115,
|
||||
F5: 116,
|
||||
F6: 117,
|
||||
F7: 118,
|
||||
F8: 119,
|
||||
F9: 120,
|
||||
F10: 121,
|
||||
F11: 122,
|
||||
F12: 123,
|
||||
"=": 187,
|
||||
"-": 189,
|
||||
"[": 219,
|
||||
"]": 221,
|
||||
"\\": 220,
|
||||
";": 186,
|
||||
"'": 222,
|
||||
",": 188,
|
||||
".": 190,
|
||||
"/": 191,
|
||||
"`": 192,
|
||||
};
|
||||
|
||||
function keyToPlatformString(key: string): string {
|
||||
if (key.startsWith(alphabeticPrefix)) {
|
||||
return key.slice(alphabeticPrefix.length);
|
||||
} else if (key.startsWith(numericPrefix)) {
|
||||
return key.slice(numericPrefix.length);
|
||||
} else if (Object.prototype.hasOwnProperty.call(keyToCharacterMap, key)) {
|
||||
return keyToCharacterMap[key];
|
||||
} else {
|
||||
return key;
|
||||
}
|
||||
function isRequiredModifier(modifier: string): boolean {
|
||||
return !modifier.endsWith("?");
|
||||
}
|
||||
|
||||
function toPlatformString(modifiersAndKey: string[]): string {
|
||||
return `${modifiersToPlatformString(
|
||||
modifiersAndKey.slice(0, -1)
|
||||
)}${keyToPlatformString(modifiersAndKey[modifiersAndKey.length - 1])}`;
|
||||
function splitKeyCombinationString(keyCombinationString: string): string[][] {
|
||||
return keyCombinationString.split(", ").map((segment) => segment.split("+"));
|
||||
}
|
||||
|
||||
function toPlatformString(keyCombination: string[]): string {
|
||||
return (
|
||||
modifiersToPlatformString(
|
||||
keyCombination.slice(0, -1).filter(isRequiredModifier)
|
||||
) + keyCombination[keyCombination.length - 1]
|
||||
);
|
||||
}
|
||||
|
||||
export function getPlatformString(keyCombinationString: string): string {
|
||||
|
@ -76,78 +82,107 @@ export function getPlatformString(keyCombinationString: string): string {
|
|||
.join(", ");
|
||||
}
|
||||
|
||||
function checkKey(event: KeyboardEvent, key: string): boolean {
|
||||
return event.code === key;
|
||||
function checkKey(event: KeyboardEvent, key: number): boolean {
|
||||
return event.which === key;
|
||||
}
|
||||
|
||||
function checkModifiers(
|
||||
event: KeyboardEvent,
|
||||
optionalModifiers: Modifier[],
|
||||
activeModifiers: string[]
|
||||
): boolean {
|
||||
return modifiers.reduce(
|
||||
(matches: boolean, modifier: string, currentIndex: number): boolean =>
|
||||
const allModifiers: Modifier[] = ["Control", "Alt", "Shift", "Meta"];
|
||||
|
||||
function partition<T>(predicate: (t: T) => boolean, items: T[]): [T[], T[]] {
|
||||
const trueItems: T[] = [];
|
||||
const falseItems: T[] = [];
|
||||
|
||||
items.forEach((t) => {
|
||||
const target = predicate(t) ? trueItems : falseItems;
|
||||
target.push(t);
|
||||
});
|
||||
|
||||
return [trueItems, falseItems];
|
||||
}
|
||||
|
||||
function removeTrailing(modifier: string): string {
|
||||
return modifier.substring(0, modifier.length - 1);
|
||||
}
|
||||
|
||||
function checkModifiers(event: KeyboardEvent, modifiers: string[]): boolean {
|
||||
const [requiredModifiers, otherModifiers] = partition(
|
||||
isRequiredModifier,
|
||||
modifiers
|
||||
);
|
||||
|
||||
const optionalModifiers = otherModifiers.map(removeTrailing);
|
||||
|
||||
return allModifiers.reduce(
|
||||
(matches: boolean, currentModifier: string, currentIndex: number): boolean =>
|
||||
matches &&
|
||||
(optionalModifiers.includes(modifier as Modifier) ||
|
||||
(optionalModifiers.includes(currentModifier as Modifier) ||
|
||||
event.getModifierState(platformModifiers[currentIndex]) ===
|
||||
activeModifiers.includes(modifier)),
|
||||
requiredModifiers.includes(currentModifier)),
|
||||
true
|
||||
);
|
||||
}
|
||||
|
||||
function check(
|
||||
event: KeyboardEvent,
|
||||
optionalModifiers: Modifier[],
|
||||
modifiersAndKey: string[]
|
||||
): boolean {
|
||||
return (
|
||||
checkKey(event, modifiersAndKey[modifiersAndKey.length - 1]) &&
|
||||
checkModifiers(event, optionalModifiers, modifiersAndKey.slice(0, -1))
|
||||
);
|
||||
const check = (keyCode: number, modifiers: string[]) => (
|
||||
event: KeyboardEvent
|
||||
): boolean => {
|
||||
return checkKey(event, keyCode) && checkModifiers(event, modifiers);
|
||||
};
|
||||
|
||||
function keyToCode(key: string): number {
|
||||
return keyCodeLookup[key] || key.toUpperCase().charCodeAt(0);
|
||||
}
|
||||
|
||||
const shortcutTimeoutMs = 400;
|
||||
function keyCombinationToCheck(
|
||||
keyCombination: string[]
|
||||
): (event: KeyboardEvent) => boolean {
|
||||
const keyCode = keyToCode(keyCombination[keyCombination.length - 1]);
|
||||
const modifiers = keyCombination.slice(0, -1);
|
||||
|
||||
return check(keyCode, modifiers);
|
||||
}
|
||||
|
||||
const GENERAL_KEY = 0;
|
||||
const NUMPAD_KEY = 3;
|
||||
|
||||
function innerShortcut(
|
||||
lastEvent: KeyboardEvent,
|
||||
callback: (event: KeyboardEvent) => void,
|
||||
optionalModifiers: Modifier[],
|
||||
...keyCombination: string[][]
|
||||
...checks: ((event: KeyboardEvent) => boolean)[]
|
||||
): void {
|
||||
let interval: number;
|
||||
|
||||
if (keyCombination.length === 0) {
|
||||
if (checks.length === 0) {
|
||||
callback(lastEvent);
|
||||
} else {
|
||||
const [nextKey, ...restKeys] = keyCombination;
|
||||
|
||||
const [nextCheck, ...restChecks] = checks;
|
||||
const handler = (event: KeyboardEvent): void => {
|
||||
if (check(event, optionalModifiers, nextKey)) {
|
||||
innerShortcut(event, callback, optionalModifiers, ...restKeys);
|
||||
if (nextCheck(event)) {
|
||||
innerShortcut(event, callback, ...restChecks);
|
||||
clearTimeout(interval);
|
||||
} else if (
|
||||
event.location === GENERAL_KEY ||
|
||||
event.location === NUMPAD_KEY
|
||||
) {
|
||||
// Any non-modifier key will cancel the shortcut sequence
|
||||
document.removeEventListener("keydown", handler);
|
||||
}
|
||||
};
|
||||
|
||||
interval = setTimeout(
|
||||
(): void => document.removeEventListener("keydown", handler),
|
||||
shortcutTimeoutMs
|
||||
);
|
||||
|
||||
document.addEventListener("keydown", handler, { once: true });
|
||||
}
|
||||
}
|
||||
|
||||
export function registerShortcut(
|
||||
callback: (event: KeyboardEvent) => void,
|
||||
keyCombinationString: string,
|
||||
optionalModifiers: Modifier[] = []
|
||||
keyCombinationString: string
|
||||
): () => void {
|
||||
const keyCombination = splitKeyCombinationString(keyCombinationString);
|
||||
const [firstKey, ...restKeys] = keyCombination;
|
||||
const [check, ...restChecks] = splitKeyCombinationString(keyCombinationString).map(
|
||||
keyCombinationToCheck
|
||||
);
|
||||
|
||||
const handler = (event: KeyboardEvent): void => {
|
||||
if (check(event, optionalModifiers, firstKey)) {
|
||||
innerShortcut(event, callback, optionalModifiers, ...restKeys);
|
||||
if (check(event)) {
|
||||
innerShortcut(event, callback, ...restChecks);
|
||||
}
|
||||
};
|
||||
|
||||
|
|
Loading…
Reference in a new issue