Files
encoach_frontend/src/components/ExamEditor/Exercises/Blanks/BlanksReducer.tsx
2024-11-06 09:23:34 +00:00

248 lines
7.4 KiB
TypeScript

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: '<br>',
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<React.SetStateAction<boolean>>;
};
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;
};