Improved add-on extension API (#1626)

* Add componentHook functionality

* Register package NoteEditor

* Rename OldEditorAdapter to NoteEditor

* Expose instances in component-hook as well

* Rename NoteTypeButtons to NotetypeButtons

* Move PreviewButton initialization to BrowserEditor.svelte

* Remove focusInRichText

- Same thing can be done by inspecting activeInput

* Satisfy formatter

* Fix remaining rebase issues

* Add .bazel to .prettierignore

* Rename currentField and activeInput to focused{Field,Input}

* Move identifier to lib and registration to sveltelib

* Fix Dynamic component insertion

* Simplify editingInputIsRichText

* Give extra warning in svelte/svelte.ts

- This was caused by doing a rename of a files, that only differed in
  case: NoteTypeButtons.svelte to NotetypeButtons.svelte
- It was quite tough to figure out, and this console.log might make it
  easier if it ever happens again

* Change signature of contextProperty

* Add ts/typings for add-on definition files

* Add Anki types in typings/common/index.d.ts

* Export without .svelte suffix

It conflicts with how Svelte types its packages

* Fix left over .svelte import from editor.py

* Rename NoteTypeButtons to unrelated to ensure case-only rename

* Rename back to NotetypeButtons.svelte

* Remove unused component-hook.ts, Fix typing in lifecycle-hooks

* Merge runtime-require and register-package into one file

+ Give some preliminary types to require

* Rename uiDidLoad to loaded

* Fix eslint / svelte-check

* Rename context imports to noteEditorContext

* Fix import name mismatch

- I wonder why these issues are not caught by svelte-check?

* Rename two missed usages of uiDidLoad

* Fix ButtonDropdown from having wrong border-radius

* Uniformly rename libraries to packages

- I don't have a strong opinion on whether to name them libraries or
  packages, I just think we should have a uniform name.
- JS/TS only uses the terms "module" and "namespace", however `package`
  is a reserved keyword for future use, whereas `library` is not.

* Refactor registration.ts into dynamic-slotting

- This is part of an effort to refactor the dynamic slotting (extending
  buttons) functionality out of components like ButtonGroup.

* Remove dynamically-slottable logic from ButtonToolbar

* Use DynamicallySlottable in editor-toolbar

* Fix no border radius on indentation button dropdown

* Fix AddonButtons

* Remove Item/ButtonGroupItem in deck-options, where it's not necessary

* Remove unnecessary uses of Item and ButtonGroupItem

* Fix remaining tests

* Fix relative imports

* Revert change return value of remapBinToSrcDir to ./bazel/out...

* Remove typings directory

* Adjust comments for dynamic-slottings
This commit is contained in:
Henrik Giesel 2022-02-03 05:52:11 +01:00 committed by GitHub
parent 8afe36b8e9
commit a981e56008
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
89 changed files with 2227 additions and 1957 deletions

View file

@ -2,5 +2,6 @@ licenses.json
vendor vendor
node_modules node_modules
bazel-* bazel-*
.bazel
ftl/usage ftl/usage
.mypy_cache .mypy_cache

View file

@ -413,9 +413,6 @@ class Browser(QMainWindow):
def add_preview_button(editor: Editor) -> None: def add_preview_button(editor: Editor) -> None:
editor._links["preview"] = lambda _editor: self.onTogglePreview() editor._links["preview"] = lambda _editor: self.onTogglePreview()
editor.web.eval(
"noteEditorPromise.then(noteEditor => noteEditor.toolbar.notetypeButtons.appendButton({ component: editorToolbar.PreviewButton, id: 'preview' }));",
)
gui_hooks.editor_did_init.append(add_preview_button) gui_hooks.editor_did_init.append(add_preview_button)
self.editor = aqt.editor.Editor( self.editor = aqt.editor.Editor(
@ -633,9 +630,7 @@ class Browser(QMainWindow):
def toggle_preview_button_state(self, active: bool) -> None: def toggle_preview_button_state(self, active: bool) -> None:
if self.editor.web: if self.editor.web:
self.editor.web.eval( self.editor.web.eval(f"togglePreviewButtonState({json.dumps(active)});")
f"editorToolbar.togglePreviewButtonState({json.dumps(active)});"
)
def _cleanup_preview(self) -> None: def _cleanup_preview(self) -> None:
if self._previewer: if self._previewer:

View file

@ -174,7 +174,7 @@ class Editor:
righttopbtns_defs = ", ".join([json.dumps(button) for button in righttopbtns]) righttopbtns_defs = ", ".join([json.dumps(button) for button in righttopbtns])
righttopbtns_js = ( righttopbtns_js = (
f""" f"""
uiPromise.then(noteEditor => noteEditor.toolbar.toolbar.appendGroup({{ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].toolbar.toolbar.append({{
component: editorToolbar.AddonButtons, component: editorToolbar.AddonButtons,
id: "addons", id: "addons",
props: {{ buttons: [ {righttopbtns_defs} ] }}, props: {{ buttons: [ {righttopbtns_defs} ] }},
@ -525,7 +525,9 @@ uiPromise.then(noteEditor => noteEditor.toolbar.toolbar.appendGroup({{
js += " setSticky(%s);" % json.dumps(sticky) js += " setSticky(%s);" % json.dumps(sticky)
js = gui_hooks.editor_will_load_note(js, self.note, self) js = gui_hooks.editor_will_load_note(js, self.note, self)
self.web.evalWithCallback(f"uiPromise.then(() => {{ {js} }})", oncallback) self.web.evalWithCallback(
f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback
)
def _save_current_note(self) -> None: def _save_current_note(self) -> None:
"Call after note is updated with data from webview." "Call after note is updated with data from webview."
@ -579,8 +581,12 @@ uiPromise.then(noteEditor => noteEditor.toolbar.toolbar.appendGroup({{
elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE: elif result == NoteFieldsCheckResult.FIELD_NOT_CLOZE:
cloze_hint = tr.adding_cloze_outside_cloze_field() cloze_hint = tr.adding_cloze_outside_cloze_field()
self.web.eval(f"uiPromise.then(() => setBackgrounds({json.dumps(cols)}));") self.web.eval(
self.web.eval(f"uiPromise.then(() => setClozeHint({json.dumps(cloze_hint)}));") 'require("anki/ui").loaded.then(() => {'
f"setBackgrounds({json.dumps(cols)});\n"
f"setClozeHint({json.dumps(cloze_hint)});\n"
"}); "
)
def showDupes(self) -> None: def showDupes(self) -> None:
aqt.dialogs.open( aqt.dialogs.open(
@ -1353,14 +1359,12 @@ gui_hooks.editor_will_munge_html.append(reverse_url_quoting)
def set_cloze_button(editor: Editor) -> None: def set_cloze_button(editor: Editor) -> None:
if editor.note.note_type()["type"] == MODEL_CLOZE: action = "show" if editor.note.note_type()["type"] == MODEL_CLOZE else "hide"
editor.web.eval( editor.web.eval(
'uiPromise.then((noteEditor) => noteEditor.toolbar.templateButtons.showButton("cloze")); ' 'require("anki/ui").loaded.then(() =>'
) f'require("anki/NoteEditor").instances[0].toolbar.templateButtons.{action}("cloze")'
else: "); "
editor.web.eval( )
'uiPromise.then((noteEditor) => noteEditor.toolbar.templateButtons.hideButton("cloze")); '
)
gui_hooks.editor_did_load_note.append(set_cloze_button) gui_hooks.editor_did_load_note.append(set_cloze_button)

View file

@ -6,9 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { ChangeNotetypeState } from "./lib"; import type { ChangeNotetypeState } from "./lib";
import StickyContainer from "../components/StickyContainer.svelte"; import StickyContainer from "../components/StickyContainer.svelte";
import ButtonToolbar from "../components/ButtonToolbar.svelte"; import ButtonToolbar from "../components/ButtonToolbar.svelte";
import Item from "../components/Item.svelte";
import ButtonGroup from "../components/ButtonGroup.svelte"; import ButtonGroup from "../components/ButtonGroup.svelte";
import ButtonGroupItem from "../components/ButtonGroupItem.svelte";
import LabelButton from "../components/LabelButton.svelte"; import LabelButton from "../components/LabelButton.svelte";
import Badge from "../components/Badge.svelte"; import Badge from "../components/Badge.svelte";
import { arrowRightIcon, arrowLeftIcon } from "./icons"; import { arrowRightIcon, arrowLeftIcon } from "./icons";
@ -33,41 +31,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--sticky-borders="0 0 1px" --sticky-borders="0 0 1px"
> >
<ButtonToolbar class="justify-content-between" size={2.3} wrap={false}> <ButtonToolbar class="justify-content-between" size={2.3} wrap={false}>
<Item> <LabelButton disabled={true}>
<ButtonGroupItem> {$info.oldNotetypeName}
<LabelButton disabled={true}> </LabelButton>
{$info.oldNotetypeName} <Badge iconSize={70}>
</LabelButton> {#if window.getComputedStyle(document.body).direction == "rtl"}
</ButtonGroupItem> {@html arrowLeftIcon}
</Item> {:else}
<Item> {@html arrowRightIcon}
<Badge iconSize={70}> {/if}
{#if window.getComputedStyle(document.body).direction == "rtl"} </Badge>
{@html arrowLeftIcon} <ButtonGroup class="flex-grow-1">
{:else} <SelectButton class="flex-grow-1" on:change={blur}>
{@html arrowRightIcon} {#each $notetypes as entry}
{/if} <SelectOption value={String(entry.idx)} selected={entry.current}>
</Badge> {entry.name}
</Item> </SelectOption>
<Item> {/each}
<ButtonGroup class="flex-grow-1"> </SelectButton>
<ButtonGroupItem> </ButtonGroup>
<SelectButton class="flex-grow-1" on:change={blur}>
{#each $notetypes as entry}
<SelectOption
value={String(entry.idx)}
selected={entry.current}
>
{entry.name}
</SelectOption>
{/each}
</SelectButton>
</ButtonGroupItem>
</ButtonGroup>
</Item>
<Item> <SaveButton {state} />
<SaveButton {state} />
</Item>
</ButtonToolbar> </ButtonToolbar>
</StickyContainer> </StickyContainer>

View file

@ -8,7 +8,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getPlatformString } from "../lib/shortcuts"; import { getPlatformString } from "../lib/shortcuts";
import ButtonGroup from "../components/ButtonGroup.svelte"; import ButtonGroup from "../components/ButtonGroup.svelte";
import ButtonGroupItem from "../components/ButtonGroupItem.svelte";
import LabelButton from "../components/LabelButton.svelte"; import LabelButton from "../components/LabelButton.svelte";
import Shortcut from "../components/Shortcut.svelte"; import Shortcut from "../components/Shortcut.svelte";
@ -25,12 +24,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<ButtonGroup> <ButtonGroup>
<ButtonGroupItem> <LabelButton
<LabelButton theme="primary"
theme="primary" tooltip={getPlatformString(keyCombination)}
tooltip={getPlatformString(keyCombination)} on:click={save}
on:click={save}>{tr.actionsSave()}</LabelButton --border-left-radius="5px"
> --border-right-radius="5px">{tr.actionsSave()}</LabelButton
<Shortcut {keyCombination} on:action={save} /> >
</ButtonGroupItem> <Shortcut {keyCombination} on:action={save} />
</ButtonGroup> </ButtonGroup>

View file

@ -42,6 +42,7 @@ svelte_check(
"//sass:breakpoints_lib", "//sass:breakpoints_lib",
"//sass/bootstrap", "//sass/bootstrap",
"@npm//@types/bootstrap", "@npm//@types/bootstrap",
"//ts/lib:lib_pkg",
"//ts/sveltelib:sveltelib_pkg", "//ts/sveltelib:sveltelib_pkg",
], ],
) )

View file

@ -12,17 +12,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let className = ""; let className = "";
export { className as class }; export { className as class };
export let api: Record<string, unknown> | undefined = undefined;
setContext(dropdownKey, null); setContext(dropdownKey, null);
</script> </script>
<ButtonToolbar <ButtonToolbar {id} class="dropdown-menu btn-dropdown-menu {className}" wrap={false}>
{id}
class="dropdown-menu btn-dropdown-menu {className}"
wrap={false}
{api}
>
<div on:mousedown|preventDefault|stopPropagation on:click> <div on:mousedown|preventDefault|stopPropagation on:click>
<slot /> <slot />
</div> </div>

View file

@ -2,35 +2,12 @@
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
--> -->
<script context="module" lang="ts">
import type { SvelteComponent } from "./registration";
import type { Identifier } from "./identifier";
export interface ButtonGroupAPI {
insertButton(button: SvelteComponent, position: Identifier): void;
appendButton(button: SvelteComponent, position: Identifier): void;
showButton(position: Identifier): void;
hideButton(position: Identifier): void;
toggleButton(position: Identifier): void;
}
</script>
<script lang="ts"> <script lang="ts">
import ButtonGroupItem from "./ButtonGroupItem.svelte";
import { setContext } from "svelte";
import { writable } from "svelte/store";
import { buttonGroupKey } from "./context-keys";
import { insertElement, appendElement } from "./identifier";
import type { ButtonRegistration } from "./buttons";
import { ButtonPosition } from "./buttons";
import { makeInterface } from "./registration";
export let id: string | undefined = undefined; export let id: string | undefined = undefined;
let className: string = ""; let className: string = "";
export { className as class }; export { className as class };
export let size: number | undefined = undefined; export let size: number | undefined = undefined;
export let wrap: boolean | undefined = undefined; export let wrap: boolean | undefined = undefined;
$: buttonSize = size ? `--buttons-size: ${size}rem; ` : ""; $: buttonSize = size ? `--buttons-size: ${size}rem; ` : "";
@ -42,86 +19,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
$: style = buttonSize + buttonWrap; $: style = buttonSize + buttonWrap;
function makeRegistration(): ButtonRegistration {
const detach = writable(false);
const position = writable(ButtonPosition.Standalone);
return { detach, position };
}
const { registerComponent, items, dynamicItems, getDynamicInterface } =
makeInterface(makeRegistration);
$: for (const [index, item] of $items.entries()) {
item.position.update(() => {
if ($items.length === 1) {
return ButtonPosition.Standalone;
} else if (index === 0) {
return ButtonPosition.InlineStart;
} else if (index === $items.length - 1) {
return ButtonPosition.InlineEnd;
} else {
return ButtonPosition.Center;
}
});
}
setContext(buttonGroupKey, registerComponent);
export let api: Partial<ButtonGroupAPI> | undefined = undefined;
let buttonGroupRef: HTMLDivElement;
function createApi(): void {
const { addComponent, updateRegistration } =
getDynamicInterface(buttonGroupRef);
const insertButton = (button: SvelteComponent, position: Identifier = 0) =>
addComponent(button, (added, parent) =>
insertElement(added, parent, position),
);
const appendButton = (button: SvelteComponent, position: Identifier = -1) =>
addComponent(button, (added, parent) =>
appendElement(added, parent, position),
);
const showButton = (id: Identifier) =>
updateRegistration(({ detach }) => detach.set(false), id);
const hideButton = (id: Identifier) =>
updateRegistration(({ detach }) => detach.set(true), id);
const toggleButton = (id: Identifier) =>
updateRegistration(
({ detach }) => detach.update((old: boolean): boolean => !old),
id,
);
Object.assign(api, {
insertButton,
appendButton,
showButton,
hideButton,
toggleButton,
} as ButtonGroupAPI);
}
$: if (api && buttonGroupRef) {
createApi();
}
</script> </script>
<div <div {id} class="button-group btn-group {className}" {style} dir="ltr" role="group">
bind:this={buttonGroupRef}
{id}
class="button-group btn-group {className}"
{style}
dir="ltr"
role="group"
>
<slot /> <slot />
{#each $dynamicItems as item (item[0].id)}
<ButtonGroupItem id={item[0].id} registration={item[1]}>
<svelte:component this={item[0].component} {...item[0].props} />
</ButtonGroupItem>
{/each}
</div> </div>
<style lang="scss"> <style lang="scss">

View file

@ -1,67 +1,105 @@
<!-- <!--
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
--> -->
<script context="module" lang="ts">
import { writable, get } from "svelte/store";
import contextProperty from "../sveltelib/context-property";
import type { Writable } from "svelte/store";
import type {
SlotHostProps,
GetSlotHostProps,
} from "../sveltelib/dynamic-slotting";
enum ButtonPosition {
Standalone,
InlineStart,
Center,
InlineEnd,
}
interface ButtonSlotHostProps extends SlotHostProps {
position: Writable<ButtonPosition>;
}
const key = Symbol("buttonGroup");
const [context, setSlotHostContext] =
contextProperty<GetSlotHostProps<ButtonSlotHostProps>>(key);
export { setSlotHostContext };
export function createProps(): ButtonSlotHostProps {
return {
detach: writable(false),
position: writable(ButtonPosition.Standalone),
};
}
function nonDetached(props: ButtonSlotHostProps): boolean {
return !get(props.detach);
}
export function updatePropsList(
propsList: ButtonSlotHostProps[],
): ButtonSlotHostProps[] {
const list = Array.from(propsList.filter(nonDetached).entries());
for (const [index, props] of list) {
const position = props.position;
if (list.length === 1) {
position.set(ButtonPosition.Standalone);
} else if (index === 0) {
position.set(ButtonPosition.InlineStart);
} else if (index === list.length - 1) {
position.set(ButtonPosition.InlineEnd);
} else {
position.set(ButtonPosition.Center);
}
}
return propsList;
}
</script>
<script lang="ts"> <script lang="ts">
import Detachable from "./Detachable.svelte";
import type { ButtonRegistration } from "./buttons";
import { ButtonPosition } from "./buttons";
import type { Register } from "./registration";
import { getContext, hasContext } from "svelte";
import { buttonGroupKey } from "./context-keys";
export let id: string | undefined = undefined; export let id: string | undefined = undefined;
export let registration: ButtonRegistration | undefined = undefined; export let hostProps: ButtonSlotHostProps | undefined = undefined;
let detached: boolean;
let position_: ButtonPosition;
let style: string; let style: string;
if (!context.available()) {
console.log("ButtonGroupItem: should always have a slotHostContext");
}
const { detach, position } = hostProps ?? context.get().getProps();
const radius = "5px"; const radius = "5px";
const leftStyle = `--border-left-radius: ${radius}; --border-right-radius: 0; `; function updateButtonStyle(position: ButtonPosition) {
const rightStyle = `--border-left-radius: 0; --border-right-radius: ${radius}; `; switch (position) {
$: {
switch (position_) {
case ButtonPosition.Standalone: case ButtonPosition.Standalone:
style = `--border-left-radius: ${radius}; --border-right-radius: ${radius}; `; style = `--border-left-radius: ${radius}; --border-right-radius: ${radius}; `;
break; break;
case ButtonPosition.InlineStart: case ButtonPosition.InlineStart:
style = leftStyle; style = `--border-left-radius: ${radius}; --border-right-radius: 0; `;
break; break;
case ButtonPosition.Center: case ButtonPosition.Center:
style = "--border-left-radius: 0; --border-right-radius: 0; "; style = "--border-left-radius: 0; --border-right-radius: 0; ";
break; break;
case ButtonPosition.InlineEnd: case ButtonPosition.InlineEnd:
style = rightStyle; style = `--border-left-radius: 0; --border-right-radius: ${radius}; `;
break; break;
} }
} }
if (registration) { $: updateButtonStyle($position);
const { detach, position } = registration;
detach.subscribe((value: boolean) => (detached = value));
position.subscribe((value: ButtonPosition) => (position_ = value));
} else if (hasContext(buttonGroupKey)) {
const registerComponent =
getContext<Register<ButtonRegistration>>(buttonGroupKey);
const { detach, position } = registerComponent();
detach.subscribe((value: boolean) => (detached = value));
position.subscribe((value: ButtonPosition) => (position_ = value));
} else {
detached = false;
position_ = ButtonPosition.Standalone;
}
</script> </script>
<!-- div is necessary to preserve item position --> <!-- div is necessary to preserve item position -->
<div {id} class="button-group-item" {style}> <div class="button-group-item" {id} {style}>
<Detachable {detached}> {#if !$detach}
<slot /> <slot />
</Detachable> {/if}
</div> </div>
<style lang="scss"> <style lang="scss">

View file

@ -2,27 +2,7 @@
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
--> -->
<script context="module" lang="ts">
import type { Identifier } from "./identifier";
import type { SvelteComponent } from "./registration";
export interface ButtonToolbarAPI {
insertGroup(button: SvelteComponent, position: Identifier): void;
appendGroup(button: SvelteComponent, position: Identifier): void;
showGroup(position: Identifier): void;
hideGroup(position: Identifier): void;
toggleGroup(position: Identifier): void;
}
</script>
<script lang="ts"> <script lang="ts">
import { setContext } from "svelte";
import { writable } from "svelte/store";
import Item from "./Item.svelte";
import type { Registration } from "./registration";
import { sectionKey } from "./context-keys";
import { insertElement, appendElement } from "./identifier";
import { makeInterface } from "./registration";
import { pageTheme } from "../sveltelib/theme"; import { pageTheme } from "../sveltelib/theme";
export let id: string | undefined = undefined; export let id: string | undefined = undefined;
@ -41,59 +21,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
$: style = buttonSize + buttonWrap; $: style = buttonSize + buttonWrap;
function makeRegistration(): Registration {
const detach = writable(false);
return { detach };
}
const { registerComponent, dynamicItems, getDynamicInterface } =
makeInterface(makeRegistration);
setContext(sectionKey, registerComponent);
export let api: Partial<ButtonToolbarAPI> | undefined = undefined;
let buttonToolbarRef: HTMLDivElement;
function createApi(): void {
const { addComponent, updateRegistration } =
getDynamicInterface(buttonToolbarRef);
const insertGroup = (group: SvelteComponent, position: Identifier = 0) =>
addComponent(group, (added, parent) =>
insertElement(added, parent, position),
);
const appendGroup = (group: SvelteComponent, position: Identifier = -1) =>
addComponent(group, (added, parent) =>
appendElement(added, parent, position),
);
const showGroup = (id: Identifier) =>
updateRegistration(({ detach }) => detach.set(false), id);
const hideGroup = (id: Identifier) =>
updateRegistration(({ detach }) => detach.set(true), id);
const toggleGroup = (id: Identifier) =>
updateRegistration(
({ detach }) => detach.update((old: boolean): boolean => !old),
id,
);
Object.assign(api, {
insertGroup,
appendGroup,
showGroup,
hideGroup,
toggleGroup,
});
}
$: if (buttonToolbarRef && api) {
createApi();
}
</script> </script>
<div <div
bind:this={buttonToolbarRef}
{id} {id}
class="button-toolbar btn-toolbar {className}" class="button-toolbar btn-toolbar {className}"
class:nightMode={$pageTheme.isDark} class:nightMode={$pageTheme.isDark}
@ -102,11 +32,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
on:focusout on:focusout
> >
<slot /> <slot />
{#each $dynamicItems as item}
<Item id={item[0].id} registration={item[1]}>
<svelte:component this={item[0].component} {...item[0].props} />
</Item>
{/each}
</div> </div>
<style lang="scss"> <style lang="scss">
@ -114,7 +39,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
flex-wrap: var(--buttons-wrap); flex-wrap: var(--buttons-wrap);
padding-left: 0.15rem; padding-left: 0.15rem;
> :global(*) > :global(*) { :global(.button-group) {
/* TODO replace with gap once available */ /* TODO replace with gap once available */
margin-right: 0.15rem; margin-right: 0.15rem;
margin-bottom: 0.15rem; margin-bottom: 0.15rem;

View file

@ -3,7 +3,6 @@ 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 Section from "./Section.svelte";
import type { Breakpoint } from "./types"; import type { Breakpoint } from "./types";
export let id: string | undefined = undefined; export let id: string | undefined = undefined;
@ -12,7 +11,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* width: 100% if viewport < breakpoint otherwise with gutters */ /* width: 100% if viewport < breakpoint otherwise with gutters */
export let breakpoint: Breakpoint | "fluid" = "fluid"; export let breakpoint: Breakpoint | "fluid" = "fluid";
export let api: Record<string, never> | undefined = undefined;
</script> </script>
<div <div
@ -26,9 +24,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
class:container-xxl={breakpoint === "xxl"} class:container-xxl={breakpoint === "xxl"}
class:container-fluid={breakpoint === "fluid"} class:container-fluid={breakpoint === "fluid"}
> >
<Section {api}> <slot />
<slot />
</Section>
</div> </div>
<style lang="scss"> <style lang="scss">

View file

@ -1,11 +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">
export let detached = false;
</script>
{#if !detached}
<slot />
{/if}

View file

@ -0,0 +1,64 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import type Item from "./Item.svelte";
import type ButtonGroupItem from "./ButtonGroupItem.svelte";
/* import type { SlotHostProps } from "../sveltelib/dynamic-slotting"; */
import dynamicSlotting, {
defaultProps,
defaultInterface,
setSlotHostContext as defaultContext,
} from "../sveltelib/dynamic-slotting";
function id<T>(value: T): T {
return value;
}
/**
* This should be a Svelte component that accepts `id` and `hostProps`
* as their props, only mounts a div with display:contents, and retrieves
* its props via .getProps().
* For a minimal example, have a look at `Item.svelte`.
*/
export let slotHost: typeof Item | typeof ButtonGroupItem;
/**
* We cannot properly type these right now.
*/
export let createProps: any /* <T extends SlotHostProps>() => T */ =
defaultProps as any;
export let updatePropsList: any /* <T extends SlotHostProps>(list: T[]) => T[] */ =
id;
export let setSlotHostContext = defaultContext;
export let createInterface = defaultInterface;
const { slotsInterface, resolveSlotContainer, dynamicSlotted } = dynamicSlotting(
createProps,
updatePropsList,
setSlotHostContext,
createInterface,
);
export let api: Partial<Record<string, unknown>>;
Object.assign(api, slotsInterface);
</script>
<div class="dynamically-slottable" use:resolveSlotContainer>
<slot />
{#each $dynamicSlotted as { component, hostProps } (component.id)}
<svelte:component this={slotHost} id={component.id} {hostProps}>
<svelte:component this={component.component} {...component.props} />
</svelte:component>
{/each}
</div>
<style lang="scss">
.dynamically-slottable {
display: contents;
}
</style>

View file

@ -3,35 +3,24 @@ 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 Detachable from "./Detachable.svelte"; import type { SlotHostProps } from "../sveltelib/dynamic-slotting";
import { defaultSlotHostContext } from "../sveltelib/dynamic-slotting";
import type { Register, Registration } from "./registration";
import { getContext, hasContext } from "svelte";
import { sectionKey } from "./context-keys";
export let id: string | undefined = undefined; export let id: string | undefined = undefined;
export let registration: Registration | undefined = undefined; export let hostProps: SlotHostProps | undefined = undefined;
let detached: boolean; if (!defaultSlotHostContext.available()) {
console.log("Item: should always have a slotHostContext");
if (registration) {
const { detach } = registration;
detach.subscribe((value: boolean) => (detached = value));
} else if (hasContext(sectionKey)) {
const registerComponent = getContext<Register<Registration>>(sectionKey);
const { detach } = registerComponent();
detach.subscribe((value: boolean) => (detached = value));
} else {
detached = false;
} }
const { detach } = hostProps ?? defaultSlotHostContext.get().getProps();
</script> </script>
<!-- div is necessary to preserve item position --> <!-- div is necessary to preserve item position -->
<div class="item" {id}> <div class="item" {id}>
<Detachable {detached}> {#if !$detach}
<slot /> <slot />
</Detachable> {/if}
</div> </div>
<style lang="scss"> <style lang="scss">

View file

@ -3,18 +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 Item from "../components/Item.svelte";
export let id: string | undefined = undefined;
let className: string = ""; let className: string = "";
export { className as class }; export { className as class };
</script> </script>
<Item {id}> <div class="row {className}">
<div class="row {className}"> <slot />
<slot /> </div>
</div>
</Item>
<style lang="scss"> <style lang="scss">
.row { .row {

View file

@ -1,69 +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 { setContext } from "svelte";
import { writable } from "svelte/store";
import Item from "./Item.svelte";
import { sectionKey } from "./context-keys";
import type { Identifier } from "./identifier";
import { insertElement, appendElement } from "./identifier";
import type { SvelteComponent, Registration } from "./registration";
import { makeInterface } from "./registration";
export let id: string | undefined = undefined;
function makeRegistration(): Registration {
const detach = writable(false);
return { detach };
}
const { registerComponent, dynamicItems, getDynamicInterface } =
makeInterface(makeRegistration);
setContext(sectionKey, registerComponent);
export let api: Record<string, never> | undefined = undefined;
let sectionRef: HTMLDivElement;
$: if (sectionRef && api) {
const { addComponent, updateRegistration } = getDynamicInterface(sectionRef);
const insert = (group: SvelteComponent, position: Identifier = 0) =>
addComponent(group, (added, parent) =>
insertElement(added, parent, position),
);
const append = (group: SvelteComponent, position: Identifier = -1) =>
addComponent(group, (added, parent) =>
appendElement(added, parent, position),
);
const show = (id: Identifier) =>
updateRegistration(({ detach }) => detach.set(false), id);
const hide = (id: Identifier) =>
updateRegistration(({ detach }) => detach.set(true), id);
const toggle = (id: Identifier) =>
updateRegistration(
({ detach }) => detach.update((old: boolean): boolean => !old),
id,
);
Object.assign(api, { insert, append, show, hide, toggle });
}
</script>
<div bind:this={sectionRef} {id}>
<slot />
{#each $dynamicItems as item}
<Item id={item[0].id} registration={item[1]}>
<svelte:component this={item[0].component} {...item[0].props} />
</Item>
{/each}
</div>
<style lang="scss">
div {
display: contents;
}
</style>

View file

@ -12,11 +12,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let height: number = 0; export let height: number = 0;
export let breakpoint: Breakpoint | "fluid" = "fluid"; export let breakpoint: Breakpoint | "fluid" = "fluid";
export let api: Record<string, never> | undefined = undefined;
</script> </script>
<div {id} bind:offsetHeight={height} class="sticky-container {className}"> <div {id} bind:offsetHeight={height} class="sticky-container {className}">
<Container {breakpoint} {api}> <Container {breakpoint}>
<slot /> <slot />
</Container> </Container>
</div> </div>

View file

@ -85,9 +85,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
api = { api = {
show: dropdown.show.bind(dropdown), show: dropdown.show.bind(dropdown),
// TODO this is quite confusing, but commenting this fixes Bootstrap // TODO this is quite confusing, but having a noop function fixes Bootstrap
// in the deck-options when not including Bootstrap via <script /> // in the deck-options when not including Bootstrap via <script />
toggle: () => {}, // toggle: dropdown.toggle.bind(dropdown), toggle: () => {},
/* 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),
@ -100,7 +101,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onDestroy(() => dropdown?.dispose()); onDestroy(() => dropdown?.dispose());
</script> </script>
<div class={dropClass}> <div class="with-dropdown {dropClass}">
<slot {createDropdown} dropdownObject={api} /> <slot {createDropdown} dropdownObject={api} />
</div> </div>

View file

@ -1,15 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { Writable } from "svelte/store";
import type { Registration } from "./registration";
export enum ButtonPosition {
Standalone,
InlineStart,
Center,
InlineEnd,
}
export interface ButtonRegistration extends Registration {
position: Writable<ButtonPosition>;
}

View file

@ -1,87 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export type Identifier = string | number;
export function findElement(
collection: HTMLCollection,
idOrIndex: Identifier,
): [number, Element] | null {
let result: [number, Element] | null = null;
if (typeof idOrIndex === "string") {
const element = collection.namedItem(idOrIndex);
if (element) {
const index = Array.prototype.indexOf.call(collection, element);
result = [index, element];
}
} else if (idOrIndex < 0) {
const index = collection.length + idOrIndex;
const element = collection.item(index);
if (element) {
result = [index, element];
}
} else {
const index = idOrIndex;
const element = collection.item(index);
if (element) {
result = [index, element];
}
}
return result;
}
export function insertElement(
element: Element,
collection: Element,
idOrIndex: Identifier,
): number {
const match = findElement(collection.children, idOrIndex);
if (match) {
const [index, reference] = match;
collection.insertBefore(element, reference[0]);
return index;
}
return -1;
}
export function appendElement(
element: Element,
collection: Element,
idOrIndex: Identifier,
): number {
const match = findElement(collection.children, idOrIndex);
if (match) {
const [index, before] = match;
const reference = before.nextElementSibling ?? null;
collection.insertBefore(element, reference);
return index + 1;
}
return -1;
}
export function updateElement(
f: (element: Element) => void,
collection: Element,
idOrIndex: Identifier,
): number {
const match = findElement(collection.children, idOrIndex);
if (match) {
const [index, element] = match;
f(element[0]);
return index;
}
return -1;
}

View file

@ -1,126 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { SvelteComponentTyped } from "svelte/internal";
import type { Writable, Readable } from "svelte/store";
import { writable } from "svelte/store";
import type { Identifier } from "./identifier";
import { findElement } from "./identifier";
export interface SvelteComponent {
component: SvelteComponentTyped;
id: string;
props: Record<string, unknown> | undefined;
}
export interface Registration {
detach: Writable<boolean>;
}
export type Register<T extends Registration> = (index?: number, registration?: T) => T;
export interface RegistrationAPI<T extends Registration> {
registerComponent: Register<T>;
items: Readable<T[]>;
dynamicItems: Readable<[SvelteComponent, T][]>;
getDynamicInterface: (elementRef: HTMLElement) => DynamicRegistrationAPI<T>;
}
export interface DynamicRegistrationAPI<T> {
addComponent: (
component: SvelteComponent,
add: (added: Element, parent: Element) => number,
) => void;
updateRegistration: (
update: (registration: T) => void,
position: Identifier,
) => void;
}
export function nodeIsElement(node: Node): node is Element {
return node.nodeType === Node.ELEMENT_NODE;
}
export function makeInterface<T extends Registration>(
makeRegistration: () => T,
): RegistrationAPI<T> {
const registrations: T[] = [];
const items = writable(registrations);
function registerComponent(
index: number = registrations.length,
registration = makeRegistration(),
): T {
items.update((registrations) => {
registrations.splice(index, 0, registration);
return registrations;
});
return registration;
}
const dynamicRegistrations: [SvelteComponent, T][] = [];
const dynamicItems = writable(dynamicRegistrations);
function getDynamicInterface(elementRef: HTMLElement): DynamicRegistrationAPI<T> {
function addComponent(
component: SvelteComponent,
add: (added: Element, parent: Element) => number,
): void {
const registration = makeRegistration();
const callback = (
mutations: MutationRecord[],
observer: MutationObserver,
): void => {
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
if (
nodeIsElement(addedNode) &&
(!component.id || addedNode.id === component.id)
) {
const index = add(addedNode, elementRef);
if (index >= 0) {
registerComponent(index, registration);
}
return observer.disconnect();
}
}
}
};
const observer = new MutationObserver(callback);
observer.observe(elementRef, { childList: true });
dynamicRegistrations.push([component, registration]);
dynamicItems.set(dynamicRegistrations);
}
function updateRegistration(
update: (registration: T) => void,
position: Identifier,
): void {
const match = findElement(elementRef.children, position);
if (match) {
const [index] = match;
const registration = registrations[index];
update(registration);
items.set(registrations);
}
}
return {
addComponent,
updateRegistration,
};
}
return {
registerComponent,
items,
dynamicItems,
getDynamicInterface,
};
}

View file

@ -7,14 +7,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let api: Record<string, never>;
let components = state.addonComponents; let components = state.addonComponents;
const auxData = state.currentAuxData; const auxData = state.currentAuxData;
</script> </script>
{#if $components.length || state.haveAddons} {#if $components.length || state.haveAddons}
<TitledContainer title="Add-ons" {api}> <TitledContainer title="Add-ons">
<p> <p>
If you're using an add-on that hasn't been updated to use this new screen If you're using an add-on that hasn't been updated to use this new screen
yet, you can access the old deck options screen by holding down the shift yet, you can access the old deck options screen by holding down the shift

View file

@ -9,6 +9,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte"; import SpinBoxFloatRow from "./SpinBoxFloatRow.svelte";
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
import CardStateCustomizer from "./CardStateCustomizer.svelte"; import CardStateCustomizer from "./CardStateCustomizer.svelte";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import Item from "../components/Item.svelte";
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let api: Record<string, never>; export let api: Record<string, never>;
@ -18,67 +20,83 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let cardStateCustomizer = state.cardStateCustomizer; let cardStateCustomizer = state.cardStateCustomizer;
</script> </script>
<TitledContainer title={tr.deckConfigAdvancedTitle()} {api}> <TitledContainer title={tr.deckConfigAdvancedTitle()}>
<SpinBoxRow <DynamicallySlottable slotHost={Item} {api}>
bind:value={$config.maximumReviewInterval} <Item>
defaultValue={defaults.maximumReviewInterval} <SpinBoxRow
min={1} bind:value={$config.maximumReviewInterval}
max={365 * 100} defaultValue={defaults.maximumReviewInterval}
markdownTooltip={tr.deckConfigMaximumIntervalTooltip()} min={1}
> max={365 * 100}
{tr.schedulingMaximumInterval()} markdownTooltip={tr.deckConfigMaximumIntervalTooltip()}
</SpinBoxRow> >
{tr.schedulingMaximumInterval()}
</SpinBoxRow>
</Item>
<SpinBoxFloatRow <Item>
bind:value={$config.initialEase} <SpinBoxFloatRow
defaultValue={defaults.initialEase} bind:value={$config.initialEase}
min={1.31} defaultValue={defaults.initialEase}
max={5} min={1.31}
markdownTooltip={tr.deckConfigStartingEaseTooltip()} max={5}
> markdownTooltip={tr.deckConfigStartingEaseTooltip()}
{tr.schedulingStartingEase()} >
</SpinBoxFloatRow> {tr.schedulingStartingEase()}
</SpinBoxFloatRow>
</Item>
<SpinBoxFloatRow <Item>
bind:value={$config.easyMultiplier} <SpinBoxFloatRow
defaultValue={defaults.easyMultiplier} bind:value={$config.easyMultiplier}
min={1} defaultValue={defaults.easyMultiplier}
max={3} min={1}
markdownTooltip={tr.deckConfigEasyBonusTooltip()} max={3}
> markdownTooltip={tr.deckConfigEasyBonusTooltip()}
{tr.schedulingEasyBonus()} >
</SpinBoxFloatRow> {tr.schedulingEasyBonus()}
</SpinBoxFloatRow>
</Item>
<SpinBoxFloatRow <Item>
bind:value={$config.intervalMultiplier} <SpinBoxFloatRow
defaultValue={defaults.intervalMultiplier} bind:value={$config.intervalMultiplier}
min={0.5} defaultValue={defaults.intervalMultiplier}
max={2} min={0.5}
markdownTooltip={tr.deckConfigIntervalModifierTooltip()} max={2}
> markdownTooltip={tr.deckConfigIntervalModifierTooltip()}
{tr.schedulingIntervalModifier()} >
</SpinBoxFloatRow> {tr.schedulingIntervalModifier()}
</SpinBoxFloatRow>
</Item>
<SpinBoxFloatRow <Item>
bind:value={$config.hardMultiplier} <SpinBoxFloatRow
defaultValue={defaults.hardMultiplier} bind:value={$config.hardMultiplier}
min={0.5} defaultValue={defaults.hardMultiplier}
max={1.3} min={0.5}
markdownTooltip={tr.deckConfigHardIntervalTooltip()} max={1.3}
> markdownTooltip={tr.deckConfigHardIntervalTooltip()}
{tr.schedulingHardInterval()} >
</SpinBoxFloatRow> {tr.schedulingHardInterval()}
</SpinBoxFloatRow>
</Item>
<SpinBoxFloatRow <Item>
bind:value={$config.lapseMultiplier} <SpinBoxFloatRow
defaultValue={defaults.lapseMultiplier} bind:value={$config.lapseMultiplier}
max={1} defaultValue={defaults.lapseMultiplier}
markdownTooltip={tr.deckConfigNewIntervalTooltip()} max={1}
> markdownTooltip={tr.deckConfigNewIntervalTooltip()}
{tr.schedulingNewInterval()} >
</SpinBoxFloatRow> {tr.schedulingNewInterval()}
</SpinBoxFloatRow>
</Item>
{#if state.v3Scheduler} {#if state.v3Scheduler}
<CardStateCustomizer bind:value={$cardStateCustomizer} /> <Item>
{/if} <CardStateCustomizer bind:value={$cardStateCustomizer} />
</Item>
{/if}
</DynamicallySlottable>
</TitledContainer> </TitledContainer>

View file

@ -7,6 +7,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import TitledContainer from "./TitledContainer.svelte"; import TitledContainer from "./TitledContainer.svelte";
import SwitchRow from "./SwitchRow.svelte"; import SwitchRow from "./SwitchRow.svelte";
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import Item from "../components/Item.svelte";
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let api: Record<string, never>; export let api: Record<string, never>;
@ -15,19 +17,25 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let defaults = state.defaults; let defaults = state.defaults;
</script> </script>
<TitledContainer title={tr.deckConfigAudioTitle()} {api}> <TitledContainer title={tr.deckConfigAudioTitle()}>
<SwitchRow <DynamicallySlottable slotHost={Item} {api}>
bind:value={$config.disableAutoplay} <Item>
defaultValue={defaults.disableAutoplay} <SwitchRow
> bind:value={$config.disableAutoplay}
{tr.deckConfigDisableAutoplay()} defaultValue={defaults.disableAutoplay}
</SwitchRow> >
{tr.deckConfigDisableAutoplay()}
</SwitchRow>
</Item>
<SwitchRow <Item>
bind:value={$config.skipQuestionWhenReplayingAnswer} <SwitchRow
defaultValue={defaults.skipQuestionWhenReplayingAnswer} bind:value={$config.skipQuestionWhenReplayingAnswer}
markdownTooltip={tr.deckConfigAlwaysIncludeQuestionAudioTooltip()} defaultValue={defaults.skipQuestionWhenReplayingAnswer}
> markdownTooltip={tr.deckConfigAlwaysIncludeQuestionAudioTooltip()}
{tr.deckConfigSkipQuestionWhenReplaying()} >
</SwitchRow> {tr.deckConfigSkipQuestionWhenReplaying()}
</SwitchRow>
</Item>
</DynamicallySlottable>
</TitledContainer> </TitledContainer>

View file

@ -17,6 +17,7 @@ compile_sass(
"//sass:base_lib", "//sass:base_lib",
"//sass:breakpoints_lib", "//sass:breakpoints_lib",
"//sass:scrollbar_lib", "//sass:scrollbar_lib",
"//sass:night_mode_lib",
"//sass/bootstrap", "//sass/bootstrap",
], ],
) )
@ -37,6 +38,10 @@ _ts_deps = [
compile_svelte( compile_svelte(
deps = _ts_deps + [ deps = _ts_deps + [
"//sass:base_lib",
"//sass:breakpoints_lib",
"//sass:scrollbar_lib",
"//sass:night_mode_lib",
"//sass/bootstrap", "//sass/bootstrap",
], ],
) )
@ -77,9 +82,11 @@ svelte_check(
"*.ts", "*.ts",
"*.svelte", "*.svelte",
]) + [ ]) + [
"//sass:base_lib",
"//sass:button_mixins_lib", "//sass:button_mixins_lib",
"//sass:night_mode_lib", "//sass:scrollbar_lib",
"//sass:breakpoints_lib", "//sass:breakpoints_lib",
"//sass:night_mode_lib",
"//sass/bootstrap", "//sass/bootstrap",
"//ts/components", "//ts/components",
"//ts/sveltelib:sveltelib_pkg", "//ts/sveltelib:sveltelib_pkg",

View file

@ -7,6 +7,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import SwitchRow from "./SwitchRow.svelte"; import SwitchRow from "./SwitchRow.svelte";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import Item from "../components/Item.svelte";
export let state: DeckOptionsState; export let state: DeckOptionsState;
export let api: Record<string, never>; export let api: Record<string, never>;
@ -15,20 +17,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let defaults = state.defaults; let defaults = state.defaults;
</script> </script>
<TitledContainer title={tr.deckConfigBuryTitle()} {api}> <TitledContainer title={tr.deckConfigBuryTitle()}>
<SwitchRow <DynamicallySlottable slotHost={Item} {api}>
bind:value={$config.buryNew} <Item>
defaultValue={defaults.buryNew} <SwitchRow
markdownTooltip={tr.deckConfigBuryTooltip()} bind:value={$config.buryNew}
> defaultValue={defaults.buryNew}
{tr.deckConfigBuryNewSiblings()} markdownTooltip={tr.deckConfigBuryTooltip()}
</SwitchRow> >
{tr.deckConfigBuryNewSiblings()}
</SwitchRow>
</Item>
<SwitchRow <Item>
bind:value={$config.buryReviews} <SwitchRow
defaultValue={defaults.buryReviews} bind:value={$config.buryReviews}
markdownTooltip={tr.deckConfigBuryTooltip()} defaultValue={defaults.buryReviews}
> markdownTooltip={tr.deckConfigBuryTooltip()}
{tr.deckConfigBuryReviewSiblings()} >
</SwitchRow> {tr.deckConfigBuryReviewSiblings()}
</SwitchRow>
</Item>
</DynamicallySlottable>
</TitledContainer> </TitledContainer>

View file

@ -12,9 +12,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import TextInputModal from "./TextInputModal.svelte"; import TextInputModal from "./TextInputModal.svelte";
import StickyContainer from "../components/StickyContainer.svelte"; import StickyContainer from "../components/StickyContainer.svelte";
import ButtonToolbar from "../components/ButtonToolbar.svelte"; import ButtonToolbar from "../components/ButtonToolbar.svelte";
import Item from "../components/Item.svelte";
import ButtonGroup from "../components/ButtonGroup.svelte"; import ButtonGroup from "../components/ButtonGroup.svelte";
import ButtonGroupItem from "../components/ButtonGroupItem.svelte";
import SelectButton from "../components/SelectButton.svelte"; import SelectButton from "../components/SelectButton.svelte";
import SelectOption from "../components/SelectOption.svelte"; import SelectOption from "../components/SelectOption.svelte";
@ -89,30 +87,26 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<StickyContainer --gutter-block="0.5rem" --sticky-borders="0 0 1px" breakpoint="sm"> <StickyContainer --gutter-block="0.5rem" --sticky-borders="0 0 1px" breakpoint="sm">
<ButtonToolbar class="justify-content-between" size={2.3} wrap={false}> <ButtonToolbar class="justify-content-between" size={2.3} wrap={false}>
<Item> <ButtonGroup class="flex-grow-1">
<ButtonGroup class="flex-grow-1"> <SelectButton
<ButtonGroupItem> class="flex-grow-1"
<SelectButton class="flex-grow-1" on:change={blur}> on:change={blur}
{#each $configList as entry} --border-left-radius="5px"
<SelectOption --border-right-radius="5px"
value={String(entry.idx)} >
selected={entry.current} {#each $configList as entry}
> <SelectOption value={String(entry.idx)} selected={entry.current}>
{configLabel(entry)} {configLabel(entry)}
</SelectOption> </SelectOption>
{/each} {/each}
</SelectButton> </SelectButton>
</ButtonGroupItem> </ButtonGroup>
</ButtonGroup>
</Item>
<Item> <SaveButton
<SaveButton {state}
{state} on:add={promptToAdd}
on:add={promptToAdd} on:clone={promptToClone}
on:clone={promptToClone} on:rename={promptToRename}
on:rename={promptToRename} />
/>
</Item>
</ButtonToolbar> </ButtonToolbar>
</StickyContainer> </StickyContainer>

View file

@ -5,6 +5,7 @@
<script lang="ts"> <script lang="ts">
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import TitledContainer from "./TitledContainer.svelte"; import TitledContainer from "./TitledContainer.svelte";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import Item from "../components/Item.svelte"; import Item from "../components/Item.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte"; import SpinBoxRow from "./SpinBoxRow.svelte";
import Warning from "./Warning.svelte"; import Warning from "./Warning.svelte";
@ -40,28 +41,34 @@
: ""; : "";
</script> </script>
<TitledContainer title={tr.deckConfigDailyLimits()} {api}> <TitledContainer title={tr.deckConfigDailyLimits()}>
<SpinBoxRow <DynamicallySlottable slotHost={Item} {api}>
bind:value={$config.newPerDay} <Item>
defaultValue={defaults.newPerDay} <SpinBoxRow
markdownTooltip={tr.deckConfigNewLimitTooltip() + v3Extra} bind:value={$config.newPerDay}
> defaultValue={defaults.newPerDay}
{tr.schedulingNewCardsday()} markdownTooltip={tr.deckConfigNewLimitTooltip() + v3Extra}
</SpinBoxRow> >
{tr.schedulingNewCardsday()}
</SpinBoxRow>
</Item>
<Item> <Item>
<Warning warning={newCardsGreaterThanParent} /> <Warning warning={newCardsGreaterThanParent} />
</Item> </Item>
<SpinBoxRow <Item>
bind:value={$config.reviewsPerDay} <SpinBoxRow
defaultValue={defaults.reviewsPerDay} bind:value={$config.reviewsPerDay}
markdownTooltip={tr.deckConfigReviewLimitTooltip() + v3Extra} defaultValue={defaults.reviewsPerDay}
> markdownTooltip={tr.deckConfigReviewLimitTooltip() + v3Extra}
{tr.schedulingMaximumReviewsday()} >
</SpinBoxRow> {tr.schedulingMaximumReviewsday()}
</SpinBoxRow>
</Item>
<Item> <Item>
<Warning warning={reviewsTooLow} /> <Warning warning={reviewsTooLow} />
</Item> </Item>
</DynamicallySlottable>
</TitledContainer> </TitledContainer>

View file

@ -15,6 +15,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import TimerOptions from "./TimerOptions.svelte"; import TimerOptions from "./TimerOptions.svelte";
import AudioOptions from "./AudioOptions.svelte"; import AudioOptions from "./AudioOptions.svelte";
import Addons from "./Addons.svelte"; import Addons from "./Addons.svelte";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import Item from "../components/Item.svelte";
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
import type { Writable } from "svelte/store"; import type { Writable } from "svelte/store";
@ -51,7 +53,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export const displayOrder = {}; export const displayOrder = {};
export const timerOptions = {}; export const timerOptions = {};
export const audioOptions = {}; export const audioOptions = {};
export const addonOptions = {};
export const advancedOptions = {}; export const advancedOptions = {};
</script> </script>
@ -63,45 +64,64 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--gutter-inline="0.25rem" --gutter-inline="0.25rem"
--gutter-block="0.5rem" --gutter-block="0.5rem"
class="container-columns" class="container-columns"
api={options}
> >
<Row class="row-columns"> <DynamicallySlottable slotHost={Item} api={options}>
<DailyLimits {state} api={dailyLimits} /> <Item>
</Row> <Row class="row-columns">
<DailyLimits {state} api={dailyLimits} />
</Row>
</Item>
<Row class="row-columns"> <Item>
<NewOptions {state} api={newOptions} /> <Row class="row-columns">
</Row> <NewOptions {state} api={newOptions} />
</Row>
</Item>
<Row class="row-columns"> <Item>
<LapseOptions {state} api={lapseOptions} /> <Row class="row-columns">
</Row> <LapseOptions {state} api={lapseOptions} />
</Row>
</Item>
{#if state.v3Scheduler} {#if state.v3Scheduler}
<Row class="row-columns"> <Item>
<DisplayOrder {state} api={displayOrder} /> <Row class="row-columns">
</Row> <DisplayOrder {state} api={displayOrder} />
{/if} </Row>
</Item>
{/if}
<Row class="row-columns"> <Item>
<TimerOptions {state} api={timerOptions} /> <Row class="row-columns">
</Row> <TimerOptions {state} api={timerOptions} />
</Row>
</Item>
<Row class="row-columns"> <Item>
<BuryOptions {state} api={buryOptions} /> <Row class="row-columns">
</Row> <BuryOptions {state} api={buryOptions} />
</Row>
</Item>
<Row class="row-columns"> <Item>
<AudioOptions {state} api={audioOptions} /> <Row class="row-columns">
</Row> <AudioOptions {state} api={audioOptions} />
</Row>
</Item>
<Row class="row-columns"> <Item>
<Addons {state} api={addonOptions} /> <Row class="row-columns">
</Row> <Addons {state} />
</Row>
</Item>
<Row class="row-columns"> <Item>
<AdvancedOptions {state} api={advancedOptions} /> <Row class="row-columns">
</Row> <AdvancedOptions {state} api={advancedOptions} />
</Row>
</Item>
</DynamicallySlottable>
</Container> </Container>
</div> </div>

View file

@ -5,8 +5,9 @@ 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/ftl"; import * as tr from "../lib/ftl";
import TitledContainer from "./TitledContainer.svelte"; import TitledContainer from "./TitledContainer.svelte";
import Item from "../components/Item.svelte";
import EnumSelectorRow from "./EnumSelectorRow.svelte"; import EnumSelectorRow from "./EnumSelectorRow.svelte";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import Item from "../components/Item.svelte";
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
import { reviewMixChoices } from "./strings"; import { reviewMixChoices } from "./strings";
@ -45,59 +46,62 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
]; ];
</script> </script>
<TitledContainer title={tr.deckConfigOrderingTitle()} {api}> <TitledContainer title={tr.deckConfigOrderingTitle()}>
<Item> <DynamicallySlottable slotHost={Item} {api}>
<EnumSelectorRow <Item>
bind:value={$config.newCardGatherPriority} <EnumSelectorRow
defaultValue={defaults.newCardGatherPriority} bind:value={$config.newCardGatherPriority}
choices={newGatherPriorityChoices} defaultValue={defaults.newCardGatherPriority}
markdownTooltip={tr.deckConfigNewGatherPriorityTooltip() + currentDeck} choices={newGatherPriorityChoices}
> markdownTooltip={tr.deckConfigNewGatherPriorityTooltip() + currentDeck}
{tr.deckConfigNewGatherPriority()} >
</EnumSelectorRow> {tr.deckConfigNewGatherPriority()}
</Item> </EnumSelectorRow>
</Item>
<Item> <Item>
<EnumSelectorRow <EnumSelectorRow
bind:value={$config.newCardSortOrder} bind:value={$config.newCardSortOrder}
defaultValue={defaults.newCardSortOrder} defaultValue={defaults.newCardSortOrder}
choices={newSortOrderChoices} choices={newSortOrderChoices}
markdownTooltip={tr.deckConfigNewCardSortOrderTooltip() + currentDeck} markdownTooltip={tr.deckConfigNewCardSortOrderTooltip() + currentDeck}
> >
{tr.deckConfigNewCardSortOrder()} {tr.deckConfigNewCardSortOrder()}
</EnumSelectorRow> </EnumSelectorRow>
</Item> </Item>
<Item> <Item>
<EnumSelectorRow <EnumSelectorRow
bind:value={$config.newMix} bind:value={$config.newMix}
defaultValue={defaults.newMix} defaultValue={defaults.newMix}
choices={reviewMixChoices()} choices={reviewMixChoices()}
markdownTooltip={tr.deckConfigNewReviewPriorityTooltip() + currentDeck} markdownTooltip={tr.deckConfigNewReviewPriorityTooltip() + currentDeck}
> >
{tr.deckConfigNewReviewPriority()} {tr.deckConfigNewReviewPriority()}
</EnumSelectorRow> </EnumSelectorRow>
</Item> </Item>
<Item> <Item>
<EnumSelectorRow <EnumSelectorRow
bind:value={$config.interdayLearningMix} bind:value={$config.interdayLearningMix}
defaultValue={defaults.interdayLearningMix} defaultValue={defaults.interdayLearningMix}
choices={reviewMixChoices()} choices={reviewMixChoices()}
markdownTooltip={tr.deckConfigInterdayStepPriorityTooltip() + currentDeck} markdownTooltip={tr.deckConfigInterdayStepPriorityTooltip() +
> currentDeck}
{tr.deckConfigInterdayStepPriority()} >
</EnumSelectorRow> {tr.deckConfigInterdayStepPriority()}
</Item> </EnumSelectorRow>
</Item>
<Item> <Item>
<EnumSelectorRow <EnumSelectorRow
bind:value={$config.reviewOrder} bind:value={$config.reviewOrder}
defaultValue={defaults.reviewOrder} defaultValue={defaults.reviewOrder}
choices={reviewOrderChoices} choices={reviewOrderChoices}
markdownTooltip={tr.deckConfigReviewSortOrderTooltip() + currentDeck} markdownTooltip={tr.deckConfigReviewSortOrderTooltip() + currentDeck}
> >
{tr.deckConfigReviewSortOrder()} {tr.deckConfigReviewSortOrder()}
</EnumSelectorRow> </EnumSelectorRow>
</Item> </Item>
</DynamicallySlottable>
</TitledContainer> </TitledContainer>

View file

@ -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/ftl"; import * as tr from "../lib/ftl";
import TitledContainer from "./TitledContainer.svelte"; import TitledContainer from "./TitledContainer.svelte";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import Item from "../components/Item.svelte"; import Item from "../components/Item.svelte";
import StepsInputRow from "./StepsInputRow.svelte"; import StepsInputRow from "./StepsInputRow.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte"; import SpinBoxRow from "./SpinBoxRow.svelte";
@ -32,44 +33,54 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const leechChoices = [tr.actionsSuspendCard(), tr.schedulingTagOnly()]; const leechChoices = [tr.actionsSuspendCard(), tr.schedulingTagOnly()];
</script> </script>
<TitledContainer title={tr.schedulingLapses()} {api}> <TitledContainer title={tr.schedulingLapses()}>
<StepsInputRow <DynamicallySlottable slotHost={Item} {api}>
bind:value={$config.relearnSteps} <Item>
defaultValue={defaults.relearnSteps} <StepsInputRow
markdownTooltip={tr.deckConfigRelearningStepsTooltip()} bind:value={$config.relearnSteps}
> defaultValue={defaults.relearnSteps}
{tr.deckConfigRelearningSteps()} markdownTooltip={tr.deckConfigRelearningStepsTooltip()}
</StepsInputRow> >
{tr.deckConfigRelearningSteps()}
</StepsInputRow>
</Item>
<SpinBoxRow <Item>
bind:value={$config.minimumLapseInterval} <SpinBoxRow
defaultValue={defaults.minimumLapseInterval} bind:value={$config.minimumLapseInterval}
min={1} defaultValue={defaults.minimumLapseInterval}
markdownTooltip={tr.deckConfigMinimumIntervalTooltip()} min={1}
> markdownTooltip={tr.deckConfigMinimumIntervalTooltip()}
{tr.schedulingMinimumInterval()} >
</SpinBoxRow> {tr.schedulingMinimumInterval()}
</SpinBoxRow>
</Item>
<Item> <Item>
<Warning warning={stepsExceedMinimumInterval} /> <Warning warning={stepsExceedMinimumInterval} />
</Item> </Item>
<SpinBoxRow <Item>
bind:value={$config.leechThreshold} <SpinBoxRow
defaultValue={defaults.leechThreshold} bind:value={$config.leechThreshold}
min={1} defaultValue={defaults.leechThreshold}
markdownTooltip={tr.deckConfigLeechThresholdTooltip()} min={1}
> markdownTooltip={tr.deckConfigLeechThresholdTooltip()}
{tr.schedulingLeechThreshold()} >
</SpinBoxRow> {tr.schedulingLeechThreshold()}
</SpinBoxRow>
</Item>
<EnumSelectorRow <Item>
bind:value={$config.leechAction} <EnumSelectorRow
defaultValue={defaults.leechAction} bind:value={$config.leechAction}
choices={leechChoices} defaultValue={defaults.leechAction}
breakpoint="sm" choices={leechChoices}
markdownTooltip={tr.deckConfigLeechActionTooltip()} breakpoint="sm"
> markdownTooltip={tr.deckConfigLeechActionTooltip()}
{tr.schedulingLeechAction()} >
</EnumSelectorRow> {tr.schedulingLeechAction()}
</EnumSelectorRow>
</Item>
</DynamicallySlottable>
</TitledContainer> </TitledContainer>

View file

@ -7,8 +7,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import StepsInputRow from "./StepsInputRow.svelte"; import StepsInputRow from "./StepsInputRow.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte"; import SpinBoxRow from "./SpinBoxRow.svelte";
import EnumSelectorRow from "./EnumSelectorRow.svelte"; import EnumSelectorRow from "./EnumSelectorRow.svelte";
import Item from "../components/Item.svelte";
import Warning from "./Warning.svelte"; import Warning from "./Warning.svelte";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import Item from "../components/Item.svelte";
import type { DeckOptionsState } from "./lib"; import type { DeckOptionsState } from "./lib";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
@ -41,46 +42,56 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
: ""; : "";
</script> </script>
<TitledContainer title={tr.schedulingNewCards()} {api}> <TitledContainer title={tr.schedulingNewCards()}>
<StepsInputRow <DynamicallySlottable slotHost={Item} {api}>
bind:value={$config.learnSteps} <Item>
defaultValue={defaults.learnSteps} <StepsInputRow
markdownTooltip={tr.deckConfigLearningStepsTooltip()} bind:value={$config.learnSteps}
> defaultValue={defaults.learnSteps}
{tr.deckConfigLearningSteps()} markdownTooltip={tr.deckConfigLearningStepsTooltip()}
</StepsInputRow> >
{tr.deckConfigLearningSteps()}
</StepsInputRow>
</Item>
<SpinBoxRow <Item>
bind:value={$config.graduatingIntervalGood} <SpinBoxRow
defaultValue={defaults.graduatingIntervalGood} bind:value={$config.graduatingIntervalGood}
markdownTooltip={tr.deckConfigGraduatingIntervalTooltip()} defaultValue={defaults.graduatingIntervalGood}
> markdownTooltip={tr.deckConfigGraduatingIntervalTooltip()}
{tr.schedulingGraduatingInterval()} >
</SpinBoxRow> {tr.schedulingGraduatingInterval()}
</SpinBoxRow>
</Item>
<Item> <Item>
<Warning warning={stepsExceedGraduatingInterval} /> <Warning warning={stepsExceedGraduatingInterval} />
</Item> </Item>
<SpinBoxRow <Item>
bind:value={$config.graduatingIntervalEasy} <SpinBoxRow
defaultValue={defaults.graduatingIntervalEasy} bind:value={$config.graduatingIntervalEasy}
markdownTooltip={tr.deckConfigEasyIntervalTooltip()} defaultValue={defaults.graduatingIntervalEasy}
> markdownTooltip={tr.deckConfigEasyIntervalTooltip()}
{tr.schedulingEasyInterval()} >
</SpinBoxRow> {tr.schedulingEasyInterval()}
</SpinBoxRow>
</Item>
<Item> <Item>
<Warning warning={goodExceedsEasy} /> <Warning warning={goodExceedsEasy} />
</Item> </Item>
<EnumSelectorRow <Item>
bind:value={$config.newCardInsertOrder} <EnumSelectorRow
defaultValue={defaults.newCardInsertOrder} bind:value={$config.newCardInsertOrder}
choices={newInsertOrderChoices} defaultValue={defaults.newCardInsertOrder}
breakpoint={"md"} choices={newInsertOrderChoices}
markdownTooltip={tr.deckConfigNewInsertionOrderTooltip()} breakpoint={"md"}
> markdownTooltip={tr.deckConfigNewInsertionOrderTooltip()}
{tr.deckConfigNewInsertionOrder()} >
</EnumSelectorRow> {tr.deckConfigNewInsertionOrder()}
</EnumSelectorRow>
</Item>
</DynamicallySlottable>
</TitledContainer> </TitledContainer>

View file

@ -11,8 +11,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { withCollapsedWhitespace } from "../lib/i18n"; import { withCollapsedWhitespace } from "../lib/i18n";
import ButtonGroup from "../components/ButtonGroup.svelte"; import ButtonGroup from "../components/ButtonGroup.svelte";
import ButtonGroupItem from "../components/ButtonGroupItem.svelte";
import LabelButton from "../components/LabelButton.svelte"; import LabelButton from "../components/LabelButton.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";
@ -62,40 +60,36 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<ButtonGroup> <ButtonGroup>
<ButtonGroupItem> <LabelButton
<LabelButton theme="primary"
theme="primary" on:click={() => save(false)}
on:click={() => save(false)} tooltip={getPlatformString(saveKeyCombination)}
tooltip={getPlatformString(saveKeyCombination)} --border-left-radius="5px">{tr.deckConfigSaveButton()}</LabelButton
>{tr.deckConfigSaveButton()}</LabelButton >
> <Shortcut keyCombination={saveKeyCombination} on:click={() => save(false)} />
<Shortcut keyCombination={saveKeyCombination} on:click={() => save(false)} />
</ButtonGroupItem>
<ButtonGroupItem> <WithDropdown let:createDropdown --border-right-radius="5px">
<WithDropdown let:createDropdown> <LabelButton
<LabelButton on:click={() => dropdown.toggle()}
on:mount={(event) => (dropdown = createDropdown(event.detail.button))} on:mount={(event) => (dropdown = createDropdown(event.detail.button))}
on:click={() => dropdown.toggle()} />
/> <DropdownMenu>
<DropdownMenu> <DropdownItem on:click={() => dispatch("add")}
<DropdownItem on:click={() => dispatch("add")} >{tr.deckConfigAddGroup()}</DropdownItem
>{tr.deckConfigAddGroup()}</DropdownItem >
> <DropdownItem on:click={() => dispatch("clone")}
<DropdownItem on:click={() => dispatch("clone")} >{tr.deckConfigCloneGroup()}</DropdownItem
>{tr.deckConfigCloneGroup()}</DropdownItem >
> <DropdownItem on:click={() => dispatch("rename")}>
<DropdownItem on:click={() => dispatch("rename")}> {tr.deckConfigRenameGroup()}
{tr.deckConfigRenameGroup()} </DropdownItem>
</DropdownItem> <DropdownItem on:click={removeConfig}
<DropdownItem on:click={removeConfig} >{tr.deckConfigRemoveGroup()}</DropdownItem
>{tr.deckConfigRemoveGroup()}</DropdownItem >
> <DropdownDivider />
<DropdownDivider /> <DropdownItem on:click={() => save(true)}>
<DropdownItem on:click={() => save(true)}> {tr.deckConfigSaveToAllSubdecks()}
{tr.deckConfigSaveToAllSubdecks()} </DropdownItem>
</DropdownItem> </DropdownMenu>
</DropdownMenu> </WithDropdown>
</WithDropdown>
</ButtonGroupItem>
</ButtonGroup> </ButtonGroup>

View file

@ -4,6 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import TitledContainer from "./TitledContainer.svelte"; import TitledContainer from "./TitledContainer.svelte";
import DynamicallySlottable from "../components/DynamicallySlottable.svelte";
import Item from "../components/Item.svelte"; import Item from "../components/Item.svelte";
import SpinBoxRow from "./SpinBoxRow.svelte"; import SpinBoxRow from "./SpinBoxRow.svelte";
import SwitchRow from "./SwitchRow.svelte"; import SwitchRow from "./SwitchRow.svelte";
@ -17,26 +18,28 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let defaults = state.defaults; let defaults = state.defaults;
</script> </script>
<TitledContainer title={tr.deckConfigTimerTitle()} {api}> <TitledContainer title={tr.deckConfigTimerTitle()}>
<Item> <DynamicallySlottable slotHost={Item} {api}>
<SpinBoxRow <Item>
bind:value={$config.capAnswerTimeToSecs} <SpinBoxRow
defaultValue={defaults.capAnswerTimeToSecs} bind:value={$config.capAnswerTimeToSecs}
min={30} defaultValue={defaults.capAnswerTimeToSecs}
max={600} min={30}
markdownTooltip={tr.deckConfigMaximumAnswerSecsTooltip()} max={600}
> markdownTooltip={tr.deckConfigMaximumAnswerSecsTooltip()}
{tr.deckConfigMaximumAnswerSecs()} >
</SpinBoxRow> {tr.deckConfigMaximumAnswerSecs()}
</Item> </SpinBoxRow>
</Item>
<Item> <Item>
<SwitchRow <SwitchRow
bind:value={$config.showTimer} bind:value={$config.showTimer}
defaultValue={defaults.showTimer} defaultValue={defaults.showTimer}
markdownTooltip={tr.deckConfigShowAnswerTimerTooltip()} markdownTooltip={tr.deckConfigShowAnswerTimerTooltip()}
> >
{tr.schedulingShowAnswerTimer()} {tr.schedulingShowAnswerTimer()}
</SwitchRow> </SwitchRow>
</Item> </Item>
</DynamicallySlottable>
</TitledContainer> </TitledContainer>

View file

@ -6,10 +6,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Container from "../components/Container.svelte"; import Container from "../components/Container.svelte";
export let title: string; export let title: string;
export let api: Record<string, never> | undefined = undefined;
</script> </script>
<Container --gutter-block="2px" --container-margin="0" {api}> <Container --gutter-block="2px" --container-margin="0">
<h1>{title}</h1> <h1>{title}</h1>
<slot /> <slot />

View file

@ -1,7 +1,7 @@
// 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 { registerPackage } from "../../lib/register-package"; import { registerPackage } from "../../lib/runtime-require";
import { saveSelection, restoreSelection } from "./document"; import { saveSelection, restoreSelection } from "./document";
import { Position } from "./location"; import { Position } from "./location";

View file

@ -1,7 +1,7 @@
// 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 { registerPackage } from "../../lib/register-package"; import { registerPackage } from "../../lib/runtime-require";
import { surroundNoSplitting } from "./no-splitting"; import { surroundNoSplitting } from "./no-splitting";
import { unsurround } from "./unsurround"; import { unsurround } from "./unsurround";

View file

@ -3,11 +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 OldEditorAdapter from "../editor/OldEditorAdapter.svelte"; import NoteEditor from "./NoteEditor.svelte";
import type { NoteEditorAPI } from "../editor/OldEditorAdapter.svelte"; import ButtonGroupItem from "../components/ButtonGroupItem.svelte";
import PreviewButton from "./PreviewButton.svelte";
import type { NoteEditorAPI } from "./NoteEditor.svelte";
const api: Partial<NoteEditorAPI> = {}; const api: Partial<NoteEditorAPI> = {};
let noteEditor: OldEditorAdapter; let noteEditor: NoteEditor;
export let uiResolve: (api: NoteEditorAPI) => void; export let uiResolve: (api: NoteEditorAPI) => void;
@ -16,4 +18,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
</script> </script>
<OldEditorAdapter bind:this={noteEditor} {api} /> <NoteEditor bind:this={noteEditor} {api}>
<svelte:fragment slot="notetypeButtons">
<ButtonGroupItem>
<PreviewButton />
</ButtonGroupItem>
</svelte:fragment>
</NoteEditor>

View file

@ -12,14 +12,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const decoratedElements = new CustomElementArray<DecoratedElementConstructor>(); const decoratedElements = new CustomElementArray<DecoratedElementConstructor>();
const key = Symbol("decoratedElements"); const key = Symbol("decoratedElements");
const [set, getDecoratedElements, hasDecoratedElements] = const [context, setContextProperty] =
contextProperty<CustomElementArray<DecoratedElementConstructor>>(key); contextProperty<CustomElementArray<DecoratedElementConstructor>>(key);
export { getDecoratedElements, hasDecoratedElements }; export { context };
</script> </script>
<script lang="ts"> <script lang="ts">
set(decoratedElements); setContextProperty(decoratedElements);
</script> </script>
<slot /> <slot />

View file

@ -22,9 +22,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
const key = Symbol("editingArea"); const key = Symbol("editingArea");
const [set, getEditingArea, hasEditingArea] = contextProperty<EditingAreaAPI>(key); const [context, setContextProperty] = contextProperty<EditingAreaAPI>(key);
export { getEditingArea, hasEditingArea }; export { context };
</script> </script>
<script lang="ts"> <script lang="ts">
@ -117,17 +117,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
export let api: Partial<EditingAreaAPI>; let apiPartial: Partial<EditingAreaAPI>;
export { apiPartial as api };
Object.assign( const api = Object.assign(apiPartial, {
api, content,
set({ editingInputs: inputsStore,
content, focus,
editingInputs: inputsStore, refocus,
focus, });
refocus,
}), setContextProperty(api);
);
onMount(() => { onMount(() => {
if (autofocus) { if (autofocus) {

View file

@ -22,9 +22,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
const key = Symbol("editorField"); const key = Symbol("editorField");
const [set, getEditorField, hasEditorField] = contextProperty<EditorFieldAPI>(key); const [context, setContextProperty] = contextProperty<EditorFieldAPI>(key);
export { getEditorField, hasEditorField }; export { context };
</script> </script>
<script lang="ts"> <script lang="ts">
@ -45,8 +45,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let field: FieldData; export let field: FieldData;
export let autofocus = false; export let autofocus = false;
export let api: (Partial<EditorFieldAPI> & Destroyable) | undefined = undefined;
const directionStore = writable<"ltr" | "rtl">(); const directionStore = writable<"ltr" | "rtl">();
setContext(directionKey, directionStore); setContext(directionKey, directionStore);
@ -55,15 +53,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
const editingArea: Partial<EditingAreaAPI> = {}; const editingArea: Partial<EditingAreaAPI> = {};
const [element, elementResolve] = promiseWithResolver<HTMLElement>(); const [element, elementResolve] = promiseWithResolver<HTMLElement>();
const editorFieldApi = set({ let apiPartial: Partial<EditorFieldAPI> & Destroyable;
export { apiPartial as api };
const api: EditorFieldAPI & Destroyable = Object.assign(apiPartial, {
element, element,
direction: directionStore, direction: directionStore,
editingArea: editingArea as EditingAreaAPI, editingArea: editingArea as EditingAreaAPI,
}); });
if (api) { setContextProperty(api);
Object.assign(api, editorFieldApi);
}
onDestroy(() => api?.destroy()); onDestroy(() => api?.destroy());
</script> </script>

View file

@ -3,10 +3,10 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import { getDecoratedElements } from "./DecoratedElements.svelte"; import { context } from "./DecoratedElements.svelte";
import { Mathjax } from "../editable/mathjax-element"; import { Mathjax } from "../editable/mathjax-element";
const decoratedElements = getDecoratedElements(); const decoratedElements = context.get();
decoratedElements.push(Mathjax); decoratedElements.push(Mathjax);
import { parsingInstructions } from "./plain-text-input"; import { parsingInstructions } from "./plain-text-input";

View file

@ -7,11 +7,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { bridgeCommand } from "../lib/bridgecommand"; import { bridgeCommand } from "../lib/bridgecommand";
import { registerShortcut } from "../lib/shortcuts"; import { registerShortcut } from "../lib/shortcuts";
import StickyBadge from "./StickyBadge.svelte"; import StickyBadge from "./StickyBadge.svelte";
import OldEditorAdapter from "./OldEditorAdapter.svelte"; import NoteEditor from "./NoteEditor.svelte";
import type { NoteEditorAPI } from "./OldEditorAdapter.svelte"; import type { NoteEditorAPI } from "./NoteEditor.svelte";
const api: Partial<NoteEditorAPI> = {}; const api: Partial<NoteEditorAPI> = {};
let noteEditor: OldEditorAdapter; let noteEditor: NoteEditor;
export let uiResolve: (api: NoteEditorAPI) => void; export let uiResolve: (api: NoteEditorAPI) => void;
@ -43,8 +43,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onDestroy(() => deregisterSticky); onDestroy(() => deregisterSticky);
</script> </script>
<OldEditorAdapter bind:this={noteEditor} {api}> <NoteEditor bind:this={noteEditor} {api}>
<svelte:fragment slot="field-state" let:index> <svelte:fragment slot="field-state" let:index>
<StickyBadge active={stickies[index]} {index} /> <StickyBadge active={stickies[index]} {index} />
</svelte:fragment> </svelte:fragment>
</OldEditorAdapter> </NoteEditor>

View file

@ -2,14 +2,371 @@
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
--> -->
<script context="module" lang="ts">
import type { Writable } from "svelte/store";
import type { EditorFieldAPI } from "./EditorField.svelte";
import type { EditingInputAPI } from "./EditingArea.svelte";
import type { EditorToolbarAPI } from "./editor-toolbar";
export interface NoteEditorAPI {
fields: EditorFieldAPI[];
focusedField: Writable<EditorFieldAPI | null>;
focusedInput: Writable<EditingInputAPI | null>;
toolbar: EditorToolbarAPI;
}
import contextProperty from "../sveltelib/context-property";
import lifecycleHooks from "../sveltelib/lifecycle-hooks";
import { registerPackage } from "../lib/runtime-require";
const key = Symbol("noteEditor");
const [context, setContextProperty] = contextProperty<NoteEditorAPI>(key);
const [lifecycle, instances, setupLifecycleHooks] = lifecycleHooks<NoteEditorAPI>();
export { context };
registerPackage("anki/NoteEditor", {
context,
lifecycle,
instances,
});
</script>
<script lang="ts">
import { onMount } from "svelte";
import { writable, get } from "svelte/store";
import Absolute from "../components/Absolute.svelte";
import Badge from "../components/Badge.svelte";
import { bridgeCommand } from "../lib/bridgecommand";
import { isApplePlatform } from "../lib/platform";
import FieldsEditor from "./FieldsEditor.svelte";
import Fields from "./Fields.svelte";
import EditorField from "./EditorField.svelte";
import type { FieldData } from "./EditorField.svelte";
import { TagEditor } from "./tag-editor";
import { EditorToolbar } from "./editor-toolbar";
import Notification from "./Notification.svelte";
import DuplicateLink from "./DuplicateLink.svelte";
import DecoratedElements from "./DecoratedElements.svelte";
import { RichTextInput, editingInputIsRichText } from "./rich-text-input";
import { PlainTextInput } from "./plain-text-input";
import { MathjaxHandle } from "./mathjax-overlay";
import { ImageHandle } from "./image-overlay";
import MathjaxElement from "./MathjaxElement.svelte";
import FrameElement from "./FrameElement.svelte";
import RichTextBadge from "./RichTextBadge.svelte";
import PlainTextBadge from "./PlainTextBadge.svelte";
import { ChangeTimer } from "./change-timer";
import { clearableArray } from "./destroyable";
import { alertIcon } from "./icons";
function quoteFontFamily(fontFamily: string): string {
// generic families (e.g. sans-serif) must not be quoted
if (!/^[-a-z]+$/.test(fontFamily)) {
fontFamily = `"${fontFamily}"`;
}
return fontFamily;
}
let size = isApplePlatform() ? 1.6 : 1.8;
let wrap = true;
let fieldStores: Writable<string>[] = [];
let fieldNames: string[] = [];
export function setFields(fs: [string, string][]): void {
// this is a bit of a mess -- when moving to Rust calls, we should make
// sure to have two backend endpoints for:
// * the note, which can be set through this view
// * the fieldname, font, etc., which cannot be set
const newFieldNames: string[] = [];
for (const [index, [fieldName]] of fs.entries()) {
newFieldNames[index] = fieldName;
}
for (let i = fieldStores.length; i < newFieldNames.length; i++) {
const newStore = writable("");
fieldStores[i] = newStore;
newStore.subscribe((value) => updateField(i, value));
}
for (
let i = fieldStores.length;
i > newFieldNames.length;
i = fieldStores.length
) {
fieldStores.pop();
}
for (const [index, [_, fieldContent]] of fs.entries()) {
fieldStores[index].set(fieldContent);
}
fieldNames = newFieldNames;
}
let fieldDescriptions: string[] = [];
export function setDescriptions(fs: string[]): void {
fieldDescriptions = fs;
}
let fonts: [string, number, boolean][] = [];
let richTextsHidden: boolean[] = [];
let plainTextsHidden: boolean[] = [];
let fields = clearableArray<EditorFieldAPI>();
export function setFonts(fs: [string, number, boolean][]): void {
fonts = fs;
richTextsHidden = fonts.map((_, index) => richTextsHidden[index] ?? false);
plainTextsHidden = fonts.map((_, index) => plainTextsHidden[index] ?? true);
}
let focusTo: number = 0;
export function focusField(n: number): void {
if (typeof n === "number") {
focusTo = n;
fields[focusTo].editingArea?.refocus();
}
}
let textColor: string = "black";
let highlightColor: string = "black";
export function setColorButtons([textClr, highlightClr]: [string, string]): void {
textColor = textClr;
highlightColor = highlightClr;
}
let tags = writable<string[]>([]);
export function setTags(ts: string[]): void {
$tags = ts;
}
let noteId: number | null = null;
export function setNoteId(ntid: number): void {
noteId = ntid;
}
function getNoteId(): number | null {
return noteId;
}
let cols: ("dupe" | "")[] = [];
export function setBackgrounds(cls: ("dupe" | "")[]): void {
cols = cls;
}
let hint: string = "";
export function setClozeHint(hnt: string): void {
hint = hnt;
}
$: fieldsData = fieldNames.map((name, index) => ({
name,
description: fieldDescriptions[index],
fontFamily: quoteFontFamily(fonts[index][0]),
fontSize: fonts[index][1],
direction: fonts[index][2] ? "rtl" : "ltr",
})) as FieldData[];
function saveTags({ detail }: CustomEvent): void {
bridgeCommand(`saveTags:${JSON.stringify(detail.tags)}`);
}
const fieldSave = new ChangeTimer();
function updateField(index: number, content: string): void {
fieldSave.schedule(
() => bridgeCommand(`key:${index}:${getNoteId()}:${content}`),
600,
);
}
export function saveFieldNow(): void {
/* this will always be a key save */
fieldSave.fireImmediately();
}
export function saveOnPageHide() {
if (document.visibilityState === "hidden") {
// will fire on session close and minimize
saveFieldNow();
}
}
export function focusIfField(x: number, y: number): boolean {
const elements = document.elementsFromPoint(x, y);
const first = elements[0];
if (first.shadowRoot) {
const richTextInput = first.shadowRoot.lastElementChild! as HTMLElement;
richTextInput.focus();
return true;
}
return false;
}
let richTextInputs: RichTextInput[] = [];
$: richTextInputs = richTextInputs.filter(Boolean);
let plainTextInputs: PlainTextInput[] = [];
$: plainTextInputs = plainTextInputs.filter(Boolean);
let toolbar: Partial<EditorToolbarAPI> = {};
import { wrapInternal } from "../lib/wrap";
import * as oldEditorAdapter from "./old-editor-adapter";
onMount(() => {
function wrap(before: string, after: string): void {
if (!$focusedInput || !editingInputIsRichText($focusedInput)) {
return;
}
$focusedInput.element.then((element) => {
wrapInternal(element, before, after, false);
});
}
Object.assign(globalThis, {
setFields,
setDescriptions,
setFonts,
focusField,
setColorButtons,
setTags,
setBackgrounds,
setClozeHint,
saveNow: saveFieldNow,
focusIfField,
setNoteId,
wrap,
...oldEditorAdapter,
});
document.addEventListener("visibilitychange", saveOnPageHide);
return () => document.removeEventListener("visibilitychange", saveOnPageHide);
});
let apiPartial: Partial<NoteEditorAPI> = {};
export { apiPartial as api };
const focusedField: NoteEditorAPI["focusedField"] = writable(null);
const focusedInput: NoteEditorAPI["focusedInput"] = writable(null);
const api: NoteEditorAPI = {
...apiPartial,
focusedField,
focusedInput,
toolbar: toolbar as EditorToolbarAPI,
fields,
};
setContextProperty(api);
setupLifecycleHooks(api);
</script>
<div class="note-editor"> <div class="note-editor">
<slot /> <FieldsEditor>
<EditorToolbar {size} {wrap} {textColor} {highlightColor} api={toolbar}>
<slot slot="notetypeButtons" name="notetypeButtons" />
</EditorToolbar>
{#if hint}
<Absolute bottom right --margin="10px">
<Notification>
<Badge --badge-color="tomato" --icon-align="top"
>{@html alertIcon}</Badge
>
<span>{@html hint}</span>
</Notification>
</Absolute>
{/if}
<Fields>
<DecoratedElements>
{#each fieldsData as field, index}
<EditorField
{field}
content={fieldStores[index]}
autofocus={index === focusTo}
api={fields[index]}
on:focusin={() => {
$focusedField = fields[index];
bridgeCommand(`focus:${index}`);
}}
on:focusout={() => {
$focusedField = null;
bridgeCommand(
`blur:${index}:${getNoteId()}:${get(
fieldStores[index],
)}`,
);
}}
--label-color={cols[index] === "dupe"
? "var(--flag1-bg)"
: "transparent"}
>
<svelte:fragment slot="field-state">
{#if cols[index] === "dupe"}
<DuplicateLink />
{/if}
<RichTextBadge bind:off={richTextsHidden[index]} />
<PlainTextBadge bind:off={plainTextsHidden[index]} />
<slot name="field-state" {field} {index} />
</svelte:fragment>
<svelte:fragment slot="editing-inputs">
<RichTextInput
hidden={richTextsHidden[index]}
on:focusin={() => {
$focusedInput = richTextInputs[index].api;
}}
on:focusout={() => {
$focusedInput = null;
saveFieldNow();
}}
bind:this={richTextInputs[index]}
>
<ImageHandle />
<MathjaxHandle />
</RichTextInput>
<PlainTextInput
hidden={plainTextsHidden[index]}
on:focusin={() => {
$focusedInput = plainTextInputs[index].api;
}}
on:focusout={() => {
$focusedInput = null;
saveFieldNow();
}}
bind:this={plainTextInputs[index]}
/>
</svelte:fragment>
</EditorField>
{/each}
<MathjaxElement />
<FrameElement />
</DecoratedElements>
</Fields>
</FieldsEditor>
<TagEditor {size} {wrap} {tags} on:tagsupdate={saveTags} />
</div> </div>
<style lang="scss"> <style lang="scss">
.note-editor { .note-editor {
height: 100%; height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
} }

View file

@ -1,361 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script context="module" lang="ts">
import type { EditorFieldAPI } from "./EditorField.svelte";
import type { RichTextInputAPI } from "./rich-text-input";
import type { PlainTextInputAPI } from "./plain-text-input";
import type { EditorToolbarAPI } from "./editor-toolbar";
import contextProperty from "../sveltelib/context-property";
import { writable, get } from "svelte/store";
export interface NoteEditorAPI {
fields: EditorFieldAPI[];
currentField: Writable<EditorFieldAPI | null>;
activeInput: Writable<RichTextInputAPI | PlainTextInputAPI | null>;
focusInRichText: Writable<boolean>;
toolbar: EditorToolbarAPI;
}
const key = Symbol("noteEditor");
const [set, getNoteEditor, hasNoteEditor] = contextProperty<NoteEditorAPI>(key);
export { getNoteEditor, hasNoteEditor };
const activeInput = writable<RichTextInputAPI | PlainTextInputAPI | null>(null);
const currentField = writable<EditorFieldAPI | null>(null);
</script>
<script lang="ts">
import NoteEditor from "./NoteEditor.svelte";
import FieldsEditor from "./FieldsEditor.svelte";
import Fields from "./Fields.svelte";
import EditorField from "./EditorField.svelte";
import type { FieldData } from "./EditorField.svelte";
import { TagEditor } from "./tag-editor";
import { EditorToolbar } from "./editor-toolbar";
import Notification from "./Notification.svelte";
import Absolute from "../components/Absolute.svelte";
import Badge from "../components/Badge.svelte";
import DuplicateLink from "./DuplicateLink.svelte";
import DecoratedElements from "./DecoratedElements.svelte";
import { RichTextInput } from "./rich-text-input";
import { MathjaxHandle } from "./mathjax-overlay";
import { ImageHandle } from "./image-overlay";
import { PlainTextInput } from "./plain-text-input";
import MathjaxElement from "./MathjaxElement.svelte";
import FrameElement from "./FrameElement.svelte";
import RichTextBadge from "./RichTextBadge.svelte";
import PlainTextBadge from "./PlainTextBadge.svelte";
import { onMount } from "svelte";
import type { Writable } from "svelte/store";
import { bridgeCommand } from "../lib/bridgecommand";
import { isApplePlatform } from "../lib/platform";
import { ChangeTimer } from "./change-timer";
import { alertIcon } from "./icons";
import { clearableArray } from "./destroyable";
function quoteFontFamily(fontFamily: string): string {
// generic families (e.g. sans-serif) must not be quoted
if (!/^[-a-z]+$/.test(fontFamily)) {
fontFamily = `"${fontFamily}"`;
}
return fontFamily;
}
let size = isApplePlatform() ? 1.6 : 1.8;
let wrap = true;
let fieldStores: Writable<string>[] = [];
let fieldNames: string[] = [];
export function setFields(fs: [string, string][]): void {
// this is a bit of a mess -- when moving to Rust calls, we should make
// sure to have two backend endpoints for:
// * the note, which can be set through this view
// * the fieldname, font, etc., which cannot be set
const newFieldNames: string[] = [];
for (const [index, [fieldName]] of fs.entries()) {
newFieldNames[index] = fieldName;
}
for (let i = fieldStores.length; i < newFieldNames.length; i++) {
const newStore = writable("");
fieldStores[i] = newStore;
newStore.subscribe((value) => updateField(i, value));
}
for (
let i = fieldStores.length;
i > newFieldNames.length;
i = fieldStores.length
) {
fieldStores.pop();
}
for (const [index, [_, fieldContent]] of fs.entries()) {
fieldStores[index].set(fieldContent);
}
fieldNames = newFieldNames;
}
let fieldDescriptions: string[] = [];
export function setDescriptions(fs: string[]): void {
fieldDescriptions = fs;
}
let fonts: [string, number, boolean][] = [];
let richTextsHidden: boolean[] = [];
let plainTextsHidden: boolean[] = [];
let fields = clearableArray<EditorFieldAPI>();
export function setFonts(fs: [string, number, boolean][]): void {
fonts = fs;
richTextsHidden = fonts.map((_, index) => richTextsHidden[index] ?? false);
plainTextsHidden = fonts.map((_, index) => plainTextsHidden[index] ?? true);
}
let focusTo: number = 0;
export function focusField(n: number): void {
if (typeof n === "number") {
focusTo = n;
fields[focusTo].editingArea?.refocus();
}
}
let textColor: string = "black";
let highlightColor: string = "black";
export function setColorButtons([textClr, highlightClr]: [string, string]): void {
textColor = textClr;
highlightColor = highlightClr;
}
let tags = writable<string[]>([]);
export function setTags(ts: string[]): void {
$tags = ts;
}
let noteId: number | null = null;
export function setNoteId(ntid: number): void {
noteId = ntid;
}
function getNoteId(): number | null {
return noteId;
}
let cols: ("dupe" | "")[] = [];
export function setBackgrounds(cls: ("dupe" | "")[]): void {
cols = cls;
}
let hint: string = "";
export function setClozeHint(hnt: string): void {
hint = hnt;
}
$: fieldsData = fieldNames.map((name, index) => ({
name,
description: fieldDescriptions[index],
fontFamily: quoteFontFamily(fonts[index][0]),
fontSize: fonts[index][1],
direction: fonts[index][2] ? "rtl" : "ltr",
})) as FieldData[];
function saveTags({ detail }: CustomEvent): void {
bridgeCommand(`saveTags:${JSON.stringify(detail.tags)}`);
}
const fieldSave = new ChangeTimer();
function updateField(index: number, content: string): void {
fieldSave.schedule(
() => bridgeCommand(`key:${index}:${getNoteId()}:${content}`),
600,
);
}
export function saveFieldNow(): void {
/* this will always be a key save */
fieldSave.fireImmediately();
}
export function saveOnPageHide() {
if (document.visibilityState === "hidden") {
// will fire on session close and minimize
saveFieldNow();
}
}
export function focusIfField(x: number, y: number): boolean {
const elements = document.elementsFromPoint(x, y);
const first = elements[0];
if (first.shadowRoot) {
const richTextInput = first.shadowRoot.lastElementChild! as HTMLElement;
richTextInput.focus();
return true;
}
return false;
}
let richTextInputs: RichTextInput[] = [];
$: richTextInputs = richTextInputs.filter(Boolean);
let plainTextInputs: PlainTextInput[] = [];
$: plainTextInputs = plainTextInputs.filter(Boolean);
const focusInRichText = writable<boolean>(false);
let toolbar: Partial<EditorToolbarAPI> = {};
export let api = {};
Object.assign(
api,
set({
currentField,
activeInput,
focusInRichText,
toolbar: toolbar as EditorToolbarAPI,
fields,
}),
);
import { wrapInternal } from "../lib/wrap";
import * as oldEditorAdapter from "./old-editor-adapter";
onMount(() => {
function wrap(before: string, after: string): void {
if (!get(focusInRichText)) {
return;
}
const input = get(activeInput!) as RichTextInputAPI;
input.element.then((element) => {
wrapInternal(element, before, after, false);
});
}
Object.assign(globalThis, {
setFields,
setDescriptions,
setFonts,
focusField,
setColorButtons,
setTags,
setBackgrounds,
setClozeHint,
saveNow: saveFieldNow,
focusIfField,
setNoteId,
wrap,
...oldEditorAdapter,
});
document.addEventListener("visibilitychange", saveOnPageHide);
return () => document.removeEventListener("visibilitychange", saveOnPageHide);
});
</script>
<NoteEditor>
<FieldsEditor>
<EditorToolbar {size} {wrap} {textColor} {highlightColor} api={toolbar} />
{#if hint}
<Absolute bottom right --margin="10px">
<Notification>
<Badge --badge-color="tomato" --icon-align="top"
>{@html alertIcon}</Badge
>
<span>{@html hint}</span>
</Notification>
</Absolute>
{/if}
<Fields>
<DecoratedElements>
{#each fieldsData as field, index}
<EditorField
{field}
content={fieldStores[index]}
autofocus={index === focusTo}
api={fields[index]}
on:focusin={() => {
$currentField = fields[index];
bridgeCommand(`focus:${index}`);
}}
on:focusout={() => {
$currentField = null;
bridgeCommand(
`blur:${index}:${getNoteId()}:${get(
fieldStores[index],
)}`,
);
}}
--label-color={cols[index] === "dupe"
? "var(--flag1-bg)"
: "transparent"}
>
<svelte:fragment slot="field-state">
{#if cols[index] === "dupe"}
<DuplicateLink />
{/if}
<RichTextBadge bind:off={richTextsHidden[index]} />
<PlainTextBadge bind:off={plainTextsHidden[index]} />
<slot name="field-state" {field} {index} />
</svelte:fragment>
<svelte:fragment slot="editing-inputs">
<RichTextInput
hidden={richTextsHidden[index]}
on:focusin={() => {
$focusInRichText = true;
$activeInput = richTextInputs[index].api;
}}
on:focusout={() => {
$focusInRichText = false;
$activeInput = null;
saveFieldNow();
}}
bind:this={richTextInputs[index]}
>
<ImageHandle />
<MathjaxHandle />
</RichTextInput>
<PlainTextInput
hidden={plainTextsHidden[index]}
on:focusin={() => {
$activeInput = plainTextInputs[index].api;
}}
on:focusout={() => {
$activeInput = null;
saveFieldNow();
}}
bind:this={plainTextInputs[index]}
/>
</svelte:fragment>
</EditorField>
{/each}
<MathjaxElement />
<FrameElement />
</DecoratedElements>
</Fields>
</FieldsEditor>
<TagEditor {size} {wrap} {tags} on:tagsupdate={saveTags} />
</NoteEditor>

View file

@ -7,10 +7,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { htmlOn, htmlOff } from "./icons"; import { htmlOn, htmlOff } from "./icons";
import { getEditorField } from "./EditorField.svelte"; import { context as editorFieldContext } from "./EditorField.svelte";
import { registerShortcut, getPlatformString } from "../lib/shortcuts"; import { registerShortcut, getPlatformString } from "../lib/shortcuts";
const editorField = getEditorField(); const editorField = editorFieldContext.get();
const keyCombination = "Control+Shift+X"; const keyCombination = "Control+Shift+X";
export let off = false; export let off = false;

View file

@ -6,16 +6,21 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { writable } from "svelte/store"; import { writable } from "svelte/store";
const active = writable(false); const active = writable(false);
export const togglePreviewButtonState = (state: boolean) => active.set(state);
export function togglePreviewButtonState(state: boolean): void {
active.set(state);
}
Object.assign(globalThis, { togglePreviewButtonState });
</script> </script>
<script lang="ts"> <script lang="ts">
import { bridgeCommand } from "../../lib/bridgecommand"; import * as tr from "../lib/ftl";
import { getPlatformString } from "../../lib/shortcuts"; import { bridgeCommand } from "../lib/bridgecommand";
import * as tr from "../../lib/ftl"; import { getPlatformString } from "../lib/shortcuts";
import LabelButton from "../../components/LabelButton.svelte"; import LabelButton from "../components/LabelButton.svelte";
import Shortcut from "../../components/Shortcut.svelte"; import Shortcut from "../components/Shortcut.svelte";
const keyCombination = "Control+Shift+P"; const keyCombination = "Control+Shift+P";
function preview(): void { function preview(): void {

View file

@ -3,11 +3,11 @@ 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 OldEditorAdapter from "../editor/OldEditorAdapter.svelte"; import NoteEditor from "../editor/NoteEditor.svelte";
import type { NoteEditorAPI } from "../editor/OldEditorAdapter.svelte"; import type { NoteEditorAPI } from "../editor/NoteEditor.svelte";
const api: Partial<NoteEditorAPI> = {}; const api: Partial<NoteEditorAPI> = {};
let noteEditor: OldEditorAdapter; let noteEditor: NoteEditor;
export let uiResolve: (api: NoteEditorAPI) => void; export let uiResolve: (api: NoteEditorAPI) => void;
@ -16,4 +16,4 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
</script> </script>
<OldEditorAdapter bind:this={noteEditor} {api} /> <NoteEditor bind:this={noteEditor} {api} />

View file

@ -6,7 +6,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Badge from "../components/Badge.svelte"; import Badge from "../components/Badge.svelte";
import { onMount } from "svelte"; import { onMount } from "svelte";
import { stickyOn, stickyOff } from "./icons"; import { stickyOn, stickyOff } from "./icons";
import { getEditorField } from "./EditorField.svelte"; import { context as editorFieldContext } from "./EditorField.svelte";
import * as tr from "../lib/ftl"; import * as tr from "../lib/ftl";
import { bridgeCommand } from "../lib/bridgecommand"; import { bridgeCommand } from "../lib/bridgecommand";
import { registerShortcut, getPlatformString } from "../lib/shortcuts"; import { registerShortcut, getPlatformString } from "../lib/shortcuts";
@ -15,7 +15,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: icon = active ? stickyOn : stickyOff; $: icon = active ? stickyOn : stickyOff;
const editorField = getEditorField(); const editorField = editorFieldContext.get();
const keyCombination = "F9"; const keyCombination = "F9";
export let index: number; export let index: number;

View file

@ -7,7 +7,7 @@
import "./legacy.css"; import "./legacy.css";
import "./editor-base.css"; import "./editor-base.css";
import "../lib/register-package"; import "../lib/runtime-require";
import "../sveltelib/export-runtime"; import "../sveltelib/export-runtime";
declare global { declare global {

View file

@ -4,15 +4,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import ButtonGroup from "../../components/ButtonGroup.svelte"; import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
export let buttons: string[]; export let buttons: string[];
const radius = "5px";
function getBorderRadius(index: number, length: number): string {
if (index === 0 && length === 1) {
return `--border-left-radius: ${radius}; --border-right-radius: ${radius}; `;
} else if (index === 0) {
return `--border-left-radius: ${radius}; --border-right-radius: 0; `;
} else if (index === length - 1) {
return `--border-left-radius: 0; --border-right-radius: ${radius}; `;
} else {
return "--border-left-radius: 0; --border-right-radius: 0; ";
}
}
</script> </script>
<ButtonGroup> <ButtonGroup>
{#each buttons as button} {#each buttons as button, index}
<ButtonGroupItem> <div style={getBorderRadius(index, buttons.length)}>
{@html button} {@html button}
</ButtonGroupItem> </div>
{/each} {/each}
</ButtonGroup> </ButtonGroup>
<style lang="scss">
div {
display: contents;
}
</style>

View file

@ -10,8 +10,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { MatchResult } from "../../domlib/surround"; import { MatchResult } from "../../domlib/surround";
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { getSurrounder } from "../surround"; import { getSurrounder } from "../surround";
import { getNoteEditor } from "../OldEditorAdapter.svelte"; import { context as noteEditorContext } from "../NoteEditor.svelte";
import type { RichTextInputAPI } from "../rich-text-input"; import type { RichTextInputAPI } from "../rich-text-input";
import { editingInputIsRichText } from "../rich-text-input";
import { boldIcon } from "./icons"; import { boldIcon } from "./icons";
function matchBold(element: Element): Exclude<MatchResult, MatchResult.ALONG> { function matchBold(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
@ -42,11 +43,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return !htmlElement.hasAttribute("style") && element.className.length === 0; return !htmlElement.hasAttribute("style") && element.className.length === 0;
} }
const { focusInRichText, activeInput } = getNoteEditor(); const { focusedInput } = noteEditorContext.get();
$: input = $activeInput; $: input = $focusedInput as RichTextInputAPI;
$: disabled = !$focusInRichText; $: disabled = !editingInputIsRichText($focusedInput);
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI); $: surrounder = disabled ? null : getSurrounder(input);
function updateStateFromActiveInput(): Promise<boolean> { function updateStateFromActiveInput(): Promise<boolean> {
return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(matchBold); return disabled ? Promise.resolve(false) : surrounder!.isSurrounded(matchBold);

View file

@ -10,18 +10,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { wrapInternal } from "../../lib/wrap"; import { wrapInternal } from "../../lib/wrap";
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { get } from "svelte/store"; import { get } from "svelte/store";
import { getNoteEditor } from "../OldEditorAdapter.svelte"; import { context as noteEditorContext } from "../NoteEditor.svelte";
import type { RichTextInputAPI } from "../rich-text-input"; import type { RichTextInputAPI } from "../rich-text-input";
import { editingInputIsRichText } from "../rich-text-input";
import { ellipseIcon } from "./icons"; import { ellipseIcon } from "./icons";
const noteEditor = getNoteEditor(); const { focusedInput, fields } = noteEditorContext.get();
const { focusInRichText, activeInput } = noteEditor;
const clozePattern = /\{\{c(\d+)::/gu; const clozePattern = /\{\{c(\d+)::/gu;
function getCurrentHighestCloze(increment: boolean): number { function getCurrentHighestCloze(increment: boolean): number {
let highest = 0; let highest = 0;
for (const field of noteEditor.fields) { for (const field of fields) {
const content = field.editingArea?.content; const content = field.editingArea?.content;
const fieldHTML = content ? get(content) : ""; const fieldHTML = content ? get(content) : "";
@ -42,7 +42,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return Math.max(1, highest); return Math.max(1, highest);
} }
$: richTextAPI = $activeInput as RichTextInputAPI; $: richTextAPI = $focusedInput as RichTextInputAPI;
async function onCloze(event: KeyboardEvent | MouseEvent): Promise<void> { async function onCloze(event: KeyboardEvent | MouseEvent): Promise<void> {
const highestCloze = getCurrentHighestCloze(!event.getModifierState("Alt")); const highestCloze = getCurrentHighestCloze(!event.getModifierState("Alt"));
@ -50,7 +50,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
wrapInternal(richText, `{{c${highestCloze}::`, "}}", false); wrapInternal(richText, `{{c${highestCloze}::`, "}}", false);
} }
$: disabled = !$focusInRichText; $: disabled = !editingInputIsRichText($focusedInput);
const keyCombination = "Control+Alt?+Shift+C"; const keyCombination = "Control+Alt?+Shift+C";
</script> </script>

View file

@ -4,17 +4,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import ButtonGroup from "../../components/ButtonGroup.svelte"; import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte"; import IconButton from "../../components/IconButton.svelte";
import ColorPicker from "../../components/ColorPicker.svelte"; import ColorPicker from "../../components/ColorPicker.svelte";
import Shortcut from "../../components/Shortcut.svelte"; import Shortcut from "../../components/Shortcut.svelte";
import WithColorHelper from "./WithColorHelper.svelte"; import WithColorHelper from "./WithColorHelper.svelte";
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
import ButtonGroupItem, {
createProps,
updatePropsList,
setSlotHostContext,
} from "../../components/ButtonGroupItem.svelte";
import * as tr from "../../lib/ftl"; import * as tr from "../../lib/ftl";
import { bridgeCommand } from "../../lib/bridgecommand"; import { bridgeCommand } from "../../lib/bridgecommand";
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { execCommand } from "../helpers"; import { execCommand } from "../helpers";
import { getNoteEditor } from "../OldEditorAdapter.svelte"; import { context } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { textColorIcon, highlightColorIcon, arrowIcon } from "./icons"; import { textColorIcon, highlightColorIcon, arrowIcon } from "./icons";
export let api = {}; export let api = {};
@ -35,87 +41,95 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
execCommand("backcolor", false, color); execCommand("backcolor", false, color);
}; };
const { focusInRichText } = getNoteEditor(); const { focusedInput } = context.get();
$: disabled = !$focusInRichText; $: disabled = !editingInputIsRichText($focusedInput);
</script> </script>
<ButtonGroup {api}> <ButtonGroup>
<WithColorHelper color={textColor} let:colorHelperIcon let:setColor> <DynamicallySlottable
<ButtonGroupItem> slotHost={ButtonGroupItem}
<IconButton {createProps}
tooltip="{tr.editingSetTextColor()} ({getPlatformString( {updatePropsList}
forecolorKeyCombination, {setSlotHostContext}
)})" {api}
{disabled} >
on:click={forecolorWrap} <WithColorHelper color={textColor} let:colorHelperIcon let:setColor>
> <ButtonGroupItem>
{@html textColorIcon} <IconButton
{@html colorHelperIcon} tooltip="{tr.editingSetTextColor()} ({getPlatformString(
</IconButton> forecolorKeyCombination,
<Shortcut )})"
keyCombination={forecolorKeyCombination} {disabled}
on:action={forecolorWrap} on:click={forecolorWrap}
/> >
</ButtonGroupItem> {@html textColorIcon}
{@html colorHelperIcon}
</IconButton>
<Shortcut
keyCombination={forecolorKeyCombination}
on:action={forecolorWrap}
/>
</ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<IconButton <IconButton
tooltip="{tr.editingChangeColor()} ({getPlatformString( tooltip="{tr.editingChangeColor()} ({getPlatformString(
backcolorKeyCombination, backcolorKeyCombination,
)})" )})"
{disabled} {disabled}
widthMultiplier={0.5} widthMultiplier={0.5}
> >
{@html arrowIcon} {@html arrowIcon}
<ColorPicker <ColorPicker
on:change={(event) => { on:change={(event) => {
const textColor = setColor(event);
bridgeCommand(`lastTextColor:${textColor}`);
forecolorWrap = wrapWithForecolor(setColor(event));
forecolorWrap();
}}
/>
</IconButton>
<Shortcut
keyCombination={backcolorKeyCombination}
on:action={(event) => {
const textColor = setColor(event); const textColor = setColor(event);
bridgeCommand(`lastTextColor:${textColor}`); bridgeCommand(`lastTextColor:${textColor}`);
forecolorWrap = wrapWithForecolor(setColor(event)); forecolorWrap = wrapWithForecolor(setColor(event));
forecolorWrap(); forecolorWrap();
}} }}
/> />
</IconButton> </ButtonGroupItem>
<Shortcut </WithColorHelper>
keyCombination={backcolorKeyCombination}
on:action={(event) => {
const textColor = setColor(event);
bridgeCommand(`lastTextColor:${textColor}`);
forecolorWrap = wrapWithForecolor(setColor(event));
forecolorWrap();
}}
/>
</ButtonGroupItem>
</WithColorHelper>
<WithColorHelper color={highlightColor} let:colorHelperIcon let:setColor> <WithColorHelper color={highlightColor} let:colorHelperIcon let:setColor>
<ButtonGroupItem> <ButtonGroupItem>
<IconButton <IconButton
tooltip={tr.editingSetTextHighlightColor()} tooltip={tr.editingSetTextHighlightColor()}
{disabled} {disabled}
on:click={backcolorWrap} on:click={backcolorWrap}
> >
{@html highlightColorIcon} {@html highlightColorIcon}
{@html colorHelperIcon} {@html colorHelperIcon}
</IconButton> </IconButton>
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<IconButton <IconButton
tooltip={tr.editingChangeColor()} tooltip={tr.editingChangeColor()}
widthMultiplier={0.5} widthMultiplier={0.5}
{disabled} {disabled}
> >
{@html arrowIcon} {@html arrowIcon}
<ColorPicker <ColorPicker
on:change={(event) => { on:change={(event) => {
const highlightColor = setColor(event); const highlightColor = setColor(event);
bridgeCommand(`lastHighlightColor:${highlightColor}`); bridgeCommand(`lastHighlightColor:${highlightColor}`);
backcolorWrap = wrapWithBackcolor(highlightColor); backcolorWrap = wrapWithBackcolor(highlightColor);
backcolorWrap(); backcolorWrap();
}} }}
/> />
</IconButton> </IconButton>
</ButtonGroupItem> </ButtonGroupItem>
</WithColorHelper> </WithColorHelper>
</DynamicallySlottable>
</ButtonGroup> </ButtonGroup>

View file

@ -8,7 +8,8 @@ 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 { execCommand, queryCommandState } from "../helpers"; import { execCommand, queryCommandState } from "../helpers";
import { getNoteEditor } from "../OldEditorAdapter.svelte"; import { context as noteEditorContext } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
export let key: string; export let key: string;
export let tooltip: string; export let tooltip: string;
@ -17,13 +18,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let withoutShortcut = false; export let withoutShortcut = false;
export let withoutState = false; export let withoutState = false;
const { focusInRichText } = getNoteEditor(); const { focusedInput } = noteEditorContext.get();
function action() { function action() {
execCommand(key); execCommand(key);
} }
$: disabled = !$focusInRichText; $: disabled = !editingInputIsRichText($focusedInput);
</script> </script>
{#if withoutState} {#if withoutState}

View file

@ -4,8 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script context="module" lang="ts"> <script context="module" lang="ts">
import { updateAllState, resetAllState } from "../../components/WithState.svelte"; import { updateAllState, resetAllState } from "../../components/WithState.svelte";
import type { ButtonGroupAPI } from "../../components/ButtonGroup.svelte"; import type { DefaultSlotInterface } from "../../sveltelib/dynamic-slotting";
import type { ButtonToolbarAPI } from "../../components/ButtonToolbar.svelte";
export function updateActiveButtons(event: Event) { export function updateActiveButtons(event: Event) {
updateAllState(event); updateAllState(event);
@ -16,31 +15,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
export interface EditorToolbarAPI { export interface EditorToolbarAPI {
toolbar: ButtonToolbarAPI; toolbar: DefaultSlotInterface;
notetypeButtons: ButtonGroupAPI; notetypeButtons: DefaultSlotInterface;
formatInlineButtons: ButtonGroupAPI; formatInlineButtons: DefaultSlotInterface;
formatBlockButtons: ButtonGroupAPI; formatBlockButtons: DefaultSlotInterface;
colorButtons: ButtonGroupAPI; colorButtons: DefaultSlotInterface;
templateButtons: ButtonGroupAPI; templateButtons: DefaultSlotInterface;
} }
/* Our dynamic components */ /* Our dynamic components */
import AddonButtons from "./AddonButtons.svelte"; import AddonButtons from "./AddonButtons.svelte";
import PreviewButton, { togglePreviewButtonState } from "./PreviewButton.svelte";
export const editorToolbar = { export const editorToolbar = {
AddonButtons, AddonButtons,
PreviewButton,
togglePreviewButtonState,
}; };
</script> </script>
<script lang="ts"> <script lang="ts">
import StickyContainer from "../../components/StickyContainer.svelte"; import StickyContainer from "../../components/StickyContainer.svelte";
import ButtonToolbar from "../../components/ButtonToolbar.svelte"; import ButtonToolbar from "../../components/ButtonToolbar.svelte";
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
import Item from "../../components/Item.svelte"; import Item from "../../components/Item.svelte";
import NoteTypeButtons from "./NoteTypeButtons.svelte"; import NotetypeButtons from "./NotetypeButtons.svelte";
import FormatInlineButtons from "./FormatInlineButtons.svelte"; import FormatInlineButtons from "./FormatInlineButtons.svelte";
import FormatBlockButtons from "./FormatBlockButtons.svelte"; import FormatBlockButtons from "./FormatBlockButtons.svelte";
import ColorButtons from "./ColorButtons.svelte"; import ColorButtons from "./ColorButtons.svelte";
@ -72,25 +69,29 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<StickyContainer --gutter-block="0.1rem" --sticky-borders="0 0 1px"> <StickyContainer --gutter-block="0.1rem" --sticky-borders="0 0 1px">
<ButtonToolbar {size} {wrap} api={toolbar}> <ButtonToolbar {size} {wrap}>
<Item id="notetype"> <DynamicallySlottable slotHost={Item} api={toolbar}>
<NoteTypeButtons api={notetypeButtons} /> <Item id="notetype">
</Item> <NotetypeButtons api={notetypeButtons}>
<slot name="notetypeButtons" />
</NotetypeButtons>
</Item>
<Item id="inlineFormatting"> <Item id="inlineFormatting">
<FormatInlineButtons api={formatInlineButtons} /> <FormatInlineButtons api={formatInlineButtons} />
</Item> </Item>
<Item id="blockFormatting"> <Item id="blockFormatting">
<FormatBlockButtons api={formatBlockButtons} /> <FormatBlockButtons api={formatBlockButtons} />
</Item> </Item>
<Item id="color"> <Item id="color">
<ColorButtons {textColor} {highlightColor} api={colorButtons} /> <ColorButtons {textColor} {highlightColor} api={colorButtons} />
</Item> </Item>
<Item id="template"> <Item id="template">
<TemplateButtons api={templateButtons} /> <TemplateButtons api={templateButtons} />
</Item> </Item>
</DynamicallySlottable>
</ButtonToolbar> </ButtonToolbar>
</StickyContainer> </StickyContainer>

View file

@ -4,13 +4,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import ButtonGroup from "../../components/ButtonGroup.svelte"; import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte"; import IconButton from "../../components/IconButton.svelte";
import ButtonDropdown from "../../components/ButtonDropdown.svelte"; import ButtonDropdown from "../../components/ButtonDropdown.svelte";
import Item from "../../components/Item.svelte"; import Item from "../../components/Item.svelte";
import Shortcut from "../../components/Shortcut.svelte"; import Shortcut from "../../components/Shortcut.svelte";
import WithDropdown from "../../components/WithDropdown.svelte"; import WithDropdown from "../../components/WithDropdown.svelte";
import CommandIconButton from "./CommandIconButton.svelte"; import CommandIconButton from "./CommandIconButton.svelte";
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
import ButtonGroupItem, {
createProps,
updatePropsList,
setSlotHostContext,
} from "../../components/ButtonGroupItem.svelte";
import * as tr from "../../lib/ftl"; import * as tr from "../../lib/ftl";
import { getListItem } from "../../lib/dom"; import { getListItem } from "../../lib/dom";
@ -27,7 +32,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
indentIcon, indentIcon,
outdentIcon, outdentIcon,
} from "./icons"; } from "./icons";
import { getNoteEditor } from "../OldEditorAdapter.svelte"; import { context } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
export let api = {}; export let api = {};
@ -49,86 +55,87 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
const { focusInRichText } = getNoteEditor(); const { focusedInput } = context.get();
$: disabled = !$focusInRichText; $: disabled = !editingInputIsRichText($focusedInput);
</script> </script>
<ButtonGroup {api}> <ButtonGroup>
<ButtonGroupItem> <DynamicallySlottable
<CommandIconButton slotHost={ButtonGroupItem}
key="insertUnorderedList" {createProps}
tooltip={tr.editingUnorderedList()} {updatePropsList}
shortcut="Control+,">{@html ulIcon}</CommandIconButton {setSlotHostContext}
> {api}
</ButtonGroupItem> >
<ButtonGroupItem>
<ButtonGroupItem> <CommandIconButton
<CommandIconButton key="insertUnorderedList"
key="insertOrderedList" tooltip={tr.editingUnorderedList()}
tooltip={tr.editingOrderedList()} shortcut="Control+,">{@html ulIcon}</CommandIconButton
shortcut="Control+.">{@html olIcon}</CommandIconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<WithDropdown let:createDropdown>
<IconButton
{disabled}
on:mount={(event) => createDropdown(event.detail.button)}
> >
{@html listOptionsIcon} </ButtonGroupItem>
</IconButton>
<ButtonDropdown> <ButtonGroupItem>
<Item id="justify"> <CommandIconButton
<ButtonGroup> key="insertOrderedList"
<ButtonGroupItem> tooltip={tr.editingOrderedList()}
shortcut="Control+.">{@html olIcon}</CommandIconButton
>
</ButtonGroupItem>
<ButtonGroupItem>
<WithDropdown let:createDropdown>
<IconButton
{disabled}
on:mount={(event) => createDropdown(event.detail.button)}
>
{@html listOptionsIcon}
</IconButton>
<ButtonDropdown>
<Item id="justify">
<ButtonGroup>
<CommandIconButton <CommandIconButton
key="justifyLeft" key="justifyLeft"
tooltip={tr.editingAlignLeft()} tooltip={tr.editingAlignLeft()}
withoutShortcut withoutShortcut
--border-left-radius="5px"
>{@html justifyLeftIcon}</CommandIconButton >{@html justifyLeftIcon}</CommandIconButton
> >
</ButtonGroupItem>
<ButtonGroupItem>
<CommandIconButton <CommandIconButton
key="justifyCenter" key="justifyCenter"
tooltip={tr.editingCenter()} tooltip={tr.editingCenter()}
withoutShortcut withoutShortcut
>{@html justifyCenterIcon}</CommandIconButton >{@html justifyCenterIcon}</CommandIconButton
> >
</ButtonGroupItem>
<ButtonGroupItem>
<CommandIconButton <CommandIconButton
key="justifyRight" key="justifyRight"
tooltip={tr.editingAlignRight()} tooltip={tr.editingAlignRight()}
withoutShortcut withoutShortcut
>{@html justifyRightIcon}</CommandIconButton >{@html justifyRightIcon}</CommandIconButton
> >
</ButtonGroupItem>
<ButtonGroupItem>
<CommandIconButton <CommandIconButton
key="justifyFull" key="justifyFull"
tooltip={tr.editingJustify()} tooltip={tr.editingJustify()}
withoutShortcut withoutShortcut
--border-right-radius="5px"
>{@html justifyFullIcon}</CommandIconButton >{@html justifyFullIcon}</CommandIconButton
> >
</ButtonGroupItem> </ButtonGroup>
</ButtonGroup> </Item>
</Item>
<Item id="indentation"> <Item id="indentation">
<ButtonGroup> <ButtonGroup>
<ButtonGroupItem>
<IconButton <IconButton
tooltip="{tr.editingOutdent()} ({getPlatformString( tooltip="{tr.editingOutdent()} ({getPlatformString(
outdentKeyCombination, outdentKeyCombination,
)})" )})"
{disabled} {disabled}
on:click={outdentListItem} on:click={outdentListItem}
--border-left-radius="5px"
> >
{@html outdentIcon} {@html outdentIcon}
</IconButton> </IconButton>
@ -137,15 +144,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
keyCombination={outdentKeyCombination} keyCombination={outdentKeyCombination}
on:action={outdentListItem} on:action={outdentListItem}
/> />
</ButtonGroupItem>
<ButtonGroupItem>
<IconButton <IconButton
tooltip="{tr.editingIndent()} ({getPlatformString( tooltip="{tr.editingIndent()} ({getPlatformString(
indentKeyCombination, indentKeyCombination,
)})" )})"
{disabled} {disabled}
on:click={indentListItem} on:click={indentListItem}
--border-right-radius="5px"
> >
{@html indentIcon} {@html indentIcon}
</IconButton> </IconButton>
@ -154,10 +160,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
keyCombination={indentKeyCombination} keyCombination={indentKeyCombination}
on:action={indentListItem} on:action={indentListItem}
/> />
</ButtonGroupItem> </ButtonGroup>
</ButtonGroup> </Item>
</Item> </ButtonDropdown>
</ButtonDropdown> </WithDropdown>
</WithDropdown> </ButtonGroupItem>
</ButtonGroupItem> </DynamicallySlottable>
</ButtonGroup> </ButtonGroup>

View file

@ -4,11 +4,16 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import ButtonGroup from "../../components/ButtonGroup.svelte"; import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import CommandIconButton from "./CommandIconButton.svelte"; import CommandIconButton from "./CommandIconButton.svelte";
import BoldButton from "./BoldButton.svelte"; import BoldButton from "./BoldButton.svelte";
import ItalicButton from "./ItalicButton.svelte"; import ItalicButton from "./ItalicButton.svelte";
import UnderlineButton from "./UnderlineButton.svelte"; import UnderlineButton from "./UnderlineButton.svelte";
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
import ButtonGroupItem, {
createProps,
updatePropsList,
setSlotHostContext,
} from "../../components/ButtonGroupItem.svelte";
import * as tr from "../../lib/ftl"; import * as tr from "../../lib/ftl";
import { superscriptIcon, subscriptIcon, eraserIcon } from "./icons"; import { superscriptIcon, subscriptIcon, eraserIcon } from "./icons";
@ -16,41 +21,50 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let api = {}; export let api = {};
</script> </script>
<ButtonGroup {api}> <ButtonGroup>
<ButtonGroupItem> <DynamicallySlottable
<BoldButton /> slotHost={ButtonGroupItem}
</ButtonGroupItem> {createProps}
{updatePropsList}
{setSlotHostContext}
{api}
>
<ButtonGroupItem>
<BoldButton />
</ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<ItalicButton /> <ItalicButton />
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<UnderlineButton /> <UnderlineButton />
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<CommandIconButton <CommandIconButton
key="superscript" key="superscript"
shortcut="Control+=" shortcut="Control+="
tooltip={tr.editingSuperscript()}>{@html superscriptIcon}</CommandIconButton tooltip={tr.editingSuperscript()}
> >{@html superscriptIcon}</CommandIconButton
</ButtonGroupItem> >
</ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<CommandIconButton <CommandIconButton
key="subscript" key="subscript"
shortcut="Control+Shift+=" shortcut="Control+Shift+="
tooltip={tr.editingSubscript()}>{@html subscriptIcon}</CommandIconButton tooltip={tr.editingSubscript()}>{@html subscriptIcon}</CommandIconButton
> >
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<CommandIconButton <CommandIconButton
key="removeFormat" key="removeFormat"
shortcut="Control+R" shortcut="Control+R"
tooltip={tr.editingRemoveFormatting()} tooltip={tr.editingRemoveFormatting()}
withoutState>{@html eraserIcon}</CommandIconButton withoutState>{@html eraserIcon}</CommandIconButton
> >
</ButtonGroupItem> </ButtonGroupItem>
</DynamicallySlottable>
</ButtonGroup> </ButtonGroup>

View file

@ -11,8 +11,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { getSurrounder } from "../surround"; import { getSurrounder } from "../surround";
import { italicIcon } from "./icons"; import { italicIcon } from "./icons";
import { getNoteEditor } from "../OldEditorAdapter.svelte"; import { context as noteEditorContext } from "../NoteEditor.svelte";
import type { RichTextInputAPI } from "../rich-text-input"; import type { RichTextInputAPI } from "../rich-text-input";
import { editingInputIsRichText } from "../rich-text-input";
function matchItalic(element: Element): Exclude<MatchResult, MatchResult.ALONG> { function matchItalic(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
@ -41,14 +42,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return !htmlElement.hasAttribute("style") && element.className.length === 0; return !htmlElement.hasAttribute("style") && element.className.length === 0;
} }
const { focusInRichText, activeInput } = getNoteEditor(); const { focusedInput } = noteEditorContext.get();
$: input = $activeInput; $: input = $focusedInput as RichTextInputAPI;
$: disabled = !$focusInRichText; $: disabled = !editingInputIsRichText($focusedInput);
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI); $: surrounder = disabled ? null : getSurrounder(input);
function updateStateFromActiveInput(): Promise<boolean> { function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text" return disabled
? Promise.resolve(false) ? Promise.resolve(false)
: surrounder!.isSurrounded(matchItalic); : surrounder!.isSurrounded(matchItalic);
} }

View file

@ -13,11 +13,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { wrapInternal } from "../../lib/wrap"; import { wrapInternal } from "../../lib/wrap";
import { functionIcon } from "./icons"; import { functionIcon } from "./icons";
import { getNoteEditor } from "../OldEditorAdapter.svelte"; import { context as noteEditorContext } from "../NoteEditor.svelte";
import type { RichTextInputAPI } from "../rich-text-input"; import type { RichTextInputAPI } from "../rich-text-input";
import { editingInputIsRichText } from "../rich-text-input";
const { activeInput, focusInRichText } = getNoteEditor(); const { focusedInput } = noteEditorContext.get();
$: richTextAPI = $activeInput as RichTextInputAPI; $: richTextAPI = $focusedInput as RichTextInputAPI;
async function surround(front: string, back: string): Promise<void> { async function surround(front: string, back: string): Promise<void> {
const element = await richTextAPI.element; const element = await richTextAPI.element;
@ -59,7 +60,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
[onLatexMathEnv, "Control+T, M", tr.editingLatexMathEnv()], [onLatexMathEnv, "Control+T, M", tr.editingLatexMathEnv()],
]; ];
$: disabled = !$focusInRichText; $: disabled = !editingInputIsRichText($focusedInput);
</script> </script>
<WithDropdown let:createDropdown> <WithDropdown let:createDropdown>

View file

@ -1,41 +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/ftl";
import { bridgeCommand } from "../../lib/bridgecommand";
import { getPlatformString } from "../../lib/shortcuts";
import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import LabelButton from "../../components/LabelButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
export let api = {};
const keyCombination = "Control+L";
</script>
<ButtonGroup {api}>
<ButtonGroupItem>
<LabelButton
tooltip={tr.editingCustomizeFields()}
on:click={() => bridgeCommand("fields")}
>
{tr.editingFields()}...
</LabelButton>
</ButtonGroupItem>
<ButtonGroupItem>
<LabelButton
tooltip="{tr.editingCustomizeCardTemplates()} ({getPlatformString(
keyCombination,
)})"
on:click={() => bridgeCommand("cards")}
>
{tr.editingCards()}...
</LabelButton>
<Shortcut {keyCombination} on:action={() => bridgeCommand("cards")} />
</ButtonGroupItem>
</ButtonGroup>

View file

@ -0,0 +1,56 @@
<!--
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/ftl";
import { bridgeCommand } from "../../lib/bridgecommand";
import { getPlatformString } from "../../lib/shortcuts";
import ButtonGroup from "../../components/ButtonGroup.svelte";
import LabelButton from "../../components/LabelButton.svelte";
import Shortcut from "../../components/Shortcut.svelte";
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
import ButtonGroupItem, {
createProps,
updatePropsList,
setSlotHostContext,
} from "../../components/ButtonGroupItem.svelte";
export let api = {};
const keyCombination = "Control+L";
</script>
<ButtonGroup>
<DynamicallySlottable
slotHost={ButtonGroupItem}
{createProps}
{updatePropsList}
{setSlotHostContext}
{api}
>
<ButtonGroupItem>
<LabelButton
tooltip={tr.editingCustomizeFields()}
on:click={() => bridgeCommand("fields")}
>
{tr.editingFields()}...
</LabelButton>
</ButtonGroupItem>
<ButtonGroupItem>
<LabelButton
tooltip="{tr.editingCustomizeCardTemplates()} ({getPlatformString(
keyCombination,
)})"
on:click={() => bridgeCommand("cards")}
>
{tr.editingCards()}...
</LabelButton>
<Shortcut {keyCombination} on:action={() => bridgeCommand("cards")} />
</ButtonGroupItem>
<slot />
</DynamicallySlottable>
</ButtonGroup>

View file

@ -3,68 +3,87 @@ 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 * as tr from "../../lib/ftl";
import ButtonGroup from "../../components/ButtonGroup.svelte"; import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte"; import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
import ButtonGroupItem, {
createProps,
updatePropsList,
setSlotHostContext,
} from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte"; import IconButton from "../../components/IconButton.svelte";
import Shortcut from "../../components/Shortcut.svelte"; import Shortcut from "../../components/Shortcut.svelte";
import ClozeButton from "./ClozeButton.svelte"; import ClozeButton from "./ClozeButton.svelte";
import LatexButton from "./LatexButton.svelte"; import LatexButton from "./LatexButton.svelte";
import * as tr from "../../lib/ftl";
import { bridgeCommand } from "../../lib/bridgecommand"; import { bridgeCommand } from "../../lib/bridgecommand";
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { getNoteEditor } from "../OldEditorAdapter.svelte"; import { context } from "../NoteEditor.svelte";
import { editingInputIsRichText } from "../rich-text-input";
import { paperclipIcon, micIcon } from "./icons"; import { paperclipIcon, micIcon } from "./icons";
export let api = {}; const { focusedInput } = context.get();
const { focusInRichText } = getNoteEditor();
const attachmentKeyCombination = "F3"; const attachmentKeyCombination = "F7";
function onAttachment(): void { function onAttachment(): void {
bridgeCommand("attach"); bridgeCommand("attach");
} }
const recordKeyCombination = "F5"; const recordKeyCombination = "F8";
function onRecord(): void { function onRecord(): void {
bridgeCommand("record"); bridgeCommand("record");
} }
$: disabled = !$focusInRichText; $: disabled = !editingInputIsRichText($focusedInput);
export let api = {};
</script> </script>
<ButtonGroup {api}> <ButtonGroup>
<ButtonGroupItem> <DynamicallySlottable
<IconButton slotHost={ButtonGroupItem}
tooltip="{tr.editingAttachPicturesaudiovideo()} ({getPlatformString( {createProps}
attachmentKeyCombination, {updatePropsList}
)})" {setSlotHostContext}
iconSize={70} {api}
{disabled} >
on:click={onAttachment} <ButtonGroupItem>
> <IconButton
{@html paperclipIcon} tooltip="{tr.editingAttachPicturesaudiovideo()} ({getPlatformString(
</IconButton> attachmentKeyCombination,
<Shortcut keyCombination={attachmentKeyCombination} on:action={onAttachment} /> )})"
</ButtonGroupItem> iconSize={70}
{disabled}
on:click={onAttachment}
>
{@html paperclipIcon}
</IconButton>
<Shortcut
keyCombination={attachmentKeyCombination}
on:action={onAttachment}
/>
</ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<IconButton <IconButton
tooltip="{tr.editingRecordAudio()} ({getPlatformString( tooltip="{tr.editingRecordAudio()} ({getPlatformString(
recordKeyCombination, recordKeyCombination,
)})" )})"
iconSize={70} iconSize={70}
{disabled} {disabled}
on:click={onRecord} on:click={onRecord}
> >
{@html micIcon} {@html micIcon}
</IconButton> </IconButton>
<Shortcut keyCombination={recordKeyCombination} on:action={onRecord} /> <Shortcut keyCombination={recordKeyCombination} on:action={onRecord} />
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem id="cloze"> <ButtonGroupItem id="cloze">
<ClozeButton /> <ClozeButton />
</ButtonGroupItem> </ButtonGroupItem>
<ButtonGroupItem> <ButtonGroupItem>
<LatexButton /> <LatexButton />
</ButtonGroupItem> </ButtonGroupItem>
</DynamicallySlottable>
</ButtonGroup> </ButtonGroup>

View file

@ -10,9 +10,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { MatchResult } from "../../domlib/surround"; import { MatchResult } from "../../domlib/surround";
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { getSurrounder } from "../surround"; import { getSurrounder } from "../surround";
import { getNoteEditor } from "../OldEditorAdapter.svelte";
import type { RichTextInputAPI } from "../rich-text-input";
import { underlineIcon } from "./icons"; import { underlineIcon } from "./icons";
import { context } from "../NoteEditor.svelte";
import type { RichTextInputAPI } from "../rich-text-input";
import { editingInputIsRichText } from "../rich-text-input";
function matchUnderline(element: Element): Exclude<MatchResult, MatchResult.ALONG> { function matchUnderline(element: Element): Exclude<MatchResult, MatchResult.ALONG> {
if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) { if (!(element instanceof HTMLElement) && !(element instanceof SVGElement)) {
@ -26,14 +27,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return MatchResult.NO_MATCH; return MatchResult.NO_MATCH;
} }
const { focusInRichText, activeInput } = getNoteEditor(); const { focusedInput } = context.get();
$: input = $activeInput; $: input = $focusedInput as RichTextInputAPI;
$: disabled = !$focusInRichText; $: disabled = !editingInputIsRichText($focusedInput);
$: surrounder = disabled ? null : getSurrounder(input as RichTextInputAPI); $: surrounder = disabled ? null : getSurrounder(input);
function updateStateFromActiveInput(): Promise<boolean> { function updateStateFromActiveInput(): Promise<boolean> {
return !input || input.name === "plain-text" return disabled
? Promise.resolve(false) ? Promise.resolve(false)
: surrounder!.isSurrounded(matchUnderline); : surrounder!.isSurrounded(matchUnderline);
} }

View file

@ -9,7 +9,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { directionKey } from "../../lib/context-keys"; import { directionKey } from "../../lib/context-keys";
import ButtonGroup from "../../components/ButtonGroup.svelte"; import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte"; import IconButton from "../../components/IconButton.svelte";
import { createEventDispatcher } from "svelte"; import { createEventDispatcher } from "svelte";
@ -27,44 +26,40 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<ButtonGroup size={1.6} wrap={false}> <ButtonGroup size={1.6} wrap={false}>
<ButtonGroupItem> <IconButton
<IconButton tooltip={tr.editingFloatLeft()}
tooltip={tr.editingFloatLeft()} active={image.style.float === "left"}
active={image.style.float === "left"} flipX={$direction === "rtl"}
flipX={$direction === "rtl"} on:click={() => {
on:click={() => { image.style.float = "left";
image.style.float = "left"; setTimeout(() => dispatch("update"));
setTimeout(() => dispatch("update")); }}
}}>{@html inlineStartIcon}</IconButton --border-left-radius="5px">{@html inlineStartIcon}</IconButton
> >
</ButtonGroupItem>
<ButtonGroupItem> <IconButton
<IconButton tooltip={tr.editingFloatNone()}
tooltip={tr.editingFloatNone()} active={image.style.float === "" || image.style.float === "none"}
active={image.style.float === "" || image.style.float === "none"} flipX={$direction === "rtl"}
flipX={$direction === "rtl"} on:click={() => {
on:click={() => { image.style.removeProperty("float");
image.style.removeProperty("float");
if (image.getAttribute("style")?.length === 0) { if (image.getAttribute("style")?.length === 0) {
image.removeAttribute("style"); image.removeAttribute("style");
} }
setTimeout(() => dispatch("update")); setTimeout(() => dispatch("update"));
}}>{@html floatNoneIcon}</IconButton }}>{@html floatNoneIcon}</IconButton
> >
</ButtonGroupItem>
<ButtonGroupItem> <IconButton
<IconButton tooltip={tr.editingFloatRight()}
tooltip={tr.editingFloatRight()} active={image.style.float === "right"}
active={image.style.float === "right"} flipX={$direction === "rtl"}
flipX={$direction === "rtl"} on:click={() => {
on:click={() => { image.style.float = "right";
image.style.float = "right"; setTimeout(() => dispatch("update"));
setTimeout(() => dispatch("update")); }}
}}>{@html inlineEndIcon}</IconButton --border-right-radius="5px">{@html inlineEndIcon}</IconButton
> >
</ButtonGroupItem>
</ButtonGroup> </ButtonGroup>

View file

@ -6,19 +6,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { tick, onDestroy } from "svelte"; import { tick, onDestroy } from "svelte";
import WithDropdown from "../../components/WithDropdown.svelte"; import WithDropdown from "../../components/WithDropdown.svelte";
import ButtonDropdown from "../../components/ButtonDropdown.svelte"; import ButtonDropdown from "../../components/ButtonDropdown.svelte";
import Item from "../../components/Item.svelte";
import HandleBackground from "../HandleBackground.svelte"; import HandleBackground from "../HandleBackground.svelte";
import HandleSelection from "../HandleSelection.svelte"; import HandleSelection from "../HandleSelection.svelte";
import HandleControl from "../HandleControl.svelte"; import HandleControl from "../HandleControl.svelte";
import HandleLabel from "../HandleLabel.svelte"; import HandleLabel from "../HandleLabel.svelte";
import { getRichTextInput } from "../rich-text-input"; import { context } from "../rich-text-input";
import WithImageConstrained from "./WithImageConstrained.svelte"; import WithImageConstrained from "./WithImageConstrained.svelte";
import FloatButtons from "./FloatButtons.svelte"; import FloatButtons from "./FloatButtons.svelte";
import SizeSelect from "./SizeSelect.svelte"; import SizeSelect from "./SizeSelect.svelte";
const { container, styles } = getRichTextInput(); const { container, styles } = context.get();
const sheetPromise = styles const sheetPromise = styles
.addStyleTag("imageOverlay") .addStyleTag("imageOverlay")
@ -233,15 +232,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/> />
</HandleSelection> </HandleSelection>
<ButtonDropdown on:click={updateSizesWithDimensions}> <ButtonDropdown on:click={updateSizesWithDimensions}>
<Item> <FloatButtons
<FloatButtons image={activeImage}
image={activeImage} on:update={dropdownObject.update}
on:update={dropdownObject.update} />
/> <SizeSelect {active} on:click={toggleActualSize} />
</Item>
<Item>
<SizeSelect {active} on:click={toggleActualSize} />
</Item>
</ButtonDropdown> </ButtonDropdown>
{/if} {/if}
</WithImageConstrained> </WithImageConstrained>

View file

@ -9,7 +9,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { directionKey } from "../../lib/context-keys"; import { directionKey } from "../../lib/context-keys";
import ButtonGroup from "../../components/ButtonGroup.svelte"; import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte"; import IconButton from "../../components/IconButton.svelte";
import { sizeActual, sizeMinimized } from "./icons"; import { sizeActual, sizeMinimized } from "./icons";
@ -22,12 +21,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<ButtonGroup size={1.6}> <ButtonGroup size={1.6}>
<ButtonGroupItem> <IconButton
<IconButton {active}
{active} flipX={$direction === "rtl"}
flipX={$direction === "rtl"} tooltip={tr.editingActualSize()}
tooltip={tr.editingActualSize()} on:click
on:click>{@html icon}</IconButton --border-left-radius="5px"
> --border-right-radius="5px">{@html icon}</IconButton
</ButtonGroupItem> >
</ButtonGroup> </ButtonGroup>

View file

@ -2,11 +2,9 @@
// 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 BrowserEditor from "./BrowserEditor.svelte"; import BrowserEditor from "./BrowserEditor.svelte";
import { editorModules } from "./base"; import { editorModules } from "./base";
import { promiseWithResolver } from "../lib/promise";
import { globalExport } from "../lib/globals"; import { globalExport } from "../lib/globals";
import { setupI18n } from "../lib/i18n"; import { setupI18n } from "../lib/i18n";
import { uiResolve } from "../lib/ui";
const [uiPromise, uiResolve] = promiseWithResolver();
async function setupBrowserEditor(): Promise<void> { async function setupBrowserEditor(): Promise<void> {
await setupI18n({ modules: editorModules }); await setupI18n({ modules: editorModules });
@ -20,9 +18,4 @@ async function setupBrowserEditor(): Promise<void> {
setupBrowserEditor(); setupBrowserEditor();
import * as base from "./base"; import * as base from "./base";
globalExport(base);
globalExport({
...base,
uiPromise,
noteEditorPromise: uiPromise,
});

View file

@ -2,11 +2,9 @@
// 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 NoteCreator from "./NoteCreator.svelte"; import NoteCreator from "./NoteCreator.svelte";
import { editorModules } from "./base"; import { editorModules } from "./base";
import { promiseWithResolver } from "../lib/promise";
import { globalExport } from "../lib/globals"; import { globalExport } from "../lib/globals";
import { setupI18n } from "../lib/i18n"; import { setupI18n } from "../lib/i18n";
import { uiResolve } from "../lib/ui";
const [uiPromise, uiResolve] = promiseWithResolver();
async function setupNoteCreator(): Promise<void> { async function setupNoteCreator(): Promise<void> {
await setupI18n({ modules: editorModules }); await setupI18n({ modules: editorModules });
@ -20,9 +18,4 @@ async function setupNoteCreator(): Promise<void> {
setupNoteCreator(); setupNoteCreator();
import * as base from "./base"; import * as base from "./base";
globalExport(base);
globalExport({
...base,
uiPromise,
noteEditorPromise: uiPromise,
});

View file

@ -2,11 +2,9 @@
// 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 ReviewerEditor from "./ReviewerEditor.svelte"; import ReviewerEditor from "./ReviewerEditor.svelte";
import { editorModules } from "./base"; import { editorModules } from "./base";
import { promiseWithResolver } from "../lib/promise";
import { globalExport } from "../lib/globals"; import { globalExport } from "../lib/globals";
import { setupI18n } from "../lib/i18n"; import { setupI18n } from "../lib/i18n";
import { uiResolve } from "../lib/ui";
const [uiPromise, uiResolve] = promiseWithResolver();
async function setupReviewerEditor(): Promise<void> { async function setupReviewerEditor(): Promise<void> {
await setupI18n({ modules: editorModules }); await setupI18n({ modules: editorModules });
@ -20,9 +18,4 @@ async function setupReviewerEditor(): Promise<void> {
setupReviewerEditor(); setupReviewerEditor();
import * as base from "./base"; import * as base from "./base";
globalExport(base);
globalExport({
...base,
uiPromise,
noteEditorPromise: uiPromise,
});

View file

@ -4,9 +4,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import ButtonToolbar from "../../components/ButtonToolbar.svelte"; import ButtonToolbar from "../../components/ButtonToolbar.svelte";
import Item from "../../components/Item.svelte";
import ButtonGroup from "../../components/ButtonGroup.svelte"; import ButtonGroup from "../../components/ButtonGroup.svelte";
import ButtonGroupItem from "../../components/ButtonGroupItem.svelte";
import IconButton from "../../components/IconButton.svelte"; import IconButton from "../../components/IconButton.svelte";
import * as tr from "../../lib/ftl"; import * as tr from "../../lib/ftl";
import { inlineIcon, blockIcon, deleteIcon } from "./icons"; import { inlineIcon, blockIcon, deleteIcon } from "./icons";
@ -25,42 +23,36 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<ButtonToolbar size={1.6} wrap={false}> <ButtonToolbar size={1.6} wrap={false}>
<Item> <ButtonGroup>
<ButtonGroup> <IconButton
<ButtonGroupItem> tooltip={tr.editingMathjaxInline()}
<IconButton active={!isBlock}
tooltip={tr.editingMathjaxInline()} on:click={() => {
active={!isBlock} isBlock = false;
on:click={() => { updateBlock();
isBlock = false; }}
updateBlock(); on:click
}} --border-left-radius="5px">{@html inlineIcon}</IconButton
on:click>{@html inlineIcon}</IconButton >
>
</ButtonGroupItem>
<ButtonGroupItem> <IconButton
<IconButton tooltip={tr.editingMathjaxBlock()}
tooltip={tr.editingMathjaxBlock()} active={isBlock}
active={isBlock} on:click={() => {
on:click={() => { isBlock = true;
isBlock = true; updateBlock();
updateBlock(); }}
}} on:click
on:click>{@html blockIcon}</IconButton --border-right-radius="5px">{@html blockIcon}</IconButton
> >
</ButtonGroupItem> </ButtonGroup>
</ButtonGroup>
</Item>
<Item> <ButtonGroup>
<ButtonGroup> <IconButton
<ButtonGroupItem> tooltip={tr.actionsDelete()}
<IconButton on:click={() => dispatch("delete")}
tooltip={tr.actionsDelete()} --border-left-radius="5px"
on:click={() => dispatch("delete")}>{@html deleteIcon}</IconButton --border-right-radius="5px">{@html deleteIcon}</IconButton
> >
</ButtonGroupItem> </ButtonGroup>
</ButtonGroup>
</Item>
</ButtonToolbar> </ButtonToolbar>

View file

@ -12,10 +12,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import HandleSelection from "../HandleSelection.svelte"; import HandleSelection from "../HandleSelection.svelte";
import HandleBackground from "../HandleBackground.svelte"; import HandleBackground from "../HandleBackground.svelte";
import HandleControl from "../HandleControl.svelte"; import HandleControl from "../HandleControl.svelte";
import { getRichTextInput } from "../rich-text-input"; import { context } from "../rich-text-input";
import MathjaxMenu from "./MathjaxMenu.svelte"; import MathjaxMenu from "./MathjaxMenu.svelte";
const { container, api } = getRichTextInput(); const { container, api } = context.get();
const { flushCaret, preventResubscription } = api; const { flushCaret, preventResubscription } = api;
const code = writable(""); const code = writable("");

View file

@ -20,8 +20,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { tick, onMount } from "svelte"; import { tick, onMount } from "svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { pageTheme } from "../../sveltelib/theme"; import { pageTheme } from "../../sveltelib/theme";
import { getDecoratedElements } from "../DecoratedElements.svelte"; import { context as editingAreaContext } from "../EditingArea.svelte";
import { getEditingArea } from "../EditingArea.svelte"; import { context as decoratedElementsContext } from "../DecoratedElements.svelte";
import CodeMirror from "../CodeMirror.svelte"; import CodeMirror from "../CodeMirror.svelte";
import type { CodeMirrorAPI } from "../CodeMirror.svelte"; import type { CodeMirrorAPI } from "../CodeMirror.svelte";
import { htmlanki, baseOptions, gutterOptions } from "../code-mirror"; import { htmlanki, baseOptions, gutterOptions } from "../code-mirror";
@ -34,8 +34,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
...gutterOptions, ...gutterOptions,
}; };
const { editingInputs, content } = getEditingArea(); const { editingInputs, content } = editingAreaContext.get();
const decoratedElements = getDecoratedElements(); const decoratedElements = decoratedElementsContext.get();
const code = writable($content); const code = writable($content);
function adjustInputHTML(html: string): string { function adjustInputHTML(html: string): string {

View file

@ -27,6 +27,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
getTriggerAfterInput(): Trigger<OnInputCallback>; getTriggerAfterInput(): Trigger<OnInputCallback>;
} }
export function editingInputIsRichText(
editingInput: EditingInputAPI | null,
): editingInput is RichTextInputAPI {
return editingInput?.name === "rich-text";
}
export interface RichTextInputContextAPI { export interface RichTextInputContextAPI {
styles: CustomStyles; styles: CustomStyles;
container: HTMLElement; container: HTMLElement;
@ -34,10 +40,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
const key = Symbol("richText"); const key = Symbol("richText");
const [set, getRichTextInput, hasRichTextInput] = const [context, setContextProperty] = contextProperty<RichTextInputContextAPI>(key);
contextProperty<RichTextInputContextAPI>(key);
export { getRichTextInput, hasRichTextInput };
import getDOMMirror from "../../sveltelib/mirror-dom"; import getDOMMirror from "../../sveltelib/mirror-dom";
import getInputManager from "../../sveltelib/input-manager"; import getInputManager from "../../sveltelib/input-manager";
@ -49,7 +52,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
getTriggerOnNextInsert, getTriggerOnNextInsert,
} = getInputManager(); } = getInputManager();
export { getTriggerAfterInput, getTriggerOnInput, getTriggerOnNextInsert }; export { context, getTriggerAfterInput, getTriggerOnInput, getTriggerOnNextInsert };
</script> </script>
<script lang="ts"> <script lang="ts">
@ -61,8 +64,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} from "../../lib/dom"; } from "../../lib/dom";
import ContentEditable from "../../editable/ContentEditable.svelte"; import ContentEditable from "../../editable/ContentEditable.svelte";
import { placeCaretAfterContent } from "../../domlib/place-caret"; import { placeCaretAfterContent } from "../../domlib/place-caret";
import { getDecoratedElements } from "../DecoratedElements.svelte"; import { context as decoratedElementsContext } from "../DecoratedElements.svelte";
import { getEditingArea } from "../EditingArea.svelte"; import { context as editingAreaContext } from "../EditingArea.svelte";
import { promiseWithResolver } from "../../lib/promise"; import { promiseWithResolver } from "../../lib/promise";
import { bridgeCommand } from "../../lib/bridgecommand"; import { bridgeCommand } from "../../lib/bridgecommand";
import { on } from "../../lib/events"; import { on } from "../../lib/events";
@ -73,8 +76,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let hidden: boolean; export let hidden: boolean;
const { content, editingInputs } = getEditingArea(); const { content, editingInputs } = editingAreaContext.get();
const decoratedElements = getDecoratedElements(); const decoratedElements = decoratedElementsContext.get();
const range = document.createRange(); const range = document.createRange();
@ -269,7 +272,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<div class="rich-text-widgets"> <div class="rich-text-widgets">
{#await Promise.all( [richTextPromise, stylesPromise], ) then [container, styles]} {#await Promise.all( [richTextPromise, stylesPromise], ) then [container, styles]}
<SetContext setter={set} value={{ container, styles, api }}> <SetContext
setter={setContextProperty}
value={{ container, styles, api }}
>
<slot /> <slot />
</SetContext> </SetContext>
{/await} {/await}

View file

@ -1,5 +1,9 @@
// 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
export { default as RichTextInput, getRichTextInput } from "./RichTextInput.svelte"; export {
default as RichTextInput,
context,
editingInputIsRichText,
} from "./RichTextInput.svelte";
export type { RichTextInputAPI } from "./RichTextInput.svelte"; export type { RichTextInputAPI } from "./RichTextInput.svelte";

View file

@ -1,7 +1,7 @@
// 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 { registerPackage } from "./register-package"; import { registerPackage } from "./runtime-require";
declare global { declare global {
interface Window { interface Window {

109
ts/lib/children-access.ts Normal file
View file

@ -0,0 +1,109 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export type Identifier = Element | string | number;
function findElement<T extends Element>(
collection: HTMLCollection,
identifier: Identifier,
): [T, number] | null {
let element: T;
let index: number;
if (identifier instanceof Element) {
element = identifier as T;
index = Array.prototype.indexOf.call(collection, element);
if (index < 0) {
return null;
}
} else if (typeof identifier === "string") {
const item = collection.namedItem(identifier);
if (!item) {
return null;
}
element = item as T;
index = Array.prototype.indexOf.call(collection, element);
if (index < 0) {
return null;
}
} else if (identifier < 0) {
index = collection.length + identifier;
const item = collection.item(index);
if (!item) {
return null;
}
element = item as T;
} else {
index = identifier;
const item = collection.item(index);
if (!item) {
return null;
}
element = item as T;
}
return [element, index];
}
/**
* Creates a convenient access API for the children
* of an element via identifiers. Identifiers can be:
* - integers: signify the position
* - negative integers: signify the offset from the end (-1 being the last element)
* - strings: signify the id of an element
* - the child directly
*/
class ChildrenAccess<T extends Element> {
parent: T;
constructor(parent: T) {
this.parent = parent;
}
insertElement(element: Element, identifier: Identifier): number {
const match = findElement(this.parent.children, identifier);
if (!match) {
return -1;
}
const [reference, index] = match;
this.parent.insertBefore(element, reference[0]);
return index;
}
appendElement(element: Element, identifier: Identifier): number {
const match = findElement(this.parent.children, identifier);
if (!match) {
return -1;
}
const [before, index] = match;
const reference = before.nextElementSibling ?? null;
this.parent.insertBefore(element, reference);
return index + 1;
}
updateElement(
f: (element: T, index: number) => void,
identifier: Identifier,
): boolean {
const match = findElement<T>(this.parent.children, identifier);
if (!match) {
return false;
}
f(...match);
return true;
}
}
function childrenAccess<T extends Element>(parent: T): ChildrenAccess<T> {
return new ChildrenAccess<T>(parent);
}
export default childrenAccess;
export type { ChildrenAccess };

12
ts/lib/helpers.ts Normal file
View file

@ -0,0 +1,12 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export type Callback = () => void;
export function removeItem<T>(items: T[], item: T): void {
const index = items.findIndex((i: T): boolean => i === item);
if (index >= 0) {
items.splice(index, 1);
}
}

View file

@ -1,57 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { runtimeLibraries } from "./runtime-require";
const prohibit = () => false;
export function registerPackage(
name: string,
entries: Record<string, unknown>,
deprecation?: Record<string, string>,
): void {
const pack = deprecation
? new Proxy(entries, {
set: prohibit,
defineProperty: prohibit,
deleteProperty: prohibit,
get: (target, name: string) => {
if (name in deprecation) {
console.log(`anki: ${name} is deprecated: ${deprecation[name]}`);
}
return target[name];
},
})
: entries;
runtimeLibraries[name] = pack;
}
function listPackages(): string[] {
return Object.keys(runtimeLibraries);
}
function hasPackages(...names: string[]): boolean {
const libraries = listPackages();
return names.reduce(
(accu: boolean, name: string) => accu && libraries.includes(name),
true,
);
}
function immediatelyDeprecated() {
return false;
}
registerPackage(
"anki/packages",
{
listPackages,
hasPackages,
immediatelyDeprecated,
},
{
[immediatelyDeprecated.name]: "Do not use this function",
},
);

View file

@ -1,19 +1,97 @@
// 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
/* eslint /**
@typescript-eslint/no-explicit-any: "off", * Names of anki packages
*
* @privateRemarks
* Originally this was more strictly typed as a record:
* ```ts
* type AnkiPackages = {
* "anki/NoteEditor": NoteEditorPackage,
* }
* ```
* This would be very useful for `require`: the result could be strictly typed.
* However cross-module type imports currently don't work.
*/ */
type AnkiPackages =
| "anki/NoteEditor"
| "anki/packages"
| "anki/bridgecommand"
| "anki/shortcuts"
| "anki/theme"
| "anki/location"
| "anki/surround"
| "anki/ui";
type PackageDeprecation<T extends Record<string, unknown>> = {
[key in keyof T]?: string;
};
/// This can be extended to allow require() calls at runtime, for libraries /// This can be extended to allow require() calls at runtime, for packages
/// that are not included at bundling time. /// that are not included at bundling time.
export const runtimeLibraries = {}; const runtimePackages: Partial<Record<AnkiPackages, Record<string, unknown>>> = {};
const prohibit = () => false;
/**
* Packages registered with this function escape the typing provided by `AnkiPackages`
*/
export function registerPackageRaw(
name: string,
entries: Record<string, unknown>,
): void {
runtimePackages[name] = entries;
}
export function registerPackage<
T extends AnkiPackages,
U extends Record<string, unknown>,
>(name: T, entries: U, deprecation?: PackageDeprecation<U>): void {
const pack = deprecation
? new Proxy(entries, {
set: prohibit,
defineProperty: prohibit,
deleteProperty: prohibit,
get: (target, name: string) => {
if (name in deprecation) {
console.log(`anki: ${name} is deprecated: ${deprecation[name]}`);
}
return target[name];
},
})
: entries;
registerPackageRaw(name, pack);
}
function require<T extends AnkiPackages>(name: T): Record<string, unknown> | undefined {
if (!(name in runtimePackages)) {
throw new Error(`Cannot require "${name}" at runtime.`);
} else {
return runtimePackages[name];
}
}
function listPackages(): string[] {
return Object.keys(runtimePackages);
}
function hasPackages(...names: string[]): boolean {
for (const name of names) {
if (!(name in runtimePackages)) {
return false;
}
}
return true;
}
// Export require() as a global. // Export require() as a global.
(window as any).require = function (name: string): unknown { Object.assign(window, { require });
const lib = runtimeLibraries[name];
if (lib === undefined) { registerPackage("anki/packages", {
throw new Error(`Cannot require(${name}) at runtime.`); // We also register require here, so add-ons can have a type-save variant of require (TODO, see AnkiPackages above)
} require,
return lib; listPackages,
}; hasPackages,
});

View file

@ -3,7 +3,7 @@
import type { Modifier } from "./keys"; import type { Modifier } from "./keys";
import { registerPackage } from "./register-package"; import { registerPackage } from "./runtime-require";
import { import {
modifiersToPlatformString, modifiersToPlatformString,
keyToPlatformString, keyToPlatformString,

13
ts/lib/ui.ts Normal file
View file

@ -0,0 +1,13 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { promiseWithResolver } from "./promise";
import { registerPackage } from "./runtime-require";
const [loaded, uiResolve] = promiseWithResolver();
registerPackage("anki/ui", {
loaded,
});
export { uiResolve };

View file

@ -96,7 +96,7 @@ async function emitTypings(svelte: SvelteTsxFile[], deps: InputFile[]): Promise<
const tsHost = ts.createCompilerHost(parsedCommandLine.options); const tsHost = ts.createCompilerHost(parsedCommandLine.options);
const createdFiles = {}; const createdFiles = {};
const cwd = ts.sys.getCurrentDirectory().replace(/\\/g, "/"); const cwd = ts.sys.getCurrentDirectory().replace(/\\/g, "/");
tsHost.writeFile = (fileName, contents) => { tsHost.writeFile = (fileName: string, contents: string): void => {
// tsc makes some paths absolute for some reason // tsc makes some paths absolute for some reason
if (fileName.startsWith(cwd)) { if (fileName.startsWith(cwd)) {
fileName = fileName.substring(cwd.length + 1); fileName = fileName.substring(cwd.length + 1);
@ -109,32 +109,28 @@ async function emitTypings(svelte: SvelteTsxFile[], deps: InputFile[]): Promise<
// } // }
for (const file of svelte) { for (const file of svelte) {
await writeFile(file.realDtsPath, createdFiles[file.virtualDtsPath]); if (!(file.virtualDtsPath in createdFiles)) {
/**
* This can happen if you do a case-only rename
* e.g. NoteTypeButtons.svelte -> NotetypeButtons.svelte
*/
console.log(
"file not among created files: ",
file.virtualDtsPath,
Object.keys(createdFiles),
);
} else {
await writeFile(file.realDtsPath, createdFiles[file.virtualDtsPath]);
}
} }
} }
function writeFile(file, data): Promise<void> { async function writeFile(file: string, data: string): Promise<void> {
return new Promise((resolve, reject) => { await fs.promises.writeFile(file, data);
fs.writeFile(file, data, (err) => {
if (err) {
reject(err);
return;
}
resolve();
});
});
} }
function readFile(file) { function readFile(file: string): Promise<string> {
return new Promise((resolve, reject) => { return fs.promises.readFile(file, "utf-8");
fs.readFile(file, "utf8", (err, data) => {
if (err) {
reject(err);
return;
}
resolve(data);
});
});
} }
async function compileSingleSvelte( async function compileSingleSvelte(

View file

@ -3,29 +3,44 @@
import { setContext, getContext, hasContext } from "svelte"; import { setContext, getContext, hasContext } from "svelte";
type ContextProperty<T> = [ type SetContextPropertyAction<T> = (value: T) => void;
(value: T) => T,
// this typing is a lie insofar that calling get
// outside of the component's context will return undefined
() => T,
() => boolean,
];
function contextProperty<T>(key: symbol): ContextProperty<T> { export interface ContextProperty<T> {
function set(context: T): T { /**
* Retrieves the component's context
*
* @remarks
* The typing of the return value is a lie insofar as calling `get` outside
* of the component's context will return `undefined`.
* If you are uncertain if your component is actually within the context
* of this component, you should check with `available` first.
*
* @returns The component's context
*/
get(): T;
/**
* Checks whether the component's context is available
*/
available(): boolean;
}
function contextProperty<T>(
key: symbol,
): [ContextProperty<T>, SetContextPropertyAction<T>] {
function set(context: T): void {
setContext(key, context); setContext(key, context);
return context;
} }
function get(): T { const context = {
return getContext(key); get(): T {
} return getContext(key);
},
available(): boolean {
return hasContext(key);
},
};
function has(): boolean { return [context, set];
return hasContext(key);
}
return [set, get, has];
} }
export default contextProperty; export default contextProperty;

View file

@ -0,0 +1,299 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { SvelteComponent } from "svelte";
import type { Readable, Writable } from "svelte/store";
import { writable } from "svelte/store";
import type { Identifier } from "../lib/children-access";
import { promiseWithResolver } from "../lib/promise";
import type { ChildrenAccess } from "../lib/children-access";
import type { Callback } from "../lib/helpers";
import { removeItem } from "../lib/helpers";
import childrenAccess from "../lib/children-access";
import { nodeIsElement } from "../lib/dom";
export interface DynamicSvelteComponent {
component: typeof SvelteComponent;
/**
* Props that are passed to the component
*/
props?: Record<string, unknown>;
/**
* ID that will be assigned to the component that hosts
* the dynamic component (slot host)
*/
id?: string;
}
/**
* Props that will be passed to the slot host, e.g. ButtonGroupItem.
*/
export interface SlotHostProps {
detach: Writable<boolean>;
}
export interface CreateInterfaceAPI<T extends SlotHostProps, U extends Element> {
addComponent(
component: DynamicSvelteComponent,
reinsert: (newElement: U, access: ChildrenAccess<U>) => number,
): Promise<{ destroy: Callback }>;
updateProps(update: (hostProps: T) => T, identifier: Identifier): Promise<boolean>;
}
export interface GetSlotHostProps<T> {
getProps(): T;
}
export interface DynamicSlotted<T extends SlotHostProps = SlotHostProps> {
component: DynamicSvelteComponent;
hostProps: T;
}
export interface DynamicSlottingAPI<
T extends SlotHostProps,
U extends Element,
X extends Record<string, unknown>,
> {
/**
* This should be used as an action on the element that hosts the slot hosts.
*/
resolveSlotContainer: (element: U) => void;
/**
* Contains the props for the DynamicSlot component
*/
dynamicSlotted: Readable<DynamicSlotted<T>[]>;
slotsInterface: X;
}
/**
* Allow add-on developers to dynamically extend/modify components our components
*
* @remarks
* It allows to insert elements inbetween the components, or modify their props.
* Practically speaking, we let Svelte do the initial insertion of an element,
* but then immediately move it to its destination, and save a reference to it.
*
* @experimental
*/
function dynamicSlotting<
T extends SlotHostProps,
U extends Element,
X extends Record<string, unknown>,
>(
/**
* A function which will create props which are passed to the dynamically
* slotted component's host component, the slot host, e.g. `ButtonGroupItem`
*/
makeProps: () => T,
/**
* This is called on *all* items whenever any item updates
*/
updatePropsList: (propsList: T[]) => T[],
/**
* A function to create an interface to interact with slotted components
*/
setSlotHostContext: (callback: GetSlotHostProps<T>) => void,
createInterface: (api: CreateInterfaceAPI<T, U>) => X,
): DynamicSlottingAPI<T, U, X> {
const slotted = writable<T[]>([]);
slotted.subscribe(updatePropsList);
function addDynamicallySlotted(index: number, props: T): void {
slotted.update((slotted: T[]): T[] => {
slotted.splice(index, 0, props);
return slotted;
});
}
const [elementPromise, resolveSlotContainer] = promiseWithResolver<U>();
const accessPromise = elementPromise.then(childrenAccess);
const dynamicSlotted = writable<DynamicSlotted<T>[]>([]);
async function addComponent(
component: DynamicSvelteComponent,
reinsert: (newElement: U, access: ChildrenAccess<U>) => number,
): Promise<{ destroy: Callback }> {
const [dynamicallySlottedMounted, resolveDynamicallySlotted] =
promiseWithResolver();
const access = await accessPromise;
const hostProps = makeProps();
function elementIsDynamicComponent(element: Element): boolean {
return !component.id || element.id === component.id;
}
async function callback(
mutations: MutationRecord[],
observer: MutationObserver,
): Promise<void> {
for (const mutation of mutations) {
for (const addedNode of mutation.addedNodes) {
if (
!nodeIsElement(addedNode) ||
!elementIsDynamicComponent(addedNode)
) {
continue;
}
const theElement = addedNode as U;
const index = reinsert(theElement, access);
if (index >= 0) {
addDynamicallySlotted(index, hostProps);
}
resolveDynamicallySlotted(undefined);
return observer.disconnect();
}
}
}
const observer = new MutationObserver(callback);
observer.observe(access.parent, { childList: true });
const dynamicSlot = {
component,
hostProps,
};
dynamicSlotted.update(
(dynamicSlotted: DynamicSlotted<T>[]): DynamicSlotted<T>[] => {
dynamicSlotted.push(dynamicSlot);
return dynamicSlotted;
},
);
await dynamicallySlottedMounted;
return {
destroy() {
dynamicSlotted.update(
(dynamicSlotted: DynamicSlotted<T>[]): DynamicSlotted<T>[] => {
// TODO needs testing, if Svelte actually correctly removes the element
removeItem(dynamicSlotted, dynamicSlot);
return dynamicSlotted;
},
);
},
};
}
async function updateProps(
update: (props: T) => T,
identifier: Identifier,
): Promise<boolean> {
const access = await accessPromise;
return access.updateElement((_element: U, index: number): void => {
slotted.update((slottedProps: T[]) => {
slottedProps[index] = update(slottedProps[index]);
return slottedProps;
});
}, identifier);
}
const slotsInterface = createInterface({ addComponent, updateProps });
function getSlotHostProps(): T {
const props = makeProps();
slotted.update((slotted: T[]): T[] => {
slotted.push(props);
return slotted;
});
return props;
}
setSlotHostContext({ getProps: getSlotHostProps });
return {
dynamicSlotted,
resolveSlotContainer,
slotsInterface,
};
}
export default dynamicSlotting;
/** Convenient default functions for dynamic slotting */
export function defaultProps(): SlotHostProps {
return {
detach: writable(false),
};
}
export interface DefaultSlotInterface extends Record<string, unknown> {
insert(
button: DynamicSvelteComponent,
position?: Identifier,
): Promise<{ destroy: Callback }>;
append(
button: DynamicSvelteComponent,
position?: Identifier,
): Promise<{ destroy: Callback }>;
show(position: Identifier): Promise<boolean>;
hide(position: Identifier): Promise<boolean>;
toggle(position: Identifier): Promise<boolean>;
}
export function defaultInterface<T extends SlotHostProps, U extends Element>({
addComponent,
updateProps,
}: CreateInterfaceAPI<T, U>): DefaultSlotInterface {
function insert(
component: DynamicSvelteComponent,
id: Identifier = 0,
): Promise<{ destroy: Callback }> {
return addComponent(component, (element: Element, access: ChildrenAccess<U>) =>
access.insertElement(element, id),
);
}
function append(
component: DynamicSvelteComponent,
id: Identifier = -1,
): Promise<{ destroy: Callback }> {
return addComponent(component, (element: Element, access: ChildrenAccess<U>) =>
access.appendElement(element, id),
);
}
function show(id: Identifier): Promise<boolean> {
return updateProps((props: T): T => {
props.detach.set(false);
return props;
}, id);
}
function hide(id: Identifier): Promise<boolean> {
return updateProps((props: T): T => {
props.detach.set(true);
return props;
}, id);
}
function toggle(id: Identifier): Promise<boolean> {
return updateProps((props: T): T => {
props.detach.update((detached: boolean) => !detached);
return props;
}, id);
}
return {
insert,
append,
show,
hide,
toggle,
};
}
import contextProperty from "./context-property";
const key = Symbol("dynamicSlotting");
const [defaultSlotHostContext, setSlotHostContext] =
contextProperty<GetSlotHostProps<SlotHostProps>>(key);
export { defaultSlotHostContext, setSlotHostContext };

View file

@ -5,7 +5,7 @@
// If they were to bundle their own runtime, things like bindings and contexts // If they were to bundle their own runtime, things like bindings and contexts
// would not work. // would not work.
import { runtimeLibraries } from "../lib/runtime-require"; import { registerPackageRaw } from "../lib/runtime-require";
import * as svelteRuntime from "svelte/internal"; import * as svelteRuntime from "svelte/internal";
runtimeLibraries["svelte/internal"] = svelteRuntime; registerPackageRaw("svelte/internal", svelteRuntime);

View file

@ -0,0 +1,76 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { onMount as svelteOnMount, onDestroy as svelteOnDestroy } from "svelte";
import type { Callback } from "../lib/helpers";
import { removeItem } from "../lib/helpers";
type ComponentAPIMount<T> = (api: T) => Callback | void;
type ComponentAPIDestroy<T> = (api: T) => void;
type SetLifecycleHooksAction<T> = (api: T) => void;
export interface LifecycleHooks<T> {
onMount(callback: ComponentAPIMount<T>): Callback;
onDestroy(callback: ComponentAPIDestroy<T>): Callback;
}
/**
* Makes the Svelte lifecycle hooks accessible to add-ons.
* Currently we expose onMount and onDestroy in here, but it is fully
* thinkable to expose the others as well, given a good use case.
*/
function lifecycleHooks<T>(): [LifecycleHooks<T>, T[], SetLifecycleHooksAction<T>] {
const instances: T[] = [];
const mountCallbacks: ComponentAPIMount<T>[] = [];
const destroyCallbacks: ComponentAPIDestroy<T>[] = [];
function setup(api: T): void {
svelteOnMount(() => {
const cleanups: Callback[] = [];
for (const callback of mountCallbacks) {
const cleanup = callback(api);
if (cleanup) {
cleanups.push(cleanup);
}
}
instances.push(api);
return () => {
for (const cleanup of cleanups) {
cleanup();
}
};
});
svelteOnDestroy(() => {
removeItem(instances, api);
for (const callback of destroyCallbacks) {
callback(api);
}
});
}
function onMount(callback: ComponentAPIMount<T>): Callback {
mountCallbacks.push(callback);
return () => removeItem(mountCallbacks, callback);
}
function onDestroy(callback: ComponentAPIDestroy<T>): Callback {
destroyCallbacks.push(callback);
return () => removeItem(mountCallbacks, callback);
}
const lifecycle = {
onMount,
onDestroy,
};
return [lifecycle, instances, setup];
}
export default lifecycleHooks;

View file

@ -2,7 +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
import { readable, get } from "svelte/store"; import { readable, get } from "svelte/store";
import { registerPackage } from "../lib/register-package"; import { registerPackage } from "../lib/runtime-require";
interface ThemeInfo { interface ThemeInfo {
isDark: boolean; isDark: boolean;

0
ts/sveltelib/types.ts Normal file
View file