Feat: make notes on caption (#544)

* add segment model

* add note model

* db handle segment & note

* add notes & segments handler

* refactor media caption components

* segment & note create

* fix type

* update note column & may sync

* display selected words for note

* refactor selected words

* auto select words when editing note

* refactor

* refactor caption component

* display notes

* refactor notes components

* fix

* refactor segment & notes into context

* destroy note

* update locale

* fix caption switch issue

* fix layout

* refactor caption layout

* remove deprecated code

* may share note

* improve UI

* fix notes list auto update after created

* remove console.log

* add notes page

* refactor note parameters

* refactor components

* mark note on transcription

* handle no notes

* improve style

* improve style

* show context menu on selection text

* fix utils
This commit is contained in:
an-lee
2024-04-26 15:05:36 +08:00
committed by GitHub
parent 5740b2635c
commit 0644c3bbd7
58 changed files with 2586 additions and 677 deletions

View File

@@ -0,0 +1,4 @@
export * from './note-card';
export * from './note-form';
export * from './note-segment';
export * from './note-segment-group';

View File

@@ -0,0 +1,165 @@
import { AppSettingsProviderContext } from "@renderer/context";
import { useContext, useState } from "react";
import {
AlertDialog,
AlertDialogAction,
AlertDialogContent,
AlertDialogCancel,
AlertDialogDescription,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogTitle,
Button,
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuTrigger,
toast,
} from "@renderer/components/ui";
import { MoreHorizontalIcon } from "lucide-react";
import Markdown from "react-markdown";
import { t } from "i18next";
export const NoteCard = (props: {
note: NoteType;
onEdit?: (note: NoteType) => void;
}) => {
if (props.note.targetType === "Segment") {
return <SegmentNoteCard {...props} />;
}
};
export const SegmentNoteCard = (props: {
note: NoteType;
onEdit?: (note: NoteType) => void;
}) => {
const { note } = props;
return (
<div
id={`note-${note.id}`}
className="w-full rounded px-4 py-2 bg-muted/50"
>
<Markdown className="select-text prose prose-sm dark:prose-invert max-w-full mb-2">
{note.content}
</Markdown>
<div className="flex justify-between space-x-2">
{note.parameters?.quote ? (
<div className="flex">
<span className="text-muted-foreground text-sm px-1 border-b border-red-500 border-dashed">
{note.parameters.quote}
</span>
</div>
) : (
<div></div>
)}
<NoteActionsDropdownMenu {...props} />
</div>
</div>
);
};
const NoteActionsDropdownMenu = (props: {
note: NoteType;
onEdit?: (note: NoteType) => void;
}) => {
const { EnjoyApp, webApi } = useContext(AppSettingsProviderContext);
const { note, onEdit } = props;
const [deleting, setDeleting] = useState(false);
const [sharing, setSharing] = useState(false);
const handleDelete = () => {
EnjoyApp.notes.delete(note.id);
};
const handleShare = async () => {
try {
if (
note.segment &&
(!note.segment.syncedAt || !note.segment.uploadedAt)
) {
await EnjoyApp.segments.sync(note.segment.id);
}
if (!note.syncedAt) {
await EnjoyApp.notes.sync(note.id);
}
} catch (e) {
toast.error(t("shareFailed"), { description: e.message });
}
webApi
.createPost({
targetId: note.id,
targetType: "Note",
})
.then(() => {
toast.success(t("sharedSuccessfully"));
})
.catch((e) => {
toast.error(t("shareFailed"), { description: e.message });
});
};
return (
<>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="ghost" className="w-4 h-4 p-0">
<MoreHorizontalIcon className="w-4 h-4 text-muted-foreground" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent>
{onEdit && (
<DropdownMenuItem onClick={() => onEdit(note)}>
{t("edit")}
</DropdownMenuItem>
)}
<DropdownMenuItem onClick={() => setSharing(true)}>
{t("share")}
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setDeleting(true)}>
{t("delete")}
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
<AlertDialog open={sharing} onOpenChange={(value) => setSharing(value)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("shareNote")}</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSureToShareThisNoteToCommunity")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={handleShare}>{t("share")}</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<AlertDialog open={deleting} onOpenChange={(value) => setDeleting(value)}>
<AlertDialogContent>
<AlertDialogHeader>
<AlertDialogTitle>{t("deleteNote")}</AlertDialogTitle>
<AlertDialogDescription>
{t("areYouSureToDeleteThisNote")}
</AlertDialogDescription>
</AlertDialogHeader>
<AlertDialogFooter>
<AlertDialogCancel>{t("cancel")}</AlertDialogCancel>
<AlertDialogAction asChild>
<Button onClick={handleDelete}>{t("delete")}</Button>
</AlertDialogAction>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
</>
);
};

