Anki/ts/sveltelib/dynamic-slotting.ts
Henrik Giesel a981e56008
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
2022-02-03 14:52:11 +10:00

299 lines
9 KiB
TypeScript

// 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 };