Make tags editor resizable using Henrik's components (#2046)

* Make tags editor resizable using Henrik's components

All credit for the components goes to Henrik. I just tweaked the design a bit and implemented them in NoteEditor.

Co-Authored-By: Henrik Giesel <hengiesel@gmail.com>

* Remove PaneContent padding

Co-Authored-By: Henrik Giesel <hengiesel@gmail.com>

* Add responsive box-shadows on scroll/resize

only shown when content overflows in the respective direction.

* Remove comment

* Fix overflow calculations and shadow mix-up

This happened when I switched from using scrolledToX to overflowX booleans.

* Simplify overflow calculations

* Make drag handles 0 height/width

The remaining height requirement comes from a margin set on NoteEditor.

* Run eslint on components

* Split editor into three panes: Toolbar, Fields, Tags

* Remove upper split for now

to unblock 2.1.55 beta

* Move panes.scss to sass folder

* Use single type for resizable panes

* Implement collapsed state toggled with click on resizer

* Add button to uncollapse tags pane and focus input

* Add indicator for # of tags

* Use dbclick to prevent interference with resize state

* Add utility functions for expand/collapse

* Meddle around with types and formatting

* Fix collapsed state being forgotten on second browser open (dae)

* Fix typecheck (dae)

Our tooling generates .d.ts files from the Svelte files, but it doesn't
expect variables to be exported. By changing them into functions, they
get included in .bazel/bin/ts/components/Pane.svelte.d.ts

* Remove an unnecessary bridgeCommand (dae)

* Fix the bottom of tags getting cut off (dae)

Not sure why offsetHeight is inaccurate in this case.

* Add missing header (dae)

Co-authored-by: Henrik Giesel <hengiesel@gmail.com>
This commit is contained in:
Matthias Metelka 2022-09-28 06:02:32 +02:00 committed by GitHub
parent a54b815c7b
commit f72570c604
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
18 changed files with 801 additions and 167 deletions

View file

@ -533,6 +533,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
setNoteId({}); setNoteId({});
setColorButtons({}); setColorButtons({});
setTags({}); setTags({});
setTagsCollapsed({});
setMathjaxEnabled({}); setMathjaxEnabled({});
setShrinkImages({}); setShrinkImages({});
""".format( """.format(
@ -545,6 +546,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
json.dumps(self.note.id), json.dumps(self.note.id),
json.dumps([text_color, highlight_color]), json.dumps([text_color, highlight_color]),
json.dumps(self.note.tags), json.dumps(self.note.tags),
json.dumps(self.mw.pm.tags_collapsed(self.editorMode)),
json.dumps(self.mw.col.get_config("renderMathjax", True)), json.dumps(self.mw.col.get_config("renderMathjax", True)),
json.dumps(self.mw.col.get_config("shrinkEditorImages", True)), json.dumps(self.mw.col.get_config("shrinkEditorImages", True)),
) )
@ -1167,6 +1169,12 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
not self.mw.col.get_config("shrinkEditorImages", True), not self.mw.col.get_config("shrinkEditorImages", True),
) )
def collapseTags(self) -> None:
aqt.mw.pm.set_tags_collapsed(self.editorMode, True)
def expandTags(self) -> None:
aqt.mw.pm.set_tags_collapsed(self.editorMode, False)
# Links from HTML # Links from HTML
###################################################################### ######################################################################
@ -1195,6 +1203,8 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
mathjaxChemistry=Editor.insertMathjaxChemistry, mathjaxChemistry=Editor.insertMathjaxChemistry,
toggleMathjax=Editor.toggleMathjax, toggleMathjax=Editor.toggleMathjax,
toggleShrinkImages=Editor.toggleShrinkImages, toggleShrinkImages=Editor.toggleShrinkImages,
expandTags=Editor.expandTags,
collapseTags=Editor.collapseTags,
) )

View file

