Merge branch 'main' into collapsible-patch

This commit is contained in:
Matthias Metelka 2022-09-12 15:55:18 +02:00
commit 11423b290f
39 changed files with 829 additions and 305 deletions

View file

@ -64,12 +64,14 @@
"@types/marked": "^4.0.1", "@types/marked": "^4.0.1",
"bootstrap": "=5.0.2", "bootstrap": "=5.0.2",
"bootstrap-icons": "^1.4.0", "bootstrap-icons": "^1.4.0",
"character-entities": "^2.0.2",
"codemirror": "^5.63.1", "codemirror": "^5.63.1",
"css-browser-selector": "^0.6.5", "css-browser-selector": "^0.6.5",
"d3": "^7.0.0", "d3": "^7.0.0",
"eslint-plugin-import": "^2.25.4", "eslint-plugin-import": "^2.25.4",
"eslint-plugin-simple-import-sort": "^7.0.0", "eslint-plugin-simple-import-sort": "^7.0.0",
"eslint-plugin-svelte3": "^3.4.0", "eslint-plugin-svelte3": "^3.4.0",
"gemoji": "^7.1.0",
"intl-pluralrules": "^1.2.2", "intl-pluralrules": "^1.2.2",
"jquery": "^3.5.1", "jquery": "^3.5.1",
"jquery-ui-dist": "^1.12.1", "jquery-ui-dist": "^1.12.1",

View file

