mirror of
https://github.com/ankitects/anki.git
synced 2025-09-18 14:02:21 -04:00

* Feat/90% desired retention warning * Update ftl/core/deck-config.ftl * show on newly enabled * Show warning on focus * Never hide warning * Display relative change * Add: Separate warning for too long and short * Revert unchanged text changes * interval -> workload * Remove dead code * fsrs-rs/@L-M-Sherlock's workload calculation * Added: delay * CONSTANT_CASE * Fix: optimized state * Removed "Processing" * Remove dead code * 1 digit precision * bump fsrs-rs * typo * Apply suggestions from code review Co-authored-by: Damien Elmes <dae@users.noreply.github.com> * Improve rounding * improve comment * rounding <1% * decrease rounding precision * bump ts-fsrs * use actual cost values * ./check * typo * include relearning * change factor wording * simplify sql * ./check * Apply suggestions from code review Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com> * Fix: missing search_cids * @dae's style patch * Fix: Doesn't update on arrow keys change * force two lines * center two lines --------- Co-authored-by: user1823 <92206575+user1823@users.noreply.github.com>
268 lines
7.7 KiB
Svelte
268 lines
7.7 KiB
Svelte
<!--
|
|
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 "@generated/ftl";
|
|
import { isDesktop } from "@tslib/platform";
|
|
import { tick } from "svelte";
|
|
|
|
import { chevronDown, chevronUp } from "$lib/components/icons";
|
|
|
|
import Icon from "./Icon.svelte";
|
|
import IconConstrain from "./IconConstrain.svelte";
|
|
|
|
export let value: number;
|
|
export let step = 1;
|
|
export let min = 1;
|
|
export let max = 9999;
|
|
/**
|
|
* Whether the value is shown as a percentage to the user.
|
|
* It's saved as a proportion.
|
|
*/
|
|
export let percentage = false;
|
|
|
|
let input: HTMLInputElement;
|
|
export let focused = false;
|
|
let multiplier: number;
|
|
$: multiplier = percentage ? 100 : 1;
|
|
|
|
/** Set value to a new number, clamping it to a valid range, and
|
|
leaving it unchanged if `newValue` is NaN. */
|
|
function updateValue(newValue: number) {
|
|
if (Number.isNaN(newValue)) {
|
|
// avoid updating the value
|
|
} else {
|
|
value = Math.min(max, Math.max(min, newValue));
|
|
}
|
|
// Assigning to `value` will trigger the stringValue reactive statement below,
|
|
// but Svelte may not redraw the UI. For example, if '1' was shown, and the user
|
|
// enters '0', if the value gets clamped back to '1', Svelte will think the value hasn't
|
|
// changed, and will skip the UI update. So we manually update the DOM to ensure it stays
|
|
// in sync.
|
|
tick().then(() => {
|
|
input.value = stringValue;
|
|
updatePercentageText(stringValue);
|
|
});
|
|
}
|
|
|
|
/**
|
|
* The number of decimal places to record. May be different than the number of decimal places displayed for percentages.
|
|
* @param value The size of the step.
|
|
*/
|
|
function decimalPlaces(value: number) {
|
|
if (Math.floor(value) === value) {
|
|
// If the step is an integer, do not show decimal places.
|
|
return 0;
|
|
}
|
|
const places = value.toString().split(".")[1].length || 0;
|
|
const displayedPlace = percentage ? places - 2 : places;
|
|
return Math.max(0, displayedPlace);
|
|
}
|
|
|
|
let stringValue: string;
|
|
$: stringValue = (value * multiplier).toFixed(decimalPlaces(step));
|
|
|
|
function update(this: HTMLInputElement): void {
|
|
updateValue(parseFloat(this.value) / multiplier);
|
|
}
|
|
|
|
function handleWheel(event: WheelEvent) {
|
|
if (focused) {
|
|
updateValue(value + (event.deltaY < 0 ? step : -step));
|
|
event.preventDefault();
|
|
}
|
|
}
|
|
|
|
function change(step: number): void {
|
|
updateValue(value + step);
|
|
if (pressed) {
|
|
setTimeout(() => change(step), timeout);
|
|
}
|
|
}
|
|
|
|
const progression = [1500, 1250, 1000, 750, 500, 250];
|
|
|
|
async function longPress(func: Function): Promise<void> {
|
|
pressed = true;
|
|
timeout = 128;
|
|
pressTimer = setTimeout(func, 250);
|
|
|
|
for (const delay of progression) {
|
|
timeout = await new Promise((resolve) =>
|
|
setTimeout(() => resolve(pressed ? timeout / 2 : 128), delay),
|
|
);
|
|
}
|
|
}
|
|
|
|
function updatePercentageText(value: string) {
|
|
// Separate the % from the padding text.
|
|
percentage_text = tr
|
|
.deckConfigPercentInput({ pct: value })
|
|
.replaceAll("%", "-%-")
|
|
.split("-");
|
|
}
|
|
|
|
function onInput() {
|
|
updatePercentageText(input.value);
|
|
}
|
|
|
|
// Invisible, used to shift the % sign the correct amount.
|
|
let percentage_text: string[];
|
|
$: updatePercentageText(stringValue);
|
|
// If the input box should be moved right for leading percentage symbol.
|
|
$: percentage_padding = percentage && !percentage_text[0] ? "2.2ch" : undefined;
|
|
|
|
let pressed = false;
|
|
let timeout: number;
|
|
let pressTimer: any;
|
|
</script>
|
|
|
|
<div class="spin-box" on:wheel={handleWheel}>
|
|
<input
|
|
type="number"
|
|
pattern="[0-9]*"
|
|
inputmode="numeric"
|
|
min={min * multiplier}
|
|
max={max * multiplier}
|
|
step={step * multiplier}
|
|
value={stringValue}
|
|
bind:this={input}
|
|
on:blur={update}
|
|
on:change={update}
|
|
on:input={onInput}
|
|
on:focusin={() => (focused = true)}
|
|
on:focusout={() => (focused = false)}
|
|
style:padding-left={percentage_padding}
|
|
/>
|
|
{#if percentage}
|
|
<span class="suffix">
|
|
{#each percentage_text as str}
|
|
{#if str == "%"}
|
|
%
|
|
{:else}
|
|
<span class="invisible">{str}</span>
|
|
{/if}
|
|
{/each}
|
|
</span>
|
|
{/if}
|
|
{#if isDesktop()}
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<div
|
|
class="spinner decrement"
|
|
class:active={value > min}
|
|
tabindex="-1"
|
|
title={tr.actionsDecrementValue()}
|
|
role="button"
|
|
on:click={() => {
|
|
input.focus();
|
|
if (value > min) {
|
|
change(-step);
|
|
}
|
|
}}
|
|
on:mousedown={() =>
|
|
longPress(() => {
|
|
if (value > min) {
|
|
change(-step);
|
|
}
|
|
})}
|
|
on:mouseup={() => {
|
|
clearTimeout(pressTimer);
|
|
pressed = false;
|
|
}}
|
|
>
|
|
<IconConstrain>
|
|
<Icon icon={chevronDown} />
|
|
</IconConstrain>
|
|
</div>
|
|
<!-- svelte-ignore a11y-click-events-have-key-events -->
|
|
<div
|
|
class="spinner increment"
|
|
class:active={value < max}
|
|
tabindex="-1"
|
|
title={tr.actionsIncrementValue()}
|
|
role="button"
|
|
on:click={() => {
|
|
input.focus();
|
|
if (value < max) {
|
|
change(step);
|
|
}
|
|
}}
|
|
on:mousedown={() =>
|
|
longPress(() => {
|
|
if (value < max) {
|
|
change(step);
|
|
}
|
|
})}
|
|
on:mouseup={() => {
|
|
clearTimeout(pressTimer);
|
|
pressed = false;
|
|
}}
|
|
>
|
|
<IconConstrain>
|
|
<Icon icon={chevronUp} />
|
|
</IconConstrain>
|
|
</div>
|
|
{/if}
|
|
</div>
|
|
|
|
<style lang="scss">
|
|
.spin-box {
|
|
width: 100%;
|
|
background: var(--canvas-inset);
|
|
border: 1px solid var(--border);
|
|
border-radius: var(--border-radius);
|
|
overflow: hidden;
|
|
position: relative;
|
|
display: flex;
|
|
justify-content: space-between;
|
|
|
|
.suffix {
|
|
position: absolute;
|
|
pointer-events: none;
|
|
white-space: pre;
|
|
left: 0.5em;
|
|
top: 1px;
|
|
|
|
@supports (-webkit-touch-callout: none) {
|
|
/* CSS specific to iOS devices */
|
|
top: 3.5px;
|
|
}
|
|
}
|
|
|
|
.invisible {
|
|
color: transparent;
|
|
pointer-events: none;
|
|
}
|
|
|
|
input {
|
|
flex-grow: 1;
|
|
border: none;
|
|
outline: none;
|
|
background: transparent;
|
|
&::-webkit-inner-spin-button {
|
|
display: none;
|
|
}
|
|
padding-left: 0.5em;
|
|
padding-right: 0.5em;
|
|
}
|
|
|
|
&:hover,
|
|
&:focus-within {
|
|
.spinner {
|
|
opacity: 0.1;
|
|
&.active {
|
|
opacity: 0.4;
|
|
cursor: pointer;
|
|
&:hover {
|
|
opacity: 1;
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
.spinner {
|
|
opacity: 0;
|
|
height: 100%;
|
|
}
|
|
</style>
|