@ -28,6 +28,7 @@ from aqt.utils import disable_help_button, send_to_trash, showWarning, tr
if TYPE_CHECKING: if TYPE_CHECKING:
from aqt.browser.layout import BrowserLayout from aqt.browser.layout import BrowserLayout
from aqt.editor import EditorMode
# Profile handling # Profile handling
@ -553,6 +554,21 @@ create table if not exists profiles
def set_browser_layout(self, layout: BrowserLayout) -> None: def set_browser_layout(self, layout: BrowserLayout) -> None:
self.meta["browser_layout"] = layout.value self.meta["browser_layout"] = layout.value
def editor_key(self, mode: EditorMode) -> str:
from aqt.editor import EditorMode
return {
EditorMode.ADD_CARDS: "add",
EditorMode.BROWSER: "browser",
EditorMode.EDIT_CURRENT: "current",
}[mode]
def tags_collapsed(self, mode: EditorMode) -> bool:
return self.meta.get(f"{self.editor_key(mode)}TagsCollapsed", False)
def set_tags_collapsed(self, mode: EditorMode, collapsed: bool) -> None:
self.meta[f"{self.editor_key(mode)}TagsCollapsed"] = collapsed
def legacy_import_export(self) -> bool: def legacy_import_export(self) -> bool:
return self.meta.get("legacy_import", False) return self.meta.get("legacy_import", False)

View file

