Compare commits

...

20 Commits

Author SHA1 Message Date
Elian Doran
2d63e2a00f feat(note_list): display files full-width on mobile 2026-03-21 10:29:57 +02:00
Elian Doran
5a16aa416f feat(video): improve layout on mobile 2026-03-21 10:20:19 +02:00
Elian Doran
df2a53e010 feat(video): group less common options into a dropdown menu 2026-03-21 10:15:22 +02:00
Elian Doran
4f08389f80 chore(video): use dropdown for volume control 2026-03-21 10:03:32 +02:00
Elian Doran
ff0fb4bcfd fix(video): styles not applied to note list 2026-03-21 09:59:43 +02:00
Elian Doran
5f410faaa9 fix(video): consume event to prevent clicking through it 2026-03-21 09:56:08 +02:00
Elian Doran
25bd9e8abd chore(video): use wrapperless rendering into note content 2026-03-21 09:54:17 +02:00
Elian Doran
301a1b2288 feat(video): basic integration into note list 2026-03-21 09:50:34 +02:00
Elian Doran
00c4933344 fix(collections/grid): full-width images are too small in preview (closes #9116) 2026-03-21 09:15:13 +02:00
Elian Doran
cd9b46e1c7 fix(attributes): attribute detail not showing up for first item (closes #6948) 2026-03-21 09:06:21 +02:00
Elian Doran
b356b355ca fix(layout): attribute details not visible in new layout (closes #9005) 2026-03-21 08:58:13 +02:00
Elian Doran
8834899012 fix(math): limit size of popup and add back overflow (closes #9117) 2026-03-20 20:57:07 +02:00
Elian Doran
55dea474e9 chore(deps): update dependency @ckeditor/ckeditor5-dev-build-tools to v55.2.0 (#9099) 2026-03-20 13:45:51 +02:00
Elian Doran
bc74455a64 chore(deps): update dependency @smithy/middleware-retry to v4.4.44 (#9111) 2026-03-20 13:45:21 +02:00
Elian Doran
2d0b28367f chore(deps): update dependency vite to v8.0.1 (#9112) 2026-03-20 13:45:00 +02:00
Elian Doran
7d8a3e2811 fix(deps): update dependency katex to v0.16.39 (#9114) 2026-03-20 13:44:32 +02:00
renovate[bot]
79e5d9595a chore(deps): update dependency @ckeditor/ckeditor5-dev-build-tools to v55.2.0 2026-03-20 00:11:04 +00:00
renovate[bot]
0310626025 fix(deps): update dependency katex to v0.16.39 2026-03-20 00:08:50 +00:00
renovate[bot]
fefbb40c03 chore(deps): update dependency vite to v8.0.1 2026-03-20 00:07:33 +00:00
renovate[bot]
12f89078b8 chore(deps): update dependency @smithy/middleware-retry to v4.4.44 2026-03-20 00:06:57 +00:00
24 changed files with 537 additions and 471 deletions

View File

@@ -58,7 +58,7 @@
"jquery": "4.0.0",
"jquery.fancytree": "2.38.5",
"jsplumb": "2.15.6",
"katex": "0.16.38",
"katex": "0.16.39",
"leaflet": "1.9.4",
"leaflet-gpx": "2.2.0",
"mark.js": "8.11.1",

View File

@@ -8,6 +8,7 @@ import FAttachment from "../entities/fattachment.js";
import FNote from "../entities/fnote.js";
import imageContextMenuService from "../menus/image_context_menu.js";
import { t } from "../services/i18n.js";
import { renderReactWidget, renderReactWidgetAtElement } from "../widgets/react/react_utils";
import renderText from "./content_renderer_text.js";
import renderDoc from "./doc_renderer.js";
import { loadElkIfNeeded, postprocessMermaidSvg } from "./mermaid.js";
@@ -212,15 +213,16 @@ async function renderFile(entity: FNote | FAttachment, type: string, $renderedCo
$content.append($audioPreview);
} else if (type === "video") {
const $videoPreview = $("<video controls></video>")
.attr("src", openService.getUrlForDownload(`api/${entityType}/${entityId}/open-partial`))
.attr("type", entity.mime)
.css("width", "100%");
const url = openService.getUrlForDownload(`api/${entityType}/${entityId}/open-partial`);
const mime = entity.mime;
$content.append($videoPreview);
const VideoPreviewContent = (await import("../widgets/type_widgets/file/Video")).VideoPreviewContent;
const $viewer = renderReactWidget(null, h(VideoPreviewContent, { url, mime }));
$content.append($viewer);
}
if (entityType === "notes" && "noteId" in entity) {
if (entityType === "notes" && "noteId" in entity && type !== "video") {
// TODO: we should make this available also for attachments, but there's a problem with "Open externally" support
// in attachment list
const $downloadButton = $(`

View File

@@ -1042,6 +1042,7 @@
"pause": "Pause (Space)",
"back-10s": "Back 10s (Left arrow key)",
"forward-30s": "Forward 30s",
"volume": "Volume",
"mute": "Mute (M)",
"unmute": "Unmute (M)",
"playback-speed": "Playback speed",
@@ -1054,7 +1055,8 @@
"exit-fullscreen": "Exit fullscreen",
"unsupported-format": "Media preview is not available for this file format:\n{{mime}}",
"zoom-to-fit": "Zoom to fill",
"zoom-reset": "Reset zoom to fill"
"zoom-reset": "Reset zoom to fill",
"more-options": "More options"
},
"protected_session": {
"enter_password_instruction": "Showing protected note requires entering your password:",

View File

@@ -1,18 +1,18 @@
import { t } from "../../services/i18n.js";
import server from "../../services/server.js";
import froca from "../../services/froca.js";
import linkService from "../../services/link.js";
import appContext from "../../components/app_context.js";
import attributeAutocompleteService from "../../services/attribute_autocomplete.js";
import type { Attribute } from "../../services/attribute_parser.js";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
import froca from "../../services/froca.js";
import { t } from "../../services/i18n.js";
import linkService from "../../services/link.js";
import noteAutocompleteService from "../../services/note_autocomplete.js";
import promotedAttributeDefinitionParser from "../../services/promoted_attribute_definition_parser.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
import server from "../../services/server.js";
import shortcutService from "../../services/shortcuts.js";
import SpacedUpdate from "../../services/spaced_update.js";
import utils from "../../services/utils.js";
import shortcutService from "../../services/shortcuts.js";
import appContext from "../../components/app_context.js";
import type { Attribute } from "../../services/attribute_parser.js";
import { focusSavedElement, saveFocusedElement } from "../../services/focus.js";
import { isExperimentalFeatureEnabled } from "../../services/experimental_features.js";
import NoteContextAwareWidget from "../note_context_aware_widget.js";
const TPL = /*html*/`
<div class="attr-detail tn-tool-dialog">
@@ -29,6 +29,7 @@ const TPL = /*html*/`
max-height: 600px;
overflow: auto;
box-shadow: 10px 10px 93px -25px black;
contain: none;
}
.attr-help td {
@@ -343,6 +344,7 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
private $relatedNotesList!: JQuery<HTMLElement>;
private $relatedNotesMoreNotes!: JQuery<HTMLElement>;
private $attrHelp!: JQuery<HTMLElement>;
private $statusBar?: JQuery<HTMLElement>;
private relatedNotesSpacedUpdate!: SpacedUpdate;
private attribute!: Attribute;
@@ -577,17 +579,24 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
return;
}
this.$widget
.css("left", detPosition.left)
.css("right", detPosition.right)
.css("top", y - offset.top + 70)
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
if (isNewLayout) {
if (!this.$statusBar) {
this.$statusBar = $(document.body).find(".component.status-bar");
}
const statusBarHeight = this.$statusBar.outerHeight() ?? 0;
const maxHeight = document.body.clientHeight - statusBarHeight;
this.$widget
.css("left", offset.left + (typeof detPosition.left === "number" ? detPosition.left : 0))
.css("top", "unset")
.css("bottom", 70)
.css("max-height", "80vh");
.css("bottom", statusBarHeight ?? 0)
.css("max-height", maxHeight);
} else {
this.$widget
.css("left", detPosition.left)
.css("right", detPosition.right)
.css("top", y - offset.top + 70)
.css("max-height", outerHeight + y > height - 50 ? height - y - 50 : 10000);
}
if (focus === "name") {
@@ -695,14 +704,14 @@ export default class AttributeDetailWidget extends NoteContextAwareWidget {
return "label-definition";
} else if (attribute.name.startsWith("relation:")) {
return "relation-definition";
} else {
return "label";
}
return "label";
} else if (attribute.type === "relation") {
return "relation";
} else {
this.$title.text("");
}
this.$title.text("");
}
updateAttributeInEditor() {

View File

@@ -12,6 +12,11 @@
display: flex;
flex-wrap: wrap;
gap: 10px;
body.mobile & {
display: grid;
grid-template-columns: 1fr 1fr;
}
}
.note-list-bottom-pager {
@@ -269,8 +274,9 @@
overflow: hidden;
user-select: none;
body.mobile & {
flex-basis: 150px;
body.mobile &.mobile-full-width {
grid-column-start: 1;
grid-column-end: 3;
}
&:hover {
@@ -364,23 +370,19 @@
mask-repeat: no-repeat;
mask-size: 100% 100%;
}
.ck-content p {
margin-bottom: 0.5em;
line-height: 1.3;
}
.ck-content figure.image {
width: 25%;
}
.ck-content .table {
display: flex;
flex-direction: column-reverse;
overflow-x: scroll;
--scrollbar-thickness: 0;
scrollbar-width: none;
table {
width: max-content;
table-layout: auto;
@@ -435,4 +437,4 @@
}
}
/* #endregion */
/* #endregion */

View File

@@ -1,25 +1,25 @@
import "./ListOrGridView.css";
import { Card, CardFrame, CardSection } from "../../react/Card";
import { clsx } from "clsx";
import { ComponentChildren, TargetedMouseEvent } from "preact";
import { useCallback, useEffect, useRef, useState } from "preact/hooks";
import { JSX } from "preact/jsx-runtime";
import FNote from "../../../entities/fnote";
import linkContextMenuService from "../../../menus/link_context_menu";
import attribute_renderer from "../../../services/attribute_renderer";
import content_renderer from "../../../services/content_renderer";
import { t } from "../../../services/i18n";
import link from "../../../services/link";
import CollectionProperties from "../../note_bars/CollectionProperties";
import ActionButton from "../../react/ActionButton";
import { Card, CardFrame, CardSection } from "../../react/Card";
import { useImperativeSearchHighlighlighting, useNoteLabel, useNoteLabelBoolean, useNoteProperty } from "../../react/hooks";
import Icon from "../../react/Icon";
import NoteLink from "../../react/NoteLink";
import { ViewModeProps } from "../interface";
import { Pager, usePagination, PaginationContext } from "../Pagination";
import { Pager, PaginationContext,usePagination } from "../Pagination";
import { filterChildNotes, useFilteredNoteIds } from "./utils";
import { JSX } from "preact/jsx-runtime";
import { clsx } from "clsx";
import ActionButton from "../../react/ActionButton";
import linkContextMenuService from "../../../menus/link_context_menu";
import { ComponentChildren, TargetedMouseEvent } from "preact";
const contentSizeObserver = new ResizeObserver(onContentResized);
@@ -53,13 +53,13 @@ export function GridView({ note, noteIds: unfilteredNoteIds, highlightedTokens }
<div className={clsx("note-list-container use-tn-links", {"search-results": (noteType === "search")})}>
{pageNotes?.map(childNote => (
<GridNoteCard key={childNote.noteId}
note={childNote}
parentNote={note}
highlightedTokens={highlightedTokens}
includeArchived={includeArchived} />
note={childNote}
parentNote={note}
highlightedTokens={highlightedTokens}
includeArchived={includeArchived} />
))}
</div>
</NoteList>
</NoteList>;
}
interface NoteListProps {
@@ -82,13 +82,13 @@ function NoteList(props: NoteListProps) {
{props.noteIds.length > 0 && <div className="note-list-wrapper">
{!hasCollectionProperties && <Pager {...props.pagination} />}
{props.children}
<Pager className="note-list-bottom-pager" {...props.pagination} />
</div>}
</div>
</div>;
}
function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expandDepth, includeArchived }: {
@@ -106,25 +106,25 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan
// Reset expand state if switching to another note, or if user manually toggled expansion state.
useEffect(() => setExpanded(currentLevel <= expandDepth), [ note, currentLevel, expandDepth ]);
let subSections: JSX.Element | undefined = undefined;
let subSections: JSX.Element | undefined;
if (isExpanded) {
subSections = <>
<CardSection className="note-content-preview">
<NoteContent note={note}
highlightedTokens={highlightedTokens}
noChildrenList
includeArchivedNotes={includeArchived} />
highlightedTokens={highlightedTokens}
noChildrenList
includeArchivedNotes={includeArchived} />
</CardSection>
<NoteChildren note={note}
parentNote={parentNote}
highlightedTokens={highlightedTokens}
currentLevel={currentLevel}
expandDepth={expandDepth}
includeArchived={includeArchived} />
</>
parentNote={parentNote}
highlightedTokens={highlightedTokens}
currentLevel={currentLevel}
expandDepth={expandDepth}
includeArchived={includeArchived} />
</>;
}
return (
<CardSection
className={clsx("nested-note-list-item", "no-tooltip-preview", note.getColorClass(), {
@@ -137,14 +137,14 @@ function ListNoteCard({ note, parentNote, highlightedTokens, currentLevel, expan
data-note-id={note.noteId}
>
<h5>
<span className={`note-expander ${isExpanded ? "bx bx-chevron-down" : "bx bx-chevron-right"}`}
onClick={() => setExpanded(!isExpanded)}/>
<span className={`note-expander ${isExpanded ? "bx bx-chevron-down" : "bx bx-chevron-right"}`}
onClick={() => setExpanded(!isExpanded)}/>
<Icon className="note-icon" icon={note.getIcon()} />
<NoteLink className="note-book-title"
notePath={notePath}
noPreview
showNotePath={parentNote.type === "search"}
highlightedTokens={highlightedTokens} />
notePath={notePath}
noPreview
showNotePath={parentNote.type === "search"}
highlightedTokens={highlightedTokens} />
<NoteAttributes note={note} />
<NoteMenuButton notePath={notePath} />
</h5>
@@ -164,27 +164,28 @@ function GridNoteCard(props: GridNoteCardProps) {
return (
<CardFrame className={clsx("note-book-card", "no-tooltip-preview", "block-link", props.note.getColorClass(), {
"archived": props.note.isArchived
})}
data-href={`#${notePath}`}
data-note-id={props.note.noteId}
onClick={(e) => link.goToLink(e)}
"archived": props.note.isArchived,
"mobile-full-width": props.note.type === "file"
})}
data-href={`#${notePath}`}
data-note-id={props.note.noteId}
onClick={(e) => link.goToLink(e)}
>
<h5 className={clsx("note-book-header")}>
<Icon className="note-icon" icon={props.note.getIcon()} />
<NoteLink className="note-book-title"
notePath={notePath}
noPreview
showNotePath={props.parentNote.type === "search"}
highlightedTokens={props.highlightedTokens}
notePath={notePath}
noPreview
showNotePath={props.parentNote.type === "search"}
highlightedTokens={props.highlightedTokens}
/>
{!props.note.isOptions() && <NoteMenuButton notePath={notePath} />}
</h5>
<NoteContent note={props.note}
trim
highlightedTokens={props.highlightedTokens}
includeArchivedNotes={props.includeArchived}
trim
highlightedTokens={props.highlightedTokens}
includeArchivedNotes={props.includeArchived}
/>
</CardFrame>
);
@@ -222,7 +223,7 @@ export function NoteContent({ note, trim, noChildrenList, highlightedTokens, inc
return () => {
contentSizeObserver.unobserve(contentElement);
}
};
}, []);
useEffect(() => {
@@ -281,13 +282,13 @@ function NoteChildren({ note, parentNote, highlightedTokens, currentLevel, expan
function NoteMenuButton(props: {notePath: string}) {
const openMenu = useCallback((e: TargetedMouseEvent<HTMLElement>) => {
linkContextMenuService.openContextMenu(props.notePath, e);
e.stopPropagation()
e.stopPropagation();
}, [props.notePath]);
return <ActionButton className="note-book-item-menu"
icon="bx bx-dots-vertical-rounded" text=""
onClick={openMenu}
/>
icon="bx bx-dots-vertical-rounded" text=""
onClick={openMenu}
/>;
}
function getNotePath(parentNote: FNote, childNote: FNote) {
@@ -315,7 +316,7 @@ function useExpansionDepth(note: FNote) {
function onContentResized(entries: ResizeObserverEntry[], observer: ResizeObserver): void {
for (const contentElement of entries) {
const isOverflowing = ((contentElement.target.scrollHeight > contentElement.target.clientHeight))
const isOverflowing = ((contentElement.target.scrollHeight > contentElement.target.clientHeight));
contentElement.target.classList.toggle("note-book-content-overflowing", isOverflowing);
}
}
}

View File

@@ -1,3 +1,4 @@
import { createPortal } from "preact/compat";
import { useEffect, useState } from "preact/hooks";
import FAttribute from "../../entities/fattribute";
@@ -74,7 +75,7 @@ export default function InheritedAttributesTab({ note, componentId, emptyListStr
)}
</div>
{attributeDetailWidgetEl}
{createPortal(attributeDetailWidgetEl, document.body)}
</div>
);
}

View File

@@ -1,5 +1,6 @@
import { AttributeEditor as CKEditorAttributeEditor, MentionFeed, ModelElement, ModelNode, ModelPosition } from "@triliumnext/ckeditor5";
import { AttributeType } from "@triliumnext/commons";
import { createPortal } from "preact/compat";
import { MutableRef, useEffect, useImperativeHandle, useMemo, useRef, useState } from "preact/hooks";
import type { CommandData, FilteredCommandNames } from "../../../components/app_context";
@@ -336,7 +337,8 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
let matchedAttr: Attribute | null = null;
for (const attr of parsedAttrs) {
if (attr.startIndex && clickIndex > attr.startIndex && attr.endIndex && clickIndex <= attr.endIndex) {
if (attr.startIndex !== undefined && clickIndex > attr.startIndex &&
attr.endIndex !== undefined && clickIndex <= attr.endIndex) {
matchedAttr = attr;
break;
}
@@ -407,7 +409,7 @@ export default function AttributeEditor({ api, note, componentId, notePath, ntxI
)}
</div>}
{attributeDetailWidgetEl}
{createPortal(attributeDetailWidgetEl, document.body)}
</>
);
}

View File

@@ -50,13 +50,21 @@
}
}
.media-volume-row {
.media-volume-dropdown-content {
display: flex;
align-items: center;
gap: 0.25em;
padding: 0.5em;
.volume-mute-btn {
padding: 0.25em;
display: flex;
align-items: center;
justify-content: center;
}
.media-volume-slider {
width: 80px;
width: 100px;
cursor: pointer;
}
}

View File

@@ -102,30 +102,47 @@ export function VolumeControl({ mediaRef }: { mediaRef: RefObject<HTMLVideoEleme
}
};
const toggleMute = () => {
const toggleMute = (e: MouseEvent) => {
e.stopPropagation();
const media = mediaRef.current;
if (!media) return;
media.muted = !media.muted;
setMuted(media.muted);
};
const volumeIcon = muted || volume === 0
? "bx bx-volume-mute"
: volume < 0.5
? "bx bx-volume-low"
: "bx bx-volume-full";
return (
<div class="media-volume-row">
<ActionButton
icon={muted || volume === 0 ? "bx bx-volume-mute" : volume < 0.5 ? "bx bx-volume-low" : "bx bx-volume-full"}
text={muted ? t("media.unmute") : t("media.mute")}
onClick={toggleMute}
/>
<input
type="range"
class="media-volume-slider"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onInput={onVolumeChange}
/>
</div>
<Dropdown
iconAction
hideToggleArrow
buttonClassName="volume-dropdown"
text={<Icon icon={volumeIcon} />}
title={t("media.volume")}
>
<li class="media-volume-dropdown-content">
<button
class="dropdown-item volume-mute-btn"
onClick={toggleMute}
title={muted ? t("media.unmute") : t("media.mute")}
>
<Icon icon={volumeIcon} />
</button>
<input
type="range"
class="media-volume-slider"
min={0}
max={1}
step={0.05}
value={muted ? 0 : volume}
onInput={onVolumeChange}
/>
</li>
</Dropdown>
);
}

View File

@@ -1,8 +1,8 @@
.note-detail-file > .video-preview-wrapper {
.video-preview-wrapper {
width: 100%;
height: 100%;
position: relative;
background-color: black;
background-color: black;
.video-preview {
background-color: black;

View File

@@ -7,19 +7,29 @@ import FNote from "../../../entities/fnote";
import { t } from "../../../services/i18n";
import { getUrlForDownload } from "../../../services/open";
import ActionButton from "../../react/ActionButton";
import Dropdown from "../../react/Dropdown";
import { FormListHeader, FormListItem } from "../../react/FormList";
import Icon from "../../react/Icon";
import NoItems from "../../react/NoItems";
import { LoopButton, PlaybackSpeed, PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
import { PlayPauseButton, SeekBar, SkipButton, VolumeControl } from "./MediaPlayer";
const AUTO_HIDE_DELAY = 3000;
export default function VideoPreview({ note }: { note: FNote }) {
return <VideoPreviewContent
url={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
mime={note.mime}
/>;
}
export function VideoPreviewContent({ url, mime }: { url: string, mime: string }) {
const wrapperRef = useRef<HTMLDivElement>(null);
const videoRef = useRef<HTMLVideoElement>(null);
const [playing, setPlaying] = useState(false);
const [error, setError] = useState(false);
const { visible: controlsVisible, onMouseMove, flash: flashControls } = useAutoHideControls(videoRef, playing);
useEffect(() => setError(false), [note.noteId]);
useEffect(() => setError(false), [ url ]);
const onError = useCallback(() => setError(true), []);
const togglePlayback = useCallback(() => {
@@ -33,6 +43,7 @@ export default function VideoPreview({ note }: { note: FNote }) {
}, []);
const onVideoClick = useCallback((e: MouseEvent) => {
e.stopPropagation();
if ((e.target as HTMLElement).closest(".media-preview-controls")) return;
togglePlayback();
}, [togglePlayback]);
@@ -40,7 +51,7 @@ export default function VideoPreview({ note }: { note: FNote }) {
const onKeyDown = useKeyboardShortcuts(videoRef, wrapperRef, togglePlayback, flashControls);
if (error) {
return <NoItems icon="bx bx-video-off" text={t("media.unsupported-format", { mime: note.mime.replace("/", "-") })} />;
return <NoItems icon="bx bx-video-off" text={t("media.unsupported-format", { mime: mime.replace("/", "-") })} />;
}
return (
@@ -48,8 +59,8 @@ export default function VideoPreview({ note }: { note: FNote }) {
<video
ref={videoRef}
class="video-preview"
src={getUrlForDownload(`api/notes/${note.noteId}/open-partial`)}
datatype={note?.mime}
src={url}
datatype={mime}
onPlay={() => setPlaying(true)}
onPause={() => setPlaying(false)}
onError={onError}
@@ -59,19 +70,17 @@ export default function VideoPreview({ note }: { note: FNote }) {
<SeekBar mediaRef={videoRef} />
<div class="media-buttons-row">
<div className="left">
<PlaybackSpeed mediaRef={videoRef} />
<RotateButton videoRef={videoRef} />
<OverflowMenu videoRef={videoRef} />
</div>
<div className="center">
<div className="spacer" />
<SkipButton mediaRef={videoRef} seconds={-10} icon="bx bx-rewind" text={t("media.back-10s")} />
<PlayPauseButton playing={playing} togglePlayback={togglePlayback} />
<SkipButton mediaRef={videoRef} seconds={30} icon="bx bx-fast-forward" text={t("media.forward-30s")} />
<LoopButton mediaRef={videoRef} />
<div className="spacer" />
</div>
<div className="right">
<VolumeControl mediaRef={videoRef} />
<ZoomToFitButton videoRef={videoRef} />
<PictureInPictureButton videoRef={videoRef} />
<FullscreenButton targetRef={wrapperRef} />
</div>
@@ -171,8 +180,49 @@ function useAutoHideControls(videoRef: RefObject<HTMLVideoElement>, playing: boo
return { visible, onMouseMove, flash: onMouseMove };
}
function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const PLAYBACK_SPEEDS = [0.5, 1, 1.25, 1.5, 2];
function OverflowMenu({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [speed, setSpeed] = useState(() => videoRef.current?.playbackRate ?? 1);
const [loop, setLoop] = useState(() => videoRef.current?.loop ?? false);
const [rotation, setRotation] = useState(0);
const [fitted, setFitted] = useState(false);
// Sync playback rate
useEffect(() => {
const video = videoRef.current;
if (!video) return;
setSpeed(video.playbackRate);
const onRateChange = () => setSpeed(video.playbackRate);
video.addEventListener("ratechange", onRateChange);
return () => video.removeEventListener("ratechange", onRateChange);
}, [videoRef]);
// Sync loop state
useEffect(() => {
const video = videoRef.current;
if (!video) return;
setLoop(video.loop);
const observer = new MutationObserver(() => setLoop(video.loop));
observer.observe(video, { attributes: true, attributeFilter: ["loop"] });
return () => observer.disconnect();
}, [videoRef]);
const selectSpeed = (rate: number) => {
const video = videoRef.current;
if (!video) return;
video.playbackRate = rate;
setSpeed(rate);
};
const toggleLoop = () => {
const video = videoRef.current;
if (!video) return;
video.loop = !video.loop;
setLoop(video.loop);
};
const rotate = () => {
const video = videoRef.current;
@@ -182,7 +232,6 @@ function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const isSideways = next === 90 || next === 270;
if (isSideways) {
// Scale down so the rotated video fits within its container.
const container = video.parentElement;
if (container) {
const ratio = container.clientWidth / container.clientHeight;
@@ -195,19 +244,7 @@ function RotateButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
}
};
return (
<ActionButton
icon="bx bx-rotate-right"
text={t("media.rotate")}
onClick={rotate}
/>
);
}
function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }) {
const [fitted, setFitted] = useState(false);
const toggle = () => {
const toggleFit = () => {
const video = videoRef.current;
if (!video) return;
const next = !fitted;
@@ -216,12 +253,50 @@ function ZoomToFitButton({ videoRef }: { videoRef: RefObject<HTMLVideoElement> }
};
return (
<ActionButton
className={fitted ? "active" : ""}
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
text={fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
onClick={toggle}
/>
<Dropdown
iconAction
hideToggleArrow
noSelectButtonStyle
noDropdownListStyle
mobileBackdrop
buttonClassName="overflow-menu-dropdown"
dropdownContainerClassName="mobile-bottom-menu"
text={<Icon icon="bx bx-dots-horizontal-rounded" />}
title={t("media.more-options")}
>
<FormListHeader text={t("media.playback-speed")} />
{PLAYBACK_SPEEDS.map((rate) => (
<FormListItem
key={rate}
icon={rate === speed ? "bx bx-check" : "bx bx-empty"}
active={rate === speed}
onClick={() => selectSpeed(rate)}
>
{rate}x
</FormListItem>
))}
<li class="dropdown-divider" />
<FormListItem
icon="bx bx-rotate-right"
onClick={rotate}
>
{t("media.rotate")}
</FormListItem>
<FormListItem
icon={loop ? "bx bx-check" : "bx bx-repeat"}
active={loop}
onClick={toggleLoop}
>
{loop ? t("media.disable-loop") : t("media.loop")}
</FormListItem>
<FormListItem
icon={fitted ? "bx bx-collapse" : "bx bx-expand"}
active={fitted}
onClick={toggleFit}
>
{fitted ? t("media.zoom-reset") : t("media.zoom-to-fit")}
</FormListItem>
</Dropdown>
);
}

View File

@@ -126,7 +126,7 @@
"tmp": "0.2.5",
"turnish": "1.8.0",
"unescape": "1.0.1",
"vite": "8.0.0",
"vite": "8.0.1",
"ws": "8.19.0",
"xml2js": "0.6.2",
"yauzl": "3.2.1"

View File

@@ -22,7 +22,7 @@
"eslint-config-preact": "2.0.0",
"typescript": "5.9.3",
"user-agent-data-types": "0.4.2",
"vite": "8.0.0",
"vite": "8.0.1",
"vitest": "4.1.0"
},
"eslintConfig": {

View File

@@ -76,7 +76,7 @@
"typescript": "5.9.3",
"typescript-eslint": "8.57.1",
"upath": "2.0.1",
"vite": "8.0.0",
"vite": "8.0.1",
"vite-plugin-dts": "4.5.4",
"vitest": "4.1.0"
},

View File

@@ -21,7 +21,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",

View File

@@ -22,7 +22,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",

View File

@@ -22,8 +22,9 @@
flex-direction: column;
padding: var(--ck-spacing-standard);
box-sizing: border-box;
max-width: 80vw;
max-height: 80vh;
min-width: 400px;
max-width: 60vw;
max-height: 350px;
overflow: visible;
user-select: text;
}
@@ -63,8 +64,8 @@
border-radius: var(--ck-border-radius);
background: var(--ck-color-input-background) !important;
transition: border-color 120ms ease;
overflow: visible !important;
clip-path: none !important;
overflow: auto;
clip-path: none;
}
.ck.ck-math-input .ck-mathlive-container:focus-within {
border-color: var(--ck-color-focus-border);
@@ -159,16 +160,12 @@
.ck.ck-math-preview {
width: 100%;
min-height: 40px;
max-height: none !important;
height: auto !important;
padding: var(--ck-spacing-small);
background: transparent !important;
border: none !important;
display: block;
text-align: left;
overflow-x: auto !important;
overflow-y: visible !important;
flex-shrink: 0;
overflow: auto;
}
/* Center equation when in display mode */
@@ -213,8 +210,7 @@
.ck.ck-balloon-panel .ck-balloon-panel__content,
.ck.ck-math-form,
.ck-math-view,
.ck.ck-math-input,
.ck.ck-math-input .ck-mathlive-container {
.ck.ck-math-input {
overflow: visible !important;
clip-path: none !important;
}

View File

@@ -24,7 +24,7 @@
"ckeditor5-metadata.json"
],
"devDependencies": {
"@ckeditor/ckeditor5-dev-build-tools": "55.0.0",
"@ckeditor/ckeditor5-dev-build-tools": "55.2.0",
"@ckeditor/ckeditor5-inspector": ">=4.1.0",
"@ckeditor/ckeditor5-package-tools": "5.1.0",
"@typescript-eslint/eslint-plugin": "8.57.1",

View File

@@ -16,7 +16,7 @@
"ckeditor5-premium-features": "47.6.1"
},
"devDependencies": {
"@smithy/middleware-retry": "4.4.43",
"@smithy/middleware-retry": "4.4.44",
"@types/jquery": "4.0.0"
}
}

View File

@@ -25,7 +25,7 @@
"license": "Apache-2.0",
"dependencies": {
"fuse.js": "7.1.0",
"katex": "0.16.38",
"katex": "0.16.39",
"mermaid": "11.13.0"
},
"devDependencies": {

563
pnpm-lock.yaml generated

File diff suppressed because it is too large Load Diff