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:
parent
a54b815c7b
commit
f72570c604
|
@ -533,6 +533,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
|
|||
setNoteId({});
|
||||
setColorButtons({});
|
||||
setTags({});
|
||||
setTagsCollapsed({});
|
||||
setMathjaxEnabled({});
|
||||
setShrinkImages({});
|
||||
""".format(
|
||||
|
@ -545,6 +546,7 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
|
|||
json.dumps(self.note.id),
|
||||
json.dumps([text_color, highlight_color]),
|
||||
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("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),
|
||||
)
|
||||
|
||||
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
|
||||
######################################################################
|
||||
|
||||
|
@ -1195,6 +1203,8 @@ require("anki/ui").loaded.then(() => require("anki/NoteEditor").instances[0].too
|
|||
mathjaxChemistry=Editor.insertMathjaxChemistry,
|
||||
toggleMathjax=Editor.toggleMathjax,
|
||||
toggleShrinkImages=Editor.toggleShrinkImages,
|
||||
expandTags=Editor.expandTags,
|
||||
collapseTags=Editor.collapseTags,
|
||||
)
|
||||
|
||||
|
||||
|
|
|
@ -28,6 +28,7 @@ from aqt.utils import disable_help_button, send_to_trash, showWarning, tr
|
|||
|
||||
if TYPE_CHECKING:
|
||||
from aqt.browser.layout import BrowserLayout
|
||||
from aqt.editor import EditorMode
|
||||
|
||||
|
||||
# Profile handling
|
||||
|
@ -553,6 +554,21 @@ create table if not exists profiles
|
|||
def set_browser_layout(self, layout: BrowserLayout) -> None:
|
||||
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:
|
||||
return self.meta.get("legacy_import", False)
|
||||
|
||||
|
|
|
@ -59,6 +59,14 @@ sass_library(
|
|||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
sass_library(
|
||||
name = "panes_lib",
|
||||
srcs = [
|
||||
"panes.scss",
|
||||
],
|
||||
visibility = ["//visibility:public"],
|
||||
)
|
||||
|
||||
sass_library(
|
||||
name = "breakpoints_lib",
|
||||
srcs = [
|
||||
|
|
|
@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
|
@ -41,6 +41,7 @@ svelte_check(
|
|||
"//sass:base_lib",
|
||||
"//sass:button_mixins_lib",
|
||||
"//sass:scrollbar_lib",
|
||||
"//sass:panes_lib",
|
||||
"//sass:breakpoints_lib",
|
||||
"//sass:elevation_lib",
|
||||
"//sass/bootstrap",
|
||||
|
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -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>
|
|
@ -3,6 +3,10 @@
|
|||
|
||||
/// <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 chevronLeft } from "@mdi/svg/svg/chevron-left.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";
|
||||
|
|
|
@ -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 },
|
||||
];
|
||||
}
|
|
@ -1,5 +1,14 @@
|
|||
// Copyright: Ankitects Pty Ltd and contributors
|
||||
// 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 Breakpoint = "xs" | "sm" | "md" | "lg" | "xl" | "xxl";
|
||||
|
||||
export class ResizablePane {
|
||||
resizable = {} as Pane;
|
||||
height = 0;
|
||||
minHeight = 0;
|
||||
maxHeight = Infinity;
|
||||
}
|
||||
|
|
|
@ -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 */
|
||||
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;
|
||||
}
|
||||
|
|
|
@ -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 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 { TagEditor } from "../tag-editor";
|
||||
import TagAddButton from "../tag-editor/tag-options-button/TagAddButton.svelte";
|
||||
import { ChangeTimer } from "./change-timer";
|
||||
import DecoratedElements from "./DecoratedElements.svelte";
|
||||
import { clearableArray } from "./destroyable";
|
||||
|
@ -165,6 +170,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
$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;
|
||||
export function setNoteId(ntid: number): void {
|
||||
// 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[];
|
||||
|
||||
function saveTags({ detail }: CustomEvent): void {
|
||||
tagAmount = detail.tags.filter((tag: string) => tag != "").length;
|
||||
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,
|
||||
focusField,
|
||||
setTags,
|
||||
setTagsCollapsed,
|
||||
setBackgrounds,
|
||||
setClozeHint,
|
||||
saveNow: saveFieldNow,
|
||||
|
@ -323,6 +338,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
setContextProperty(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>
|
||||
|
||||
<!--
|
||||
|
@ -333,7 +366,7 @@ components and functionality for general note editing.
|
|||
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.
|
||||
-->
|
||||
<div class="note-editor">
|
||||
<div class="note-editor" bind:clientHeight>
|
||||
<EditorToolbar {size} {wrap} api={toolbar}>
|
||||
<slot slot="notetypeButtons" name="notetypeButtons" />
|
||||
</EditorToolbar>
|
||||
|
@ -341,7 +374,7 @@ the AddCards dialog) should be implemented in the user of this component.
|
|||
{#if hint}
|
||||
<Absolute bottom right --margin="10px">
|
||||
<Notification>
|
||||
<Badge --badge-color="var(--accent-danger)" --icon-align="top"
|
||||
<Badge --badge-color="tomato" --icon-align="top"
|
||||
>{@html alertIcon}</Badge
|
||||
>
|
||||
<span>{@html hint}</span>
|
||||
|
@ -349,162 +382,227 @@ the AddCards dialog) should be implemented in the user of this component.
|
|||
</Absolute>
|
||||
{/if}
|
||||
|
||||
<Fields>
|
||||
<DecoratedElements>
|
||||
{#each fieldsData as field, index}
|
||||
{@const content = fieldStores[index]}
|
||||
<Pane
|
||||
bind:this={fieldsPane.resizable}
|
||||
on:resize={(e) => (fieldsPane.height = e.detail.height)}
|
||||
>
|
||||
<PaneContent>
|
||||
<Fields>
|
||||
<DecoratedElements>
|
||||
{#each fieldsData as field, index}
|
||||
{@const content = fieldStores[index]}
|
||||
|
||||
<EditorField
|
||||
{field}
|
||||
{content}
|
||||
flipInputs={plainTextDefaults[index]}
|
||||
api={fields[index]}
|
||||
on:focusin={() => {
|
||||
$focusedField = fields[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;
|
||||
}
|
||||
<EditorField
|
||||
{field}
|
||||
{content}
|
||||
flipInputs={plainTextDefaults[index]}
|
||||
api={fields[index]}
|
||||
on:focusin={() => {
|
||||
$focusedField = fields[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-name">
|
||||
<LabelName>
|
||||
{field.name}
|
||||
</LabelName>
|
||||
<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;
|
||||
}
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
<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);
|
||||
}
|
||||
<svelte:fragment slot="rich-text-input">
|
||||
<Collapsible
|
||||
collapse={richTextsHidden[index]}
|
||||
let:collapsed={hidden}
|
||||
>
|
||||
<RichTextInput
|
||||
{hidden}
|
||||
on:focusout={() => {
|
||||
saveFieldNow();
|
||||
$focusedInput = null;
|
||||
}}
|
||||
/>
|
||||
{: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,
|
||||
);
|
||||
}
|
||||
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]}
|
||||
/>
|
||||
{/if}
|
||||
<slot
|
||||
name="field-state"
|
||||
{field}
|
||||
{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}
|
||||
</Collapsible>
|
||||
</svelte:fragment>
|
||||
</EditorField>
|
||||
{/each}
|
||||
|
||||
<MathjaxElement />
|
||||
<FrameElement />
|
||||
</DecoratedElements>
|
||||
</Fields>
|
||||
<MathjaxElement />
|
||||
<FrameElement />
|
||||
</DecoratedElements>
|
||||
</Fields>
|
||||
</PaneContent>
|
||||
</Pane>
|
||||
|
||||
<div class="note-editor-tag-editor">
|
||||
<TagEditor {tags} on:tagsupdate={saveTags} />
|
||||
</div>
|
||||
{#if $tagsCollapsed}
|
||||
<div class="tags-expander">
|
||||
<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>
|
||||
|
||||
<style lang="scss">
|
||||
|
@ -513,12 +611,7 @@ the AddCards dialog) should be implemented in the user of this component.
|
|||
flex-direction: column;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.note-editor-tag-editor {
|
||||
padding: 2px 0 0;
|
||||
|
||||
border-width: thin 0 0;
|
||||
border-style: solid;
|
||||
border-color: var(--border-subtle);
|
||||
.tags-expander {
|
||||
margin-top: 0.5rem;
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -9,6 +9,7 @@ $btn-disabled-opacity: 0.4;
|
|||
@import "sass/bootstrap/scss/dropdown";
|
||||
@import "sass/bootstrap-tooltip";
|
||||
|
||||
html {
|
||||
html,
|
||||
body {
|
||||
overflow: hidden;
|
||||
}
|
||||
|
|
|
@ -48,6 +48,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
</script>
|
||||
|
||||
<script lang="ts">
|
||||
import { createEventDispatcher } from "svelte";
|
||||
import { writable } from "svelte/store";
|
||||
|
||||
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);
|
||||
|
||||
setContextProperty(api);
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
let clientHeight: number;
|
||||
$: dispatch("heightChange", { height: clientHeight });
|
||||
</script>
|
||||
|
||||
<div class="editor-toolbar">
|
||||
<div class="editor-toolbar" bind:clientHeight>
|
||||
<ButtonToolbar {size} {wrap}>
|
||||
<DynamicallySlottable slotHost={Item} api={toolbar}>
|
||||
<Item id="notetype">
|
||||
|
@ -119,10 +125,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
|
||||
<style lang="scss">
|
||||
.editor-toolbar {
|
||||
padding: 0 0 2px;
|
||||
|
||||
border-width: 0 0 thin;
|
||||
border-style: solid;
|
||||
border-color: var(--border-subtle);
|
||||
padding: 0 0 4px;
|
||||
border-bottom: 1px solid var(--border);
|
||||
}
|
||||
</style>
|
||||
|
|
|
@ -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
|
||||
deselect();
|
||||
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);
|
||||
$: shortenTags = shortenTags || assumedRows > 2;
|
||||
$: anyTagsSelected = tagTypes.some((tag) => tag.selected);
|
||||
|
||||
$: dispatch("heightChange", { height: height * 1.15 });
|
||||
</script>
|
||||
|
||||
<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:input={activeInput}
|
||||
on:focus={() => {
|
||||
dispatch("tagsFocused");
|
||||
activeName = tag.name;
|
||||
autocomplete = createAutocomplete();
|
||||
}}
|
||||
|
|
|
@ -31,6 +31,9 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html
|
|||
{@html tagIcon}
|
||||
{@html addTagIcon}
|
||||
</IconConstrain>
|
||||
<span class="tags-info">
|
||||
<slot />
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<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) {
|
||||
opacity: 1;
|
||||
}
|
||||
.tags-info {
|
||||
cursor: pointer;
|
||||
color: var(--fg-subtle);
|
||||
margin-left: 0.75rem;
|
||||
}
|
||||
}
|
||||
:global([dir="rtl"]) .tags-info {
|
||||
margin-right: 0.75rem;
|
||||
}
|
||||
</style>
|
||||
|
|
Loading…
Reference in New Issue