@ -59,6 +59,14 @@ sass_library(
visibility = ["//visibility:public"], visibility = ["//visibility:public"],
) )
sass_library(
name = "panes_lib",
srcs = [
"panes.scss",
],
visibility = ["//visibility:public"],
)
sass_library( sass_library(
name = "breakpoints_lib", name = "breakpoints_lib",
srcs = [ srcs = [

29
sass/panes.scss Normal file
View file

@ -0,0 +1,29 @@
/* Copyright: Ankitects Pty Ltd and contributors
* License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html */
@mixin resizable($direction, $width-resizable, $height-resizable) {
display: flex;
flex-flow: #{$direction} nowrap;
flex-basis: 0;
flex-grow: var(--pane-size);
overflow: hidden;
overflow-y: auto;
&.resize {
flex-basis: auto;
@if $width-resizable {
&.resize-width {
width: var(--resized-width);
}
}
@if $height-resizable {
&.resize-height {
height: var(--resized-height);
}
}
}
}

View file

@ -41,6 +41,7 @@ svelte_check(
"//sass:base_lib", "//sass:base_lib",
"//sass:button_mixins_lib", "//sass:button_mixins_lib",
"//sass:scrollbar_lib", "//sass:scrollbar_lib",
"//sass:panes_lib",
"//sass:breakpoints_lib", "//sass:breakpoints_lib",
"//sass:elevation_lib", "//sass:elevation_lib",
"//sass/bootstrap", "//sass/bootstrap",

View file

@ -0,0 +1,117 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { on } from "../lib/events";
import { Callback, singleCallback } from "../lib/typing";
import IconConstrain from "./IconConstrain.svelte";
import { horizontalHandle } from "./icons";
import type { ResizablePane } from "./types";
export let panes: ResizablePane[];
export let index = 0;
export let tip = "";
export let clientHeight: number;
let destroy: Callback;
let before: ResizablePane;
let after: ResizablePane;
$: resizerAmount = panes.length - 1;
$: componentsHeight = clientHeight - resizerHeight * resizerAmount;
export function move(targets: ResizablePane[], targetHeight: number): void {
const [resizeTarget, resizePartner] = targets;
if (targetHeight <= resizeTarget.maxHeight) {
resizeTarget.resizable.getHeightResizer().setSize(targetHeight);
resizePartner.resizable
.getHeightResizer()
.setSize(componentsHeight - targetHeight);
}
}
function onMove(this: Window, { movementY }: PointerEvent): void {
if (movementY < 0) {
if (after.height - movementY <= after.maxHeight) {
const resized = before.resizable.getHeightResizer().resize(movementY);
after.resizable.getHeightResizer().resize(-resized);
} else {
const resized = before.resizable
.getHeightResizer()
.resize(after.height - after.maxHeight);
after.resizable.getHeightResizer().resize(-resized);
}
} else if (before.height + movementY <= before.maxHeight) {
const resized = after.resizable.getHeightResizer().resize(-movementY);
before.resizable.getHeightResizer().resize(-resized);
} else {
const resized = after.resizable
.getHeightResizer()
.resize(before.height - before.maxHeight);
before.resizable.getHeightResizer().resize(-resized);
}
}
let resizerHeight: number;
function releasePointer(this: Window): void {
destroy();
document.exitPointerLock();
for (const pane of panes) {
pane.resizable.getHeightResizer().stop(componentsHeight, panes.length);
}
}
function lockPointer(this: HTMLDivElement) {
this.requestPointerLock();
before = panes[index];
after = panes[index + 1];
for (const pane of panes) {
pane.resizable.getHeightResizer().start();
}
destroy = singleCallback(
on(window, "pointermove", onMove),
on(window, "pointerup", releasePointer),
);
}
</script>
<div
class="horizontal-resizer"
title={tip}
bind:clientHeight={resizerHeight}
on:pointerdown|preventDefault={lockPointer}
on:dblclick
>
<div class="drag-handle">
<IconConstrain iconSize={80}>{@html horizontalHandle}</IconConstrain>
</div>
</div>
<style lang="scss">
.horizontal-resizer {
width: 100%;
cursor: row-resize;
position: relative;
height: 10px;
border-top: 1px solid var(--border);
z-index: 20;
.drag-handle {
position: absolute;
left: 50%;
top: 50%;
transform: translate(-50%, -50%);
opacity: 0.4;
}
&:hover .drag-handle {
opacity: 0.8;
}
}
</style>

63
ts/components/Pane.svelte Normal file
View file

@ -0,0 +1,63 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { createEventDispatcher } from "svelte";
import { writable } from "svelte/store";
import { resizable, Resizer } from "./resizable";
export let baseSize = 600;
const resizes = writable(false);
const paneSize = writable(baseSize);
const [
{ resizesDimension: resizesWidth, resizedDimension: resizedWidth },
widthAction,
widthResizer,
] = resizable(baseSize, resizes, paneSize);
const [
{ resizesDimension: resizesHeight, resizedDimension: resizedHeight },
heightAction,
heightResizer,
] = resizable(baseSize, resizes, paneSize);
const dispatch = createEventDispatcher();
$: resizeArgs = { width: $resizedWidth, height: $resizedHeight };
$: dispatch("resize", resizeArgs);
export function getHeightResizer(): Resizer {
return heightResizer;
}
export function getWidthResizer(): Resizer {
return widthResizer;
}
</script>
<div
class="pane"
class:resize={$resizes}
class:resize-width={$resizesWidth}
class:resize-height={$resizesHeight}
style:--pane-size={$paneSize}
style:--resized-width="{$resizedWidth}px"
style:--resized-height="{$resizedHeight}px"
on:focusin
on:pointerdown
use:widthAction={(element) => element.offsetWidth}
use:heightAction={(element) => element.offsetHeight}
>
<slot />
</div>
<style lang="scss">
@use "sass/panes" as panes;
.pane {
@include panes.resizable(column, true, true);
}
</style>

View file

@ -0,0 +1,81 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { promiseWithResolver } from "../lib/promise";
export let scroll = true;
const [element, elementResolve] = promiseWithResolver<HTMLElement>();
let clientWidth = 0;
let clientHeight = 0;
let scrollWidth = 0;
let scrollHeight = 0;
let scrollTop = 0;
let scrollLeft = 0;
$: overflowTop = scrollTop > 0;
$: overflowBottom = scrollTop < scrollHeight - clientHeight;
$: overflowLeft = scrollLeft > 0;
$: overflowRight = scrollLeft < scrollWidth - clientWidth;
$: shadows = {
top: overflowTop ? "0 5px" : null,
bottom: overflowBottom ? "0 -5px" : null,
left: overflowLeft ? "5px 0" : null,
right: overflowRight ? "-5px 0" : null,
};
const rest = "5px -5px var(--shadow)";
$: shadow = Array.from(
Object.values(shadows).filter((v) => v != null),
(v) => `inset ${v} ${rest}`,
).join(", ");
async function updateScrollState(): Promise<void> {
const el = await element;
scrollHeight = el.scrollHeight;
scrollWidth = el.scrollWidth;
scrollTop = el.scrollTop;
scrollLeft = el.scrollLeft;
}
</script>
<div
class="pane-content"
class:scroll
style:--box-shadow={shadow}
style:--client-height="{clientHeight}px"
use:elementResolve
bind:clientHeight
bind:clientWidth
on:scroll={updateScrollState}
on:resize={updateScrollState}
>
<slot />
</div>
<style lang="scss">
.pane-content {
display: flex;
flex-direction: column;
flex-grow: 1;
overflow: hidden;
&.scroll {
overflow: auto;
}
/* force box-shadow to be rendered above children */
&::before {
content: "";
position: fixed;
pointer-events: none;
left: 0;
right: 0;
z-index: 4;
height: var(--client-height);
box-shadow: var(--box-shadow);
transition: box-shadow 0.1s ease-in-out;
}
}
</style>

View file

@ -0,0 +1,101 @@
<!--
Copyright: Ankitects Pty Ltd and contributors
License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
-->
<script lang="ts">
import { on } from "../lib/events";
import { Callback, singleCallback } from "../lib/typing";
import IconConstrain from "./IconConstrain.svelte";
import { verticalHandle } from "./icons";
import type { ResizablePane } from "./types";
export let components: ResizablePane[];
export let index = 0;
export let clientWidth: number;
let destroy: Callback;
let before: ResizablePane;
let after: ResizablePane;
function onMove(this: Window, { movementX }: PointerEvent): void {
if (movementX < 0) {
const resized = before.resizable.getWidthResizer().resize(movementX);
after.resizable.getWidthResizer().resize(-resized);
} else if (movementX > 0) {
const resized = after.resizable.getWidthResizer().resize(-movementX);
before.resizable.getWidthResizer().resize(-resized);
}
}
let minWidth: number;
function releasePointer(this: Window): void {
destroy();
document.exitPointerLock();
const resizerAmount = components.length - 1;
const componentsWidth = clientWidth - minWidth * resizerAmount;
for (const component of components) {
component.resizable
.getWidthResizer()
.stop(componentsWidth, components.length);
}
}
function lockPointer(this: HTMLHRElement) {
this.requestPointerLock();
before = components[index];
after = components[index + 1];
for (const component of components) {
component.resizable.getWidthResizer().start();
}
destroy = singleCallback(
on(window, "pointermove", onMove),
on(window, "pointerup", releasePointer),
);
}
</script>
<div
bind:clientWidth={minWidth}
class="vertical-resizer"
on:pointerdown|preventDefault={lockPointer}
>
<div class="drag-handle">
<IconConstrain iconSize={80}>{@html verticalHandle}</IconConstrain>
</div>
</div>
<style lang="scss">
.vertical-resizer {
height: 100%;
cursor: col-resize;
position: relative;
z-index: 20;
.drag-handle {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
opacity: 0.4;
}
&::before {
content: "";
position: absolute;
width: 10px;
left: -5px;
top: 0;
height: 100%;
}
&:hover .drag-handle {
opacity: 0.8;
}
}
</style>

View file

@ -3,6 +3,10 @@
/// <reference types="../lib/image-import" /> /// <reference types="../lib/image-import" />
export { default as hsplitIcon } from "@mdi/svg/svg/arrow-split-horizontal.svg";
export { default as vsplitIcon } from "@mdi/svg/svg/arrow-split-vertical.svg";
export { default as chevronDown } from "@mdi/svg/svg/chevron-down.svg"; export { default as chevronDown } from "@mdi/svg/svg/chevron-down.svg";
export { default as chevronLeft } from "@mdi/svg/svg/chevron-left.svg"; export { default as chevronLeft } from "@mdi/svg/svg/chevron-left.svg";
export { default as chevronRight } from "@mdi/svg/svg/chevron-right.svg"; export { default as chevronRight } from "@mdi/svg/svg/chevron-right.svg";
export { default as horizontalHandle } from "@mdi/svg/svg/drag-horizontal.svg";
export { default as verticalHandle } from "@mdi/svg/svg/drag-vertical.svg";

View file

@ -0,0 +1,87 @@
// 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 { writable } from "svelte/store";
export interface Resizer {
start(): void;
/**
* @returns Actually applied resize. If the resizedWidth is too small,
* no resize can be applied anymore.
*/
resize(increment: number): number;
setSize(size: number): void;
stop(fullWidth: number, amount: number): void;
}
interface ResizedStores {
resizesDimension: Writable<boolean>;
resizedDimension: Writable<number>;
}
type ResizableResult = [
ResizedStores,
(element: HTMLElement, getter: (element: HTMLElement) => number) => void,
Resizer,
];
export function resizable(
baseSize: number,
resizes: Writable<boolean>,
paneSize: Writable<number>,
): ResizableResult {
const resizesDimension = writable(false);
const resizedDimension = writable(0);
let pane: HTMLElement;
let getter: (element: HTMLElement) => number;
let dimension = 0;
function resizeAction(
element: HTMLElement,
getValue: (element: HTMLElement) => number,
): void {
pane = element;
getter = getValue;
}
function start() {
resizes.set(true);
resizesDimension.set(true);
dimension = getter(pane);
resizedDimension.set(dimension);
}
function resize(increment = 0): number {
if (dimension + increment < 0) {
const applied = -dimension;
dimension = 0;
resizedDimension.set(dimension);
return applied;
}
dimension += increment;
resizedDimension.set(dimension);
return increment;
}
function setSize(size = 0): void {
paneSize.set(size);
}
function stop(fullDimension: number, amount: number): void {
paneSize.set((dimension / fullDimension) * amount * baseSize);
resizesDimension.set(false);
resizes.set(false);
}
return [
{ resizesDimension, resizedDimension },
resizeAction,
{ start, resize, setSize, stop },
];
}

View file

@ -1,5 +1,14 @@
// 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 Pane from "./Pane.svelte";
export type Size = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12; export type Size = 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12;
export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl"; export type Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
export class ResizablePane {
resizable = {} as Pane;
height = 0;
minHeight = 0;
maxHeight = Infinity;
}

View file

@ -20,9 +20,6 @@ Contains the fields. This contains the scrollable area.
/* Add space after the last field and the start of the tag editor */ /* Add space after the last field and the start of the tag editor */
padding-bottom: 5px; 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 */ /* Push the tag editor to the bottom of the note editor */
flex-grow: 1; flex-grow: 1;
} }

View file

@ -44,8 +44,13 @@ 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 HorizontalResizer from "../components/HorizontalResizer.svelte";
import Pane from "../components/Pane.svelte";
import PaneContent from "../components/PaneContent.svelte";
import { ResizablePane } from "../components/types";
import { bridgeCommand } from "../lib/bridgecommand"; import { bridgeCommand } from "../lib/bridgecommand";
import { TagEditor } from "../tag-editor"; import { TagEditor } from "../tag-editor";
import TagAddButton from "../tag-editor/tag-options-button/TagAddButton.svelte";
import { ChangeTimer } from "./change-timer"; import { ChangeTimer } from "./change-timer";
import DecoratedElements from "./DecoratedElements.svelte"; import DecoratedElements from "./DecoratedElements.svelte";
import { clearableArray } from "./destroyable"; import { clearableArray } from "./destroyable";
@ -165,6 +170,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$tags = ts; $tags = ts;
} }
const tagsCollapsed = writable<boolean>();
export function setTagsCollapsed(collapsed: boolean): void {
$tagsCollapsed = collapsed;
if (collapsed) {
lowerResizer.move([tagsPane, fieldsPane], tagsPane.minHeight);
}
}
let noteId: number | null = null; let noteId: number | null = null;
export function setNoteId(ntid: number): void { export function setNoteId(ntid: number): void {
// TODO this is a hack, because it requires the NoteEditor to know implementation details of the PlainTextInput. // TODO this is a hack, because it requires the NoteEditor to know implementation details of the PlainTextInput.
@ -206,6 +219,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
})) as FieldData[]; })) as FieldData[];
function saveTags({ detail }: CustomEvent): void { function saveTags({ detail }: CustomEvent): void {
tagAmount = detail.tags.filter((tag: string) => tag != "").length;
bridgeCommand(`saveTags:${JSON.stringify(detail.tags)}`); bridgeCommand(`saveTags:${JSON.stringify(detail.tags)}`);
} }
@ -288,6 +302,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setFonts, setFonts,
focusField, focusField,
setTags, setTags,
setTagsCollapsed,
setBackgrounds, setBackgrounds,
setClozeHint, setClozeHint,
saveNow: saveFieldNow, saveNow: saveFieldNow,
@ -323,6 +338,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
setContextProperty(api); setContextProperty(api);
setupLifecycleHooks(api); setupLifecycleHooks(api);
let clientHeight: number;
const fieldsPane = new ResizablePane();
const tagsPane = new ResizablePane();
let lowerResizer: HorizontalResizer;
let tagEditor: TagEditor;
$: tagAmount = $tags.length;
function collapseTags(): void {
lowerResizer.move([tagsPane, fieldsPane], tagsPane.minHeight);
}
function expandTags(): void {
lowerResizer.move([tagsPane, fieldsPane], tagsPane.maxHeight);
}
</script> </script>
<!-- <!--
@ -333,7 +366,7 @@ components and functionality for general note editing.
Functionality exclusive to specifc note-editing views (e.g. in the browser or 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" bind:clientHeight>
<EditorToolbar {size} {wrap} api={toolbar}> <EditorToolbar {size} {wrap} api={toolbar}>
<slot slot="notetypeButtons" name="notetypeButtons" /> <slot slot="notetypeButtons" name="notetypeButtons" />
</EditorToolbar> </EditorToolbar>
@ -341,7 +374,7 @@ the AddCards dialog) should be implemented in the user of this component.
{#if hint} {#if hint}
<Absolute bottom right --margin="10px"> <Absolute bottom right --margin="10px">
<Notification> <Notification>
<Badge --badge-color="var(--accent-danger)" --icon-align="top" <Badge --badge-color="tomato" --icon-align="top"
>{@html alertIcon}</Badge >{@html alertIcon}</Badge
> >
<span>{@html hint}</span> <span>{@html hint}</span>
@ -349,162 +382,227 @@ the AddCards dialog) should be implemented in the user of this component.
</Absolute> </Absolute>
{/if} {/if}
<Fields> <Pane
<DecoratedElements> bind:this={fieldsPane.resizable}
{#each fieldsData as field, index} on:resize={(e) => (fieldsPane.height = e.detail.height)}
{@const content = fieldStores[index]} >
<PaneContent>
<Fields>
<DecoratedElements>
{#each fieldsData as field, 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={() => {
$focusedField = null;
bridgeCommand(
`blur:${index}:${getNoteId()}:${transformContentBeforeSave(
get(content),
)}`,
);
}}
on:mouseenter={() => {
$hoveredField = fields[index];
}}
on:mouseleave={() => {
$hoveredField = null;
}}
collapsed={fieldsCollapsed[index]}
--label-color={cols[index] === "dupe"
? "palette-of(flag-1)"
: "palette-of(canvas)"}
>
<svelte:fragment slot="field-label">
<LabelContainer
collapsed={fieldsCollapsed[index]}
on:toggle={async () => {
fieldsCollapsed[index] = !fieldsCollapsed[index];
const defaultInput = !plainTextDefaults[index]
? richTextInputs[index]
: plainTextInputs[index];
if (!fieldsCollapsed[index]) {
refocusInput(defaultInput.api);
} else if (!plainTextDefaults[index]) {
plainTextsHidden[index] = true;
} else {
richTextsHidden[index] = true;
}
}} }}
on:focusout={() => {
$focusedField = null;
bridgeCommand(
`blur:${index}:${getNoteId()}:${transformContentBeforeSave(
get(content),
)}`,
);
}}
on:mouseenter={() => {
$hoveredField = fields[index];
}}
on:mouseleave={() => {
$hoveredField = null;
}}
collapsed={fieldsCollapsed[index]}
--label-color={cols[index] === "dupe"
? "palette-of(flag-1)"
: "palette-of(canvas)"}
> >
<svelte:fragment slot="field-name"> <svelte:fragment slot="field-label">
<LabelName> <LabelContainer
{field.name} collapsed={fieldsCollapsed[index]}
</LabelName> on:toggle={async () => {
fieldsCollapsed[index] =
!fieldsCollapsed[index];
const defaultInput = !plainTextDefaults[index]
? richTextInputs[index]
: plainTextInputs[index];
if (!fieldsCollapsed[index]) {
refocusInput(defaultInput.api);
} else if (!plainTextDefaults[index]) {
plainTextsHidden[index] = true;
} else {
richTextsHidden[index] = true;
}
}}
>
<svelte:fragment slot="field-name">
<LabelName>
{field.name}
</LabelName>
</svelte:fragment>
<FieldState>
{#if cols[index] === "dupe"}
<DuplicateLink />
{/if}
{#if plainTextDefaults[index]}
<RichTextBadge
visible={!fieldsCollapsed[index] &&
(fields[index] === $hoveredField ||
fields[index] ===
$focusedField)}
bind:off={richTextsHidden[index]}
on:toggle={async () => {
richTextsHidden[index] =
!richTextsHidden[index];
if (!richTextsHidden[index]) {
refocusInput(
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>
</LabelContainer>
</svelte:fragment> </svelte:fragment>
<FieldState> <svelte:fragment slot="rich-text-input">
{#if cols[index] === "dupe"} <Collapsible
<DuplicateLink /> collapse={richTextsHidden[index]}
{/if} let:collapsed={hidden}
{#if plainTextDefaults[index]} >
<RichTextBadge <RichTextInput
visible={!fieldsCollapsed[index] && {hidden}
(fields[index] === $hoveredField || on:focusout={() => {
fields[index] === $focusedField)} saveFieldNow();
bind:off={richTextsHidden[index]} $focusedInput = null;
on:toggle={async () => {
richTextsHidden[index] =
!richTextsHidden[index];
if (!richTextsHidden[index]) {
refocusInput(richTextInputs[index].api);
}
}} }}
/> bind:this={richTextInputs[index]}
{:else} >
<PlainTextBadge <ImageHandle maxWidth={250} maxHeight={125} />
visible={!fieldsCollapsed[index] && <MathjaxHandle />
(fields[index] === $hoveredField || {#if insertSymbols}
fields[index] === $focusedField)} <SymbolsOverlay />
bind:off={plainTextsHidden[index]} {/if}
on:toggle={async () => { <FieldDescription>
plainTextsHidden[index] = {field.description}
!plainTextsHidden[index]; </FieldDescription>
</RichTextInput>
if (!plainTextsHidden[index]) { </Collapsible>
refocusInput( </svelte:fragment>
plainTextInputs[index].api, <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]}
/> />
{/if} </Collapsible>
<slot </svelte:fragment>
name="field-state" </EditorField>
{field} {/each}
{index}
visible={fields[index] === $hoveredField ||
fields[index] === $focusedField}
/>
</FieldState>
</LabelContainer>
</svelte:fragment>
<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>
</PaneContent>
</Pane>
<div class="note-editor-tag-editor"> {#if $tagsCollapsed}
<TagEditor {tags} on:tagsupdate={saveTags} /> <div class="tags-expander">
</div> <TagAddButton
on:tagappend={() => {
tagEditor.appendEmptyTag();
}}
keyCombination="Control+Shift+T"
>
{@html tagAmount > 0 ? `${tagAmount} Tags` : ""}
</TagAddButton>
</div>
{/if}
<HorizontalResizer
panes={[fieldsPane, tagsPane]}
tip={`Double click to ${$tagsCollapsed ? "expand" : "collapse"} tag editor`}
{clientHeight}
bind:this={lowerResizer}
on:dblclick={() => {
if ($tagsCollapsed) {
expandTags();
bridgeCommand("expandTags");
$tagsCollapsed = false;
} else {
collapseTags();
bridgeCommand("collapseTags");
$tagsCollapsed = true;
}
}}
/>
<Pane
bind:this={tagsPane.resizable}
on:resize={(e) => {
tagsPane.height = e.detail.height;
$tagsCollapsed = tagsPane.height == 0;
}}
>
<PaneContent scroll={false}>
<TagEditor
{tags}
bind:this={tagEditor}
on:tagsupdate={saveTags}
on:tagsFocused={() => {
expandTags();
$tagsCollapsed = false;
}}
on:heightChange={(e) => {
tagsPane.maxHeight = e.detail.height;
if (!$tagsCollapsed) {
expandTags();
}
}}
/>
</PaneContent>
</Pane>
</div> </div>
<style lang="scss"> <style lang="scss">
@ -513,12 +611,7 @@ the AddCards dialog) should be implemented in the user of this component.
flex-direction: column; flex-direction: column;
height: 100%; height: 100%;
} }
.tags-expander {
.note-editor-tag-editor { margin-top: 0.5rem;
padding: 2px 0 0;
border-width: thin 0 0;
border-style: solid;
border-color: var(--border-subtle);
} }
</style> </style>

View file

@ -9,6 +9,7 @@ $btn-disabled-opacity: 0.4;
@import "sass/bootstrap/scss/dropdown"; @import "sass/bootstrap/scss/dropdown";
@import "sass/bootstrap-tooltip"; @import "sass/bootstrap-tooltip";
html { html,
body {
overflow: hidden; overflow: hidden;
} }

View file

@ -48,6 +48,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
</script> </script>
<script lang="ts"> <script lang="ts">
import { createEventDispatcher } from "svelte";
import { writable } from "svelte/store"; import { writable } from "svelte/store";
import ButtonToolbar from "../../components/ButtonToolbar.svelte"; import ButtonToolbar from "../../components/ButtonToolbar.svelte";
@ -83,9 +84,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} as EditorToolbarAPI); } as EditorToolbarAPI);
setContextProperty(api); setContextProperty(api);
const dispatch = createEventDispatcher();
let clientHeight: number;
$: dispatch("heightChange", { height: clientHeight });
</script> </script>
<div class="editor-toolbar"> <div class="editor-toolbar" bind:clientHeight>
<ButtonToolbar {size} {wrap}> <ButtonToolbar {size} {wrap}>
<DynamicallySlottable slotHost={Item} api={toolbar}> <DynamicallySlottable slotHost={Item} api={toolbar}>
<Item id="notetype"> <Item id="notetype">
@ -119,10 +125,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
<style lang="scss"> <style lang="scss">
.editor-toolbar { .editor-toolbar {
padding: 0 0 2px; padding: 0 0 4px;
border-bottom: 1px solid var(--border);
border-width: 0 0 thin;
border-style: solid;
border-color: var(--border-subtle);
} }
</style> </style>

View file

@ -111,7 +111,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
} }
} }
function appendEmptyTag(): void { export function appendEmptyTag(): void {
// used by tag badge and tag spacer // used by tag badge and tag spacer
deselect(); deselect();
const lastTag = tagTypes[tagTypes.length - 1]; const lastTag = tagTypes[tagTypes.length - 1];
@ -380,6 +380,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
$: assumedRows = Math.floor(height / badgeHeight); $: assumedRows = Math.floor(height / badgeHeight);
$: shortenTags = shortenTags || assumedRows > 2; $: shortenTags = shortenTags || assumedRows > 2;
$: anyTagsSelected = tagTypes.some((tag) => tag.selected); $: anyTagsSelected = tagTypes.some((tag) => tag.selected);
$: dispatch("heightChange", { height: height * 1.15 });
</script> </script>
<div class="tag-editor" on:focusout={deselectIfLeave} bind:offsetHeight={height}> <div class="tag-editor" on:focusout={deselectIfLeave} bind:offsetHeight={height}>
@ -435,6 +437,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
bind:name={activeName} bind:name={activeName}
bind:input={activeInput} bind:input={activeInput}
on:focus={() => { on:focus={() => {
dispatch("tagsFocused");
activeName = tag.name; activeName = tag.name;
autocomplete = createAutocomplete(); autocomplete = createAutocomplete();
}} }}

View file

@ -31,6 +31,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
{@html tagIcon} {@html tagIcon}
{@html addTagIcon} {@html addTagIcon}
</IconConstrain> </IconConstrain>
<span class="tags-info">
<slot />
</span>
</div> </div>
<Shortcut {keyCombination} on:action={() => dispatch("tagappend")} /> <Shortcut {keyCombination} on:action={() => dispatch("tagappend")} />
@ -63,5 +66,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
:global(svg:hover) { :global(svg:hover) {
opacity: 1; opacity: 1;
} }
.tags-info {
cursor: pointer;
color: var(--fg-subtle);
margin-left: 0.75rem;
}
}
:global([dir="rtl"]) .tags-info {
margin-right: 0.75rem;
} }
</style> </style>