diff --git a/CONTRIBUTORS b/CONTRIBUTORS index a874a313da3..65180ca0c6b 100644 --- a/CONTRIBUTORS +++ b/CONTRIBUTORS @@ -217,7 +217,7 @@ Mike Hardy Danika_Dakika Mumtaz Hajjo Alrifai Thomas Graves -Jakub Fidler +Jakub Fidler Valerie Enfys Julien Chol ikkz diff --git a/ftl/core/editing.ftl b/ftl/core/editing.ftl index 3aacb974673..307d1661211 100644 --- a/ftl/core/editing.ftl +++ b/ftl/core/editing.ftl @@ -69,6 +69,8 @@ editing-warning-cloze-deletions-will-not-work = Warning, cloze deletions will no editing-mathjax-preview = MathJax Preview editing-shrink-images = Shrink Images editing-close-html-tags = Auto-close HTML tags +editing-tags-show-full = Show full tags +editing-tags-show-compact = Show compact tags editing-from-clipboard = From Clipboard editing-alignment = Alignment editing-equations = Equations diff --git a/qt/aqt/editor.py b/qt/aqt/editor.py index e86f5fe96c0..661c6a55f68 100644 --- a/qt/aqt/editor.py +++ b/qt/aqt/editor.py @@ -501,6 +501,10 @@ def onBridgeCmd(self, cmd: str) -> Any: collapsed = collapsed_string == "true" self.setTagsCollapsed(collapsed) + elif cmd.startswith("setTagDisplayFull"): + (type, value) = cmd.split(":", 1) + self.mw.col.set_config("tagDisplayFull", value == "true") + elif cmd.startswith("editorState"): (_, new_state_id, old_state_id) = cmd.split(":", 2) self.signal_state_change( @@ -607,6 +611,7 @@ def oncallback(arg: Any) -> None: setMathjaxEnabled({json.dumps(self.mw.col.get_config("renderMathjax", True))}); setShrinkImages({json.dumps(self.mw.col.get_config("shrinkEditorImages", True))}); setCloseHTMLTags({json.dumps(self.mw.col.get_config("closeHTMLTags", True))}); + setTagDisplayFull({json.dumps(self.mw.col.get_config("tagDisplayFull", False))}); triggerChanges(); """ diff --git a/ts/editor/NoteEditor.svelte b/ts/editor/NoteEditor.svelte index 17ced575bc3..a86e3fc67c4 100644 --- a/ts/editor/NoteEditor.svelte +++ b/ts/editor/NoteEditor.svelte @@ -222,6 +222,18 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html $tagsCollapsed = collapsed; } + const tagDisplayFull = writable(false); + const tagDisplayModeShortcut = "Control+."; + + export function setTagDisplayFull(full: boolean): void { + $tagDisplayFull = full; + } + + function toggleTagDisplayMode(): void { + $tagDisplayFull = !$tagDisplayFull; + bridgeCommand(`setTagDisplayFull:${$tagDisplayFull}`); + } + function updateTagsCollapsed(collapsed: boolean) { $tagsCollapsed = collapsed; bridgeCommand(`setTagsCollapsed:${$tagsCollapsed}`); @@ -596,6 +608,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html setMathjaxEnabled, setShrinkImages, setCloseHTMLTags, + setTagDisplayFull, triggerChanges, setIsImageOcclusion, setupMaskEditor, @@ -802,6 +815,10 @@ the AddCards dialog) should be implemented in the user of this component. updateTagsCollapsed(false); }} /> + 0 ? tagAmount : ""} ${tr.editingTags()}`} - + {/if} diff --git a/ts/lib/components/icons.ts b/ts/lib/components/icons.ts index ab07cbf171d..46d582e8cf2 100644 --- a/ts/lib/components/icons.ts +++ b/ts/lib/components/icons.ts @@ -141,8 +141,12 @@ import Update_ from "@mdi/svg/svg/update.svg?component"; import update_ from "@mdi/svg/svg/update.svg?url"; import VectorPolygonVariant_ from "@mdi/svg/svg/vector-polygon-variant.svg?component"; import vectorPolygonVariant_ from "@mdi/svg/svg/vector-polygon-variant.svg?url"; +import ViewCompact_ from "@mdi/svg/svg/view-compact.svg?component"; +import viewCompact_ from "@mdi/svg/svg/view-compact.svg?url"; import ViewDashboard_ from "@mdi/svg/svg/view-dashboard.svg?component"; import viewDashboard_ from "@mdi/svg/svg/view-dashboard.svg?url"; +import ViewList_ from "@mdi/svg/svg/view-list.svg?component"; +import viewList_ from "@mdi/svg/svg/view-list.svg?url"; import Revert_ from "bootstrap-icons/icons/arrow-counterclockwise.svg?component"; import revert_ from "bootstrap-icons/icons/arrow-counterclockwise.svg?url"; import ArrowLeft_ from "bootstrap-icons/icons/arrow-left.svg?component"; @@ -283,5 +287,7 @@ export const mdiUndo = { url: undo_, component: Undo_ }; export const mdiUnfoldMoreHorizontal = { url: unfoldMoreHorizontal_, component: UnfoldMoreHorizontal_ }; export const mdiUngroup = { url: ungroup_, component: Ungroup_ }; export const mdiVectorPolygonVariant = { url: vectorPolygonVariant_, component: VectorPolygonVariant_ }; +export const mdiViewCompact = { url: viewCompact_, component: ViewCompact_ }; +export const mdiViewList = { url: viewList_, component: ViewList_ }; export const incrementClozeIcon = { url: incrementCloze_, component: IncrementCloze_ }; export const mdiEarth = { url: earth_, component: Earth_ }; diff --git a/ts/lib/tag-editor/TagDisplayModeButton.svelte b/ts/lib/tag-editor/TagDisplayModeButton.svelte new file mode 100644 index 00000000000..67a49457c55 --- /dev/null +++ b/ts/lib/tag-editor/TagDisplayModeButton.svelte @@ -0,0 +1,62 @@ + + + + +
+ + {#if full} + + {:else} + + {/if} + +
+ + diff --git a/ts/lib/tag-editor/TagEditMode.svelte b/ts/lib/tag-editor/TagEditMode.svelte index 190e926f8b0..f6732f70a3d 100644 --- a/ts/lib/tag-editor/TagEditMode.svelte +++ b/ts/lib/tag-editor/TagEditMode.svelte @@ -17,6 +17,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let selected: boolean; export let active: boolean; export let shorten: boolean; + export let truncateMiddle: boolean = false; + export let editorWidth: number = 0; export let flash: () => void; @@ -34,6 +36,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {selected} {active} {shorten} + {truncateMiddle} + {editorWidth} {flash} on:tagrange on:tagselect diff --git a/ts/lib/tag-editor/TagEditor.svelte b/ts/lib/tag-editor/TagEditor.svelte index eb033ef7a76..15a7977d50a 100644 --- a/ts/lib/tag-editor/TagEditor.svelte +++ b/ts/lib/tag-editor/TagEditor.svelte @@ -6,13 +6,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html import { completeTag } from "@generated/backend"; import { tagActionsShortcutsKey } from "@tslib/context-keys"; import { isArrowDown, isArrowUp } from "@tslib/keys"; - import { createEventDispatcher, setContext, tick } from "svelte"; + import { createEventDispatcher, onDestroy, setContext, tick } from "svelte"; import type { Writable } from "svelte/store"; import { writable } from "svelte/store"; import Shortcut from "$lib/components/Shortcut.svelte"; import { execCommand } from "$lib/domlib"; + import TagDisplayModeButton from "./TagDisplayModeButton.svelte"; import { TagOptionsButton } from "./tag-options-button"; import TagEditMode from "./TagEditMode.svelte"; import TagInput from "./TagInput.svelte"; @@ -28,6 +29,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html export let tags: Writable; export let keyCombination: string = "Control+Shift+T"; + export let displayFull: boolean = false; + export let displayModeShortcut: string = "Control+."; const selectAllShortcut = "Control+A"; const copyShortcut = "Control+C"; @@ -63,6 +66,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html let activeAfterBlur: number | null = null; let activeName = ""; let activeInput: HTMLInputElement; + let newTagId: string | null = null; // ID of newly created empty tag let autocomplete: any; let autocompleteDisabled: boolean = false; @@ -139,9 +143,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html } function appendTagAndFocusAt(index: number, name: string): void { - tagTypes.splice(index + 1, 0, attachId(name)); + const newTag = attachId(name); + tagTypes.splice(index + 1, 0, newTag); tagTypes = tagTypes; setActiveAfterBlur(index + 1); + // Track new empty tags for spacer collapse logic + if (name.length === 0) { + newTagId = newTag.id; + } } function isActiveNameUniqueAt(index: number): boolean { @@ -161,6 +170,13 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html const current = activeName.slice(0, start); const splitOff = activeName.slice(end); + // Don't create empty tags - if both parts are empty, just blur (blur handler deletes empty tags) + if (current.length === 0 && splitOff.length === 0) { + active = null; + activeInput.blur(); + return; + } + activeName = current; // await tag to update its name, so it can normalize correctly await tick(); @@ -208,6 +224,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html return; } + // If current tag is empty, move to previous (blur handler deletes empty tags) + if (activeName.length === 0) { + activeAfterBlur = index - 1; + active = null; + activeInput.blur(); + return; + } + const deleted = deleteTagAt(index - 1); activeName = deleted.name + activeName; active!--; @@ -243,7 +267,8 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html return; } - activeAfterBlur = index + 1; + // Blur handler deletes empty tags, so next tag shifts to current index + activeAfterBlur = activeName.length === 0 ? index : index + 1; active = null; activeInput.blur(); @@ -388,8 +413,86 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html // typically correct for rows < 7 $: assumedRows = Math.floor(height / badgeHeight); - $: shortenTags = shortenTags || assumedRows > 2; + let shortenTags = false; + let prevDisplayFull = displayFull; + let recomputingShorten = false; + let shortenSeq = 0; // incremented to cancel in-flight recomputes + + const afterPaint = () => + new Promise((resolve) => + requestAnimationFrame(() => requestAnimationFrame(() => resolve())), + ); + + async function recomputeShortenForAuto(): Promise { + const seq = ++shortenSeq; + recomputingShorten = true; + shortenTags = false; + + await tick(); + await afterPaint(); + + if (seq !== shortenSeq || displayFull || !editorElement || !badgeHeight) { + recomputingShorten = false; + return; + } + + shortenTags = Math.floor(editorElement.offsetHeight / badgeHeight) > 2; + recomputingShorten = false; + } + + $: if (displayFull !== prevDisplayFull) { + prevDisplayFull = displayFull; + + if (displayFull) { + shortenSeq++; // cancel in-flight recompute + shortenTags = false; + } else { + void recomputeShortenForAuto(); + } + } + + $: if (!displayFull && !recomputingShorten && !shortenTags && assumedRows > 2) { + shortenTags = true; + } $: anyTagsSelected = tagTypes.some((tag) => tag.selected); + // Spacer should collapse only when adding a new tag at the end + $: isAddingTagAtEnd = + active !== null && + newTagId !== null && + tagTypes[active]?.id === newTagId && + active === tagTypes.length - 1; + + // Track editor width for tag truncation + let editorElement: HTMLDivElement; + let editorWidth: number = 0; + let resizeObserver: ResizeObserver | null = null; + let resizeTimeout: ReturnType | null = null; + + function updateEditorWidth(): void { + if (editorElement) { + editorWidth = editorElement.clientWidth; + } + } + + function debouncedUpdateWidth(): void { + if (resizeTimeout) { + clearTimeout(resizeTimeout); + } + resizeTimeout = setTimeout(updateEditorWidth, 32); + } + + $: if (editorElement && !resizeObserver) { + resizeObserver = new ResizeObserver(debouncedUpdateWidth); + resizeObserver.observe(editorElement); + updateEditorWidth(); + } + + onDestroy(() => { + resizeObserver?.disconnect(); + if (resizeTimeout) { + clearTimeout(resizeTimeout); + } + }); {#if anyTagsSelected} @@ -398,17 +501,33 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html {/if} -
- +
+
+ + + +
{#each tagTypes as tag, index (tag.id)}
@@ -417,11 +536,14 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html name={index === active ? activeName : tag.name} tooltip={tag.name} active={index === active} - shorten={shortenTags} + shorten={!displayFull && shortenTags} + truncateMiddle={displayFull} + {editorWidth} bind:flash={tag.flash} bind:selected={tag.selected} on:tagedit={() => { active = index; + newTagId = null; // Clear when editing existing tag deselect(); }} on:tagselect={() => select(index)} @@ -447,7 +569,7 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html > void; + let displayName = name; + let needsTooltip = false; + let tagButton: HTMLButtonElement | null = null; + let cachedFont: string | undefined; + let cachedEllipsisWidth: number | undefined; + + const ELLIPSIS = "…"; + // Space to the right of tag text: delete badge (~20px) + tag padding pe-1 (4px) + border (1px) + safety margin + const RIGHT_PADDING_PX = 51; + + // Canvas for text measurement - no DOM thrashing + const canvas = document.createElement("canvas"); + const ctx = canvas.getContext("2d")!; + + function getTextWidth(text: string): number { + return ctx.measureText(text).width; + } + + function fitText(): void { + if (!truncateMiddle || !tagButton || editorWidth <= 0) { + displayName = name; + needsTooltip = false; + return; + } + + // Keep font correct (can change via theme/class/parent) + const font = getComputedStyle(tagButton).font; + if (cachedFont !== font) { + cachedFont = font; + cachedEllipsisWidth = undefined; + } + ctx.font = cachedFont; + + const maxW = editorWidth - RIGHT_PADDING_PX; + if (maxW <= 0) { + displayName = ELLIPSIS; + needsTooltip = true; + return; + } + + const fullWidth = getTextWidth(name); + if (fullWidth <= maxW) { + displayName = name; + needsTooltip = false; + return; + } + + const ellipsisWidth = + cachedEllipsisWidth ?? (cachedEllipsisWidth = getTextWidth(ELLIPSIS)); + + if (ellipsisWidth > maxW) { + displayName = ELLIPSIS; + needsTooltip = true; + return; + } + + const build = (k: number): string => { + const kk = Math.max(0, Math.min(k, name.length)); + const head = Math.ceil(kk / 2); + const tail = kk - head; + return ( + name.slice(0, head) + + ELLIPSIS + + (tail > 0 ? name.slice(name.length - tail) : "") + ); + }; + + // Find maximum k such that build(k) fits in maxW + let lo = 0; + let hi = name.length; + let bestK = 0; + + while (lo <= hi) { + const mid = (lo + hi) >> 1; + const candidate = build(mid); + if (getTextWidth(candidate) <= maxW) { + bestK = mid; + lo = mid + 1; + } else { + hi = mid - 1; + } + } + + displayName = build(bestK); + needsTooltip = true; + } + + function onTagMount(event: CustomEvent<{ button: HTMLButtonElement }>): void { + tagButton = event.detail.button; + cachedFont = undefined; // Reset to get fresh font on mount + cachedEllipsisWidth = undefined; + fitText(); + } + + // Re-fit when name, truncateMiddle, or editorWidth changes + $: { + name; + truncateMiddle; + editorWidth; + fitText(); + } + const dispatch = createEventDispatcher(); let control = false; @@ -94,6 +198,24 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html + {:else if needsTooltip} + + { + createTooltip(event.detail.button); + onTagMount(event); + }} + > + {displayName} + + + {:else} - {name} + {displayName} {/if} diff --git a/ts/lib/tag-editor/tag-options-button/TagAddButton.svelte b/ts/lib/tag-editor/tag-options-button/TagAddButton.svelte index fda953b4482..f5a3d0014f4 100644 --- a/ts/lib/tag-editor/tag-options-button/TagAddButton.svelte +++ b/ts/lib/tag-editor/tag-options-button/TagAddButton.svelte @@ -38,9 +38,11 @@ License: GNU AGPL, version 3 or later; http://www.gnu.org/licenses/agpl.html - - - + {#if $$slots.default} + + + + {/if}
dispatch("tagappend")} />