feat: implement editor state management with useReducer

This commit is contained in:
Kirill Siukhin 2025-07-05 17:31:01 +05:00
parent c0499a5c7a
commit c353f48259
5 changed files with 223 additions and 47 deletions

View File

@ -1,22 +1,9 @@
"use client";
import { Plus } from "lucide-react";
import { ChangeEvent } from "react";
import LineInputGroup from "@/components/LineInputGroup";
import IconOnlyButton from "@/components/IconOnlyButton";
import Editor from "@/components/Editor";
export default function Home() {
const saveChanges = (e: ChangeEvent) => {
// TODO
};
return (
<div className="flex flex-col items-center">
<div className="flex flex-col items-center px-4 max-w-2xl w-full gap-4">
<input className="font-bold text-xl w-full text-center focus:outline-none" onChange={saveChanges} defaultValue="Song" />
<LineInputGroup size={4} onChange={saveChanges} />
<IconOnlyButton icon={<Plus size={24} />} />
</div>
<Editor />
</div>
);
}

73
src/components/Block.tsx Normal file
View File

@ -0,0 +1,73 @@
"use client";
import { Lock, LockOpen, Menu, Minus, Plus, X } from "lucide-react";
import { Action, IBlock, ILine } from "@/lib/editorReducer";
import LineInput from "./LineInput";
import IconOnlyButton from "./IconOnlyButton";
import { ChangeEvent } from "react";
export default function Block({
block,
dispatch,
}: {
block: IBlock;
dispatch: React.Dispatch<Action>;
}) {
const handleAddLine = () => {
dispatch({ type: "add_line", blockId: block.id });
}
const handleDeleteLine = () => {
dispatch({ type: "delete_line", blockId: block.id });
}
const handleTagUpdate = (e: ChangeEvent<HTMLInputElement>) => {
dispatch({ type: "update_tag", blockId: block.id, tag: e.target.value });
}
const handleLineUpdate = (e: ChangeEvent<HTMLInputElement>, line: ILine) => {
dispatch({ type: "update_line_text", blockId: block.id, lineId: line.id, text: e.target.value });
}
const handleDeleteBlock = () => {
dispatch({ type: "delete_block", blockId: block.id });
}
const handleToggleLock = () => {
dispatch({ type: "toggle_lock", blockId: block.id });
}
return (
<div className="flex flex-col gap-2 w-full">
<div className="border-2 border-neutral-800 rounded-lg p-3 w-full">
<input
type="text"
placeholder="enter tag..."
className="w-full focus:outline-none"
value={block.tag}
disabled={block.locked}
onChange={handleTagUpdate}
/>
{block.lines.map((line) => (
<LineInput
key={line.id}
value={line.text}
disabled={block.locked}
onChange={(e) => handleLineUpdate(e, line)}
/>
))}
<div className="flex items-center mx-2 mt-2">
<div className="flex gap-2">
<IconOnlyButton onClick={handleAddLine} icon={<Plus size={18} />} />
<IconOnlyButton onClick={handleDeleteLine} icon={<Minus size={18} />} />
</div>
<div className="flex gap-2 ml-auto">
<IconOnlyButton onClick={handleDeleteBlock} icon={<X size={18} />} />
<IconOnlyButton onClick={handleToggleLock} icon={block.locked ? <Lock size={18} /> : <LockOpen size={18} />} alwaysOn={block.locked} />
<IconOnlyButton icon={<Menu size={18} />} />
</div>
</div>
</div>
</div>
);
}

33
src/components/Editor.tsx Normal file
View File

