Skip to content

Commit

Permalink
feat: add link parser for audio timestamp navigate
Browse files Browse the repository at this point in the history
Signed-off-by: Innei <i@innei.in>
  • Loading branch information
Innei committed Jul 31, 2024
1 parent a4f155a commit 1478fa5
Show file tree
Hide file tree
Showing 8 changed files with 138 additions and 123 deletions.
27 changes: 27 additions & 0 deletions src/renderer/src/components/ui/markdown/components/TimeStamp.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,27 @@
import { Player } from "@renderer/atoms/player"
import { nextFrame } from "@renderer/lib/dom"
import { useEntryContentContext } from "@renderer/modules/entry-content/hooks"

import { timeStringToSeconds } from "../utils"

export const TimeStamp = (props: { time: string }) => {
const { entryId, audioSrc: src } = useEntryContentContext()

if (!src) return <span>{props.time}</span>
return (
<span
className="cursor-pointer tabular-nums text-theme-accent dark:text-theme-accent-500"
onClick={() => {
Player.mount({
type: "audio",
entryId,
src,
currentTime: 0,
})
nextFrame(() => Player.seek(timeStringToSeconds(props.time) || 0))
}}
>
{props.time}
</span>
)
}
66 changes: 40 additions & 26 deletions src/renderer/src/components/ui/markdown/renderers/MarkdownLink.tsx
Original file line number Diff line number Diff line change
@@ -1,36 +1,50 @@
import { FeedViewType } from "@renderer/lib/enum"
import { useEntryContentContext } from "@renderer/modules/entry-content/hooks"

import type { LinkProps } from "../../link"
import {
Tooltip,
TooltipContent,
TooltipPortal,
TooltipTrigger,
} from "../../tooltip"
import { ensureAndRenderTimeStamp } from "../utils"

