feat: add annotation feature with customizable brush color and width

- Added new translation keys for "Annotation", "Brush color", and "Brush width" in English and Chinese locale files.
- Implemented AnnotationDialog component for users to select brush color and width for annotations.
- Integrated AnnotationDialog into the Reader component, allowing users to toggle its visibility.
- Updated Redux store to manage the state of the annotation dialog and its visibility.
- Created styles for the annotation dialog to enhance user experience.
This commit is contained in:
troyeguo committed 2026-07-11 14:16:14 +08:00
1 parent dba683d03c
commit 36b8bf4fbc
14 files changed
+302 -10

No files matched your search

+1 -1
View File
File diff suppressed because one or more lines are too long.
+4 -1
View File
@@ -1233,5 +1233,8 @@
"Search highlight style": "Search highlight style",
"Customize the highlight style when searching in books": "Customize the highlight style when searching in books",
"Theme": "Theme",
"You are clicking too fast, please try again later": "You are clicking too fast, please try again later"
"You are clicking too fast, please try again later": "You are clicking too fast, please try again later",
"Annotation": "Annotation",
"Brush color": "Brush color",
"Brush width": "Brush width"
}
+3
View File
@@ -712,6 +712,9 @@
"GitHub repository": "GitHub 仓库",
"Speak the text": "朗读文字",
"Search on the Internet": "网上搜索",
"Annotation": "批注",
"Brush color": "笔画颜色",
"Brush width": "笔画粗细",
"Search in the Book": "全书搜索",
"Change storage location": "更改存储位置",
"Change location": "更改",
@@ -0,0 +1,87 @@
.annotation-dialog-container {
padding: 12px 16px 16px;
box-sizing: border-box;
}
.annotation-dialog-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 14px;
}
.annotation-dialog-title {
font-size: 15px;
font-weight: 600;
}
.annotation-dialog-close {
font-size: 18px;
cursor: pointer;
opacity: 0.6;
transition: opacity 0.15s;
}
.annotation-dialog-close:hover {
opacity: 1;
}
.annotation-dialog-section {
margin-bottom: 14px;
}
.annotation-dialog-section:last-child {
margin-bottom: 0;
}
.annotation-dialog-label {
font-size: 13px;
margin-bottom: 8px;
opacity: 0.75;
}
.annotation-color-list {
display: flex;
flex-wrap: wrap;
gap: 10px;
margin: 0;
padding: 0;
list-style: none;
}
.annotation-color-item {
width: 26px;
height: 26px;
border-radius: 50%;
cursor: pointer;
box-sizing: border-box;
border: 2px solid transparent;
transition: border-color 0.15s, transform 0.15s;
}
.annotation-color-item:hover {
transform: scale(1.1);
}
.active-annotation-color-item {
border-color: rgba(0, 0, 0, 0.4);
}
.annotation-width-list {
display: flex;
align-items: center;
gap: 14px;
margin: 0;
padding: 0;
list-style: none;
}
.annotation-width-item {
display: flex;
align-items: center;
justify-content: center;
width: 36px;
height: 36px;
border-radius: 8px;
cursor: pointer;
box-sizing: border-box;
border: 2px solid transparent;
transition: border-color 0.15s, background-color 0.15s;
}
.annotation-width-item:hover {
background-color: rgba(0, 0, 0, 0.05);
}
.active-annotation-width-item {
border-color: rgba(0, 0, 0, 0.4);
}
.annotation-width-preview {
display: block;
width: 26px;
}
@@ -0,0 +1,118 @@
import React from "react";
import { AnnotationDialogProps, AnnotationDialogState } from "./interface";
import "./annotationDialog.css";
import { ConfigService } from "../../../assets/lib/kookit-extra-browser.min";
const BRUSH_COLORS = [
"#E53131",
"#F08C00",
"#F5C400",
"#1FA861",
"#1B8CF0",
"#6C5CE7",
"#E54394",
"#2C2C2C",
];
const BRUSH_WIDTHS = [2, 4, 8, 14];
class AnnotationDialog extends React.Component<
AnnotationDialogProps,
AnnotationDialogState
> {
constructor(props: AnnotationDialogProps) {
super(props);
this.state = {
brushColor: ConfigService.getReaderConfig("brushColor") || BRUSH_COLORS[0],
brushWidth: parseFloat(
ConfigService.getReaderConfig("brushWidth") || BRUSH_WIDTHS[1] + ""
),
};
}
handleSelectColor = (color: string) => {
this.setState({ brushColor: color });
ConfigService.setReaderConfig("brushColor", color);
};
handleSelectWidth = (width: number) => {
this.setState({ brushWidth: width });
ConfigService.setReaderConfig("brushWidth", width + "");
};
handleClose = () => {
this.props.handleAnnotationDialog(false);
};
render() {
const { brushColor, brushWidth } = this.state;
return (
<div
className="sort-dialog-container annotation-dialog-container"
style={{
left: "auto",
top: "auto",
bottom: "60px",
width: "300px",
right: this.props.isSettingLocked ? 370 : 65,
}}
>
<div className="annotation-dialog-header">
<span className="annotation-dialog-title">
{this.props.t("Annotation")}
</span>
<span
className="icon-close annotation-dialog-close"
onClick={this.handleClose}
></span>
</div>
<div className="annotation-dialog-section">
<div className="annotation-dialog-label">
{this.props.t("Brush color")}
</div>
<ul className="annotation-color-list">
{BRUSH_COLORS.map((color) => (
<li
key={color}
className={
brushColor === color
? "annotation-color-item active-annotation-color-item"
: "annotation-color-item"
}
style={{ backgroundColor: color }}
onClick={() => this.handleSelectColor(color)}
></li>
))}
</ul>
</div>
<div className="annotation-dialog-section">
<div className="annotation-dialog-label">
{this.props.t("Brush width")}
</div>
<ul className="annotation-width-list">
{BRUSH_WIDTHS.map((width) => (
<li
key={width}
className={
brushWidth === width
? "annotation-width-item active-annotation-width-item"
: "annotation-width-item"
}
onClick={() => this.handleSelectWidth(width)}
>
<span
className="annotation-width-preview"
style={{
backgroundColor: brushColor,
height: width + "px",
borderRadius: (width / 2) + "px",
}}
></span>
</li>
))}
</ul>
</div>
</div>
);
}
}
export default AnnotationDialog;
@@ -0,0 +1,19 @@
import { connect } from "react-redux";
import { handleAnnotationDialog } from "../../../store/actions";
import { stateType } from "../../../store";
import AnnotationDialog from "./component";
import { withTranslation } from "react-i18next";
const mapStateToProps = (state: stateType) => {
return {
isAnnotationOpen: state.reader.isAnnotationOpen,
isSettingLocked: state.reader.isSettingLocked,
};
};
const actionCreator = {
handleAnnotationDialog,
};
export default connect(
mapStateToProps,
actionCreator
)(withTranslation()(AnnotationDialog as any) as any);
@@ -0,0 +1,11 @@
export interface AnnotationDialogProps {
isAnnotationOpen: boolean;
isSettingLocked: boolean;
handleAnnotationDialog: (isAnnotationOpen: boolean) => void;
t: (title: string) => string;
}
export interface AnnotationDialogState {
brushColor: string;
brushWidth: number;
}
+20
View File
@@ -19,6 +19,7 @@ import PdfCropDialog from "../../components/dialogs/pdfCropDialog";
import { isElectron } from "react-device-detect";
import SettingDialog from "../../components/dialogs/settingDialog";
import SpeechDialog from "../../components/dialogs/speechDialog";
import AnnotationDialog from "../../components/dialogs/annotationDialog";
import PopupOptionDialog from "../../components/dialogs/popupOptionDialog";
import {
updateDiscordPresence,
@@ -432,6 +433,24 @@ class Reader extends React.Component<ReaderProps, ReaderState> {
AI
</div>
)}
{!this.props.isHideAnnotationButton && (
<div
className="next-chapter-single-container"
onClick={() => {
this.props.handleAnnotationDialog(
!this.props.isAnnotationOpen
);
}}
style={{ position: "static", transform: "rotate(0deg)" }}
>
<span
style={
this.props.isAnnotationOpen ? { fontWeight: "bold" } : {}
}
className={`icon-${this.props.isAnnotationOpen ? "close" : "edit"} next-chapter-single`}
></span>
</div>
)}
</div>
<div
@@ -812,6 +831,7 @@ class Reader extends React.Component<ReaderProps, ReaderState> {
<SpeechDialog />
</div>
}
{this.props.isAnnotationOpen && <AnnotationDialog />}
</div>
);
}
+8 -8
View File
@@ -61,10 +61,10 @@
}
.left-panel {
width: 40px;
height: 60%;
height: 40%;
position: absolute;
left: 0px;
top: calc(50vh - 30%);
top: calc(50vh - 20%);
background-color: rgba(75, 75, 75, 0.3);
border-bottom-right-radius: 20px;
border-top-right-radius: 20px;
@@ -77,10 +77,10 @@
}
.right-panel {
width: 40px;
height: 60%;
height: 40%;
position: absolute;
right: 0px;
top: calc(50vh - 30%);
top: calc(50vh - 20%);
background-color: rgba(75, 75, 75, 0.3);
border-bottom-left-radius: 20px;
border-top-left-radius: 20px;
@@ -92,10 +92,10 @@
cursor: pointer;
}
.top-panel {
width: 60%;
width: 40%;
height: 40px;
position: absolute;
left: calc(50vw - 30%);
left: calc(50vw - 20%);
top: 0px;
background-color: rgba(75, 75, 75, 0.3);
border-bottom-left-radius: 20px;
@@ -108,10 +108,10 @@
cursor: pointer;
}
.bottom-panel {
width: 60%;
width: 40%;
height: 40px;
position: absolute;
left: calc(50vw - 30%);
left: calc(50vw - 20%);
bottom: 0px;
background-color: rgba(75, 75, 75, 0.3);
border-top-left-radius: 20px;
+4
View File
@@ -11,6 +11,7 @@ import {
handleConvertDialog,
handlePdfCropDialog,
handleSpeechDialog,
handleAnnotationDialog,
handleScale,
handleFetchAuthed,
handleFetchUserInfo,
@@ -30,6 +31,7 @@ const mapStateToProps = (state: stateType) => {
isConvertOpen: state.reader.isConvertOpen,
isPdfCropOpen: state.reader.isPdfCropOpen,
isSpeechOpen: state.reader.isSpeechOpen,
isAnnotationOpen: state.reader.isAnnotationOpen,
isOpenPopupOptionDialog: state.backupPage.isOpenPopupOptionDialog,
isSettingLocked: state.reader.isSettingLocked,
isAuthed: state.manager.isAuthed,
@@ -40,6 +42,7 @@ const mapStateToProps = (state: stateType) => {
isHidePageButton: state.reader.isHidePageButton,
isHideMenuButton: state.reader.isHideMenuButton,
isHideAudiobookButton: state.reader.isHideAudiobookButton,
isHideAnnotationButton: state.reader.isHideAnnotationButton,
isHideAIButton: state.reader.isHideAIButton,
isHidePDFConvertButton: state.reader.isHidePDFConvertButton,
isHideScaleButton: state.reader.isHideScaleButton,
@@ -60,6 +63,7 @@ const actionCreator = {
handleScale,
handleFetchAuthed,
handleSpeechDialog,
handleAnnotationDialog,
handleFetchUserInfo,
};
export default connect(
+3
View File
@@ -10,6 +10,7 @@ export interface ReaderProps {
isConvertOpen: boolean;
isPdfCropOpen: boolean;
isSpeechOpen: boolean;
isAnnotationOpen: boolean;
isOpenPopupOptionDialog: boolean;
isSearch: boolean;
isAuthed: boolean;
@@ -17,6 +18,7 @@ export interface ReaderProps {
isSettingOpen: boolean;
isHideMenuButton: boolean;
isHideAudiobookButton: boolean;
isHideAnnotationButton: boolean;
isHideAIButton: boolean;
isHidePDFConvertButton: boolean;
isHideScaleButton: boolean;
@@ -27,6 +29,7 @@ export interface ReaderProps {
handleConvertDialog: (isConvertOpen: boolean) => void;
handlePdfCropDialog: (isPdfCropOpen: boolean) => void;
handleSpeechDialog: (isSpeechOpen: boolean) => void;
handleAnnotationDialog: (isAnnotationOpen: boolean) => void;
handleMenuMode: (menuMode: string) => void;
handleOriginalText: (originalText: string) => void;
handleFetchBooks: () => void;
+9
View File
@@ -35,6 +35,9 @@ export function handlePdfCropDialog(isPdfCropOpen: boolean) {
export function handleSpeechDialog(isSpeechOpen: boolean) {
return { type: "HANDLE_SPEECH_DIALOG", payload: isSpeechOpen };
}
export function handleAnnotationDialog(isAnnotationOpen: boolean) {
return { type: "HANDLE_ANNOTATION_DIALOG", payload: isAnnotationOpen };
}
export function handleSpeechStartText(speechStartText: string) {
return { type: "HANDLE_SPEECH_START_TEXT", payload: speechStartText };
}
@@ -107,6 +110,12 @@ export function handleHideAudiobookButton(isHideAudiobookButton: boolean) {
payload: isHideAudiobookButton,
};
}
export function handleHideAnnotationButton(isHideAnnotationButton: boolean) {
return {
type: "HANDLE_HIDE_ANNOTATION_BUTTON",
payload: isHideAnnotationButton,
};
}
export function handleHideAIButton(isHideAIButton: boolean) {
return { type: "HANDLE_HIDE_AI_BUTTON", payload: isHideAIButton };
}
+2
View File
@@ -107,12 +107,14 @@ export type stateType = {
isConvertOpen: boolean;
isPdfCropOpen: boolean;
isSpeechOpen: boolean;
isAnnotationOpen: boolean;
isHideFooter: boolean;
isHideHeader: boolean;
isHideBackground: boolean;
isHidePageButton: boolean;
isHideMenuButton: boolean;
isHideAudiobookButton: boolean;
isHideAnnotationButton: boolean;
isHideAIButton: boolean;
isHideScaleButton: boolean;
isHidePDFConvertButton: boolean;
+13
View File
@@ -30,6 +30,7 @@ const initState = {
isConvertOpen: false,
isPdfCropOpen: false,
isSpeechOpen: false,
isAnnotationOpen: false,
speechStartText: "",
isSpeechAutoStart: false,
isNavLocked: ConfigService.getReaderConfig("isNavLocked") === "yes",
@@ -41,6 +42,8 @@ const initState = {
isHideMenuButton: ConfigService.getReaderConfig("isHideMenuButton") === "yes",
isHideAudiobookButton:
ConfigService.getReaderConfig("isHideAudiobookButton") === "yes",
isHideAnnotationButton:
ConfigService.getReaderConfig("isHideAnnotationButton") === "yes",
isHideAIButton: ConfigService.getReaderConfig("isHideAIButton") === "yes",
isHideScaleButton:
ConfigService.getReaderConfig("isHideScaleButton") === "yes",
@@ -93,6 +96,11 @@ export function reader(
...state,
isSpeechOpen: action.payload,
};
case "HANDLE_ANNOTATION_DIALOG":
return {
...state,
isAnnotationOpen: action.payload,
};
case "HANDLE_SPEECH_START_TEXT":
return {
...state,
@@ -163,6 +171,11 @@ export function reader(
...state,
isHideAudiobookButton: action.payload,
};
case "HANDLE_HIDE_ANNOTATION_BUTTON":
return {
...state,
isHideAnnotationButton: action.payload,
};
case "HANDLE_HIDE_AI_BUTTON":
return {
...state,