View File

@@ -0,0 +1,110 @@
import { AppSettingsProviderContext } from "@renderer/context";
import { useContext, useEffect, useRef, useState } from "react";
import { Button, Textarea, toast } from "@renderer/components/ui";
import { t } from "i18next";
export const NoteForm = (props: {
segment: SegmentType;
note?: NoteType;
parameters: { quoteIndices: number[]; quote: string };
onParametersChange?: (parameters: any) => void;
onCancel?: () => void;
onSave?: (note: NoteType) => void;
}) => {
const { segment, note, parameters, onParametersChange, onCancel, onSave } =
props;
const [content, setContent] = useState<string>(note?.content ?? "");
const { EnjoyApp } = useContext(AppSettingsProviderContext);
const inputRef = useRef<HTMLTextAreaElement>(null);
const resizeTextarea = () => {
if (!inputRef.current) return;
inputRef.current.style.height = "auto";
inputRef.current.style.height = `${inputRef.current.scrollHeight}px`;
};
const handleSubmit = () => {
if (!content) return;
if (note) {
EnjoyApp.notes
.update(note.id, {
content,
parameters,
})
.then((note) => {
onSave && onSave(note);
})
.catch((err) => {
toast.error(err.message);
});
} else {
EnjoyApp.notes
.create({
targetId: segment.id,
targetType: "Segment",
parameters,
content,
})
.then((note) => {
onSave && onSave(note);
setContent("");
})
.catch((err) => {
toast.error(err.message);
});
}
};
useEffect(() => {
resizeTextarea();
}, [content]);
useEffect(() => {
if (!note) return;
if (note.parameters === parameters) return;
onParametersChange && onParametersChange(note.parameters);
}, [note]);
return (
<div className="w-full">
<div className="mb-2">
<Textarea
ref={inputRef}
className="w-full"
value={content}
placeholder={t("writeNoteHere")}
onChange={(e) => setContent(e.target.value)}
/>
</div>
<div className="flex items-center justify-between">
{parameters.quoteIndices?.length > 0 ? (
<div className="flex space-x-2">
<span className="text-sm px-1 rounded text-muted-foreground border-b border-red-500 border-dashed">
{parameters.quoteIndices
.map(
(index: number) => segment?.caption?.timeline?.[index]?.text
)
.join(" ")}
</span>
</div>
) : (
<div></div>
)}
<div className="flex space-x-2">
{note && (
<Button variant="secondary" size="sm" onClick={onCancel}>
{t("cancel")}
</Button>
)}
<Button disabled={!content} size="sm" onClick={handleSubmit}>
{t("save")}
</Button>
</div>
</div>
</div>
);
};

View File