export const MarkdownLink = (props: LinkProps) => {
const { view } = useEntryContentContext()

export const MarkdownLink = (props: LinkProps) => (
<Tooltip
const parseTimeStamp = view === FeedViewType.Audios
if (parseTimeStamp) {
const childrenText = props.children

delayDuration={0}
>
<TooltipTrigger asChild>
<a
className="follow-link--underline font-semibold text-foreground no-underline"
href={props.href}
title={props.title}
target="_blank"
>
{props.children}
if (typeof childrenText === "string") {
const renderer = ensureAndRenderTimeStamp(childrenText)
if (renderer) return renderer
}
}
return (
<Tooltip delayDuration={0}>
<TooltipTrigger asChild>
<a
className="follow-link--underline font-semibold text-foreground no-underline"
href={props.href}
title={props.title}
target="_blank"
>
{props.children}

{typeof props.children === "string" && (
<i className="i-mgc-arrow-right-up-cute-re size-[0.9em] translate-y-[2px] opacity-70" />
)}
</a>
</TooltipTrigger>
{!!props.href && (
<TooltipPortal>
<TooltipContent align="start" className="break-all" side="bottom">
{props.href}
</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
)
{typeof props.children === "string" && (
<i className="i-mgc-arrow-right-up-cute-re size-[0.9em] translate-y-[2px] opacity-70" />
)}
</a>
</TooltipTrigger>
{!!props.href && (
<TooltipPortal>
<TooltipContent align="start" className="break-all" side="bottom">
{props.href}
</TooltipContent>
</TooltipPortal>
)}
</Tooltip>
)
}
75 changes: 8 additions & 67 deletions src/renderer/src/components/ui/markdown/renderers/MarkdownP.tsx
Original file line number Diff line number Diff line change
@@ -1,17 +1,17 @@
/* eslint-disable regexp/no-unused-capturing-group */
import { Player } from "@renderer/atoms/player"
import { nextFrame } from "@renderer/lib/dom"
import { useEntryContentContext } from "@renderer/modules/entry-content/provider"
import { FeedViewType } from "@renderer/lib/enum"
import { useEntryContentContext } from "@renderer/modules/entry-content/hooks"
import * as React from "react"

import { ensureAndRenderTimeStamp } from "../utils"

export const MarkdownP: Component<
React.DetailedHTMLProps<
React.HTMLAttributes<HTMLParagraphElement>,
HTMLParagraphElement
> & {
parseTimeline?: boolean
}
> = ({ children, parseTimeline, ...props }) => {
>
> = ({ children, ...props }) => {
const { view } = useEntryContentContext()
const parseTimeline = view === FeedViewType.Audios
if (parseTimeline && typeof children === "string") {
const renderer = ensureAndRenderTimeStamp(children)
if (renderer) return <p>{renderer}</p>
Expand All @@ -33,62 +33,3 @@ export const MarkdownP: Component<

return <p {...props}>{children}</p>
}

const ensureAndRenderTimeStamp = (children: string) => {
const firstPart = children.replace(" ", " ").split(" ")[0]
// 00:00 , 00:00:00
if (!firstPart) {
return
}
const isTime = isValidTimeString(firstPart.trim())
if (isTime) {
return (
<>
<TimeStamp time={firstPart} />
<span>{children.slice(firstPart.length)}</span>
</>
)
}
return false
}

const TimeStamp = (props: { time: string }) => {
const { entryId, audioSrc: src } = useEntryContentContext()

if (!src) return <span>{props.time}</span>
return (
<span
className="cursor-pointer tabular-nums text-theme-accent dark:text-theme-accent-500"
onClick={() => {
Player.mount({
type: "audio",
entryId,
src,
currentTime: 0,
})
nextFrame(() => Player.seek(timeStringToSeconds(props.time) || 0))
}}
>
{props.time}
</span>
)
}

function isValidTimeString(time: string): boolean {
const timeRegex = /^(\d{1,2}):([0-5]\d)(:[0-5]\d)?$/
return timeRegex.test(time)
}

function timeStringToSeconds(time: string): number | null {
const timeParts = time.split(":").map(Number)

if (timeParts.length === 2) {
const [minutes, seconds] = timeParts
return minutes * 60 + seconds
} else if (timeParts.length === 3) {
const [hours, minutes, seconds] = timeParts
return hours * 3600 + minutes * 60 + seconds
} else {
return null
}
}
37 changes: 37 additions & 0 deletions src/renderer/src/components/ui/markdown/utils/index.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,37 @@
import { TimeStamp } from "../components/TimeStamp"

export const ensureAndRenderTimeStamp = (children: string) => {
const firstPart = children.replace(" ", " ").split(" ")[0]
// 00:00 , 00:00:00
if (!firstPart) {
return
}
const isTime = isValidTimeString(firstPart.trim())
if (isTime) {
return (
<>
<TimeStamp time={firstPart} />
<span>{children.slice(firstPart.length)}</span>
</>
)
}
return false
}
function isValidTimeString(time: string): boolean {
const timeRegex = /^\d{1,2}:[0-5]\d(?::[0-5]\d)?$/
return timeRegex.test(time)
}

export function timeStringToSeconds(time: string): number | null {
const timeParts = time.split(":").map(Number)

if (timeParts.length === 2) {
const [minutes, seconds] = timeParts
return minutes * 60 + seconds
} else if (timeParts.length === 3) {
const [hours, minutes, seconds] = timeParts
return hours * 3600 + minutes * 60 + seconds
} else {
return null
}
}
14 changes: 1 addition & 13 deletions src/renderer/src/lib/parse-html.ts
Original file line number Diff line number Diff line change
Expand Up @@ -21,11 +21,6 @@ export const parseHtml = async (
content: string,
options?: {
renderInlineStyle: boolean

/**
* parse first audio time in paragraph, e,g: 00:00
*/
parseParagraphAudioTime?: boolean
},
) => {
const file = new VFile(content)
Expand Down Expand Up @@ -88,14 +83,7 @@ export const parseHtml = async (
((item.properties as any).inline = true)
}
}
return createElement(
MarkdownP,
{
...props,
parseTimeline: options?.parseParagraphAudioTime,
},
props.children,
)
return createElement(MarkdownP, props, props.children)
},
hr: ({ node, ...props }) =>
createElement("hr", {
Expand Down
13 changes: 13 additions & 0 deletions src/renderer/src/modules/entry-content/hooks.tsx
Original file line number Diff line number Diff line change
@@ -0,0 +1,13 @@
import { useContext } from "react"

import { EntryContentContext } from "./provider"

export const useEntryContentContext = () => {
const ctx = useContext(EntryContentContext)
if (!ctx) {
throw new Error(
"useEntryContentContext must be used within EntryContentProvider",
)
}
return ctx
}
8 changes: 5 additions & 3 deletions src/renderer/src/modules/entry-content/index.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,10 +4,11 @@ import { m } from "@renderer/components/common/Motion"
import { Logo } from "@renderer/components/icons/logo"
import { AutoResizeHeight } from "@renderer/components/ui/auto-resize-height"
import { ScrollArea } from "@renderer/components/ui/scroll-area"
import { getRouteParams } from "@renderer/hooks/biz/useRouteParams"
import {
useRouteParamsSelector,
} from "@renderer/hooks/biz/useRouteParams"
import { useAuthQuery, useTitle } from "@renderer/hooks/common"
import { stopPropagation } from "@renderer/lib/dom"
import { FeedViewType } from "@renderer/lib/enum"
import { parseHtml } from "@renderer/lib/parse-html"
import type { ActiveEntryId } from "@renderer/models"
import {
Expand Down Expand Up @@ -71,7 +72,6 @@ function EntryContentRender({ entryId }: { entryId: string }) {
if (processContent) {
parseHtml(processContent, {
renderInlineStyle: readerRenderInlineStyle,
parseParagraphAudioTime: getRouteParams().view === FeedViewType.Audios,
}).then((parsed) => {
setContent(parsed.content)
})
Expand Down Expand Up @@ -118,6 +118,7 @@ function EntryContentRender({ entryId }: { entryId: string }) {
)

const readerFontFamily = useUISettingKey("readerFontFamily")
const view = useRouteParamsSelector((route) => route.view)

if (!entry) return null

Expand All @@ -126,6 +127,7 @@ function EntryContentRender({ entryId }: { entryId: string }) {
entryId={entry.entries.id}
feedId={entry.feedId}
audioSrc={entry.entries?.attachments?.[0].url}
view={view}
>
<EntryHeader
entryId={entry.entries.id}
Expand Down
21 changes: 7 additions & 14 deletions src/renderer/src/modules/entry-content/provider.tsx
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
import { createContext, useContext } from "react"
import type { FeedViewType } from "@renderer/lib/enum"
import { createContext } from "react"

interface ContextValue {
export interface EntryContentContext {
entryId: string
feedId: string

audioSrc?: string

view: FeedViewType
}
const EntryContentContext = createContext<ContextValue>(null!)
export const EntryContentContext = createContext<EntryContentContext>(null!)

export const EntryContentProvider: Component<ContextValue> = ({
export const EntryContentProvider: Component<EntryContentContext> = ({
children,
...value
}) => (
<EntryContentContext.Provider value={value}>
{children}
</EntryContentContext.Provider>
)

export const useEntryContentContext = () => {
const ctx = useContext(EntryContentContext)
if (!ctx) {
throw new Error(
"useEntryContentContext must be used within EntryContentProvider",
)
}
return ctx
}

0 comments on commit 1478fa5

Please sign in to comment.