@ -0,0 +1,33 @@
"use client";
import { useReducer } from "react";
import { editorReducer } from "@/lib/editorReducer";
import Block from "./Block";
import IconOnlyButton from "./IconOnlyButton";
import { Plus } from "lucide-react";
export default function Editor() {
const [state, dispatch] = useReducer(editorReducer, [
{
id: crypto.randomUUID(),
tag: "",
locked: false,
lines: Array.from({ length: 4 }, () => ({
id: crypto.randomUUID(),
text: "",
})),
}
]);
const handleAddBlock = () => {
dispatch({ type: "add_block" });
}
return (
<div className="flex flex-col items-center px-4 max-w-2xl w-full gap-4">
<input className="font-bold text-xl w-full text-center focus:outline-none" defaultValue="Song" />
{state.map((block) => <Block key={block.id} block={block} dispatch={dispatch} /> )}
<IconOnlyButton onClick={handleAddBlock} icon={<Plus size={24} />} />
</div>
);
}

View File

@ -1,32 +0,0 @@
"use client";
import { Lock, LockOpen, Menu, X } from "lucide-react";
import LineInput from "./LineInput";
import IconOnlyButton from "./IconOnlyButton";
import { ChangeEvent, useState } from "react";
export default function LineInputGroup({
size = 4,
onChange,
}: {
size: number;
onChange: (e: ChangeEvent) => void;
}) {
const [locked, setLocked] = useState(false);
const switchLock = () => setLocked((locked) => !locked);
return (
<div className="flex gap-2 w-full">
<div className="border-2 border-neutral-800 rounded-lg p-3 w-full">
<input type="text" placeholder="enter tag..." className="w-full focus:outline-none" disabled={locked} />
{[...Array(size)].map((_, i) => <LineInput key={i} onChange={onChange} disabled={locked} />)}
</div>
<div className="flex flex-col gap-3">
<IconOnlyButton icon={<X size={18} />} />
<IconOnlyButton alwaysOn={locked} icon={locked ? <Lock size={18} /> : <LockOpen size={18} />} onClick={switchLock} />
<IconOnlyButton icon={<Menu size={18} />} />
</div>
</div>
);
}

115
src/lib/editorReducer.ts Normal file
View File

@ -0,0 +1,115 @@
export type ILine = {
id: string;
text: string;
};
export type IBlock = {
id: string;
tag: string;
locked: boolean;
lines: ILine[];
};
type EditorState = IBlock[];
export type Action =
| { type: "add_block" }
| { type: "delete_block", blockId: string }
| { type: "add_line", blockId: string }
| { type: "delete_line", blockId: string }
| { type: "update_line_text"; blockId: string; lineId: string; text: string }
| { type: "update_tag"; blockId: string; tag: string }
| { type: "toggle_lock", blockId: string };
export function editorReducer(state: EditorState, action: Action): EditorState {
switch (action.type) {
case "add_block":
return [
...state,
{
id: crypto.randomUUID(),
tag: "",
locked: false,
lines: Array.from({ length: 4 }, () => ({
id: crypto.randomUUID(),
text: "",
})),
}
];
case "delete_block":
return state.filter((block) => block.id !== action.blockId);
case "add_line":
return state.map((block) => {
if (block.id === action.blockId) {
return {
...block,
lines: [...block.lines, { id: crypto.randomUUID(), text: "" }],
};
} else {
return block;
}
});
case "delete_line":
return state.map((block) => {
if (block.id === action.blockId && block.lines.length > 0) {
return {
...block,
lines: block.lines.slice(0, -1),
};
} else {
return block;
}
});
case "update_line_text":
return state.map((block) => {
if (block.id === action.blockId) {
return {
...block,
lines: block.lines.map((line) => {
if (line.id === action.lineId) {
return {
...line,
text: action.text,
};
} else {
return line;
}
}),
};
} else {
return block;
}
});
case "update_tag":
return state.map((block) => {
if (block.id === action.blockId) {
return {
...block,
tag: action.tag,
};
} else {
return block;
}
});
case "toggle_lock":
return state.map((block) => {
if (block.id === action.blockId) {
return {
...block,
locked: !block.locked,
};
} else {
return block;
}
});
default:
return state;
}
}