diff --git a/apps/client/package.json b/apps/client/package.json index 7714f8f40..c78e53c19 100644 --- a/apps/client/package.json +++ b/apps/client/package.json @@ -58,6 +58,7 @@ "panzoom": "9.4.3", "preact": "10.27.2", "react-i18next": "16.0.1", + "reveal.js": "5.2.1", "split.js": "1.6.5", "svg-pan-zoom": "3.6.2", "tabulator-tables": "6.3.1", @@ -71,6 +72,7 @@ "@types/leaflet": "1.9.21", "@types/leaflet-gpx": "1.3.8", "@types/mark.js": "8.11.12", + "@types/reveal.js": "5.2.1", "@types/tabulator-tables": "6.2.11", "copy-webpack-plugin": "13.0.1", "happy-dom": "20.0.2", diff --git a/apps/client/src/components/note_context.ts b/apps/client/src/components/note_context.ts index 1bc4e5498..79d1e148b 100644 --- a/apps/client/src/components/note_context.ts +++ b/apps/client/src/components/note_context.ts @@ -438,4 +438,22 @@ class NoteContext extends Component implements EventListener<"entitiesReloaded"> } } +export function openInCurrentNoteContext(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent | React.PointerEvent | null, notePath: string, viewScope?: ViewScope) { + const ntxId = $(evt?.target as Element) + .closest("[data-ntx-id]") + .attr("data-ntx-id"); + + const noteContext = ntxId ? appContext.tabManager.getNoteContextById(ntxId) : appContext.tabManager.getActiveContext(); + + if (noteContext) { + noteContext.setNote(notePath, { viewScope }).then(() => { + if (noteContext !== appContext.tabManager.getActiveContext()) { + appContext.tabManager.activateNoteContext(noteContext.ntxId); + } + }); + } else { + appContext.tabManager.openContextWithNote(notePath, { viewScope, activate: true }); + } +} + export default NoteContext; diff --git a/apps/client/src/services/content_renderer.ts b/apps/client/src/services/content_renderer.ts index b0a10b868..e891b96d7 100644 --- a/apps/client/src/services/content_renderer.ts +++ b/apps/client/src/services/content_renderer.ts @@ -29,7 +29,7 @@ interface Options { const CODE_MIME_TYPES = new Set(["application/json"]); -async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FAttachment, options: Options = {}) { +export async function getRenderedContent(this: {} | { ctx: string }, entity: FNote | FAttachment, options: Options = {}) { options = Object.assign( { diff --git a/apps/client/src/services/in_app_help.ts b/apps/client/src/services/in_app_help.ts index 9e00f7e37..a0b118e5c 100644 --- a/apps/client/src/services/in_app_help.ts +++ b/apps/client/src/services/in_app_help.ts @@ -27,7 +27,8 @@ export const byBookType: Record = { calendar: "xWbu3jpNWapp", table: "2FvYrpmOXm29", geoMap: "81SGnPGMk7Xc", - board: "CtBQqbwXDx1w" + board: "CtBQqbwXDx1w", + presentation: null }; export function getHelpUrlForNote(note: FNote | null | undefined) { diff --git a/apps/client/src/services/link.ts b/apps/client/src/services/link.ts index 16ca48bd7..b0ab04d07 100644 --- a/apps/client/src/services/link.ts +++ b/apps/client/src/services/link.ts @@ -4,6 +4,7 @@ import appContext, { type NoteCommandData } from "../components/app_context.js"; import froca from "./froca.js"; import utils from "./utils.js"; import { ALLOWED_PROTOCOLS } from "@triliumnext/commons"; +import { openInCurrentNoteContext } from "../components/note_context.js"; function getNotePathFromUrl(url: string) { const notePathMatch = /#(root[A-Za-z0-9_/]*)$/.exec(url); @@ -316,21 +317,7 @@ function goToLinkExt(evt: MouseEvent | JQuery.ClickEvent | JQuery.MouseDownEvent viewScope }); } else if (isLeftClick) { - const ntxId = $(evt?.target as any) - .closest("[data-ntx-id]") - .attr("data-ntx-id"); - - const noteContext = ntxId ? appContext.tabManager.getNoteContextById(ntxId) : appContext.tabManager.getActiveContext(); - - if (noteContext) { - noteContext.setNote(notePath, { viewScope }).then(() => { - if (noteContext !== appContext.tabManager.getActiveContext()) { - appContext.tabManager.activateNoteContext(noteContext.ntxId); - } - }); - } else { - appContext.tabManager.openContextWithNote(notePath, { viewScope, activate: true }); - } + openInCurrentNoteContext(evt, notePath, viewScope); } } else if (hrefLink) { const withinEditLink = $link?.hasClass("ck-link-actions__preview"); diff --git a/apps/client/src/services/note_types.ts b/apps/client/src/services/note_types.ts index a42631f9f..74b6f5665 100644 --- a/apps/client/src/services/note_types.ts +++ b/apps/client/src/services/note_types.ts @@ -168,7 +168,8 @@ async function getBuiltInTemplates(title: string | null, command: TreeCommandNam } for (const templateNote of childNotes) { - if (templateNote.hasLabel("collection") !== filterCollections) { + if (templateNote.hasLabel("collection") !== filterCollections || + !templateNote.hasLabel("template")) { continue; } diff --git a/apps/client/src/translations/en/translation.json b/apps/client/src/translations/en/translation.json index 305fb8202..a6858c7d6 100644 --- a/apps/client/src/translations/en/translation.json +++ b/apps/client/src/translations/en/translation.json @@ -768,6 +768,7 @@ "table": "Table", "geo-map": "Geo Map", "board": "Board", + "presentation": "Presentation", "include_archived_notes": "Show archived notes" }, "edited_notes": { @@ -2029,6 +2030,11 @@ "edit-note-title": "Click to edit note title", "edit-column-title": "Click to edit column title" }, + "presentation_view": { + "edit-slide": "Edit this slide", + "start-presentation": "Start presentation", + "slide-overview": "Toggle an overview of the slides" + }, "command_palette": { "tree-action-name": "Tree: {{name}}", "export_note_title": "Export Note", diff --git a/apps/client/src/widgets/collections/NoteList.tsx b/apps/client/src/widgets/collections/NoteList.tsx index e721d3eb9..cb43ab1be 100644 --- a/apps/client/src/widgets/collections/NoteList.tsx +++ b/apps/client/src/widgets/collections/NoteList.tsx @@ -12,8 +12,9 @@ import BoardView from "./board"; import { subscribeToMessages, unsubscribeToMessage as unsubscribeFromMessage } from "../../services/ws"; import { WebSocketMessage } from "@triliumnext/commons"; import froca from "../../services/froca"; +import PresentationView from "./presentation"; -interface NoteListProps { +interface NoteListProps { note: FNote | null | undefined; notePath: string | null | undefined; highlightedTokens?: string[] | null; @@ -23,17 +24,17 @@ interface NoteListProps { ntxId: string | null | undefined; } -export default function NoteList(props: Pick, "displayOnlyCollections">) { +export default function NoteList(props: Pick) { const { note, noteContext, notePath, ntxId } = useNoteContext(); const isEnabled = noteContext?.hasNoteList(); return } -export function SearchNoteList(props: Omit, "isEnabled">) { +export function SearchNoteList(props: Omit) { return } -function CustomNoteList({ note, isEnabled: shouldEnable, notePath, highlightedTokens, displayOnlyCollections, ntxId }: NoteListProps) { +function CustomNoteList({ note, isEnabled: shouldEnable, notePath, highlightedTokens, displayOnlyCollections, ntxId }: NoteListProps) { const widgetRef = useRef(null); const viewType = useNoteViewType(note); const noteIds = useNoteIds(note, viewType, ntxId); @@ -104,6 +105,8 @@ function getComponentByViewType(viewType: ViewTypeOptions, props: ViewModeProps< return case "board": return + case "presentation": + return } } diff --git a/apps/client/src/widgets/collections/interface.ts b/apps/client/src/widgets/collections/interface.ts index 0b2fdb22d..91b9f301b 100644 --- a/apps/client/src/widgets/collections/interface.ts +++ b/apps/client/src/widgets/collections/interface.ts @@ -1,6 +1,6 @@ import FNote from "../../entities/fnote"; -export const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board"] as const; +export const allViewTypes = ["list", "grid", "calendar", "table", "geoMap", "board", "presentation"] as const; export type ViewTypeOptions = typeof allViewTypes[number]; export interface ViewModeProps { diff --git a/apps/client/src/widgets/collections/presentation/index.css b/apps/client/src/widgets/collections/presentation/index.css new file mode 100644 index 000000000..5aafffd9f --- /dev/null +++ b/apps/client/src/widgets/collections/presentation/index.css @@ -0,0 +1,10 @@ +.presentation-button-bar { + position: absolute; + top: 1em; + right: 1em; +} + +.presentation-container { + width: 100%; + height: 100%; +} \ No newline at end of file diff --git a/apps/client/src/widgets/collections/presentation/index.tsx b/apps/client/src/widgets/collections/presentation/index.tsx new file mode 100644 index 000000000..1d9896d35 --- /dev/null +++ b/apps/client/src/widgets/collections/presentation/index.tsx @@ -0,0 +1,183 @@ +import { ViewModeProps } from "../interface"; +import { useEffect, useLayoutEffect, useRef, useState } from "preact/hooks"; +import Reveal from "reveal.js"; +import slideBaseStylesheet from "reveal.js/dist/reveal.css?raw"; +import slideCustomStylesheet from "./slidejs.css?raw"; +import { buildPresentationModel, PresentationModel, PresentationSlideBaseModel } from "./model"; +import ShadowDom from "../../react/ShadowDom"; +import ActionButton from "../../react/ActionButton"; +import "./index.css"; +import { RefObject } from "preact"; +import { openInCurrentNoteContext } from "../../../components/note_context"; +import { useNoteLabelWithDefault, useTriliumEvent } from "../../react/hooks"; +import { t } from "../../../services/i18n"; +import { DEFAULT_THEME, loadPresentationTheme } from "./themes"; +import FNote from "../../../entities/fnote"; + +export default function PresentationView({ note, noteIds }: ViewModeProps<{}>) { + const [ presentation, setPresentation ] = useState(); + const containerRef = useRef(null); + const [ api, setApi ] = useState(); + const stylesheets = usePresentationStylesheets(note); + + function refresh() { + buildPresentationModel(note).then(setPresentation); + } + + useTriliumEvent("entitiesReloaded", ({ loadResults }) => { + if (loadResults.getNoteIds().find(noteId => noteIds.includes(noteId))) { + refresh(); + } + }); + + useLayoutEffect(refresh, [ note, noteIds ]); + + return presentation && stylesheets && ( + <> + + {stylesheets.map(stylesheet => )} + + + + + ) +} + +function usePresentationStylesheets(note: FNote) { + const [ themeName ] = useNoteLabelWithDefault(note, "presentation:theme", DEFAULT_THEME); + const [ stylesheets, setStylesheets ] = useState(); + + useLayoutEffect(() => { + loadPresentationTheme(themeName).then((themeStylesheet) => { + setStylesheets([ + slideBaseStylesheet, + themeStylesheet, + slideCustomStylesheet + ].map(stylesheet => stylesheet.replace(/:root/g, ":host"))); + }); + }, [ themeName ]); + + return stylesheets; +} + +function ButtonOverlay({ containerRef, api }: { containerRef: RefObject, api: Reveal.Api | undefined }) { + const [ isOverviewActive, setIsOverviewActive ] = useState(false); + useEffect(() => { + if (!api) return; + setIsOverviewActive(api.isOverview()); + const onEnabled = () => setIsOverviewActive(true); + const onDisabled = () => setIsOverviewActive(false); + api.on("overviewshown", onEnabled); + api.on("overviewhidden", onDisabled); + return () => { + api.off("overviewshown", onEnabled); + api.off("overviewhidden", onDisabled); + }; + }, [ api ]); + + return ( +
+
+ { + const currentSlide = api?.getCurrentSlide(); + const noteId = getNoteIdFromSlide(currentSlide); + + if (noteId) { + openInCurrentNoteContext(e, noteId); + } + }} + /> + + api?.toggleOverview()} + /> + + containerRef.current?.requestFullscreen()} + /> +
+
+ ) +} + +function Presentation({ presentation, setApi } : { presentation: PresentationModel, setApi: (api: Reveal.Api | undefined) => void }) { + const containerRef = useRef(null); + const [revealApi, setRevealApi] = useState(); + + useEffect(() => { + if (!containerRef.current) return; + + const api = new Reveal(containerRef.current, { + transition: "slide", + embedded: true, + keyboardCondition(event) { + // Full-screen requests sometimes fail, we rely on the UI button instead. + if (event.key === "f") { + return false; + } + + return true; + }, + }); + api.initialize().then(() => { + setRevealApi(revealApi); + setApi(api); + }); + + return () => { + api.destroy(); + setRevealApi(undefined); + setApi(undefined); + } + }, []); + + useEffect(() => { + revealApi?.sync(); + }, [ presentation, revealApi ]); + + return ( +
+
+ {presentation.slides?.map(slide => { + if (!slide.verticalSlides) { + return + } else { + return ( +
+ + {slide.verticalSlides.map(slide => )} +
+ ); + } + })} +
+
+ ) + +} + +function Slide({ slide }: { slide: PresentationSlideBaseModel }) { + return
; +} + +function getNoteIdFromSlide(slide: HTMLElement | undefined) { + if (!slide) return; + return slide.dataset.noteId; +} diff --git a/apps/client/src/widgets/collections/presentation/model.ts b/apps/client/src/widgets/collections/presentation/model.ts new file mode 100644 index 000000000..031ff2f36 --- /dev/null +++ b/apps/client/src/widgets/collections/presentation/model.ts @@ -0,0 +1,49 @@ +import FNote from "../../../entities/fnote"; +import contentRenderer from "../../../services/content_renderer"; + +type DangerouslySetInnerHTML = { __html: string; }; + +/** A top-level slide with optional vertical slides. */ +interface PresentationSlideModel extends PresentationSlideBaseModel { + verticalSlides: PresentationSlideBaseModel[] | undefined; +} + +/** Either a top-level slide or a vertical slide. */ +export interface PresentationSlideBaseModel { + noteId: string; + content: DangerouslySetInnerHTML; +} + +export interface PresentationModel { + slides: PresentationSlideModel[]; +} + +export async function buildPresentationModel(note: FNote): Promise { + const slideNotes = await note.getChildNotes(); + const slides: PresentationSlideModel[] = await Promise.all(slideNotes.map(async slideNote => ({ + noteId: slideNote.noteId, + content: await processContent(slideNote), + verticalSlides: await buildVerticalSlides(slideNote) + }))) + + return { slides }; +} + +async function buildVerticalSlides(parentSlideNote: FNote): Promise { + const children = await parentSlideNote.getChildNotes(); + if (!children.length) return; + + const slides: PresentationSlideBaseModel[] = await Promise.all(children.map(async childNote => ({ + noteId: childNote.noteId, + content: await processContent(childNote) + }))); + + return slides; +} + +async function processContent(note: FNote): Promise { + const { $renderedContent } = await contentRenderer.getRenderedContent(note, { + + }); + return { __html: $renderedContent.html() }; +} diff --git a/apps/client/src/widgets/collections/presentation/slidejs.css b/apps/client/src/widgets/collections/presentation/slidejs.css new file mode 100644 index 000000000..c3e9735be --- /dev/null +++ b/apps/client/src/widgets/collections/presentation/slidejs.css @@ -0,0 +1,20 @@ +figure img { + aspect-ratio: unset !important; + height: auto !important; +} + +span.katex-html { + display: none !important; +} + +p:has(span.text-tiny), +p:has(span.text-small), +p:has(span.text-big), +p:has(span.text-huge) { + line-height: unset !important; +} + +span.text-tiny { font-size: 0.5em; } +span.text-small { font-size: 0.75em; } +span.text-big { font-size: 1.5em; } +span.text-huge { font-size: 2em; } \ No newline at end of file diff --git a/apps/client/src/widgets/collections/presentation/themes.spec.ts b/apps/client/src/widgets/collections/presentation/themes.spec.ts new file mode 100644 index 000000000..854cad190 --- /dev/null +++ b/apps/client/src/widgets/collections/presentation/themes.spec.ts @@ -0,0 +1,10 @@ +import { it, describe } from "vitest"; +import { getPresentationThemes, loadPresentationTheme } from "./themes"; + +describe("Presentation themes", () => { + it("can load all themes", async () => { + const themes = getPresentationThemes(); + + await Promise.all(themes.map(theme => loadPresentationTheme(theme.id))); + }); +}); diff --git a/apps/client/src/widgets/collections/presentation/themes.ts b/apps/client/src/widgets/collections/presentation/themes.ts new file mode 100644 index 000000000..414472d56 --- /dev/null +++ b/apps/client/src/widgets/collections/presentation/themes.ts @@ -0,0 +1,58 @@ +export const DEFAULT_THEME = "white"; + +const themes = { + black: { + name: "Black", + loadTheme: () => import("reveal.js/dist/theme/black.css?raw") + }, + white: { + name: "White", + loadTheme: () => import("reveal.js/dist/theme/white.css?raw") + }, + beige: { + name: "Beige", + loadTheme: () => import("reveal.js/dist/theme/beige.css?raw") + }, + serif: { + name: "Serif", + loadTheme: () => import("reveal.js/dist/theme/serif.css?raw") + }, + simple: { + name: "Simple", + loadTheme: () => import("reveal.js/dist/theme/simple.css?raw") + }, + solarized: { + name: "Solarized", + loadTheme: () => import("reveal.js/dist/theme/solarized.css?raw") + }, + moon: { + name: "Moon", + loadTheme: () => import("reveal.js/dist/theme/moon.css?raw") + }, + dracula: { + name: "Dracula", + loadTheme: () => import("reveal.js/dist/theme/dracula.css?raw") + }, + sky: { + name: "Sky", + loadTheme: () => import("reveal.js/dist/theme/sky.css?raw") + }, + blood: { + name: "Blood", + loadTheme: () => import("reveal.js/dist/theme/blood.css?raw") + } +} as const; + +export function getPresentationThemes() { + return Object.entries(themes).map(([ id, theme ]) => ({ + id: id, + name: theme.name + })); +} + +export async function loadPresentationTheme(name: keyof typeof themes | string) { + let theme = themes[name]; + if (!theme) theme = themes[DEFAULT_THEME]; + + return (await theme.loadTheme()).default; +} diff --git a/apps/client/src/widgets/react/ActionButton.tsx b/apps/client/src/widgets/react/ActionButton.tsx index 2eb69bab8..28489005d 100644 --- a/apps/client/src/widgets/react/ActionButton.tsx +++ b/apps/client/src/widgets/react/ActionButton.tsx @@ -5,16 +5,18 @@ import keyboard_actions from "../../services/keyboard_actions"; export interface ActionButtonProps { text: string; - titlePosition?: "bottom" | "left"; + titlePosition?: "top" | "right" | "bottom" | "left"; icon: string; className?: string; onClick?: (e: MouseEvent) => void; triggerCommand?: CommandNames; noIconActionClass?: boolean; frame?: boolean; + active?: boolean; + disabled?: boolean; } -export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass, frame }: ActionButtonProps) { +export default function ActionButton({ text, icon, className, onClick, triggerCommand, titlePosition, noIconActionClass, frame, active, disabled }: ActionButtonProps) { const buttonRef = useRef(null); const [ keyboardShortcut, setKeyboardShortcut ] = useState(); @@ -32,8 +34,9 @@ export default function ActionButton({ text, icon, className, onClick, triggerCo return