@@ -0,0 +1,106 @@
import {
AudioLinesIcon,
ChevronDownIcon,
ChevronUpIcon,
VideoIcon,
} from "lucide-react";
import { Button, Separator } from "@renderer/components/ui";
import { t } from "i18next";
import { Link } from "react-router-dom";
import { useState } from "react";
import { useNotes } from "@/renderer/hooks";
import { NoteCard } from "./note-card";
import { NoteSemgent } from "./note-segment";
export const NoteSegmentGroup = (props: {
count: number;
segment: SegmentType;
}) => {
const { count, segment } = props;
const [collapsed, setCollapsed] = useState<boolean>(true);
const { notes, findNotes, hasMore } = useNotes({
targetId: segment.id,
targetType: "Segment",
});
return (
<div
className={`bg-background p-4 rounded-lg border transition-[shadow] ${
collapsed ? "" : "shadow-lg"
}`}
>
<div className="flex items-center space-x-4">
<div className="flex-1">
<div className="select-text line-clamp-3 text-muted-foreground font-serif pl-3 border-l-4 mb-4">
{segment.caption.text}
</div>
<div className="font-mono text-lg mb-4">
{t("notesCount", { count })}
</div>
<div className="flex justify-start text-sm text-muted-foreground">
<Link
to={`/${segment.targetType.toLowerCase()}s/${
segment.targetId
}?segmentIndex=${segment.segmentIndex}`}
>
{t("source")}: {t(segment.targetType.toLowerCase())}
</Link>
</div>
</div>
<div className="w-24 h-24 flex">
{segment.targetType === "Audio" && (
<AudioLinesIcon className="object-cover m-auto w-5/6 h-5/6 text-muted-foreground" />
)}
{segment.targetType === "Video" && (
<VideoIcon className="object-cover m-auto w-5/6 h-5/6 text-muted-foreground" />
)}
</div>
</div>
<div
className={`overflow-hidden transition-[height] ease-in-out duration-500 ${
collapsed ? "h-0" : "h-auto"
}`}
>
<Separator className="my-4" />
<div className="mb-4">
<NoteSemgent segment={segment} notes={notes} />
</div>
<div className="grid gap-2 mb-2">
{notes.map((note) => (
<NoteCard key={note.id} note={note} />
))}
</div>
{hasMore && (
<div className="flex justify-center mb-2">
<Button
onClick={() => findNotes({ offset: notes.length })}
variant="link"
size="sm"
>
{t("loadMore")}
</Button>
</div>
)}
</div>
<div className="flex items-center justify-center">
<Button
onClick={() => setCollapsed(!collapsed)}
variant="ghost"
size="icon"
className="p-0 w-6 h-6"
>
{collapsed ? (
<ChevronDownIcon className="w-5 h-5" />
) : (
<ChevronUpIcon className="w-5 h-5" />
)}
</Button>
</div>
</div>
);
};

View File

@@ -0,0 +1,80 @@
import { TimelineEntry } from "echogarden/dist/utilities/Timeline";
import { useState } from "react";
import { WavesurferPlayer } from "@renderer/components/widgets";
export const NoteSemgent = (props: {
segment: SegmentType;
notes: NoteType[];
}) => {
const { segment, notes } = props;
const caption: TimelineEntry = segment.caption;
const [notedquoteIndices, setNotedquoteIndices] = useState<number[]>([]);
let words = caption.text.split(" ");
const ipas = caption.timeline.map((w) =>
w.timeline.map((t) => t.timeline.map((s) => s.text))
);
if (words.length !== caption.timeline.length) {
words = caption.timeline.map((w) => w.text);
}
return (
<>
<div className="flex flex-wrap p-2 rounded-t-lg bg-muted/50 mb-4">
{/* use the words splitted by caption text if it is matched with the timeline length, otherwise use the timeline */}
{words.map((word, index) => (
<div
className=""
key={`note-segment-${segment.id}-${index}`}
id={`note-segment-${segment.id}-${index}`}
>
<div
className={`select-text font-serif text-lg xl:text-xl 2xl:text-2xl p-1 ${
notedquoteIndices.includes(index)
? "border-b border-red-500 border-dashed"
: ""
}
`}
>
{word}
</div>
<div
className={`select-text text-sm 2xl:text-base text-muted-foreground font-code mb-1 ${
index === 0 ? "before:content-['/']" : ""
} ${
index === caption.timeline.length - 1
? "after:content-['/']"
: ""
}`}
>
{ipas[index]}
</div>
{notes
.filter((note) => note.parameters.quoteIndices?.[0] === index)
.map((note) => (
<div
key={`note-${segment.id}-${note.id}`}
className="mb-1 text-xs 2xl:text-sm text-red-500 max-w-64 line-clamp-3 font-code cursor-pointer"
onMouseOver={() =>
setNotedquoteIndices(note.parameters.quoteIndices)
}
onMouseLeave={() => setNotedquoteIndices([])}
onClick={() =>
document.getElementById("note-" + note.id)?.scrollIntoView()
}
>
{note.parameters.quoteIndices[0] === index && note.content}
</div>
))}
</div>
))}
</div>
{segment.src && <WavesurferPlayer id={segment.id} src={segment.src} />}
</>
);
};