import { toast } from "react-toastify"; export type TextToken = { type: 'text'; content: string; isWhitespace: boolean; isLineBreak?: boolean; }; export type BlankToken = { type: 'blank'; id: number; }; type Token = TextToken | BlankToken; export type BlankState = { id: number; position: number; }; export const getTextSegments = (text: string): Token[] => { const tokens: Token[] = []; let lastIndex = 0; const regex = /{{(\d+)}}/g; let match; const addTextTokens = (text: string) => { // Split by newlines first const lines = text.replaceAll("\\n",'\n').split(/(\n)/); lines.forEach((line, i) => { if (line === '\n') { tokens.push({ type: 'text', content: '
', isWhitespace: false, isLineBreak: true }); return; } const normalizedText = line.replace(/\s+/g, ' '); if (normalizedText) { const parts = normalizedText.split(/(\s)/); parts.forEach(part => { if (part) { tokens.push({ type: 'text', content: part, isWhitespace: /^\s+$/.test(part) }); } }); } }); }; while ((match = regex.exec(text)) !== null) { if (match.index > lastIndex) { addTextTokens(text.slice(lastIndex, match.index)); } tokens.push({ type: 'blank', id: parseInt(match[1]) }); lastIndex = regex.lastIndex; } if (lastIndex < text.length) { addTextTokens(text.slice(lastIndex)); } return tokens; } export const reconstructTextFromTokens = (tokens: Token[]): string => { return tokens.map(token => { if (token.type === 'blank') { return `{{${token.id}}}`; } if (token.type === 'text' && token.isLineBreak) { return '\n'; } return token.content; }).join(''); } export type BlanksState = { text: string; blanks: BlankState[]; selectedBlankId: number | null; draggedItemId: string | null; textMode: boolean; setEditing: React.Dispatch>; }; export type BlanksAction = | { type: "SET_TEXT"; payload: string } | { type: "SET_BLANKS"; payload: BlankState[] } | { type: "ADD_BLANK" } | { type: "REMOVE_BLANK"; payload: number } | { type: "SELECT_BLANK"; payload: number | null } | { type: "SET_DRAGGED_ITEM"; payload: string | null } | { type: "MOVE_BLANK"; payload: { blankId: number; newPosition: number } } | { type: "TOGGLE_EDIT_MODE" } | { type: "RESET", payload: { text: string } }; export const blanksReducer = (state: BlanksState, action: BlanksAction): BlanksState => { switch (action.type) { case "SET_TEXT": { return { ...state, text: action.payload, }; } case "SET_BLANKS": { return { ...state, blanks: action.payload, }; } case "ADD_BLANK": state.setEditing(true); const newBlankId = Math.max(...state.blanks.map(b => b.id), 0) + 1; const newBlanks = [ ...state.blanks, { id: newBlankId, position: state.blanks.length } ]; const newText = state.text + ` {{${newBlankId}}}`; return { ...state, blanks: newBlanks, text: newText }; case "REMOVE_BLANK": { if (state.blanks.length === 1) { toast.error("There needs to be at least 1 blank!"); break; } state.setEditing(true); const blanksToKeep = state.blanks.filter(b => b.id !== action.payload); const updatedBlanks = blanksToKeep.map((blank, index) => ({ ...blank, position: index })); const tokens = getTextSegments(state.text).filter( token => !(token.type === 'blank' && token.id === action.payload) ); const newText = reconstructTextFromTokens(tokens); return { ...state, blanks: updatedBlanks, text: newText, selectedBlankId: state.selectedBlankId === action.payload ? null : state.selectedBlankId }; } case "MOVE_BLANK": { state.setEditing(true); const { blankId, newPosition } = action.payload; const tokens = getTextSegments(state.text); // Find the current position of the blank const currentPosition = tokens.findIndex( token => token.type === 'blank' && token.id === blankId ); if (currentPosition === -1) return state; // Remove the blank and its surrounding whitespace const blankToken = tokens[currentPosition]; tokens.splice(currentPosition, 1); // When inserting at new position, ensure there's whitespace around the blank let insertPosition = newPosition; const prevToken = tokens[insertPosition - 1]; const nextToken = tokens[insertPosition]; // Insert space before if needed if (!prevToken || (prevToken.type === 'text' && !prevToken.isWhitespace)) { tokens.splice(insertPosition, 0, { type: 'text', content: ' ', isWhitespace: true }); insertPosition++; } // Insert the blank tokens.splice(insertPosition, 0, blankToken); insertPosition++; // Insert space after if needed if (!nextToken || (nextToken.type === 'text' && !nextToken.isWhitespace)) { tokens.splice(insertPosition, 0, { type: 'text', content: ' ', isWhitespace: true }); } // Reconstruct the text const newText = reconstructTextFromTokens(tokens); // Update blank positions const updatedBlanks = tokens.reduce((acc, token, idx) => { if (token.type === 'blank') { acc.push({ id: token.id, position: idx }); } return acc; }, [] as BlankState[]); return { ...state, text: newText, blanks: updatedBlanks }; } case "SELECT_BLANK": return { ...state, selectedBlankId: action.payload }; case "SET_DRAGGED_ITEM": state.setEditing(true); return { ...state, draggedItemId: action.payload }; case "TOGGLE_EDIT_MODE": return { ...state, textMode: !state.textMode }; case "RESET": return { text: action.payload.text || "", blanks: [], selectedBlankId: null, draggedItemId: null, textMode: false, setEditing: state.setEditing }; } return state; };