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:
4
enjoy/src/renderer/components/notes/index.ts
Normal file
4
enjoy/src/renderer/components/notes/index.ts
Normal file
@@ -0,0 +1,4 @@
|
||||
export * from './note-card';
|
||||
export * from './note-form';
|
||||
export * from './note-segment';
|
||||
export * from './note-segment-group';
|
||||
165
enjoy/src/renderer/components/notes/note-card.tsx
Normal file
165
enjoy/src/renderer/components/notes/note-card.tsx
Normal 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>
|
||||
</>
|
||||
);
|
||||
};
|
||||
110
enjoy/src/renderer/components/notes/note-form.tsx
Normal file
110
enjoy/src/renderer/components/notes/note-form.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
106
enjoy/src/renderer/components/notes/note-segment-group.tsx
Normal file
106
enjoy/src/renderer/components/notes/note-segment-group.tsx
Normal 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>
|
||||
);
|
||||
};
|
||||
80
enjoy/src/renderer/components/notes/note-segment.tsx
Normal file
80
enjoy/src/renderer/components/notes/note-segment.tsx
Normal 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} />}
|
||||
</>
|
||||
);
|
||||
};
|
||||
Reference in New Issue
Block a user