Clean up ButtonGroup and factor out extension logic

This commit is contained in:
Henrik Giesel 2021-05-06 18:51:44 +02:00
parent e80f43e8fc
commit 4a6b3b3786
7 changed files with 190 additions and 114 deletions

View file

@ -4,148 +4,95 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
--> -->
<script lang="typescript"> <script lang="typescript">
import ButtonGroupItem from "./ButtonGroupItem.svelte"; import ButtonGroupItem from "./ButtonGroupItem.svelte";
import type { SvelteComponentTyped } from "svelte";
import { setContext } from "svelte"; import { setContext } from "svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import { buttonGroupKey } from "./contextKeys"; import { buttonGroupKey } from "./contextKeys";
import type { Identifier } from "./identifier"; import type { Identifier } from "./identifier";
import { insert, add, find } from "./identifier"; import { insert, add } from "./identifier";
import type { ButtonRegistration } from "./buttons"; import type { ButtonRegistration } from "./buttons";
import { ButtonPosition } from "./buttons"; import { ButtonPosition } from "./buttons";
import type { SvelteComponent } from "./registration";
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 api = {};
let buttonGroupRef: HTMLDivElement;
let items: ButtonRegistration[] = [];
$: {
for (const [index, item] of items.entries()) {
if (items.length === 1) {
item.position.set(ButtonPosition.Standalone);
} else if (index === 0) {
item.position.set(ButtonPosition.Leftmost);
} else if (index === items.length - 1) {
item.position.set(ButtonPosition.Rightmost);
} else {
item.position.set(ButtonPosition.Center);
}
}
}
function makeRegistration(): ButtonRegistration { function makeRegistration(): ButtonRegistration {
const detach = writable(false); const detach = writable(false);
const position = writable(ButtonPosition.Standalone); const position = writable(ButtonPosition.Standalone);
return { detach, position }; return { detach, position };
} }
function registerButton( const {
index = items.length, registerComponent,
registration = makeRegistration() items,
): ButtonRegistration { dynamicItems,
items.splice(index, 0, registration); getDynamicInterface,
items = items; } = makeInterface(makeRegistration);
return registration;
}
interface SvelteComponent { $: for (const [index, item] of $items.entries()) {
id: string | undefined; if ($items.length === 1) {
component: SvelteComponentTyped; item.position.set(ButtonPosition.Standalone);
props: Record<string, unknown>; } else if (index === 0) {
} item.position.set(ButtonPosition.Leftmost);
} else if (index === $items.length - 1) {
const dynamicItems: ButtonRegistration[] = []; item.position.set(ButtonPosition.Rightmost);
let dynamic: SvelteComponent[] = []; } else {
item.position.set(ButtonPosition.Center);
function addButton(
button: SvelteComponent,
add: (added: Element, parent: Element) => number
): void {
const registration = makeRegistration();
const callback = (
mutations: MutationRecord[],
observer: MutationObserver
): void => {
for (const mutation of mutations) {
const addedNode = mutation.addedNodes[0];
if (addedNode.nodeType === Node.ELEMENT_NODE) {
const index = add(addedNode as Element, buttonGroupRef);
if (index >= 0) {
registerButton(index, registration);
}
}
}
observer.disconnect();
};
const observer = new MutationObserver(callback);
observer.observe(buttonGroupRef, { childList: true });
dynamicItems.push(registration);
dynamic = [...dynamic, button];
}
const insertButton = (button: SvelteComponent, position: Identifier = 0) =>
addButton(button, (added, parent) => insert(added, parent, position));
const appendButton = (button: SvelteComponent, position: Identifier = -1) =>
addButton(button, (added, parent) => add(added, parent, position));
function updateRegistration(
f: (registration: ButtonRegistration) => void,
id: Identifier
): void {
const match = find(buttonGroupRef.children, id);
if (match) {
const [index] = match;
const registration = items[index];
f(registration);
} }
} }
const showButton = (id: Identifier) => setContext(buttonGroupKey, registerComponent);
updateRegistration(({ detach }) => detach.set(false), id);
const hideButton = (id: Identifier) => export let api = {};
updateRegistration(({ detach }) => detach.set(true), id); let buttonGroupRef: HTMLDivElement;
const toggleButton = (id: Identifier) =>
updateRegistration(({ detach }) => detach.update((old) => !old), id); $: if (buttonGroupRef) {
const { addComponent, updateRegistration } = getDynamicInterface(
buttonGroupRef
);
const insertButton = (button: SvelteComponent, position: Identifier = 0) =>
addComponent(button, (added, parent) => insert(added, parent, position));
const appendButton = (button: SvelteComponent, position: Identifier = -1) =>
addComponent(button, (added, parent) => add(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) => !old), id);
setContext(
buttonGroupKey,
Object.assign(api, { Object.assign(api, {
registerButton,
insertButton, insertButton,
appendButton, appendButton,
showButton, showButton,
hideButton, hideButton,
toggleButton, toggleButton,
}) });
); }
</script> </script>
<style lang="scss"> <style lang="scss">
div { div {
display: flex;
justify-items: start;
flex-wrap: var(--toolbar-wrap); flex-wrap: var(--toolbar-wrap);
padding: calc(var(--toolbar-size) / 10); padding: calc(var(--toolbar-size) / 10);
margin: 0; margin: 0;
} }
</style> </style>
<div bind:this={buttonGroupRef} {id} class={className} dir="ltr"> <div
bind:this={buttonGroupRef}
{id}
class={`btn-group ${className}`}
dir="ltr"
role="group">
<slot /> <slot />
{#each dynamic as item, i} {#each $dynamicItems as item}
<ButtonGroupItem id={item.id} registration={dynamicItems[i]}> <ButtonGroupItem id={item[0].id} registration={item[1]}>
<svelte:component this={item.component} {...item.props} /> <svelte:component this={item[0].component} {...item[0].props} />
</ButtonGroupItem> </ButtonGroupItem>
{/each} {/each}
</div> </div>

View file

@ -8,6 +8,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import type { ButtonRegistration } from "./buttons"; import type { ButtonRegistration } from "./buttons";
import { ButtonPosition } from "./buttons"; import { ButtonPosition } from "./buttons";
import type { Register } from "./registration";
import { getContext, hasContext } from "svelte"; import { getContext, hasContext } from "svelte";
import { buttonGroupKey } from "./contextKeys"; import { buttonGroupKey } from "./contextKeys";
@ -43,8 +44,10 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
detach.subscribe((value: boolean) => (detach_ = value)); detach.subscribe((value: boolean) => (detach_ = value));
position.subscribe((value: ButtonPosition) => (position_ = value)); position.subscribe((value: ButtonPosition) => (position_ = value));
} else if (hasContext(buttonGroupKey)) { } else if (hasContext(buttonGroupKey)) {
const { registerButton } = getContext(buttonGroupKey); const registerComponent = getContext<Register<ButtonRegistration>>(
const { detach, position } = registerButton(); buttonGroupKey
);
const { detach, position } = registerComponent();
detach.subscribe((value: boolean) => (detach_ = value)); detach.subscribe((value: boolean) => (detach_ = value));
position.subscribe((value: ButtonPosition) => (position_ = value)); position.subscribe((value: ButtonPosition) => (position_ = value));
} else { } else {

View file

@ -0,0 +1,15 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="typescript">
export let id: string | undefined = undefined;
let className: string | undefined;
export { className as class };
export let nowrap = false;
</script>
<div {id} class={`btn-toolbar ${className}`} class:flex-nowrap={nowrap} role="toolbar">
<slot />
</div>

View file

@ -10,9 +10,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss"> <style lang="scss">
nav { nav {
display: flex;
flex-wrap: wrap;
position: sticky; position: sticky;
top: 0; top: 0;
left: 0; left: 0;
@ -24,6 +21,6 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
</style> </style>
<nav {id} class="pb-1 {className}"> <nav {id} class={`pb-1 ${className}`}>
<slot /> <slot />
</nav> </nav>

View file

@ -0,0 +1,110 @@
// 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 { Readable } from "svelte/store";
import { writable } from "svelte/store";
import type { Identifier } from "./identifier";
import { find } from "./identifier";
export interface SvelteComponent {
component: SvelteComponentTyped;
id: string | undefined;
props: Record<string, unknown> | undefined;
}
export type Register<T> = (index?: number, registration?: T) => T;
export interface RegistrationAPI<T> {
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 makeInterface<T>(makeRegistration: () => T): RegistrationAPI<T> {
const registrations: T[] = [];
const items = writable(registrations);
function registerComponent(
index: number = registrations.length,
registration = makeRegistration()
): T {
registrations.splice(index, 0, registration);
items.set(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) {
const addedNode = mutation.addedNodes[0];
if (addedNode.nodeType === Node.ELEMENT_NODE) {
const index = add(addedNode as Element, elementRef);
if (index >= 0) {
registerComponent(index, registration);
}
}
}
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 = find(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

@ -47,6 +47,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
import WithTheming from "components/WithTheming.svelte"; import WithTheming from "components/WithTheming.svelte";
import StickyBar from "components/StickyBar.svelte"; import StickyBar from "components/StickyBar.svelte";
import ButtonToolbar from "components/ButtonToolbar.svelte";
import NoteTypeButtons from "./NoteTypeButtons.svelte"; import NoteTypeButtons from "./NoteTypeButtons.svelte";
import FormatInlineButtons from "./FormatInlineButtons.svelte"; import FormatInlineButtons from "./FormatInlineButtons.svelte";
@ -75,10 +76,12 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<WithTheming {style}> <WithTheming {style}>
<StickyBar> <StickyBar>
<NoteTypeButtons api={notetypeButtons} /> <ButtonToolbar>
<FormatInlineButtons api={formatInlineButtons} /> <NoteTypeButtons api={notetypeButtons} />
<FormatBlockButtons api={formatBlockButtons} /> <FormatInlineButtons api={formatInlineButtons} />
<ColorButtons api={colorButtons} /> <FormatBlockButtons api={formatBlockButtons} />
<TemplateButtons api={templateButtons} /> <ColorButtons api={colorButtons} />
<TemplateButtons api={templateButtons} />
</ButtonToolbar>
</StickyBar> </StickyBar>
</WithTheming> </WithTheming>

View file

@ -5,4 +5,5 @@
$btn-disabled-opacity: 0.4; $btn-disabled-opacity: 0.4;
@import "ts/sass/bootstrap/buttons"; @import "ts/sass/bootstrap/buttons";
@import "ts/sass/bootstrap/button-group";
@import "ts/sass/bootstrap/dropdown"; @import "ts/sass/bootstrap/dropdown";