248 lines
7.4 KiB
TypeScript
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;
|
|
};
|