@ -551,6 +551,9 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
sticky = [field["sticky"] for field in self.note.note_type()["flds"]] sticky = [field["sticky"] for field in self.note.note_type()["flds"]]
js += " setSticky(%s);" % json.dumps(sticky) js += " setSticky(%s);" % json.dumps(sticky)
if os.getenv("ANKI_EDITOR_INSERT_SYMBOLS"):
js += " setInsertSymbolsEnabled();"
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( self.web.evalWithCallback(
f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback f'require("anki/ui").loaded.then(() => {{ {js} }})', oncallback

View file

@ -678,7 +678,7 @@ class AnkiQt(QMainWindow):
self.bottomWeb.show() self.bottomWeb.show()
gui_hooks.state_did_change(state, oldState) gui_hooks.state_did_change(state, oldState)
def _deckBrowserState(self, oldState: str) -> None: def _deckBrowserState(self, oldState: MainWindowState) -> None:
self.deckBrowser.show() self.deckBrowser.show()
def _selectedDeck(self) -> DeckDict | None: def _selectedDeck(self) -> DeckDict | None:
@ -688,15 +688,15 @@ class AnkiQt(QMainWindow):
return None return None
return self.col.decks.get(did) return self.col.decks.get(did)
def _overviewState(self, oldState: str) -> None: def _overviewState(self, oldState: MainWindowState) -> None:
if not self._selectedDeck(): if not self._selectedDeck():
return self.moveToState("deckBrowser") return self.moveToState("deckBrowser")
self.overview.show() self.overview.show()
def _reviewState(self, oldState: str) -> None: def _reviewState(self, oldState: MainWindowState) -> None:
self.reviewer.show() self.reviewer.show()
def _reviewCleanup(self, newState: str) -> None: def _reviewCleanup(self, newState: MainWindowState) -> None:
if newState != "resetRequired" and newState != "review": if newState != "resetRequired" and newState != "review":
self.reviewer.cleanup() self.reviewer.cleanup()

View file

@ -531,18 +531,27 @@ hooks = [
# these refer to things like deckbrowser, overview and reviewer state, # these refer to things like deckbrowser, overview and reviewer state,
Hook( Hook(
name="state_will_change", name="state_will_change",
args=["new_state: str", "old_state: str"], args=[
"new_state: aqt.main.MainWindowState",
"old_state: aqt.main.MainWindowState",
],
legacy_hook="beforeStateChange", legacy_hook="beforeStateChange",
), ),
Hook( Hook(
name="state_did_change", name="state_did_change",
args=["new_state: str", "old_state: str"], args=[
"new_state: aqt.main.MainWindowState",
"old_state: aqt.main.MainWindowState",
],
legacy_hook="afterStateChange", legacy_hook="afterStateChange",
), ),
# different sig to original # different sig to original
Hook( Hook(
name="state_shortcuts_will_change", name="state_shortcuts_will_change",
args=["state: str", "shortcuts: list[tuple[str, Callable]]"], args=[
"state: aqt.main.MainWindowState",
"shortcuts: list[tuple[str, Callable]]",
],
), ),
# UI state/refreshing # UI state/refreshing
################### ###################

View file

@ -3,10 +3,8 @@ Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="ts"> <script lang="ts">
import { createEventDispatcher, getContext, onMount } from "svelte"; import { createEventDispatcher, onMount } from "svelte";
import { dropdownKey } from "./context-keys";
import type { DropdownProps } from "./dropdown";
import IconConstrain from "./IconConstrain.svelte"; import IconConstrain from "./IconConstrain.svelte";
let className = ""; let className = "";
@ -21,8 +19,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let spanRef: HTMLSpanElement; let spanRef: HTMLSpanElement;
const dropdownProps = getContext<DropdownProps>(dropdownKey) ?? { dropdown: false };
onMount(() => { onMount(() => {
dispatch("mount", { span: spanRef }); dispatch("mount", { span: spanRef });
}); });
@ -32,8 +28,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bind:this={spanRef} bind:this={spanRef}
title={tooltip} title={tooltip}
class="badge {className}" class="badge {className}"
class:dropdown-toggle={dropdownProps.dropdown}
{...dropdownProps}
on:click on:click
on:mouseenter on:mouseenter
on:mouseleave on:mouseleave

View file

@ -9,14 +9,30 @@ 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 };
let buttonRef: HTMLButtonElement;
export let tooltip: string | undefined = undefined; export let tooltip: string | undefined = undefined;
export let tabbable: boolean = false;
export let active = false;
$: if (buttonRef && active) {
/* buttonRef.scrollIntoView({ behavior: "smooth", block: "start" }); */
/* TODO will not work on Gecko */
(buttonRef as any).scrollIntoViewIfNeeded({
behavior: "smooth",
block: "start",
});
}
export let tabbable = false;
</script> </script>
<button <button
bind:this={buttonRef}
{id} {id}
tabindex={tabbable ? 0 : -1} tabindex={tabbable ? 0 : -1}
class="dropdown-item btn {className}" class="dropdown-item {className}"
class:active
class:btn-day={!$pageTheme.isDark} class:btn-day={!$pageTheme.isDark}
class:btn-night={$pageTheme.isDark} class:btn-night={$pageTheme.isDark}
title={tooltip} title={tooltip}
@ -36,7 +52,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
display: flex; display: flex;
justify-content: start; justify-content: start;
font-size: calc(var(--base-font-size) * 0.8); font-size: var(--dropdown-font-size, calc(0.8 * var(--base-font-size)));
background: none; background: none;
box-shadow: none !important; box-shadow: none !important;

View file

@ -3,11 +3,9 @@ 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 { createEventDispatcher, getContext, onMount } from "svelte"; import { createEventDispatcher, onMount } from "svelte";
import { pageTheme } from "../sveltelib/theme"; import { pageTheme } from "../sveltelib/theme";
import { dropdownKey } from "./context-keys";
import type { DropdownProps } from "./dropdown";
export let id: string | undefined = undefined; export let id: string | undefined = undefined;
let className: string = ""; let className: string = "";
@ -23,8 +21,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let disabled = false; export let disabled = false;
export let tabbable = false; export let tabbable = false;
const dropdownProps = getContext<DropdownProps>(dropdownKey) ?? { dropdown: false };
let buttonRef: HTMLButtonElement; let buttonRef: HTMLButtonElement;
const dispatch = createEventDispatcher(); const dispatch = createEventDispatcher();
@ -36,11 +32,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{id} {id}
class="label-button {extendClassName(className, theme)}" class="label-button {extendClassName(className, theme)}"
class:active class:active
class:dropdown-toggle={dropdownProps.dropdown}
class:btn-day={theme === "anki" && !$pageTheme.isDark} class:btn-day={theme === "anki" && !$pageTheme.isDark}
class:btn-night={theme === "anki" && $pageTheme.isDark} class:btn-night={theme === "anki" && $pageTheme.isDark}
title={tooltip} title={tooltip}
{...dropdownProps}
{disabled} {disabled}
tabindex={tabbable ? 0 : -1} tabindex={tabbable ? 0 : -1}
on:click on:click

View file

@ -17,8 +17,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
min-width: 1rem; min-width: 1rem;
max-width: 95vw; max-width: 95vw;
/* Needs enough space for FloatingArrow to be positioned */ /* Needs this much space for FloatingArrow to be positioned */
padding: 6px; padding: var(--popover-padding-block, 6px) var(--popover-padding-inline, 6px);
font-size: 1rem; font-size: 1rem;
color: var(--text-fg); color: var(--text-fg);

View file

@ -3,7 +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 type { FloatingElement, Placement } from "@floating-ui/dom"; import type {
FloatingElement,
Placement,
ReferenceElement,
} from "@floating-ui/dom";
import { createEventDispatcher, onDestroy } from "svelte"; import { createEventDispatcher, onDestroy } from "svelte";
import type { ActionReturn } from "svelte/action"; import type { ActionReturn } from "svelte/action";
@ -23,7 +27,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let portalTarget: HTMLElement | null = null; export let portalTarget: HTMLElement | null = null;
export let placement: Placement | "auto" = "bottom"; export let placement: Placement | Placement[] | "auto" = "bottom";
export let offset = 5; export let offset = 5;
export let shift = 5; export let shift = 5;
export let inline = false; export let inline = false;
@ -58,11 +62,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export let closeOnInsideClick = false; export let closeOnInsideClick = false;
export let keepOnKeyup = false; export let keepOnKeyup = false;
export let reference: HTMLElement | undefined = undefined; export let reference: ReferenceElement | undefined = undefined;
let floating: FloatingElement; let floating: FloatingElement;
function applyPosition( function applyPosition(
reference: HTMLElement, reference: ReferenceElement,
floating: FloatingElement, floating: FloatingElement,
position: PositionAlgorithm, position: PositionAlgorithm,
): Promise<void> { ): Promise<void> {
@ -71,7 +75,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
async function position( async function position(
callback: ( callback: (
reference: HTMLElement, reference: ReferenceElement,
floating: FloatingElement, floating: FloatingElement,
position: PositionAlgorithm, position: PositionAlgorithm,
) => Promise<void> = applyPosition, ) => Promise<void> = applyPosition,
@ -81,12 +85,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
function asReference(referenceArgument: HTMLElement) { function asReference(referenceArgument: Element) {
reference = referenceArgument; reference = referenceArgument;
} }
function positioningCallback( function positioningCallback(
reference: HTMLElement, reference: ReferenceElement,
callback: PositioningCallback, callback: PositioningCallback,
): Callback { ): Callback {
const innerFloating = floating; const innerFloating = floating;
@ -98,7 +102,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
let cleanup: Callback | null = null; let cleanup: Callback | null = null;
function updateFloating( function updateFloating(
reference: HTMLElement | undefined, reference: ReferenceElement | undefined,
floating: FloatingElement, floating: FloatingElement,
isShowing: boolean, isShowing: boolean,
) { ) {
@ -109,6 +113,15 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
return; return;
} }
autoAction = autoUpdate(reference, positioningCallback);
// For virtual references, we cannot provide any
// default closing behavior
if (!(reference instanceof EventTarget)) {
cleanup = autoAction.destroy!;
return;
}
const closingClick = isClosingClick(documentClick, { const closingClick = isClosingClick(documentClick, {
reference, reference,
floating, floating,
@ -135,7 +148,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
); );
} }
autoAction = autoUpdate(reference, positioningCallback);
cleanup = singleCallback(...subscribers, autoAction.destroy!); cleanup = singleCallback(...subscribers, autoAction.destroy!);
} }

View file

@ -1,7 +0,0 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
export interface DropdownProps {
dropdown: boolean;
"data-bs-toggle"?: string;
"aria-expanded"?: boolean | "true" | "false";
}

View file

@ -44,6 +44,10 @@ export interface FocusHandlerAPI {
* Executed upon focus event of editable. * Executed upon focus event of editable.
*/ */
focus: HandlerList<{ event: FocusEvent }>; focus: HandlerList<{ event: FocusEvent }>;
/**
* Executed upon blur event of editable.
*/
blur: HandlerList<{ event: FocusEvent }>;
} }
export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] { export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
@ -57,6 +61,7 @@ export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
} }
const focus = new HandlerList<{ event: FocusEvent }>(); const focus = new HandlerList<{ event: FocusEvent }>();
const blur = new HandlerList<{ event: FocusEvent }>();
function prepareFocusHandling( function prepareFocusHandling(
editable: HTMLElement, editable: HTMLElement,
@ -79,6 +84,7 @@ export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
}, },
{ once: true }, { once: true },
); );
offPointerDown?.(); offPointerDown?.();
offPointerDown = on( offPointerDown = on(
editable, editable,
@ -94,8 +100,9 @@ export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
/** /**
* Must execute before DOMMirror. * Must execute before DOMMirror.
*/ */
function onBlur(this: HTMLElement): void { function onBlur(this: HTMLElement, event: FocusEvent): void {
prepareFocusHandling(this, saveSelection(this)); prepareFocusHandling(this, saveSelection(this));
blur.dispatch({ event });
} }
function setupFocusHandler(editable: HTMLElement): { destroy(): void } { function setupFocusHandler(editable: HTMLElement): { destroy(): void } {
@ -115,6 +122,7 @@ export function useFocusHandler(): [FocusHandlerAPI, SetupFocusHandlerAction] {
{ {
flushCaret, flushCaret,
focus, focus,
blur,
}, },
setupFocusHandler, setupFocusHandler,
]; ];

View file

@ -31,6 +31,8 @@ _ts_deps = [
"@npm//@types/codemirror", "@npm//@types/codemirror",
"@npm//codemirror", "@npm//codemirror",
"@npm//svelte", "@npm//svelte",
"@npm//character-entities",
"@npm//gemoji",
] ]
compile_svelte( compile_svelte(

View file

@ -111,6 +111,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss"> <style lang="scss">
.editor-field { .editor-field {
position: relative; position: relative;
padding: 0 3px;
--border-color: var(--border); --border-color: var(--border);
} }
</style> </style>

View file

@ -43,5 +43,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
/* Stay a on single line */ /* Stay a on single line */
white-space: nowrap; white-space: nowrap;
text-overflow: ellipsis; text-overflow: ellipsis;
/* The field description is placed absolutely on top of the editor field */
/* So we need to make sure it does not escape the editor field if the */
/* description is too long */
overflow: hidden;
} }
</style> </style>

View file

@ -2,6 +2,10 @@
Copyright: Ankitects Pty Ltd and contributors Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<!--
@component
Contains the fields. This contains the scrollable area.
-->
<div class="fields"> <div class="fields">
<slot /> <slot />
</div> </div>
@ -12,6 +16,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
grid-auto-rows: min-content; grid-auto-rows: min-content;
grid-gap: 6px; grid-gap: 6px;
padding: 0 3px 5px; /* Add space after the last field and the start of the tag editor */
padding-bottom: 5px;
/* Move the scrollbar for the NoteEditor into this element */
overflow-y: auto;
/* Push the tag editor to the bottom of the note editor */
flex-grow: 1;
} }
</style> </style>

View file

@ -1,22 +0,0 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<div class="fields-editor">
<slot />
</div>
<style lang="scss">
.fields-editor {
position: relative;
display: flex;
flex-direction: column;
flex-grow: 1;
/* replace with "gap: 5px" once it's available
- required: Chromium 84 (Qt6 only) and iOS 14.1 */
> :global(*) {
margin: 5px 0;
}
}
</style>

View file

@ -37,16 +37,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss"> <style lang="scss">
.label-container { .label-container {
display: flex; display: flex;
position: sticky;
justify-content: space-between; justify-content: space-between;
top: 0; padding: 0 3px 1px;
padding-bottom: 1px;
/* slightly wider than EditingArea position: sticky;
to cover field borders on scroll */ top: 0;
left: -1px;
right: -1px;
z-index: 10; z-index: 10;
background: var(--label-color); background: var(--label-color);
.clickable { .clickable {

View file

@ -44,7 +44,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import Absolute from "../components/Absolute.svelte"; import Absolute from "../components/Absolute.svelte";
import Badge from "../components/Badge.svelte"; import Badge from "../components/Badge.svelte";
import StickyContainer from "../components/StickyContainer.svelte";
import { bridgeCommand } from "../lib/bridgecommand"; import { bridgeCommand } from "../lib/bridgecommand";
import { TagEditor } from "../tag-editor"; import { TagEditor } from "../tag-editor";
import { ChangeTimer } from "./change-timer"; import { ChangeTimer } from "./change-timer";
@ -56,7 +55,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import EditorField from "./EditorField.svelte"; import EditorField from "./EditorField.svelte";
import FieldDescription from "./FieldDescription.svelte"; import FieldDescription from "./FieldDescription.svelte";
import Fields from "./Fields.svelte"; import Fields from "./Fields.svelte";
import FieldsEditor from "./FieldsEditor.svelte";
import FrameElement from "./FrameElement.svelte"; import FrameElement from "./FrameElement.svelte";
import { alertIcon } from "./icons"; import { alertIcon } from "./icons";
import ImageHandle from "./image-overlay"; import ImageHandle from "./image-overlay";
@ -67,6 +65,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import PlainTextBadge from "./PlainTextBadge.svelte"; import PlainTextBadge from "./PlainTextBadge.svelte";
import RichTextInput, { editingInputIsRichText } from "./rich-text-input"; import RichTextInput, { editingInputIsRichText } from "./rich-text-input";
import RichTextBadge from "./RichTextBadge.svelte"; import RichTextBadge from "./RichTextBadge.svelte";
import SymbolsOverlay from "./symbols-overlay";
function quoteFontFamily(fontFamily: string): string { function quoteFontFamily(fontFamily: string): string {
// generic families (e.g. sans-serif) must not be quoted // generic families (e.g. sans-serif) must not be quoted
@ -175,6 +174,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
noteId = ntid; noteId = ntid;
} }
let insertSymbols = false;
function setInsertSymbolsEnabled() {
insertSymbols = true;
}
function getNoteId(): number | null { function getNoteId(): number | null {
return noteId; return noteId;
} }
@ -277,6 +282,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setNoteId, setNoteId,
wrap, wrap,
setMathjaxEnabled, setMathjaxEnabled,
setInsertSymbolsEnabled,
...oldEditorAdapter, ...oldEditorAdapter,
}); });
@ -313,182 +319,187 @@ Functionality exclusive to specifc note-editing views (e.g. in the browser or
the AddCards dialog) should be implemented in the user of this component. the AddCards dialog) should be implemented in the user of this component.
--> -->
<div class="note-editor"> <div class="note-editor">
<FieldsEditor> <EditorToolbar {size} {wrap} api={toolbar}>
<EditorToolbar {size} {wrap} api={toolbar}> <slot slot="notetypeButtons" name="notetypeButtons" />
<slot slot="notetypeButtons" name="notetypeButtons" /> </EditorToolbar>
</EditorToolbar>
{#if hint} {#if hint}
<Absolute bottom right --margin="10px"> <Absolute bottom right --margin="10px">
<Notification> <Notification>
<Badge --badge-color="tomato" --icon-align="top" <Badge --badge-color="tomato" --icon-align="top"
>{@html alertIcon}</Badge >{@html alertIcon}</Badge
> >
<span>{@html hint}</span> <span>{@html hint}</span>
</Notification> </Notification>
</Absolute> </Absolute>
{/if} {/if}
<Fields> <Fields>
<DecoratedElements> <DecoratedElements>
{#each fieldsData as field, index} {#each fieldsData as field, index}
{@const content = fieldStores[index]} {@const content = fieldStores[index]}
<EditorField <EditorField
{field} {field}
{content} {content}
flipInputs={plainTextDefaults[index]} flipInputs={plainTextDefaults[index]}
api={fields[index]} api={fields[index]}
on:focusin={() => { on:focusin={() => {
$focusedField = fields[index]; $focusedField = fields[index];
bridgeCommand(`focus:${index}`); bridgeCommand(`focus:${index}`);
}} }}
on:focusout={() => { on:focusout={() => {
$focusedField = null; $focusedField = null;
bridgeCommand( bridgeCommand(`blur:${index}:${getNoteId()}:${get(content)}`);
`blur:${index}:${getNoteId()}:${get(content)}`, }}
); on:mouseenter={() => {
}} $hoveredField = fields[index];
on:mouseenter={() => { }}
$hoveredField = fields[index]; on:mouseleave={() => {
}} $hoveredField = null;
on:mouseleave={() => { }}
$hoveredField = null; collapsed={fieldsCollapsed[index]}
}} --label-color={cols[index] === "dupe"
collapsed={fieldsCollapsed[index]} ? "var(--flag1-bg)"
--label-color={cols[index] === "dupe" : "var(--window-bg)"}
? "var(--flag1-bg)" >
: "var(--window-bg)"} <svelte:fragment slot="field-label">
> <LabelContainer
<svelte:fragment slot="field-label"> collapsed={fieldsCollapsed[index]}
<LabelContainer on:toggle={async () => {
collapsed={fieldsCollapsed[index]} fieldsCollapsed[index] = !fieldsCollapsed[index];
on:toggle={async () => {
fieldsCollapsed[index] = !fieldsCollapsed[index];
const defaultInput = !plainTextDefaults[index] const defaultInput = !plainTextDefaults[index]
? richTextInputs[index] ? richTextInputs[index]
: plainTextInputs[index]; : plainTextInputs[index];
if (!fieldsCollapsed[index]) { if (!fieldsCollapsed[index]) {
refocusInput(defaultInput.api); refocusInput(defaultInput.api);
} else if (!plainTextDefaults[index]) { } else if (!plainTextDefaults[index]) {
plainTextsHidden[index] = true; plainTextsHidden[index] = true;
} else { } else {
richTextsHidden[index] = true; richTextsHidden[index] = true;
} }
}} }}
> >
<svelte:fragment slot="field-name"> <svelte:fragment slot="field-name">
<LabelName> <LabelName>
{field.name} {field.name}
</LabelName> </LabelName>
</svelte:fragment> </svelte:fragment>
<FieldState> <FieldState>
{#if cols[index] === "dupe"} {#if cols[index] === "dupe"}
<DuplicateLink /> <DuplicateLink />
{/if} {/if}
{#if plainTextDefaults[index]} {#if plainTextDefaults[index]}
<RichTextBadge <RichTextBadge
visible={!fieldsCollapsed[index] && visible={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField || (fields[index] === $hoveredField ||
fields[index] === $focusedField)} fields[index] === $focusedField)}
bind:off={richTextsHidden[index]} bind:off={richTextsHidden[index]}
on:toggle={async () => { on:toggle={async () => {
richTextsHidden[index] = richTextsHidden[index] =
!richTextsHidden[index]; !richTextsHidden[index];
if (!richTextsHidden[index]) { if (!richTextsHidden[index]) {
refocusInput( refocusInput(richTextInputs[index].api);
richTextInputs[index].api, }
); }}
}
}}
/>
{:else}
<PlainTextBadge
visible={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] === $focusedField)}
bind:off={plainTextsHidden[index]}
on:toggle={async () => {
plainTextsHidden[index] =
!plainTextsHidden[index];
if (!plainTextsHidden[index]) {
refocusInput(
plainTextInputs[index].api,
);
}
}}
/>
{/if}
<slot
name="field-state"
{field}
{index}
visible={fields[index] === $hoveredField ||
fields[index] === $focusedField}
/> />
</FieldState> {:else}
</LabelContainer> <PlainTextBadge
</svelte:fragment> visible={!fieldsCollapsed[index] &&
<svelte:fragment slot="rich-text-input"> (fields[index] === $hoveredField ||
<Collapsible fields[index] === $focusedField)}
collapse={richTextsHidden[index]} bind:off={plainTextsHidden[index]}
let:collapsed={hidden} on:toggle={async () => {
> plainTextsHidden[index] =
<RichTextInput !plainTextsHidden[index];
{hidden}
on:focusout={() => { if (!plainTextsHidden[index]) {
saveFieldNow(); refocusInput(
$focusedInput = null; plainTextInputs[index].api,
}} );
bind:this={richTextInputs[index]} }
> }}
<ImageHandle maxWidth={250} maxHeight={125} /> />
<MathjaxHandle /> {/if}
<FieldDescription> <slot
{field.description} name="field-state"
</FieldDescription> {field}
</RichTextInput> {index}
</Collapsible> visible={fields[index] === $hoveredField ||
</svelte:fragment> fields[index] === $focusedField}
<svelte:fragment slot="plain-text-input">
<Collapsible
collapse={plainTextsHidden[index]}
let:collapsed={hidden}
>
<PlainTextInput
{hidden}
isDefault={plainTextDefaults[index]}
richTextHidden={richTextsHidden[index]}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
}}
bind:this={plainTextInputs[index]}
/> />
</Collapsible> </FieldState>
</svelte:fragment> </LabelContainer>
</EditorField> </svelte:fragment>
{/each} <svelte:fragment slot="rich-text-input">
<Collapsible
collapse={richTextsHidden[index]}
let:collapsed={hidden}
>
<RichTextInput
{hidden}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
}}
bind:this={richTextInputs[index]}
>
<ImageHandle maxWidth={250} maxHeight={125} />
<MathjaxHandle />
{#if insertSymbols}
<SymbolsOverlay />
{/if}
<FieldDescription>
{field.description}
</FieldDescription>
</RichTextInput>
</Collapsible>
</svelte:fragment>
<svelte:fragment slot="plain-text-input">
<Collapsible
collapse={plainTextsHidden[index]}
let:collapsed={hidden}
>
<PlainTextInput
{hidden}
isDefault={plainTextDefaults[index]}
richTextHidden={richTextsHidden[index]}
on:focusout={() => {
saveFieldNow();
$focusedInput = null;
}}
bind:this={plainTextInputs[index]}
/>
</Collapsible>
</svelte:fragment>
</EditorField>
{/each}
<MathjaxElement /> <MathjaxElement />
<FrameElement /> <FrameElement />
</DecoratedElements> </DecoratedElements>
</Fields> </Fields>
</FieldsEditor>
<StickyContainer --gutter-block="0.1rem" --sticky-borders="1px 0 0" class="d-flex"> <div class="note-editor-tag-editor">
<TagEditor {tags} on:tagsupdate={saveTags} /> <TagEditor {tags} on:tagsupdate={saveTags} />
</StickyContainer> </div>
</div> </div>
<style lang="scss"> <style lang="scss">
.note-editor { .note-editor {
height: 100%;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
height: 100%;
}
.note-editor-tag-editor {
padding: 2px 0 0;
border-width: thin 0 0;
border-style: solid;
border-color: var(--medium-border);
} }
</style> </style>

View file

@ -88,6 +88,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<ButtonGroupItem> <ButtonGroupItem>
<WithFloating <WithFloating
show={showFloating && !disabled} show={showFloating && !disabled}
placement="bottom"
inline inline
on:close={() => (showFloating = false)} on:close={() => (showFloating = false)}
let:asReference let:asReference
@ -101,7 +102,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</IconButton> </IconButton>
</span> </span>
<Popover slot="floating"> <Popover slot="floating" --popover-padding-inline="0">
<ButtonToolbar wrap={false}> <ButtonToolbar wrap={false}>
<ButtonGroup> <ButtonGroup>
<CommandIconButton <CommandIconButton

View file

@ -53,7 +53,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import ButtonToolbar from "../../components/ButtonToolbar.svelte"; import ButtonToolbar from "../../components/ButtonToolbar.svelte";
import DynamicallySlottable from "../../components/DynamicallySlottable.svelte"; import DynamicallySlottable from "../../components/DynamicallySlottable.svelte";
import Item from "../../components/Item.svelte"; import Item from "../../components/Item.svelte";
import StickyContainer from "../../components/StickyContainer.svelte";
import BlockButtons from "./BlockButtons.svelte"; import BlockButtons from "./BlockButtons.svelte";
import InlineButtons from "./InlineButtons.svelte"; import InlineButtons from "./InlineButtons.svelte";
import NotetypeButtons from "./NotetypeButtons.svelte"; import NotetypeButtons from "./NotetypeButtons.svelte";
@ -85,7 +84,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setContextProperty(api); setContextProperty(api);
</script> </script>
<StickyContainer --gutter-block="0.1rem" --sticky-borders="0 0 1px"> <div class="editor-toolbar">
<ButtonToolbar {size} {wrap}> <ButtonToolbar {size} {wrap}>
<DynamicallySlottable slotHost={Item} api={toolbar}> <DynamicallySlottable slotHost={Item} api={toolbar}>
<Item id="notetype"> <Item id="notetype">
@ -111,4 +110,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</Item> </Item>
</DynamicallySlottable> </DynamicallySlottable>
</ButtonToolbar> </ButtonToolbar>
</StickyContainer> </div>
<style lang="scss">
.editor-toolbar {
padding: 0 0 2px;
border-width: 0 0 thin;
border-style: solid;
border-color: var(--medium-border);
}
</style>

View file

@ -116,6 +116,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<WithFloating <WithFloating
show={showFloating && !disabled} show={showFloating && !disabled}
placement="bottom"
inline inline
on:close={() => (showFloating = false)} on:close={() => (showFloating = false)}
let:asReference let:asReference
@ -133,7 +134,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</IconButton> </IconButton>
</span> </span>
<Popover slot="floating"> <Popover slot="floating" --popover-padding-inline="0">
{#each showFormats as format (format.name)} {#each showFormats as format (format.name)}
<DropdownItem on:click={(event) => onItemClick(event, format)}> <DropdownItem on:click={(event) => onItemClick(event, format)}>
<CheckBox bind:value={format.active} /> <CheckBox bind:value={format.active} />

View file

@ -9,6 +9,7 @@ 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 { noop } from "../../lib/functional"; import { noop } from "../../lib/functional";
import { isArrowLeft, isArrowRight } from "../../lib/keys";
import { getPlatformString } from "../../lib/shortcuts"; import { getPlatformString } from "../../lib/shortcuts";
import { pageTheme } from "../../sveltelib/theme"; import { pageTheme } from "../../sveltelib/theme";
import { baseOptions, focusAndSetCaret, latex } from "../code-mirror"; import { baseOptions, focusAndSetCaret, latex } from "../code-mirror";
@ -50,9 +51,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
editor.on( editor.on(
"keydown", "keydown",
(_instance: CodeMirrorLib.Editor, event: KeyboardEvent): void => { (_instance: CodeMirrorLib.Editor, event: KeyboardEvent): void => {
if (event.key === "ArrowLeft") { if (isArrowLeft(event)) {
direction = "start"; direction = "start";
} else if (event.key === "ArrowRight") { } else if (isArrowRight(event)) {
direction = "end"; direction = "end";
} }
}, },

View file

@ -0,0 +1,297 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { onMount } from "svelte";
import DropdownItem from "../../components/DropdownItem.svelte";
import Popover from "../../components/Popover.svelte";
import WithFloating from "../../components/WithFloating.svelte";
import {
getRange,
getSelection,
isSelectionCollapsed,
} from "../../lib/cross-browser";
import type { Callback } from "../../lib/typing";
import { singleCallback } from "../../lib/typing";
import { context } from "../rich-text-input";
import type { SymbolsTable } from "./data-provider";
import { getSymbolExact, getSymbols } from "./data-provider";
const SYMBOLS_DELIMITER = ":";
const { inputHandler, editable } = context.get();
let referenceRange: Range | undefined = undefined;
let cleanup: Callback;
let query: string = "";
let activeItem = 0;
let foundSymbols: SymbolsTable = [];
function unsetReferenceRange() {
referenceRange = undefined;
activeItem = 0;
cleanup?.();
}
async function maybeShowOverlay(
selection: Selection,
event: InputEvent,
): Promise<void> {
if (
event.inputType !== "insertText" ||
event.data === SYMBOLS_DELIMITER ||
!isSelectionCollapsed(selection)
) {
return unsetReferenceRange();
}
const currentRange = getRange(selection)!;
const offset = currentRange.endOffset;
if (!(currentRange.commonAncestorContainer instanceof Text) || offset < 2) {
return unsetReferenceRange();
}
const wholeText = currentRange.commonAncestorContainer.wholeText;
for (let index = offset - 1; index >= 0; index--) {
const currentCharacter = wholeText[index];
if (currentCharacter === " ") {
return unsetReferenceRange();
} else if (currentCharacter === SYMBOLS_DELIMITER) {
const possibleQuery =
wholeText.substring(index + 1, offset) + event.data;
if (possibleQuery.length < 2) {
return unsetReferenceRange();
}
query = possibleQuery;
referenceRange = currentRange;
foundSymbols = await getSymbols(query);
cleanup = editable.focusHandler.blur.on(
async () => unsetReferenceRange(),
{
once: true,
},
);
return;
}
}
}
async function replaceText(
selection: Selection,
text: Text,
symbolCharacter: string,
): Promise<void> {
text.replaceData(0, text.length, symbolCharacter);
unsetReferenceRange();
// Place caret behind it
const range = new Range();
range.selectNode(text);
selection.removeAllRanges();
selection.addRange(range);
selection.collapseToEnd();
}
function replaceTextOnDemand(symbolCharacter: string): void {
const commonAncestor = referenceRange!.commonAncestorContainer as Text;
const selection = getSelection(commonAncestor)!;
const replacementLength =
commonAncestor.data
.substring(0, referenceRange!.endOffset)
.split("")
.reverse()
.join("")
.indexOf(SYMBOLS_DELIMITER) + 1;
const newOffset = referenceRange!.endOffset - replacementLength + 1;
commonAncestor.replaceData(
referenceRange!.endOffset - replacementLength,
replacementLength + 1,
symbolCharacter,
);
// Place caret behind it
const range = new Range();
range.setEnd(commonAncestor, newOffset);
range.collapse(false);
selection.removeAllRanges();
selection.addRange(range);
unsetReferenceRange();
}
async function updateOverlay(
selection: Selection,
event: InputEvent,
): Promise<void> {
if (event.inputType !== "insertText") {
return unsetReferenceRange();
}
const data = event.data;
referenceRange = getRange(selection)!;
if (data === SYMBOLS_DELIMITER && query) {
const symbol = await getSymbolExact(query);
if (!symbol) {
return unsetReferenceRange();
}
const currentRange = getRange(selection)!;
const offset = currentRange.endOffset;
if (!(currentRange.commonAncestorContainer instanceof Text) || offset < 2) {
return unsetReferenceRange();
}
const commonAncestor = currentRange.commonAncestorContainer;
const replacementLength =
commonAncestor.data
.substring(0, currentRange.endOffset)
.split("")
.reverse()
.join("")
.indexOf(SYMBOLS_DELIMITER) + 1;
commonAncestor.deleteData(
currentRange.endOffset - replacementLength,
replacementLength,
);
inputHandler.insertText.on(
async ({ text }) => replaceText(selection, text, symbol),
{
once: true,
},
);
} else if (query) {
query += data!;
foundSymbols = await getSymbols(query);
}
}
async function onBeforeInput({ event }): Promise<void> {
const selection = getSelection(event.target)!;
if (referenceRange) {
await updateOverlay(selection, event);
} else {
await maybeShowOverlay(selection, event);
}
}
$: showSymbolsOverlay = referenceRange && foundSymbols.length > 0;
async function onSpecialKey({ event, action }): Promise<void> {
if (!showSymbolsOverlay) {
return;
}
if (["caretLeft", "caretRight"].includes(action)) {
return unsetReferenceRange();
}
event.preventDefault();
if (action === "caretUp") {
if (activeItem === 0) {
activeItem = foundSymbols.length - 1;
} else {
activeItem--;
}
} else if (action === "caretDown") {
if (activeItem >= foundSymbols.length - 1) {
activeItem = 0;
} else {
activeItem++;
}
} else if (action === "enter" || action === "tab") {
replaceTextOnDemand(foundSymbols[activeItem].symbol);
} else if (action === "escape") {
unsetReferenceRange();
}
}
onMount(() =>
singleCallback(
inputHandler.beforeInput.on(onBeforeInput),
inputHandler.specialKey.on(onSpecialKey),
inputHandler.pointerDown.on(async () => unsetReferenceRange()),
),
);
</script>
<div class="symbols-overlay">
{#if showSymbolsOverlay}
<WithFloating
reference={referenceRange}
placement={["top", "bottom"]}
offset={10}
>
<Popover slot="floating" --popover-padding-inline="0">
<div class="symbols-menu">
{#each foundSymbols as found, index (found.symbol)}
<DropdownItem
active={index === activeItem}
on:click={() => replaceTextOnDemand(found.symbol)}
>
<div class="symbol">{found.symbol}</div>
<div class="description">
{#each found.names as name}
<span class="name">
{SYMBOLS_DELIMITER}{name}{SYMBOLS_DELIMITER}
</span>
{/each}
</div>
</DropdownItem>
{/each}
</div>
</Popover>
</WithFloating>
{/if}
</div>
<style lang="scss">
.symbols-menu {
display: flex;
flex-flow: column nowrap;
min-width: 140px;
max-height: 15rem;
font-size: 12px;
overflow-x: hidden;
text-overflow: ellipsis;
overflow-y: auto;
}
.symbol {
transform: scale(1.1);
font-size: 150%;
/* The widest emojis I could find were couple_with_heart_ */
width: 38px;
}
.description {
align-self: center;
}
.name {
margin-left: 3px;
}
</style>

View file

@ -0,0 +1,60 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { characterEntities } from "character-entities";
import { gemoji } from "gemoji";
interface SymbolsEntry {
symbol: string;
names: string[];
tags: string[];
containsHTML?: boolean;
autoInsert?: boolean;
}
export type SymbolsTable = SymbolsEntry[];
const symbolsTable: SymbolsTable = [];
const characterTable: Record<string, string[]> = {};
// Not all characters work well in the editor field
delete characterEntities["Tab"];
for (const [name, character] of Object.entries(characterEntities)) {
if (character in characterTable) {
characterTable[character].push(name);
} else {
characterTable[character] = [name];
}
}
for (const [character, names] of Object.entries(characterTable)) {
symbolsTable.push({
symbol: character,
names,
tags: [],
});
}
for (const { emoji, names, tags } of gemoji) {
symbolsTable.push({
symbol: emoji,
names,
tags,
});
}
export async function getSymbols(query: string): Promise<SymbolsTable> {
return symbolsTable.filter(
({ names, tags }) =>
names.some((name) => name.includes(query)) ||
tags.some((tag) => tag.includes(query)),
);
}
export async function getSymbolExact(query: string): Promise<string | null> {
const found = symbolsTable.find(({ names }) => names.includes(query));
return found ? found.symbol : null;
}

View file

@ -0,0 +1,6 @@
// Copyright: Ankitects Pty Ltd and contributors
// License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import SymbolsOverlay from "./SymbolsOverlay.svelte";
export default SymbolsOverlay;

View file

@ -4,6 +4,7 @@
"*", "*",
"image-overlay/*", "image-overlay/*",
"mathjax-overlay/*", "mathjax-overlay/*",
"symbols-overlay/*",
"plain-text-input/*", "plain-text-input/*",
"rich-text-input/*", "rich-text-input/*",
"editor-toolbar/*" "editor-toolbar/*"

View file

@ -62,6 +62,7 @@ const modifierPressed =
export const controlPressed = modifierPressed("Control"); export const controlPressed = modifierPressed("Control");
export const shiftPressed = modifierPressed("Shift"); export const shiftPressed = modifierPressed("Shift");
export const altPressed = modifierPressed("Alt"); export const altPressed = modifierPressed("Alt");
export const metaPressed = modifierPressed("Meta");
export function modifiersToPlatformString(modifiers: string[]): string { export function modifiersToPlatformString(modifiers: string[]): string {
const displayModifiers = isApplePlatform() const displayModifiers = isApplePlatform()
@ -90,3 +91,35 @@ export function keyToPlatformString(key: string): string {
return key; return key;
} }
} }
export function isArrowLeft(event: KeyboardEvent): boolean {
if (event.code === "ArrowLeft") {
return true;
}
return isApplePlatform() && metaPressed(event) && event.code === "KeyB";
}
export function isArrowRight(event: KeyboardEvent): boolean {
if (event.code === "ArrowRight") {
return true;
}
return isApplePlatform() && metaPressed(event) && event.code === "KeyF";
}
export function isArrowUp(event: KeyboardEvent): boolean {
if (event.code === "ArrowUp") {
return true;
}
return isApplePlatform() && metaPressed(event) && event.code === "KeyP";
}
export function isArrowDown(event: KeyboardEvent): boolean {
if (event.code === "ArrowDown") {
return true;
}
return isApplePlatform() && metaPressed(event) && event.code === "KeyN";
}

View file

@ -361,6 +361,15 @@
"path": "node_modules/chalk", "path": "node_modules/chalk",
"licenseFile": "node_modules/chalk/license" "licenseFile": "node_modules/chalk/license"
}, },
"character-entities@2.0.2": {
"licenses": "MIT",
"repository": "https://github.com/wooorm/character-entities",
"publisher": "Titus Wormer",
"email": "tituswormer@gmail.com",
"url": "https://wooorm.com",
"path": "node_modules/character-entities",
"licenseFile": "node_modules/character-entities/license"
},
"codemirror@5.65.2": { "codemirror@5.65.2": {
"licenses": "MIT", "licenses": "MIT",
"repository": "https://github.com/codemirror/CodeMirror", "repository": "https://github.com/codemirror/CodeMirror",
@ -996,6 +1005,15 @@
"path": "node_modules/functional-red-black-tree", "path": "node_modules/functional-red-black-tree",
"licenseFile": "node_modules/functional-red-black-tree/LICENSE" "licenseFile": "node_modules/functional-red-black-tree/LICENSE"
}, },
"gemoji@7.1.0": {
"licenses": "MIT",
"repository": "https://github.com/wooorm/gemoji",
"publisher": "Titus Wormer",
"email": "tituswormer@gmail.com",
"url": "https://wooorm.com",
"path": "node_modules/gemoji",
"licenseFile": "node_modules/gemoji/license"
},
"get-intrinsic@1.1.1": { "get-intrinsic@1.1.1": {
"licenses": "MIT", "licenses": "MIT",
"repository": "https://github.com/ljharb/get-intrinsic", "repository": "https://github.com/ljharb/get-intrinsic",

View file

@ -51,6 +51,11 @@ interface HandlerOptions {
export class HandlerList<T> { export class HandlerList<T> {
#list: HandlerAccess<T>[] = []; #list: HandlerAccess<T>[] = [];
/**
* Returns a `TriggerItem`, which can be used to attach event handlers.
* This TriggerItem exposes an additional `active` store. This can be
* useful, if other components need to react to the input handler being active.
*/
trigger(options?: Partial<HandlerOptions>): TriggerItem<T> { trigger(options?: Partial<HandlerOptions>): TriggerItem<T> {
const once = options?.once ?? false; const once = options?.once ?? false;
let handler: Handler<T> | null = null; let handler: Handler<T> | null = null;
@ -84,6 +89,11 @@ export class HandlerList<T> {
); );
} }
/**
* Attaches an event handler.
* @returns a callback, which removes the event handler. Alternatively,
* you can call `off` on the HandlerList.
*/
on(handler: Handler<T>, options?: Partial<HandlerOptions>): Callback { on(handler: Handler<T>, options?: Partial<HandlerOptions>): Callback {
const once = options?.once ?? false; const once = options?.once ?? false;
let offHandler: Handler<T> | null = null; let offHandler: Handler<T> | null = null;

View file

@ -3,6 +3,8 @@
import { getRange, getSelection } from "../lib/cross-browser"; import { getRange, getSelection } from "../lib/cross-browser";
import { on } from "../lib/events"; import { on } from "../lib/events";
import { isArrowDown, isArrowLeft, isArrowRight, isArrowUp } from "../lib/keys";
import { singleCallback } from "../lib/typing";
import { HandlerList } from "./handler-list"; import { HandlerList } from "./handler-list";
const nbsp = "\xa0"; const nbsp = "\xa0";
@ -12,14 +14,30 @@ export type SetupInputHandlerAction = (element: HTMLElement) => { destroy(): voi
export interface InputEventParams { export interface InputEventParams {
event: InputEvent; event: InputEvent;
} }
export interface InsertTextParams { export interface InsertTextParams {
event: InputEvent; event: InputEvent;
text: Text; text: Text;
} }
type SpecialKeyAction =
| "caretUp"
| "caretDown"
| "caretLeft"
| "caretRight"
| "enter"
| "tab";
export interface SpecialKeyParams {
event: KeyboardEvent;
action: SpecialKeyAction;
}
export interface InputHandlerAPI { export interface InputHandlerAPI {
readonly beforeInput: HandlerList<InputEventParams>; readonly beforeInput: HandlerList<InputEventParams>;
readonly insertText: HandlerList<InsertTextParams>; readonly insertText: HandlerList<InsertTextParams>;
readonly pointerDown: HandlerList<{ event: PointerEvent }>;
readonly specialKey: HandlerList<SpecialKeyParams>;
} }
/** /**
@ -57,31 +75,53 @@ function useInputHandler(): [InputHandlerAPI, SetupInputHandlerAction] {
range.commonAncestorContainer.normalize(); range.commonAncestorContainer.normalize();
} }
const pointerDown = new HandlerList<{ event: PointerEvent }>();
function clearInsertText(): void { function clearInsertText(): void {
insertText.clear(); insertText.clear();
} }
function onPointerDown(event: PointerEvent): void {
pointerDown.dispatch({ event });
clearInsertText();
}
const specialKey = new HandlerList<SpecialKeyParams>();
async function onKeyDown(this: Element, event: KeyboardEvent): Promise<void> {
if (isArrowDown(event)) {
specialKey.dispatch({ event, action: "caretDown" });
} else if (isArrowUp(event)) {
specialKey.dispatch({ event, action: "caretUp" });
} else if (isArrowRight(event)) {
specialKey.dispatch({ event, action: "caretRight" });
} else if (isArrowLeft(event)) {
specialKey.dispatch({ event, action: "caretLeft" });
} else if (event.code === "Enter" || event.code === "NumpadEnter") {
specialKey.dispatch({ event, action: "enter" });
} else if (event.code === "Tab") {
specialKey.dispatch({ event, action: "tab" });
}
}
function setupHandler(element: HTMLElement): { destroy(): void } { function setupHandler(element: HTMLElement): { destroy(): void } {
const beforeInputOff = on(element, "beforeinput", onBeforeInput); const destroy = singleCallback(
on(element, "beforeinput", onBeforeInput),
on(element, "blur", clearInsertText),
on(element, "pointerdown", onPointerDown),
on(element, "keydown", onKeyDown),
on(document, "selectionchange", clearInsertText),
);
const blurOff = on(element, "blur", clearInsertText); return { destroy };
const pointerDownOff = on(element, "pointerdown", clearInsertText);
const selectionChangeOff = on(document, "selectionchange", clearInsertText);
return {
destroy() {
beforeInputOff();
blurOff();
pointerDownOff();
selectionChangeOff();
},
};
} }
return [ return [
{ {
beforeInput, beforeInput,
insertText, insertText,
specialKey,
pointerDown,
}, },
setupHandler, setupHandler,
]; ];

View file

@ -7,11 +7,11 @@
*/ */
function portal( function portal(
element: HTMLElement, element: HTMLElement,
targetElement: Element = document.body, targetElement: Element | null = document.body,
): { update(target: Element): void; destroy(): void } { ): { update(target: Element): void; destroy(): void } {
let target: Element; let target: Element | null;
async function update(newTarget: Element) { async function update(newTarget: Element | null) {
target = newTarget; target = newTarget;
if (!target) { if (!target) {

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 type { FloatingElement } from "@floating-ui/dom"; import type { FloatingElement, ReferenceElement } from "@floating-ui/dom";
import { autoUpdate as floatingUiAutoUpdate } from "@floating-ui/dom"; import { autoUpdate as floatingUiAutoUpdate } from "@floating-ui/dom";
import type { ActionReturn } from "svelte/action"; import type { ActionReturn } from "svelte/action";
@ -18,7 +18,7 @@ import type { Callback } from "../../lib/typing";
* })` * })`
*/ */
export type PositioningCallback = ( export type PositioningCallback = (
reference: HTMLElement, reference: ReferenceElement,
floating: FloatingElement, floating: FloatingElement,
position: Callback, position: Callback,
) => Callback; ) => Callback;
@ -27,12 +27,12 @@ export type PositioningCallback = (
* The interface of a function that calls `computePosition` of floating-ui. * The interface of a function that calls `computePosition` of floating-ui.
*/ */
export type PositionFunc = ( export type PositionFunc = (
reference: HTMLElement, reference: ReferenceElement,
callback: PositioningCallback, callback: PositioningCallback,
) => Callback; ) => Callback;
function autoUpdate( function autoUpdate(
reference: HTMLElement, reference: ReferenceElement,
/** /**
* The method to position the floating element. * The method to position the floating element.
*/ */

View file

@ -1,12 +1,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
import type { FloatingElement } from "@floating-ui/dom"; import type { FloatingElement, ReferenceElement } from "@floating-ui/dom";
/** /**
* The interface of a function that calls `computePosition` of floating-ui. * The interface of a function that calls `computePosition` of floating-ui.
*/ */
export type PositionAlgorithm = ( export type PositionAlgorithm = (
reference: HTMLElement, reference: ReferenceElement,
floating: FloatingElement, floating: FloatingElement,
) => Promise<void>; ) => Promise<void>;

View file

@ -6,6 +6,7 @@ import type {
FloatingElement, FloatingElement,
Middleware, Middleware,
Placement, Placement,
ReferenceElement,
} from "@floating-ui/dom"; } from "@floating-ui/dom";
import { import {
arrow, arrow,
@ -20,7 +21,7 @@ import {
import type { PositionAlgorithm } from "./position-algorithm"; import type { PositionAlgorithm } from "./position-algorithm";
export interface PositionFloatingArgs { export interface PositionFloatingArgs {
placement: Placement | "auto"; placement: Placement | Placement[] | "auto";
arrow: HTMLElement; arrow: HTMLElement;
shift: number; shift: number;
offset: number; offset: number;
@ -41,7 +42,7 @@ function positionFloating({
hideCallback, hideCallback,
}: PositionFloatingArgs): PositionAlgorithm { }: PositionFloatingArgs): PositionAlgorithm {
return async function ( return async function (
reference: HTMLElement, reference: ReferenceElement,
floating: FloatingElement, floating: FloatingElement,
): Promise<void> { ): Promise<void> {
const middleware: Middleware[] = [ const middleware: Middleware[] = [
@ -58,10 +59,14 @@ function positionFloating({
middleware, middleware,
}; };
if (placement !== "auto") { if (Array.isArray(placement)) {
computeArgs.placement = placement; const allowedPlacements = placement;
} else {
middleware.push(autoPlacement({ allowedPlacements }));
} else if (placement === "auto") {
middleware.push(autoPlacement()); middleware.push(autoPlacement());
} else {
computeArgs.placement = placement;
} }
if (hideIfEscaped) { if (hideIfEscaped) {

View file

@ -5,6 +5,7 @@ import type {
ComputePositionConfig, ComputePositionConfig,
FloatingElement, FloatingElement,
Middleware, Middleware,
ReferenceElement,
} from "@floating-ui/dom"; } from "@floating-ui/dom";
import { computePosition, inline, offset } from "@floating-ui/dom"; import { computePosition, inline, offset } from "@floating-ui/dom";
@ -22,7 +23,7 @@ function positionOverlay({
hideCallback, hideCallback,
}: PositionOverlayArgs): PositionAlgorithm { }: PositionOverlayArgs): PositionAlgorithm {
return async function ( return async function (
reference: HTMLElement, reference: ReferenceElement,
floating: FloatingElement, floating: FloatingElement,
): Promise<void> { ): Promise<void> {
const middleware: Middleware[] = inlineArg ? [inline()] : []; const middleware: Middleware[] = inlineArg ? [inline()] : [];

View file

@ -8,6 +8,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { execCommand } from "../domlib"; import { execCommand } from "../domlib";
import { isArrowDown, isArrowUp } from "../lib/keys";
import { Tags, tags as tagsService } from "../lib/proto"; import { Tags, tags as tagsService } from "../lib/proto";
import { TagOptionsButton } from "./tag-options-button"; import { TagOptionsButton } from "./tag-options-button";
import TagEditMode from "./TagEditMode.svelte"; import TagEditMode from "./TagEditMode.svelte";
@ -257,17 +258,17 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
function onKeydown(event: KeyboardEvent): void { function onKeydown(event: KeyboardEvent): void {
if (isArrowUp(event)) {
autocomplete.selectPrevious();
event.preventDefault();
return;
} else if (isArrowDown(event)) {
autocomplete.selectNext();
event.preventDefault();
return;
}
switch (event.code) { switch (event.code) {
case "ArrowUp":
autocomplete.selectPrevious();
event.preventDefault();
break;
case "ArrowDown":
autocomplete.selectNext();
event.preventDefault();
break;
case "Tab": case "Tab":
if (!$show) { if (!$show) {
break; break;
@ -381,7 +382,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: anyTagsSelected = tagTypes.some((tag) => tag.selected); $: anyTagsSelected = tagTypes.some((tag) => tag.selected);
</script> </script>
<div class="tag-editor-area" on:focusout={deselectIfLeave} bind:offsetHeight={height}> <div class="tag-editor" on:focusout={deselectIfLeave} bind:offsetHeight={height}>
<TagOptionsButton <TagOptionsButton
bind:badgeHeight bind:badgeHeight
tagsSelected={anyTagsSelected} tagsSelected={anyTagsSelected}
@ -482,13 +483,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</div> </div>
<style lang="scss"> <style lang="scss">
.tag-editor-area { .tag-editor {
display: flex; display: flex;
flex-flow: row wrap; flex-flow: row wrap;
align-items: flex-end; align-items: flex-end;
padding: 0 1px 1px;
overflow: hidden;
margin-bottom: 3px;
} }
.tag-relative { .tag-relative {

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 { createEventDispatcher, onMount, tick } from "svelte"; import { createEventDispatcher, onMount, tick } from "svelte";
import { isArrowLeft, isArrowRight } from "../lib/keys";
import { registerShortcut } from "../lib/shortcuts"; import { registerShortcut } from "../lib/shortcuts";
import { import {
delimChar, delimChar,
@ -168,27 +169,23 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
onDelete(event); onDelete(event);
} }
break; break;
case "ArrowLeft":
if (isEmpty()) {
joinWithPreviousTag(event);
} else if (caretAtStart()) {
dispatch("tagmoveprevious");
event.preventDefault();
}
break;
case "ArrowRight":
if (isEmpty()) {
joinWithNextTag(event);
} else if (caretAtEnd()) {
dispatch("tagmovenext");
event.preventDefault();
}
break;
} }
if (event.key === " ") { if (isArrowLeft(event)) {
if (isEmpty()) {
joinWithPreviousTag(event);
} else if (caretAtStart()) {
dispatch("tagmoveprevious");
event.preventDefault();
}
} else if (isArrowRight(event)) {
if (isEmpty()) {
joinWithNextTag(event);
} else if (caretAtEnd()) {
dispatch("tagmovenext");
event.preventDefault();
}
} else if (event.key === " ") {
onDelimiter(event, false); onDelimiter(event, false);
} else if (event.key === ":") { } else if (event.key === ":") {
onDelimiter(event, true); onDelimiter(event, true);

View file

@ -136,7 +136,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<slot {createAutocomplete} /> <slot {createAutocomplete} />
</span> </span>
<Popover slot="floating"> <Popover slot="floating" --popover-padding-inline="0">
<div class="autocomplete-menu"> <div class="autocomplete-menu">
{#each suggestionsItems as suggestion, index} {#each suggestionsItems as suggestion, index}
{#if index === selected} {#if index === selected}

View file

@ -1609,6 +1609,11 @@ char-regex@^1.0.2:
resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf" resolved "https://registry.yarnpkg.com/char-regex/-/char-regex-1.0.2.tgz#d744358226217f981ed58f479b1d6bcc29545dcf"
integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw== integrity sha512-kWWXztvZ5SBQV+eRgKFeh8q5sLuZY2+8WUIzlxWVTg+oGwY14qylx1KbKzHd8P6ZYkAg0xyIDU9JMHhyJMZ1jw==
character-entities@^2.0.2:
version "2.0.2"
resolved "https://registry.yarnpkg.com/character-entities/-/character-entities-2.0.2.tgz#2d09c2e72cd9523076ccb21157dff66ad43fcc22"
integrity sha512-shx7oQ0Awen/BRIdkjkvz54PnEEI/EjwXDSIZp86/KKdbafHh1Df/RYGBhn4hbe2+uKC9FnT5UCEdyPz3ai9hQ==
"chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1: "chokidar@>=3.0.0 <4.0.0", chokidar@^3.4.1:
version "3.5.3" version "3.5.3"
resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd" resolved "https://registry.yarnpkg.com/chokidar/-/chokidar-3.5.3.tgz#1cf37c8707b932bd1af1ae22c0432e2acd1903bd"
@ -2614,6 +2619,11 @@ functional-red-black-tree@^1.0.1:
resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327" resolved "https://registry.yarnpkg.com/functional-red-black-tree/-/functional-red-black-tree-1.0.1.tgz#1b0ab3bd553b2a0d6399d29c0e3ea0b252078327"
integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc= integrity sha1-GwqzvVU7Kg1jmdKcDj6gslIHgyc=
gemoji@^7.1.0:
version "7.1.0"
resolved "https://registry.yarnpkg.com/gemoji/-/gemoji-7.1.0.tgz#165403777681a9690d649aabd104da037bdd7739"
integrity sha512-wI0YWDIfQraQMDs0yXAVQiVBZeMm/rIYssf8LZlMDdssKF19YqJKOHkv4zvwtVQTBJ0LNmErv1S+DqlVUudz8g==
gensync@^1.0.0-beta.2: gensync@^1.0.0-beta.2:
version "1.0.0-beta.2" version "1.0.0-beta.2"
resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0" resolved "https://registry.yarnpkg.com/gensync/-/gensync-1.0.0-beta.2.tgz#32a6ee76c3d7f52d46b2b1ae5d93fea8580a25e0"