diff --git a/backend/configs/development.yaml b/backend/configs/development.yaml
index cf3e32013..7ff3744d2 100644
--- a/backend/configs/development.yaml
+++ b/backend/configs/development.yaml
@@ -139,6 +139,9 @@ promptembedder:
max_seq_length: 2048
batch_size: 8
+chunking:
+ characters_per_page: 2000
+
project_metadata:
text_language:
key: "language"
diff --git a/backend/configs/production.yaml b/backend/configs/production.yaml
index 6a1290abf..a917767ae 100644
--- a/backend/configs/production.yaml
+++ b/backend/configs/production.yaml
@@ -139,6 +139,9 @@ promptembedder:
max_seq_length: 2048
batch_size: 8
+chunking:
+ characters_per_page: 2000
+
project_metadata:
text_language:
key: "language"
diff --git a/backend/src/core/memo/memo_endpoint.py b/backend/src/core/memo/memo_endpoint.py
index ae7227533..5341bc9ac 100644
--- a/backend/src/core/memo/memo_endpoint.py
+++ b/backend/src/core/memo/memo_endpoint.py
@@ -186,9 +186,11 @@ def delete_by_id(
) -> MemoRead:
authz_user.assert_in_same_project_as(Crud.MEMO, memo_id)
- memo = crud_memo.delete(db=db, id=memo_id)
+ memo = crud_memo.read(db=db, id=memo_id)
+ memo_read = crud_memo.get_memo_read_dto_from_orm(db, memo)
+ crud_memo.delete(db=db, id=memo_id)
- return crud_memo.get_memo_read_dto_from_orm(db, memo)
+ return memo_read
@router.get(
diff --git a/backend/src/modules/doc_processing/entrypoints/doc_chunking_job.py b/backend/src/modules/doc_processing/entrypoints/doc_chunking_job.py
index 7b01becd7..3f44edec8 100644
--- a/backend/src/modules/doc_processing/entrypoints/doc_chunking_job.py
+++ b/backend/src/modules/doc_processing/entrypoints/doc_chunking_job.py
@@ -5,9 +5,14 @@
from loguru import logger
from common.job_type import JobType
+from config import conf
from core.doc.folder_crud import crud_folder
from core.doc.folder_dto import FolderCreate, FolderType
from modules.doc_processing.doc_processing_dto import ProcessingJobInput
+from modules.doc_processing.entrypoints.html_chunking_utils import (
+ split_html_into_chunks,
+)
+from modules.doc_processing.entrypoints.txt_chunking_utils import split_text_into_chunks
from repos.db.sql_repo import SQLRepo
from repos.filesystem_repo import (
FileAlreadyExistsInFilesystemError,
@@ -18,6 +23,8 @@
from systems.job_system.job_dto import Job, JobOutputBase
from systems.job_system.job_register_decorator import register_job
+CHARACTERS_PER_PAGE = conf.chunking.characters_per_page
+
sqlr = SQLRepo()
fsr = FilesystemRepo()
ray = RayRepo()
@@ -77,6 +84,33 @@ def handle_pdf_chunking_job(
return DocChunkingJobOutput(files=chunks, folder_id=folder_id)
+def _prepare_chunk_output_path(project_id: int, output_path: Path) -> None:
+ """
+ Prepare the output path for a chunk file by removing any existing file.
+
+ Args:
+ project_id: The project ID for filesystem operations.
+ output_path: The path where the chunk will be saved.
+
+ Raises:
+ FileAlreadyExistsInFilesystemError: If the file exists and cannot be removed
+ because a SourceDocument with that filename exists in the DB.
+ """
+ if output_path.exists():
+ try:
+ fsr._safe_remove_file_from_project_dir(
+ proj_id=project_id, filename=output_path.name
+ )
+ except FileDeletionNotAllowedError:
+ logger.warning(
+ f"File {output_path.name} already exists in Project {project_id} "
+ "and a SourceDocument with that filename exists in the DB. Cannot overwrite it!"
+ )
+ raise FileAlreadyExistsInFilesystemError(
+ proj_id=project_id, filename=output_path.name
+ )
+
+
def chunk_pdf(payload: DocChunkingJobInput) -> list[Path]:
try:
src = fitz.open(str(payload.filepath)) # type: ignore
@@ -117,20 +151,9 @@ def chunk_pdf(payload: DocChunkingJobInput) -> list[Path]:
new_pdf = fitz.open() # type: ignore
new_pdf.insert_pdf(src, from_page=start_page - 1, to_page=end_page - 1)
- # If the output file already exists, we try to remove it from the project filesystem
- if output_fn.exists():
- try:
- fsr._safe_remove_file_from_project_dir(
- proj_id=payload.project_id, filename=output_fn.name
- )
- except FileDeletionNotAllowedError:
- logger.warning(
- f"File {output_fn.name} already exists in Project {payload.project_id} and a SourceDocument with that filename"
- " exists in the DB. Cannot overwrite it!"
- )
- raise FileAlreadyExistsInFilesystemError(
- proj_id=payload.project_id, filename=output_fn.name
- )
+ # Prepare output path (remove existing file if necessary)
+ _prepare_chunk_output_path(payload.project_id, output_fn)
+
# Save the chunk to disk
new_pdf.save(str(output_fn))
new_pdf.close()
@@ -145,8 +168,82 @@ def chunk_pdf(payload: DocChunkingJobInput) -> list[Path]:
def chunk_txt(payload: DocChunkingJobInput) -> list[Path]:
- logger.info("txt chunking not implemented")
- return [payload.filepath]
+ """
+ Chunk a text file into smaller files based on character limits.
+
+ The function attempts to split text at line breaks to preserve document structure.
+ If no line breaks exist, it falls back to hard character splitting.
+
+ Args:
+ payload: The job input containing the filepath and chunking settings.
+
+ Returns:
+ A list of paths to the chunked files. If no chunking is needed,
+ returns a list containing only the original file path.
+ """
+ characters_per_chunk = CHARACTERS_PER_PAGE * payload.settings.pages_per_chunk
+
+ # Read the text content
+ try:
+ text = payload.filepath.read_text(encoding="utf-8")
+ except Exception as e:
+ msg = f"Error reading text file {payload.filepath.name}: {e}"
+ logger.error(msg)
+ raise RuntimeError(msg)
+
+ # Check if chunking is needed
+ if len(text) <= characters_per_chunk:
+ logger.info(
+ f"Text file {payload.filepath.name} has {len(text)} characters; "
+ f"no split needed (limit: {characters_per_chunk})."
+ )
+ return [payload.filepath]
+
+ # Split text into chunks
+ text_chunks = split_text_into_chunks(text, characters_per_chunk)
+
+ # If splitting resulted in only one chunk, no need to save
+ if len(text_chunks) == 1:
+ logger.info(
+ f"Text file {payload.filepath.name} could not be split further; "
+ "returning original file."
+ )
+ return [payload.filepath]
+
+ # Calculate total "pages" and digits needed for zero-padding
+ total_chunks = len(text_chunks)
+ total_pages = total_chunks * payload.settings.pages_per_chunk
+ total_digits = len(str(total_pages))
+
+ # Save chunks to disk
+ out_dir = payload.filepath.parent
+ logger.info(
+ f"Splitting text file {payload.filepath.name} into {total_chunks} chunks of "
+ f"up to {characters_per_chunk} characters each. Output will be saved in {out_dir}."
+ )
+
+ chunks: list[Path] = []
+ for i, chunk_text in enumerate(text_chunks):
+ # Calculate page range for this chunk
+ start_page = i * payload.settings.pages_per_chunk + 1
+ end_page = (i + 1) * payload.settings.pages_per_chunk
+ page_range_str = f"{start_page:0{total_digits}}-{end_page:0{total_digits}}"
+ output_fn = out_dir / f"{payload.filepath.stem}_pages_{page_range_str}.txt"
+
+ try:
+ # Prepare output path (remove existing file if necessary)
+ _prepare_chunk_output_path(payload.project_id, output_fn)
+
+ # Save the chunk to disk
+ output_fn.write_text(chunk_text, encoding="utf-8")
+ chunks.append(output_fn)
+ logger.debug(f"Stored chunk '{output_fn}'")
+
+ except Exception as e:
+ msg = f"Skipping due to error creating chunk (pages {page_range_str}) for text file {payload.filepath.name}: {e}"
+ logger.error(msg)
+
+ return chunks
def chunk_word(payload: DocChunkingJobInput) -> list[Path]:
@@ -155,5 +252,78 @@ def chunk_word(payload: DocChunkingJobInput) -> list[Path]:
def chunk_html(payload: DocChunkingJobInput) -> list[Path]:
- logger.info("html chunking not implemented")
- return [payload.filepath]
+ """
+ Chunk an HTML file into smaller files based on character limits.
+
+ The function splits HTML at element boundaries to preserve document structure
+ and ensures each chunk is valid HTML by properly opening/closing tags.
+
+ Args:
+ payload: The job input containing the filepath and chunking settings.
+
+ Returns:
+ A list of paths to the chunked files. If no chunking is needed,
+ returns a list containing only the original file path.
+ """
+ characters_per_chunk = CHARACTERS_PER_PAGE * payload.settings.pages_per_chunk
+
+ # Read the HTML content
+ try:
+ html_content = payload.filepath.read_text(encoding="utf-8")
+ except Exception as e:
+ msg = f"Error reading HTML file {payload.filepath.name}: {e}"
+ logger.error(msg)
+ raise RuntimeError(msg)
+
+ # Check if chunking is needed
+ if len(html_content) <= characters_per_chunk:
+ logger.info(
+ f"HTML file {payload.filepath.name} has {len(html_content)} characters; "
+ f"no split needed (limit: {characters_per_chunk})."
+ )
+ return [payload.filepath]
+
+ # Split HTML into chunks
+ html_chunks = split_html_into_chunks(html_content, characters_per_chunk)
+
+ # If splitting resulted in only one chunk, no need to save
+ if len(html_chunks) == 1:
+ logger.info(
+ f"HTML file {payload.filepath.name} could not be split further; "
+ "returning original file."
+ )
+ return [payload.filepath]
+
+ # Calculate total "pages" and digits needed for zero-padding
+ total_chunks = len(html_chunks)
+ total_pages = total_chunks * payload.settings.pages_per_chunk
+ total_digits = len(str(total_pages))
+
+ # Save chunks to disk
+ out_dir = payload.filepath.parent
+ logger.info(
+ f"Splitting HTML file {payload.filepath.name} into {total_chunks} chunks of "
+ f"up to {characters_per_chunk} characters each. Output will be saved in {out_dir}."
+ )
+
+ chunks: list[Path] = []
+ for i, chunk_html in enumerate(html_chunks):
+ # Calculate page range for this chunk
+ start_page = i * payload.settings.pages_per_chunk + 1
+ end_page = (i + 1) * payload.settings.pages_per_chunk
+ page_range_str = f"{start_page:0{total_digits}}-{end_page:0{total_digits}}"
+ output_fn = out_dir / f"{payload.filepath.stem}_pages_{page_range_str}.html"
+
+ try:
+ # Prepare output path (remove existing file if necessary)
+ _prepare_chunk_output_path(payload.project_id, output_fn)
+
+ # Save the chunk to disk
+ output_fn.write_text(chunk_html, encoding="utf-8")
+ chunks.append(output_fn)
+ logger.debug(f"Stored chunk '{output_fn}'")
+ except Exception as e:
+ msg = f"Skipping due to error creating chunk (pages {page_range_str}) for HTML file {payload.filepath.name}: {e}"
+ logger.error(msg)
+
+ return chunks
diff --git a/backend/src/modules/doc_processing/entrypoints/html_chunking_utils.py b/backend/src/modules/doc_processing/entrypoints/html_chunking_utils.py
new file mode 100644
index 000000000..e4a190464
--- /dev/null
+++ b/backend/src/modules/doc_processing/entrypoints/html_chunking_utils.py
@@ -0,0 +1,275 @@
+import re
+from typing import Any
+
+from bs4 import BeautifulSoup, NavigableString, Tag
+
+
+def split_html_into_chunks(html_content: str, max_chars: int) -> list[str]:
+ """
+ Split HTML content into chunks at element boundaries.
+
+ Each chunk is valid HTML with properly opened/closed tags.
+
+ Args:
+ html_content: The HTML content to split.
+ max_chars: Maximum number of characters per chunk.
+
+ Returns:
+ A list of valid HTML chunks.
+ """
+ soup = BeautifulSoup(html_content, "html.parser")
+
+ # Extract head content if present (to include in each chunk)
+ head = soup.find("head")
+ head_html = str(head) if head else ""
+
+ # Find the body or use the whole document
+ body = soup.find("body")
+ if body and isinstance(body, Tag):
+ elements = list(body.children)
+ else:
+ # No body tag - treat all top-level elements as content
+ elements = list(soup.children)
+
+ # Filter out whitespace-only NavigableStrings
+ elements = [el for el in elements if not (isinstance(el, str) and el.strip() == "")]
+
+ if not elements:
+ # No elements to split
+ return [html_content]
+
+ chunks: list[str] = []
+ current_elements: list[Any] = []
+ current_length = 0
+
+ # Detect document structure for wrapping
+ has_html_tag = soup.find("html") is not None
+ has_body_tag = body is not None
+
+ # Note: We do NOT subtract wrapper overhead from max_chars.
+ # The wrapper (html, head, body tags) is structural and should not
+ # count towards the content character limit.
+
+ for element in elements:
+ element_html = str(element)
+ element_length = _get_content_length(element_html)
+
+ # If a single element exceeds max_chars, we need to handle it specially
+ if element_length > max_chars:
+ # Save current accumulated content first
+ if current_elements:
+ chunk_html = _wrap_html_chunk(
+ current_elements, head_html, has_html_tag, has_body_tag
+ )
+ chunks.append(chunk_html)
+ current_elements = []
+ current_length = 0
+
+ # Try to split the large element
+ if isinstance(element, Tag):
+ sub_chunks = _split_large_element(element, max_chars)
+ for sub_chunk_elements in sub_chunks:
+ chunk_html = _wrap_html_chunk(
+ sub_chunk_elements, head_html, has_html_tag, has_body_tag
+ )
+ chunks.append(chunk_html)
+ else:
+ # It's a text node - split by characters
+ text = str(element)
+ for i in range(0, len(text), max_chars):
+ text_chunk = text[i : i + max_chars]
+ chunk_html = _wrap_html_chunk(
+ [text_chunk], head_html, has_html_tag, has_body_tag
+ )
+ chunks.append(chunk_html)
+ continue
+
+ # Check if adding this element would exceed the limit
+ if current_length + element_length > max_chars and current_elements:
+ # Save current chunk and start a new one
+ chunk_html = _wrap_html_chunk(
+ current_elements, head_html, has_html_tag, has_body_tag
+ )
+ chunks.append(chunk_html)
+ current_elements = [element]
+ current_length = element_length
+ else:
+ # Add element to current chunk
+ current_elements.append(element)
+ current_length += element_length
+
+ # Don't forget the last chunk
+ if current_elements:
+ chunk_html = _wrap_html_chunk(
+ current_elements, head_html, has_html_tag, has_body_tag
+ )
+ chunks.append(chunk_html)
+
+ return chunks
+
+
+def _get_content_length(element_html: str) -> int:
+ """
+ Calculate the content length of an HTML element, excluding img tags.
+
+ Base64-encoded images can be very large but aren't actual text content,
+ so we exclude them from the character count.
+
+ Args:
+ element_html: The HTML string to measure.
+
+ Returns:
+ The length of the HTML string with img tags removed.
+ """
+ # Pattern to match img tags with their content (including base64 data)
+ IMG_TAG_PATTERN = re.compile(r"
]*>", re.IGNORECASE | re.DOTALL)
+
+ # Remove img tags from the string for length calculation
+ content_without_images = IMG_TAG_PATTERN.sub("", element_html)
+ return len(content_without_images)
+
+
+def _wrap_html_chunk(
+ elements: list[Any],
+ head_html: str,
+ has_html_tag: bool,
+ has_body_tag: bool,
+) -> str:
+ """
+ Wrap a list of elements in proper HTML structure.
+
+ Args:
+ elements: List of HTML elements/strings to wrap.
+ head_html: The head section HTML to include.
+ has_html_tag: Whether the original had an html tag.
+ has_body_tag: Whether the original had a body tag.
+
+ Returns:
+ A valid HTML string.
+ """
+ content = "".join(str(el) for el in elements)
+
+ if has_body_tag:
+ content = f"
{content}"
+
+ if has_html_tag:
+ content = f"{head_html}{content}"
+ elif head_html:
+ content = f"{head_html}{content}"
+
+ return content
+
+
+def _split_large_element(element: Tag, max_chars: int) -> list[list[Any]]:
+ """
+ Split a large HTML element into smaller chunks by its children.
+
+ Args:
+ element: The HTML element to split.
+ max_chars: Maximum characters per chunk.
+
+ Returns:
+ A list of element lists, each representing a chunk's content.
+ """
+ children = list(element.children)
+ children = [
+ ch
+ for ch in children
+ if not (isinstance(ch, NavigableString) and ch.strip() == "")
+ ]
+
+ if not children:
+ # Element has no children - can't split further, return as-is
+ return [[element]]
+
+ # Calculate wrapper overhead for this element's tag
+ tag_name = element.name
+ attrs = (
+ "".join(f' {k}="{v}"' for k, v in element.attrs.items())
+ if element.attrs
+ else ""
+ )
+ wrapper_overhead = len(f"<{tag_name}{attrs}>{tag_name}>")
+ effective_max = max_chars - wrapper_overhead
+
+ if effective_max <= 0:
+ # Can't fit wrapper, just return element as-is
+ return [[element]]
+
+ chunks: list[list[Any]] = []
+ current_children: list[Any] = []
+ current_length = 0
+
+ for child in children:
+ child_html = str(child)
+ child_length = _get_content_length(child_html)
+
+ # If single child is too large, recursively split it
+ if child_length > effective_max:
+ # Save current content first
+ if current_children:
+ wrapped = _wrap_element_children(element, current_children)
+ chunks.append([wrapped])
+ current_children = []
+ current_length = 0
+
+ if isinstance(child, Tag):
+ sub_chunks = _split_large_element(child, effective_max)
+ for sub_chunk in sub_chunks:
+ # Wrap each sub-chunk in the parent element
+ wrapped = _wrap_element_children(element, sub_chunk)
+ chunks.append([wrapped])
+ else:
+ # Text node - split by characters
+ text = str(child)
+ for i in range(0, len(text), effective_max):
+ text_chunk = text[i : i + effective_max]
+ wrapped = _wrap_element_children(element, [text_chunk])
+ chunks.append([wrapped])
+ continue
+
+ # Check if adding this child would exceed the limit
+ if current_length + child_length > effective_max and current_children:
+ wrapped = _wrap_element_children(element, current_children)
+ chunks.append([wrapped])
+ current_children = [child]
+ current_length = child_length
+ else:
+ current_children.append(child)
+ current_length += child_length
+
+ # Don't forget the last chunk
+ if current_children:
+ wrapped = _wrap_element_children(element, current_children)
+ chunks.append([wrapped])
+
+ return chunks
+
+
+def _wrap_element_children(parent: Tag, children: list[Any]) -> Tag:
+ """
+ Create a new element with the same tag and attributes as parent, containing the given children.
+
+ Args:
+ parent: The parent element to clone.
+ children: The children to include in the new element.
+
+ Returns:
+ A new Tag with the same properties as parent but with specified children.
+ """
+ # Create a new soup to build the element
+ new_soup = BeautifulSoup("", "html.parser")
+ new_tag = new_soup.new_tag(parent.name, attrs=parent.attrs)
+
+ for child in children:
+ if isinstance(child, NavigableString):
+ new_tag.append(str(child))
+ elif isinstance(child, str):
+ new_tag.append(child)
+ else:
+ # Need to copy the child to avoid modifying original
+ child_copy = BeautifulSoup(str(child), "html.parser")
+ for item in child_copy.children:
+ new_tag.append(item)
+
+ return new_tag
diff --git a/backend/src/modules/doc_processing/entrypoints/txt_chunking_utils.py b/backend/src/modules/doc_processing/entrypoints/txt_chunking_utils.py
new file mode 100644
index 000000000..8978239bd
--- /dev/null
+++ b/backend/src/modules/doc_processing/entrypoints/txt_chunking_utils.py
@@ -0,0 +1,104 @@
+from loguru import logger
+
+
+def split_text_into_chunks(text: str, max_chars: int) -> list[str]:
+ """
+ Split text into chunks of at most max_chars characters.
+
+ Attempts to split at line breaks to preserve document structure.
+ If no line breaks exist, falls back to hard character splitting.
+
+ Args:
+ text: The text content to split.
+ max_chars: Maximum number of characters per chunk.
+
+ Returns:
+ A list of text chunks.
+ """
+ # Check if text contains line breaks
+ has_line_breaks = "\n" in text
+
+ if has_line_breaks:
+ return _split_text_by_lines(text, max_chars)
+ else:
+ # No line breaks - fall back to hard character splitting
+ logger.warning(
+ "Text has no line breaks. Falling back to hard character splitting, "
+ "which may split words."
+ )
+ return _split_text_by_chars(text, max_chars)
+
+
+def _split_text_by_lines(text: str, max_chars: int) -> list[str]:
+ """
+ Split text into chunks at line breaks, respecting the character limit.
+
+ Args:
+ text: The text content to split.
+ max_chars: Maximum number of characters per chunk.
+
+ Returns:
+ A list of text chunks split at line boundaries.
+ """
+ lines = text.split("\n")
+ chunks: list[str] = []
+ current_chunk_lines: list[str] = []
+ current_chunk_length = 0
+
+ for line in lines:
+ # Calculate length including the newline character we'll add back
+ line_length = len(line) + 1 # +1 for the newline
+
+ # If a single line exceeds max_chars, we need to split it
+ if line_length > max_chars:
+ # First, save any accumulated content
+ if current_chunk_lines:
+ chunks.append("\n".join(current_chunk_lines))
+ current_chunk_lines = []
+ current_chunk_length = 0
+
+ # Split the long line by characters
+ line_chunks = _split_text_by_chars(line, max_chars)
+ chunks.extend(line_chunks)
+ continue
+
+ # Check if adding this line would exceed the limit
+ new_length = current_chunk_length + line_length
+ if current_chunk_lines:
+ # Account for the newline between existing content and new line
+ new_length = current_chunk_length + line_length
+
+ if new_length > max_chars and current_chunk_lines:
+ # Save current chunk and start a new one
+ chunks.append("\n".join(current_chunk_lines))
+ current_chunk_lines = [line]
+ current_chunk_length = line_length
+ else:
+ # Add line to current chunk
+ current_chunk_lines.append(line)
+ current_chunk_length = new_length
+
+ # Don't forget the last chunk
+ if current_chunk_lines:
+ chunks.append("\n".join(current_chunk_lines))
+
+ return chunks
+
+
+def _split_text_by_chars(text: str, max_chars: int) -> list[str]:
+ """
+ Split text into chunks of exactly max_chars characters (hard split).
+
+ This is a fallback when no line breaks are available.
+
+ Args:
+ text: The text content to split.
+ max_chars: Maximum number of characters per chunk.
+
+ Returns:
+ A list of text chunks.
+ """
+ chunks: list[str] = []
+ for i in range(0, len(text), max_chars):
+ chunks.append(text[i : i + max_chars])
+ return chunks
diff --git a/backend/src/modules/doc_processing/html/html_cleaning_utils.py b/backend/src/modules/doc_processing/html/html_cleaning_utils.py
index 8b46df533..c1138fc12 100644
--- a/backend/src/modules/doc_processing/html/html_cleaning_utils.py
+++ b/backend/src/modules/doc_processing/html/html_cleaning_utils.py
@@ -113,7 +113,6 @@ def x(html_content: str) -> str:
"width",
"height",
"target",
- "pagenum",
],
),
string_replace(replace={"\n": "", "<": "❮", ">": "❯"}),
@@ -136,7 +135,6 @@ def x(html_content: str) -> str:
"width",
"height",
"target",
- "pagenum",
],
),
string_replace(replace={"\n": "", "<": "❮", ">": "❯"}),
diff --git a/backend/src/modules/doc_processing/text/html_extraction_job.py b/backend/src/modules/doc_processing/text/html_extraction_job.py
index ea9d212e2..8ad4efc16 100644
--- a/backend/src/modules/doc_processing/text/html_extraction_job.py
+++ b/backend/src/modules/doc_processing/text/html_extraction_job.py
@@ -166,10 +166,20 @@ def extract_html_from_pdf(payload: ExtractHTMLJobInput) -> tuple[str, list[Path]
def extract_html_from_text(payload: ExtractHTMLJobInput) -> tuple[str, list[Path]]:
+ """
+ Convert a text file to HTML, preserving line breaks as paragraphs.
+
+ Each non-empty line becomes a element to maintain the original formatting.
+ """
logger.debug(f"Extracting content as HTML from TEXT {payload.filepath.name} ...")
content = payload.filepath.read_text(encoding="utf-8")
- html_content = f"
{content}
"
+
+ # Split by line breaks and wrap each non-empty line in tags
+ lines = content.split("\n")
+ paragraphs = [f"
{line}
" for line in lines if line.strip()]
+
+ html_content = f"{''.join(paragraphs)}"
return html_content, []
diff --git a/backend/src/modules/whiteboard/whiteboard_crud.py b/backend/src/modules/whiteboard/whiteboard_crud.py
index 410a615d5..f5f7edd11 100644
--- a/backend/src/modules/whiteboard/whiteboard_crud.py
+++ b/backend/src/modules/whiteboard/whiteboard_crud.py
@@ -10,7 +10,6 @@
from core.doc.source_document_crud import crud_sdoc
from core.doc.source_document_dto import SourceDocumentRead
from core.memo.memo_crud import crud_memo
-from core.memo.memo_dto import MemoRead
from core.project.project_crud import crud_project
from core.tag.tag_dto import TagRead
from modules.whiteboard.whiteboard_dto import (
@@ -105,7 +104,7 @@ def read_data(self, db: Session, *, id: int) -> WhiteboardData:
MemoNodeData.model_validate(node.data).memoId for node in nodes
]
result.memos = [
- MemoRead.model_validate(m)
+ crud_memo.get_memo_read_dto_from_orm(db=db, db_obj=m)
for m in crud_memo.read_by_ids(db=db, ids=memo_ids)
]
case WhiteboardNodeType.SDOC:
diff --git a/backend/src/systems/search_system/search_builder.py b/backend/src/systems/search_system/search_builder.py
index 302b684a9..acaa0ad4d 100644
--- a/backend/src/systems/search_system/search_builder.py
+++ b/backend/src/systems/search_system/search_builder.py
@@ -33,6 +33,16 @@
class SearchBuilder:
+ """
+ To debug a query, you should set a breakpoint in the `execute_query` method, e.g. after befor running the query with `query.all()`.
+ Then, inspect the `self.query` and `self.subquery` attributes to see the built SQLAlchemy queries.
+ You can convert them to raw SQL strings using the `str()` function, e.g.
+ ```python
+ str(query)
+ ```
+ This will give you the raw SQL that SQLAlchemy has generated, which you can then analyze or run directly against your database for debugging purposes.
+ """
+
def __init__(self, db: Session, filter: Filter[T], sorts: list[Sort[T]]) -> None:
self.db = db
self.filter = filter
@@ -147,7 +157,7 @@ def _add_subquery_metadata_filter_statements(self, project_metadata_id: int):
self.subquery.add_column(
metadata_value_column.label(f"METADATA-{project_metadata_id}")
)
- .join(
+ .outerjoin(
metadata,
(SourceDocumentORM.id == metadata.source_document_id)
& (metadata.project_metadata_id == project_metadata_id),
diff --git a/backend/test/conftest.py b/backend/test/conftest.py
index e012bc8ac..7719db5f2 100755
--- a/backend/test/conftest.py
+++ b/backend/test/conftest.py
@@ -336,7 +336,7 @@ def upload_files(self, upload_list: list, user: dict, project: dict):
files = []
settings = {
"extract_images": True,
- "pages_per_chunk": 10,
+ "pages_per_chunk": 100, # we do not want chunking in tests
"keyword_number": 5,
"keyword_deduplication_threshold": 0.5,
"keyword_max_ngram_size": 2,
diff --git a/frontend/src/api/CodeHooks.ts b/frontend/src/api/CodeHooks.ts
index 74999167f..1ca74fdfc 100644
--- a/frontend/src/api/CodeHooks.ts
+++ b/frontend/src/api/CodeHooks.ts
@@ -1,8 +1,9 @@
import { useMutation, useQuery } from "@tanstack/react-query";
import { useCallback } from "react";
import queryClient from "../plugins/ReactQueryClient.ts";
-import { useAppSelector } from "../plugins/ReduxHooks.ts";
+import { useAppDispatch, useAppSelector } from "../plugins/ReduxHooks.ts";
import { RootState } from "../store/store.ts";
+import { AnnoActions } from "../views/annotation/annoSlice.ts";
import { QueryKey } from "./QueryKey.ts";
import { CodeRead } from "./openapi/models/CodeRead.ts";
import { CodeService } from "./openapi/services/CodeService.ts";
@@ -54,8 +55,8 @@ const useGetEnabledCodes = () => {
};
// CODE MUTATIONS
-const useCreateCode = () =>
- useMutation({
+const useCreateCode = () => {
+ return useMutation({
mutationFn: CodeService.createNewCode,
onSuccess: (data, variables) => {
queryClient.setQueryData([QueryKey.PROJECT_CODES, variables.requestBody.project_id], (oldData) =>
@@ -66,6 +67,7 @@ const useCreateCode = () =>
successMessage: (data: CodeRead) => `Created code ${data.name}`,
},
});
+};
const useUpdateCode = () =>
useMutation({
@@ -84,8 +86,9 @@ const useUpdateCode = () =>
},
});
-const useDeleteCode = () =>
- useMutation({
+const useDeleteCode = () => {
+ const dispatch = useAppDispatch();
+ return useMutation({
mutationFn: CodeService.deleteById,
onSuccess: (data) => {
queryClient.setQueryData([QueryKey.PROJECT_CODES, data.project_id], (oldData) => {
@@ -94,11 +97,20 @@ const useDeleteCode = () =>
delete newData[data.id];
return newData;
});
+ // reset global server state: invalidate everything that could be affected by a code
+ queryClient.invalidateQueries({ queryKey: [QueryKey.SDOC_SPAN_ANNOTATIONS] });
+ queryClient.invalidateQueries({ queryKey: [QueryKey.SDOC_BBOX_ANNOTATIONS] });
+ queryClient.invalidateQueries({ queryKey: [QueryKey.SDOC_SENTENCE_ANNOTATOR] });
+ queryClient.invalidateQueries({ queryKey: [QueryKey.PROJECT_WHITEBOARDS, data.project_id] });
+ queryClient.invalidateQueries({ queryKey: [QueryKey.FILTER_ENTITY_STATISTICS, data.id] });
+ // reset global client state
+ dispatch(AnnoActions.onDeleteCode(data.id));
},
meta: {
successMessage: (data: CodeRead) => `Deleted code ${data.name}`,
},
});
+};
const CodeHooks = {
// codes
diff --git a/frontend/src/api/StatisticsHooks.ts b/frontend/src/api/StatisticsHooks.ts
index ae0df391b..475d4dccc 100644
--- a/frontend/src/api/StatisticsHooks.ts
+++ b/frontend/src/api/StatisticsHooks.ts
@@ -12,7 +12,7 @@ const useFilterCodeStats = (codeId: number, sdocIds: number[] | null | undefined
const sortStatsByGlobal = useAppSelector((state) => state.search.sortStatsByGlobal);
return useQuery({
- queryKey: [QueryKey.FILTER_ENTITY_STATISTICS, sdocIds, codeId, sortStatsByGlobal],
+ queryKey: [QueryKey.FILTER_ENTITY_STATISTICS, codeId, sdocIds, sortStatsByGlobal],
queryFn: () =>
StatisticsService.filterCodeStats({
codeId,
diff --git a/frontend/src/components/FilterDialog/FilterExpressionRenderer/components/FilterOperatorSelector.tsx b/frontend/src/components/FilterDialog/FilterExpressionRenderer/components/FilterOperatorSelector.tsx
index 6d664fb89..c3771a9d6 100644
--- a/frontend/src/components/FilterDialog/FilterExpressionRenderer/components/FilterOperatorSelector.tsx
+++ b/frontend/src/components/FilterDialog/FilterExpressionRenderer/components/FilterOperatorSelector.tsx
@@ -36,7 +36,7 @@ const operator2HumanReadable: Record = {
[DateOperator.DATE_GT]: ">",
[DateOperator.DATE_LT]: "<",
[DateOperator.DATE_GTE]: ">=",
- [DateOperator.DATE_LTE]: "<+",
+ [DateOperator.DATE_LTE]: "<=",
[BooleanOperator.BOOLEAN_EQUALS]: "is",
[BooleanOperator.BOOLEAN_NOT_EQUALS]: "is not",
};
diff --git a/frontend/src/components/LLMDialog/steps/AnnotationResultStep/TextAnnotationValidationMenu.tsx b/frontend/src/components/LLMDialog/steps/AnnotationResultStep/TextAnnotationValidationMenu.tsx
index e6cf21051..16a9a1807 100644
--- a/frontend/src/components/LLMDialog/steps/AnnotationResultStep/TextAnnotationValidationMenu.tsx
+++ b/frontend/src/components/LLMDialog/steps/AnnotationResultStep/TextAnnotationValidationMenu.tsx
@@ -224,7 +224,7 @@ function AnnotationMenuListItem({ annotation, handleEdit, handleDelete }: Annota
handleDelete(annotation);
}, [annotation, handleDelete]);
- if (code.isSuccess) {
+ if (code.data) {
return (
diff --git a/frontend/src/components/Memo/MemoBlockEditor.tsx b/frontend/src/components/Memo/MemoBlockEditor.tsx
index a5b791ce7..424443997 100644
--- a/frontend/src/components/Memo/MemoBlockEditor.tsx
+++ b/frontend/src/components/Memo/MemoBlockEditor.tsx
@@ -22,7 +22,7 @@ function MemoBlockEditor({ memoId, renderToolbar, onDelete, onStarred }: MemoBlo
// global client state
const { user } = useAuth();
const memo = MemoHooks.useGetMemo(memoId);
- const attachedObject = useGetMemosAttachedObject(memo.data?.attached_object_type)(memo.data?.attached_object_id);
+ const attachedObject = useGetMemosAttachedObject(memo.data?.attached_object_type, memo.data?.attached_object_id);
const isEditable = useMemo(() => user?.id === memo.data?.user_id, [user?.id, memo.data?.user_id]);
@@ -66,8 +66,8 @@ function MemoBlockEditor({ memoId, renderToolbar, onDelete, onStarred }: MemoBlo
) : memo.isError ? (
Error: {memo.error.message}
) : attachedObject.isError ? (
- Error: {attachedObject.error.message}
- ) : memo.isSuccess && attachedObject.isSuccess ? (
+ Error: {attachedObject.error?.message}
+ ) : memo.isSuccess && attachedObject.isSuccess && attachedObject.data ? (
<>
diff --git a/frontend/src/components/Memo/MemoCard.tsx b/frontend/src/components/Memo/MemoCard.tsx
index 18efc816a..d4b69d63d 100644
--- a/frontend/src/components/Memo/MemoCard.tsx
+++ b/frontend/src/components/Memo/MemoCard.tsx
@@ -29,7 +29,7 @@ function MemoCardWithContent({
onDeleteClick,
onStarredClick,
}: MemoCardSharedProps & { memo: MemoRead }) {
- const attachedObject = useGetMemosAttachedObject(memo.attached_object_type)(memo.attached_object_id);
+ const attachedObject = useGetMemosAttachedObject(memo.attached_object_type, memo.attached_object_id);
const handleClick = useCallback(() => {
if (onClick) {
diff --git a/frontend/src/components/Memo/MemoDialog/MemoDialog.tsx b/frontend/src/components/Memo/MemoDialog/MemoDialog.tsx
index c75cf364e..7b6a62e52 100644
--- a/frontend/src/components/Memo/MemoDialog/MemoDialog.tsx
+++ b/frontend/src/components/Memo/MemoDialog/MemoDialog.tsx
@@ -122,7 +122,7 @@ function MemoDialog2({
// 1. memoId is set, attachedObjectId is set
// 2. memoId is not set, attachedObjectId is set
// 3. memoId is set, attachedObjectId is not set
- const attachedObject = useGetMemosAttachedObject(attachedObjectType)(attachedObjectId);
+ const attachedObject = useGetMemosAttachedObject(attachedObjectType, attachedObjectId);
return (
<>
diff --git a/frontend/src/components/Memo/MemoRenderer.tsx b/frontend/src/components/Memo/MemoRenderer.tsx
index f01f75182..412d35134 100644
--- a/frontend/src/components/Memo/MemoRenderer.tsx
+++ b/frontend/src/components/Memo/MemoRenderer.tsx
@@ -1,7 +1,8 @@
import StarIcon from "@mui/icons-material/Star";
import StarOutlineIcon from "@mui/icons-material/StarOutline";
-import { Stack, StackProps } from "@mui/material";
+import { Box, Stack, StackProps } from "@mui/material";
import { memo } from "react";
+import Markdown from "react-markdown";
import MemoHooks from "../../api/MemoHooks.ts";
import { MemoRead } from "../../api/openapi/models/MemoRead.ts";
import { Icon, getIconComponent } from "../../utils/icons/iconUtils.tsx";
@@ -91,7 +92,11 @@ export function MemoRendererWithData({
{showIcon && getIconComponent(Icon.MEMO, { sx: { mr: 1 } })}
{showTitle && memo.title}
- {showContent && memo.content}
+ {showContent && (
+
+ {memo.content}
+
+ )}
{showUser && }
{showStar && (memo.starred ? : )}
{showAttachedObject && (
diff --git a/frontend/src/components/Memo/useGetMemosAttachedObject.ts b/frontend/src/components/Memo/useGetMemosAttachedObject.ts
index f513229e7..f0b40367f 100644
--- a/frontend/src/components/Memo/useGetMemosAttachedObject.ts
+++ b/frontend/src/components/Memo/useGetMemosAttachedObject.ts
@@ -5,24 +5,61 @@ import SentenceAnnotationHooks from "../../api/SentenceAnnotationHooks.ts";
import SpanAnnotationHooks from "../../api/SpanAnnotationHooks.ts";
import TagHooks from "../../api/TagHooks.ts";
import { AttachedObjectType } from "../../api/openapi/models/AttachedObjectType.ts";
+import { CodeRead } from "../../api/openapi/models/CodeRead.ts";
+
+/**
+ * Hook to fetch the attached object of a memo based on its type.
+ * All hooks are called unconditionally to satisfy React's rules of hooks.
+ * Only the relevant query will be enabled based on the type (passing undefined disables the query).
+ * @param type - The type of the attached object
+ * @param id - The id of the attached object
+ * @returns The query result for the attached object
+ */
+const useGetMemosAttachedObject = (type: AttachedObjectType | undefined, id: number | undefined) => {
+ // Pass the id only when the type matches, otherwise pass undefined to disable the query
+ const tagQuery = TagHooks.useGetTag(type === AttachedObjectType.TAG ? id : undefined);
+ const codeQuery = CodeHooks.useGetCode(type === AttachedObjectType.CODE ? id : undefined);
+ const sdocQuery = SdocHooks.useGetDocument(type === AttachedObjectType.SOURCE_DOCUMENT ? id : undefined);
+ const spanQuery = SpanAnnotationHooks.useGetAnnotation(type === AttachedObjectType.SPAN_ANNOTATION ? id : undefined);
+ const bboxQuery = BboxAnnotationHooks.useGetAnnotation(type === AttachedObjectType.BBOX_ANNOTATION ? id : undefined);
+ const sentenceQuery = SentenceAnnotationHooks.useGetAnnotation(
+ type === AttachedObjectType.SENTENCE_ANNOTATION ? id : undefined,
+ );
-const useGetMemosAttachedObject = (type: AttachedObjectType | undefined) => {
switch (type) {
case AttachedObjectType.TAG:
- return TagHooks.useGetTag;
+ return tagQuery;
case AttachedObjectType.CODE:
- return CodeHooks.useGetCode;
+ return codeQuery;
case AttachedObjectType.SOURCE_DOCUMENT:
- return SdocHooks.useGetDocument;
+ return sdocQuery;
case AttachedObjectType.SPAN_ANNOTATION:
- return SpanAnnotationHooks.useGetAnnotation;
+ return spanQuery;
case AttachedObjectType.BBOX_ANNOTATION:
- return BboxAnnotationHooks.useGetAnnotation;
+ return bboxQuery;
case AttachedObjectType.SENTENCE_ANNOTATION:
- return SentenceAnnotationHooks.useGetAnnotation;
- default:
- console.warn("Unknown attached object type:", type);
- return CodeHooks.useGetCode;
+ return sentenceQuery;
+ default: {
+ // Return a "disabled" query-like object when type is undefined
+ const placeholder: CodeRead = {
+ id: 0,
+ name: "",
+ color: "",
+ description: "",
+ created: new Date().toISOString(),
+ updated: new Date().toISOString(),
+ project_id: 0,
+ is_system: false,
+ memo_ids: [],
+ };
+ return {
+ data: placeholder,
+ isLoading: false,
+ isError: false,
+ isSuccess: type === undefined,
+ error: null,
+ };
+ }
}
};
diff --git a/frontend/src/components/tableSlice.ts b/frontend/src/components/tableSlice.ts
index 87699757e..fdb8d1ac3 100644
--- a/frontend/src/components/tableSlice.ts
+++ b/frontend/src/components/tableSlice.ts
@@ -2,6 +2,7 @@ import { Draft, PayloadAction, createSelector } from "@reduxjs/toolkit";
import {
MRT_ColumnSizingState,
MRT_DensityState,
+ MRT_ExpandedState,
MRT_RowSelectionState,
MRT_SortingState,
MRT_VisibilityState,
@@ -14,6 +15,7 @@ export interface TableState {
columnVisibilityModel: MRT_VisibilityState;
columnSizingModel: MRT_ColumnSizingState;
gridDensityModel: MRT_DensityState;
+ expandedModel: MRT_ExpandedState;
fetchSize: number;
}
@@ -24,6 +26,7 @@ export const initialTableState: TableState = {
sortingModel: [],
columnVisibilityModel: {},
columnSizingModel: {},
+ expandedModel: {},
fetchSize: 20,
// app state:
gridDensityModel: "comfortable",
@@ -52,6 +55,10 @@ export const tableReducer = {
onColumnVisibilityChange: (state: Draft, action: PayloadAction) => {
state.columnVisibilityModel = action.payload;
},
+ // expanded
+ onExpandedChange: (state: Draft, action: PayloadAction) => {
+ state.expandedModel = action.payload;
+ },
// column sizing
onColumnSizingChange: (state: Draft, action: PayloadAction) => {
state.columnSizingModel = action.payload;
diff --git a/frontend/src/views/annotation/Annotation.tsx b/frontend/src/views/annotation/Annotation.tsx
index ef1099a2b..f58f38d53 100644
--- a/frontend/src/views/annotation/Annotation.tsx
+++ b/frontend/src/views/annotation/Annotation.tsx
@@ -10,9 +10,9 @@ import EditableTypography from "../../components/EditableTypography.tsx";
import DocumentInformation from "../../components/SourceDocument/DocumentInformation/DocumentInformation.tsx";
import SidebarContentSidebarLayout from "../../layouts/ContentLayouts/SidebarContentSidebarLayout.tsx";
import { useAppSelector } from "../../plugins/ReduxHooks.ts";
-import BBoxAnnotationExplorer from "./AnnotationExploer/BBoxAnnotationExplorer.tsx";
-import SentenceAnnotationExplorer from "./AnnotationExploer/SentenceAnnotationExplorer.tsx";
-import SpanAnnotationExplorer from "./AnnotationExploer/SpanAnnotationExplorer.tsx";
+import BBoxAnnotationExplorer from "./AnnotationExplorer/BBoxAnnotationExplorer.tsx";
+import SentenceAnnotationExplorer from "./AnnotationExplorer/SentenceAnnotationExplorer.tsx";
+import SpanAnnotationExplorer from "./AnnotationExplorer/SpanAnnotationExplorer.tsx";
import AnnotationMode from "./AnnotationMode.ts";
import AudioVideoViewer from "./DocumentViewer/AudioVideoViewer.tsx";
import ImageViewer from "./DocumentViewer/ImageViewer.tsx";
diff --git a/frontend/src/views/annotation/AnnotationExploer/AnnotationCardActionMenu.tsx b/frontend/src/views/annotation/AnnotationExplorer/AnnotationCardActionMenu.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/AnnotationCardActionMenu.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/AnnotationCardActionMenu.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/AnnotationCardMemo.tsx b/frontend/src/views/annotation/AnnotationExplorer/AnnotationCardMemo.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/AnnotationCardMemo.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/AnnotationCardMemo.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/AnnotationExplorer.tsx b/frontend/src/views/annotation/AnnotationExplorer/AnnotationExplorer.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/AnnotationExplorer.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/AnnotationExplorer.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationCard.tsx b/frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationCard.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationCard.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationCard.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationDeleteMenuItem.tsx b/frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationDeleteMenuItem.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationDeleteMenuItem.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationDeleteMenuItem.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationExplorer.tsx b/frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationExplorer.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/BBoxAnnotationExplorer.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/BBoxAnnotationExplorer.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationCard.tsx b/frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationCard.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationCard.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationCard.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationDeleteMenuItem.tsx b/frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationDeleteMenuItem.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationDeleteMenuItem.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationDeleteMenuItem.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationExplorer.tsx b/frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationExplorer.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/SentenceAnnotationExplorer.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/SentenceAnnotationExplorer.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/SpanAnnotationCard.tsx b/frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationCard.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/SpanAnnotationCard.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationCard.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/SpanAnnotationDeleteMenuItem.tsx b/frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationDeleteMenuItem.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/SpanAnnotationDeleteMenuItem.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationDeleteMenuItem.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/SpanAnnotationExplorer.tsx b/frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationExplorer.tsx
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/SpanAnnotationExplorer.tsx
rename to frontend/src/views/annotation/AnnotationExplorer/SpanAnnotationExplorer.tsx
diff --git a/frontend/src/views/annotation/AnnotationExploer/types/AnnotationCardProps.ts b/frontend/src/views/annotation/AnnotationExplorer/types/AnnotationCardProps.ts
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/types/AnnotationCardProps.ts
rename to frontend/src/views/annotation/AnnotationExplorer/types/AnnotationCardProps.ts
diff --git a/frontend/src/views/annotation/AnnotationExploer/types/AnnotationRead.ts b/frontend/src/views/annotation/AnnotationExplorer/types/AnnotationRead.ts
similarity index 100%
rename from frontend/src/views/annotation/AnnotationExploer/types/AnnotationRead.ts
rename to frontend/src/views/annotation/AnnotationExplorer/types/AnnotationRead.ts
diff --git a/frontend/src/views/annotation/AnnotationMenu/AnnotationMenu.tsx b/frontend/src/views/annotation/AnnotationMenu/AnnotationMenu.tsx
index e707543e8..0e3c13dc7 100644
--- a/frontend/src/views/annotation/AnnotationMenu/AnnotationMenu.tsx
+++ b/frontend/src/views/annotation/AnnotationMenu/AnnotationMenu.tsx
@@ -25,17 +25,16 @@ import { useWithLevel } from "../../../components/TreeExplorer/useWithLevel.ts";
import { useAppDispatch } from "../../../plugins/ReduxHooks.ts";
import { getIconComponent, Icon } from "../../../utils/icons/iconUtils.tsx";
import { Annotation, Annotations } from "../Annotation.ts";
-import { ICode } from "../ICode.ts";
import { useComputeCodesForSelection } from "./useComputeCodesForSelection.ts";
const filter = createFilterOptions();
interface CodeSelectorProps {
onClose?: (reason?: "backdropClick" | "escapeKeyDown") => void;
- onAdd?: (code: CodeRead, isNewCode: boolean) => void;
- onEdit?: (annotationToEdit: Annotation, newCode: ICode) => void;
+ onAdd?: (codeId: number, isNewCode: boolean) => void;
+ onEdit?: (annotationToEdit: Annotation, codeId: number) => void;
onDelete?: (annotationToDelete: Annotation) => void;
- onDuplicate?: (annotationToDuplicate: Annotation, currentCode: CodeRead) => void;
+ onDuplicate?: (annotationToDuplicate: Annotation, codeId: number) => void;
}
export interface CodeSelectorHandle {
@@ -150,15 +149,14 @@ const AnnotationMenu = forwardRef(
// submit the code selector (either we edited or created a new code)
const submit = (code: CodeRead, isNewCode: boolean) => {
- console.log("HI THIS IS TIM!", editingAnnotation);
// when the user selected an annotation to edit, we were editing
if (editingAnnotation !== undefined) {
- if (onEdit) onEdit(editingAnnotation, code);
+ if (onEdit) onEdit(editingAnnotation, code.id);
// otherwise, we opened this to add a new code
} else if (duplicatingAnnotation !== undefined) {
- if (onDuplicate) onDuplicate(duplicatingAnnotation, code);
+ if (onDuplicate) onDuplicate(duplicatingAnnotation, code.id);
} else {
- if (onAdd) onAdd(code, isNewCode);
+ if (onAdd) onAdd(code.id, isNewCode);
}
closeCodeSelector();
};
diff --git a/frontend/src/views/annotation/DocumentRenderer/CodeIndicator.tsx b/frontend/src/views/annotation/DocumentRenderer/CodeIndicator.tsx
index a967158d8..77543f9d0 100644
--- a/frontend/src/views/annotation/DocumentRenderer/CodeIndicator.tsx
+++ b/frontend/src/views/annotation/DocumentRenderer/CodeIndicator.tsx
@@ -8,10 +8,18 @@ interface CodeIndicatorProps {
groups?: number[];
}
+/**
+ * Renders a stylish tag/badge indicator for a code annotation.
+ * Displays the code name with a colored pill design for better visual recognition.
+ * @param codeId - The ID of the code to display
+ * @param annotationId - The ID of the annotation this indicator belongs to
+ * @param isSelected - Whether this annotation is currently selected
+ * @param groups - Optional group IDs for coreference annotations
+ */
function CodeIndicator({ codeId, annotationId, isSelected, groups }: CodeIndicatorProps) {
const code = CodeHooks.useGetCode(codeId);
- if (code.isSuccess) {
+ if (code.data) {
let text: string;
let color: string;
if (code.data.is_system && code.data.name === "MENTION" && groups && groups.length === 1) {
@@ -26,19 +34,21 @@ function CodeIndicator({ codeId, annotationId, isSelected, groups }: CodeIndicat
return (
- {text}
+
+ {text}
);
}
return (
-
- {" "}
- ...
+
+ ...
);
}
diff --git a/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.css b/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.css
index 6e629ad0f..b034addbe 100644
--- a/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.css
+++ b/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.css
@@ -1,76 +1,134 @@
-.mark {
- position:absolute;
- width:100%;
- top:0;
- left:0;
- z-index: -1;
+.hovered .text {
+ font-weight: bold;
}
-.mark.start {
- border-top-left-radius: 6px;
- border-bottom-left-radius: 6px;
+rect.hovered {
+ fill: #ffff0088;
}
-.mark.end {
- border-top-right-radius: 6px;
- border-bottom-right-radius: 6px;
+.hoversentence:hover {
+ background-color: rgba(0, 255, 255, 0.5);
+ cursor: pointer;
}
+.filterhighlight {
+ background-color: yellow;
+}
+
+.jumphighlight {
+ background-color: lightgreen;
+}
+
+/*
+TOKEN: Token.tsx
+A token (.tok) consists of:
+- A group (.spangroup) of CodeIndicator(s) (for NER tags) -> CodeIndicator.tsx
+- Text (.text): the actual token text
+- Mark (.mark): the background marks indicating span boundaries -> Mark.tsx
+*/
+
.tok {
- position: relative;
+ position: relative;
+ white-space: nowrap;
}
.text {
- white-space: nowrap;
+ white-space: nowrap;
+ position: relative;
+ z-index: 1;
}
-.hovered .text {
- font-weight: bold;
+.spangroup {
+ user-select: none;
+ position: relative;
+ z-index: 1;
}
-rect.hovered {
- fill: #ffff0088;
+.spangroup.above {
+ position: absolute;
+ bottom: 100%;
+ left: 0;
+ line-height: 12px;
+ white-space: nowrap;
+ user-select: auto;
}
-.spangroup {
- user-select: none;
- font-size: 14px;
- white-space: nowrap;
+.spangroup.above > span {
+ margin-left: 0;
+ margin-right: 4px;
}
-.spangroup.inline {
- vertical-align: top;
- margin-left: 4px;
+.spangroup.above:hover {
+ z-index: 100;
}
-.spangroup.above {
- position: absolute;
- bottom: 100%;
- left: 0;
- line-height: 12px;
- white-space: nowrap;
- user-select: auto;
- z-index: -1;
+/*
+CODE INDICATOR: CodeIndicator.tsx
+This is the styling of the code indicators that show NER tags above or inline with tokens.
+A code indicator (.code-indicator) can be selected (code-indicator--selected) and contains:
+- A color dot (.code-indicator__color-dot)
+- Text (.code-indicator__text)
+*/
+
+.code-indicator {
+ --indicator-color: #888;
+ border-radius: 8px;
+ background: color-mix(in srgb, var(--indicator-color) 15%, white);
+ border: 1px solid var(--indicator-color);
+ cursor: default;
+ display: inline-block;
+ line-height: 0.9;
+ margin-left: 4px;
+ padding-left: 14px;
+ padding-right: 4px;
+ position: relative;
+ transform: translateY(0px);
}
-.spangroup > span {
- margin-right: 4px;
+.code-indicator__color-dot {
+ width: 6px;
+ height: 6px;
+ border-radius: 50%;
+ background-color: var(--indicator-color);
+ position: absolute;
+ left: 4px;
+ top: calc(50% - 3px);
}
-.spangroup.inline > span {
- border: 1px solid black;
- border-radius: 4px;
+.code-indicator__text {
+ font-size: 0.75rem;
+ font-weight: bold;
+ font-style: normal;
+ color: color-mix(in srgb, var(--indicator-color) 50%, #222);
+ text-transform: uppercase;
+ vertical-align: middle;
}
-.hoversentence:hover {
- background-color: rgba(0, 255, 255, 0.5);
- cursor: pointer;
+.code-indicator--selected {
+ background: color-mix(in srgb, var(--indicator-color) 20%, white);
+ box-shadow:
+ 0 0 0 2px color-mix(in srgb, var(--indicator-color) 25%, transparent),
+ 0 2px 8px color-mix(in srgb, var(--indicator-color) 30%, transparent);
}
-.filterhighlight {
- background-color: yellow;
+/*
+MARKER: Mark.tsx
+This is the styling of the "background" marks that indicate the span boundaries.
+*/
+
+.mark {
+ position: absolute;
+ width: 100%;
+ top: 0;
+ left: 0;
}
-.jumphighlight {
- background-color: lightgreen;
+.mark.start {
+ border-top-left-radius: 6px;
+ border-bottom-left-radius: 6px;
+}
+
+.mark.end {
+ border-top-right-radius: 6px;
+ border-bottom-right-radius: 6px;
}
diff --git a/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.tsx b/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.tsx
index 594db241d..93902f5e7 100644
--- a/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.tsx
+++ b/frontend/src/views/annotation/DocumentRenderer/DocumentRenderer.tsx
@@ -1,6 +1,5 @@
import { Box, BoxProps } from "@mui/material";
-import { useVirtualizer } from "@tanstack/react-virtual";
-import React, { useCallback, useEffect, useMemo, useRef } from "react";
+import { useCallback, useEffect, useMemo } from "react";
import "./DocumentRenderer.css";
import { DOMNode, Element, HTMLReactParserOptions, domToReact } from "html-react-parser";
@@ -32,23 +31,6 @@ function DocumentRenderer({
projectId,
...props
}: DocumentRendererProps & BoxProps) {
- // computed
- const htmlPages = useMemo(() => {
- let content = html;
- if (content.startsWith("")) {
- content = content.substring(5);
- }
- if (content.endsWith("
")) {
- content = content.substring(0, content.length - 6);
- }
- content = content.trim();
- const regex = /|<\/section>|<\/section>/gm;
- let splitted = content.split(regex);
- splitted = splitted.filter((s) => s.length > 0);
- return splitted;
- }, [html]);
- const numPages = htmlPages.length;
-
// jump to annotations
const selectedAnnotationId = useAppSelector((state) => state.annotations.selectedAnnotationId);
useEffect(() => {
@@ -69,14 +51,6 @@ function DocumentRenderer({
}
}, [selectedAnnotationId]);
- // virtualization
- const listRef: React.MutableRefObject = useRef(null);
- const virtualizer = useVirtualizer({
- count: numPages,
- getScrollElement: () => listRef.current,
- estimateSize: () => 155,
- });
-
const basicProcessingInstructions = useCallback(
(options: HTMLReactParserOptions) => (domNode: Element) => {
// links
@@ -185,32 +159,8 @@ function DocumentRenderer({
}, [annotationMap, annotationsPerToken, tokenData, basicProcessingInstructions]);
return (
-
-
- {virtualizer.getVirtualItems().map((virtualItem) => (
-
-
-
- ))}
-
+
+
);
}
diff --git a/frontend/src/views/annotation/DocumentRenderer/Mark.tsx b/frontend/src/views/annotation/DocumentRenderer/Mark.tsx
index c8cf4cd74..6d5007142 100644
--- a/frontend/src/views/annotation/DocumentRenderer/Mark.tsx
+++ b/frontend/src/views/annotation/DocumentRenderer/Mark.tsx
@@ -13,7 +13,7 @@ interface MarkProps {
function Mark({ codeId, isStart, isEnd, height, top, groups }: MarkProps) {
const code = CodeHooks.useGetCode(codeId);
- if (code.isSuccess) {
+ if (code.data) {
let color: string;
if (code.data.is_system && code.data.name === "MENTION" && groups && groups.length === 1) {
// coreference annotation
diff --git a/frontend/src/views/annotation/ICode.ts b/frontend/src/views/annotation/ICode.ts
deleted file mode 100644
index ea80c8204..000000000
--- a/frontend/src/views/annotation/ICode.ts
+++ /dev/null
@@ -1,5 +0,0 @@
-export interface ICode {
- id: number;
- name: string;
- color: string;
-}
diff --git a/frontend/src/views/annotation/ImageAnnotator/ImageAnnotator.tsx b/frontend/src/views/annotation/ImageAnnotator/ImageAnnotator.tsx
index 49377c45b..adc56723a 100644
--- a/frontend/src/views/annotation/ImageAnnotator/ImageAnnotator.tsx
+++ b/frontend/src/views/annotation/ImageAnnotator/ImageAnnotator.tsx
@@ -10,7 +10,6 @@ import ConfirmationAPI from "../../../components/ConfirmationDialog/Confirmation
import { useAppSelector } from "../../../plugins/ReduxHooks.ts";
import { Annotation } from "../Annotation.ts";
import AnnotationMenu, { CodeSelectorHandle } from "../AnnotationMenu/AnnotationMenu.tsx";
-import { ICode } from "../ICode.ts";
import SVGBBox from "./SVGBBox.tsx";
import SVGBBoxText from "./SVGBBoxText.tsx";
@@ -202,14 +201,14 @@ function ImageAnnotatorWithHeight({ sdocData, height }: ImageAnnotatorProps & {
};
// code selector events
- const onCodeSelectorAddCode = (code: ICode) => {
+ const onCodeSelectorAddCode = (codeId: number) => {
const myRect = d3.select(rectRef.current);
const x = parseInt(myRect.attr("x"));
const y = parseInt(myRect.attr("y"));
const width = parseInt(myRect.attr("width"));
const height = parseInt(myRect.attr("height"));
createMutation.mutate({
- code_id: code.id,
+ code_id: codeId,
sdoc_id: sdocData.id,
x_min: x,
x_max: x + width,
@@ -218,12 +217,12 @@ function ImageAnnotatorWithHeight({ sdocData, height }: ImageAnnotatorProps & {
});
};
- const onCodeSelectorEditCode = (_annotationToEdit: Annotation, code: ICode) => {
+ const onCodeSelectorEditCode = (_annotationToEdit: Annotation, codeId: number) => {
if (selectedBbox) {
updateMutation.mutate({
bboxToUpdate: selectedBbox,
requestBody: {
- code_id: code.id,
+ code_id: codeId,
},
});
} else {
diff --git a/frontend/src/views/annotation/SentenceAnnotator/Annotator/DocumentSentence.tsx b/frontend/src/views/annotation/SentenceAnnotator/Annotator/DocumentSentence.tsx
index 27b91fdcd..473272623 100644
--- a/frontend/src/views/annotation/SentenceAnnotator/Annotator/DocumentSentence.tsx
+++ b/frontend/src/views/annotation/SentenceAnnotator/Annotator/DocumentSentence.tsx
@@ -1,14 +1,13 @@
import { ListItemButton, Stack, StackProps, Tooltip } from "@mui/material";
import { useMemo } from "react";
import { CodeMap } from "../../../../api/CodeHooks.ts";
-import { CodeRead } from "../../../../api/openapi/models/CodeRead.ts";
import { SentenceAnnotationRead } from "../../../../api/openapi/models/SentenceAnnotationRead.ts";
import ColorUtils from "../../../../utils/ColorUtils.ts";
interface DocumentSentenceProps {
sentenceId: number;
isSelected: boolean;
- selectedCode: CodeRead | undefined;
+ selectedCodeId: number | undefined;
selectedSentAnnoId: number | undefined;
hoveredSentAnnoId: number | null;
hoveredCodeId: number | undefined;
@@ -28,7 +27,7 @@ interface DocumentSentenceProps {
function DocumentSentence({
sentenceId,
isSelected,
- selectedCode,
+ selectedCodeId,
selectedSentAnnoId,
hoveredSentAnnoId,
hoveredCodeId,
@@ -59,8 +58,8 @@ function DocumentSentence({
const sentAnnoCodeIds = useMemo(() => sentenceAnnotations.map((anno) => anno.code_id), [sentenceAnnotations]);
const highlightedColor = useMemo(() => {
- if (isSelected) {
- return selectedCode?.color || "rgb(255, 0, 0)";
+ if (isSelected && selectedCodeId) {
+ return codeMap[selectedCodeId]?.color || "rgb(255, 0, 0)";
}
if (hoveredSentAnnoId) {
const sa = sentAnnoMap[hoveredSentAnnoId];
@@ -80,7 +79,7 @@ function DocumentSentence({
sentAnnoCodeIds,
selectedSentAnnoId,
sentAnnoMap,
- selectedCode?.color,
+ selectedCodeId,
codeMap,
]);
diff --git a/frontend/src/views/annotation/SentenceAnnotator/Annotator/SentenceAnnotator.tsx b/frontend/src/views/annotation/SentenceAnnotator/Annotator/SentenceAnnotator.tsx
index ec879684d..8bafb0647 100644
--- a/frontend/src/views/annotation/SentenceAnnotator/Annotator/SentenceAnnotator.tsx
+++ b/frontend/src/views/annotation/SentenceAnnotator/Annotator/SentenceAnnotator.tsx
@@ -2,14 +2,12 @@ import { Box, BoxProps } from "@mui/material";
import { useVirtualizer } from "@tanstack/react-virtual";
import { memo, useMemo, useRef, useState } from "react";
import CodeHooks from "../../../../api/CodeHooks.ts";
-import { CodeRead } from "../../../../api/openapi/models/CodeRead.ts";
import { SentenceAnnotationRead } from "../../../../api/openapi/models/SentenceAnnotationRead.ts";
import { SourceDocumentDataRead } from "../../../../api/openapi/models/SourceDocumentDataRead.ts";
import { useAppDispatch, useAppSelector } from "../../../../plugins/ReduxHooks.ts";
import { AnnoActions } from "../../annoSlice.ts";
import { Annotation } from "../../Annotation.ts";
import AnnotationMenu, { CodeSelectorHandle } from "../../AnnotationMenu/AnnotationMenu.tsx";
-import { ICode } from "../../ICode.ts";
import SentenceAnnotationHooks from "../../../../api/SentenceAnnotationHooks.ts";
import { useGetSentenceAnnotator } from "../useGetSentenceAnnotator.ts";
@@ -29,7 +27,7 @@ function SentenceAnnotator({ sdocData, virtualizerScrollElementRef, ...props }:
const annotator = useGetSentenceAnnotator({ sdocId: sdocData.id, userId: visibleUserId });
// selection
- const mostRecentCode = useAppSelector((state) => state.annotations.mostRecentCode);
+ const mostRecentCodeId = useAppSelector((state) => state.annotations.mostRecentCodeId);
const [selectedSentences, setSelectedSentences] = useState([]);
const [lastClickedIndex, setLastClickedIndex] = useState(null);
const [isDragging, setIsDragging] = useState(false);
@@ -48,21 +46,21 @@ function SentenceAnnotator({ sdocData, virtualizerScrollElementRef, ...props }:
const handleCodeSelectorDeleteAnnotation = (annotation: Annotation) => {
deleteMutation.mutate(annotation as SentenceAnnotationRead);
};
- const handleCodeSelectorEditCode = (annotation: Annotation, code: ICode) => {
+ const handleCodeSelectorEditCode = (annotation: Annotation, codeId: number) => {
updateMutation.mutate({
sentenceAnnoToUpdate: annotation as SentenceAnnotationRead,
update: {
- code_id: code.id,
+ code_id: codeId,
},
});
};
- const handleCodeSelectorAddCode = (code: CodeRead, isNewCode: boolean) => {
+ const handleCodeSelectorAddCode = (codeId: number, isNewCode: boolean) => {
setSelectedSentences([]);
setLastClickedIndex(null);
createMutation.mutate(
{
requestBody: {
- code_id: code.id,
+ code_id: codeId,
sdoc_id: sdocData.id,
sentence_id_start: selectedSentences[0],
sentence_id_end: selectedSentences[selectedSentences.length - 1],
@@ -72,7 +70,7 @@ function SentenceAnnotator({ sdocData, virtualizerScrollElementRef, ...props }:
onSuccess: () => {
if (!isNewCode) {
// if we use an existing code to annotate, we move it to the top
- dispatch(AnnoActions.moveCodeToTop(code));
+ dispatch(AnnoActions.moveCodeToTop(codeId));
}
},
},
@@ -80,10 +78,10 @@ function SentenceAnnotator({ sdocData, virtualizerScrollElementRef, ...props }:
};
const handleCodeSelectorClose = (reason?: "backdropClick" | "escapeKeyDown") => {
// i clicked away because i like the annotation as is
- if (selectedSentences.length > 0 && reason === "backdropClick" && mostRecentCode) {
+ if (selectedSentences.length > 0 && reason === "backdropClick" && mostRecentCodeId) {
createMutation.mutate({
requestBody: {
- code_id: mostRecentCode.id,
+ code_id: mostRecentCodeId,
sdoc_id: sdocData.id,
sentence_id_start: selectedSentences[0],
sentence_id_end: selectedSentences[selectedSentences.length - 1],
@@ -245,7 +243,7 @@ function SentenceAnnotator({ sdocData, virtualizerScrollElementRef, ...props }:
sentenceAnnotations={annotator.annotatorResult!.sentence_annotations[item.index]}
sentence={sentence}
isSelected={selectedSentences.includes(item.index)}
- selectedCode={mostRecentCode}
+ selectedCodeId={mostRecentCodeId}
onAnnotationClick={(event, sentAnnoId) => handleAnnotationClick(event, sentAnnoId, item.index)}
onAnnotationMouseEnter={handleAnnotationMouseEnter}
onAnnotationMouseLeave={handleAnnotationMouseLeave}
diff --git a/frontend/src/views/annotation/SentenceAnnotator/Comparator/DocumentSentence.tsx b/frontend/src/views/annotation/SentenceAnnotator/Comparator/DocumentSentence.tsx
index c8d4755e4..ad7a87576 100644
--- a/frontend/src/views/annotation/SentenceAnnotator/Comparator/DocumentSentence.tsx
+++ b/frontend/src/views/annotation/SentenceAnnotator/Comparator/DocumentSentence.tsx
@@ -5,7 +5,6 @@ import SquareIcon from "@mui/icons-material/Square";
import { Box, IconButton, ListItemButton, Stack, Tooltip } from "@mui/material";
import { useMemo } from "react";
import { CodeMap } from "../../../../api/CodeHooks.ts";
-import { CodeRead } from "../../../../api/openapi/models/CodeRead.ts";
import { SentenceAnnotationRead } from "../../../../api/openapi/models/SentenceAnnotationRead.ts";
import ColorUtils from "../../../../utils/ColorUtils.ts";
import { UseGetSentenceAnnotator } from "../useGetSentenceAnnotator.ts";
@@ -15,7 +14,7 @@ import { SentAnnoMap, useComputeSentAnnoMap } from "./useComputeSentAnnoMap.ts";
interface DocumentSentenceProps {
sentenceId: number;
isSelected: boolean;
- selectedCode: CodeRead | undefined;
+ selectedCodeId: number | undefined;
hoveredSentAnnoId: number | null;
hoveredCodeId: number | undefined;
sentence: string;
@@ -40,7 +39,7 @@ interface DocumentSentenceProps {
function DocumentSentence({
sentenceId,
isSelected,
- selectedCode,
+ selectedCodeId,
hoveredSentAnnoId,
hoveredCodeId,
sentence,
@@ -66,7 +65,7 @@ function DocumentSentence({
Object.values(sentAnnoMap).map((anno) => anno.code_id), [sentAnnoMap]);
const highlightedColor = useMemo(() => {
- if (isSelected) {
- return selectedCode?.color || "rgb(255, 0, 0)";
+ if (isSelected && selectedCodeId) {
+ return codeMap[selectedCodeId]?.color || "rgb(255, 0, 0)";
}
if (hoveredSentAnnoId) {
const sa = sentAnnoMap[hoveredSentAnnoId];
@@ -169,7 +168,7 @@ function DocumentSentencePart({
if (hoveredCodeId && sentAnnoCodeIds.includes(hoveredCodeId)) {
return codeMap[hoveredCodeId]?.color;
}
- }, [isSelected, hoveredSentAnnoId, hoveredCodeId, sentAnnoCodeIds, selectedCode?.color, sentAnnoMap, codeMap]);
+ }, [isSelected, hoveredSentAnnoId, hoveredCodeId, sentAnnoCodeIds, selectedCodeId, sentAnnoMap, codeMap]);
return (
<>
diff --git a/frontend/src/views/annotation/SentenceAnnotator/Comparator/SentenceAnnotationComparison.tsx b/frontend/src/views/annotation/SentenceAnnotator/Comparator/SentenceAnnotationComparison.tsx
index 20cf1586d..04924308d 100644
--- a/frontend/src/views/annotation/SentenceAnnotator/Comparator/SentenceAnnotationComparison.tsx
+++ b/frontend/src/views/annotation/SentenceAnnotator/Comparator/SentenceAnnotationComparison.tsx
@@ -2,7 +2,6 @@ import { Box, BoxProps } from "@mui/material";
import { useVirtualizer } from "@tanstack/react-virtual";
import { memo, useMemo, useRef, useState } from "react";
import CodeHooks from "../../../../api/CodeHooks.ts";
-import { CodeRead } from "../../../../api/openapi/models/CodeRead.ts";
import { SentenceAnnotationRead } from "../../../../api/openapi/models/SentenceAnnotationRead.ts";
import { SourceDocumentDataRead } from "../../../../api/openapi/models/SourceDocumentDataRead.ts";
import { useAuth } from "../../../../auth/useAuth.ts";
@@ -10,7 +9,6 @@ import { useAppDispatch, useAppSelector } from "../../../../plugins/ReduxHooks.t
import { AnnoActions } from "../../annoSlice.ts";
import { Annotation } from "../../Annotation.ts";
import AnnotationMenu, { CodeSelectorHandle } from "../../AnnotationMenu/AnnotationMenu.tsx";
-import { ICode } from "../../ICode.ts";
import SentenceAnnotationHooks from "../../../../api/SentenceAnnotationHooks.ts";
import { useGetSentenceAnnotator } from "../useGetSentenceAnnotator.ts";
@@ -41,7 +39,7 @@ function SentenceAnnotationComparison({
const annotatorRight = useGetSentenceAnnotator({ sdocId: sdocData.id, userId: rightUserId });
// selection
- const mostRecentCode = useAppSelector((state) => state.annotations.mostRecentCode);
+ const mostRecentCodeId = useAppSelector((state) => state.annotations.mostRecentCodeId);
const [selectedSentences, setSelectedSentences] = useState([]);
const [lastClickedIndex, setLastClickedIndex] = useState(null);
const [isDragging, setIsDragging] = useState(false);
@@ -61,21 +59,21 @@ function SentenceAnnotationComparison({
const handleCodeSelectorDeleteAnnotation = (annotation: Annotation) => {
deleteMutation.mutate(annotation as SentenceAnnotationRead);
};
- const handleCodeSelectorEditCode = (annotation: Annotation, code: ICode) => {
+ const handleCodeSelectorEditCode = (annotation: Annotation, codeId: number) => {
updateMutation.mutate({
sentenceAnnoToUpdate: annotation as SentenceAnnotationRead,
update: {
- code_id: code.id,
+ code_id: codeId,
},
});
};
- const handleCodeSelectorAddCode = (code: CodeRead, isNewCode: boolean) => {
+ const handleCodeSelectorAddCode = (codeId: number, isNewCode: boolean) => {
setSelectedSentences([]);
setLastClickedIndex(null);
createMutation.mutate(
{
requestBody: {
- code_id: code.id,
+ code_id: codeId,
sdoc_id: sdocData.id,
sentence_id_start: selectedSentences[0],
sentence_id_end: selectedSentences[selectedSentences.length - 1],
@@ -85,7 +83,7 @@ function SentenceAnnotationComparison({
onSuccess: () => {
if (!isNewCode) {
// if we use an existing code to annotate, we move it to the top
- dispatch(AnnoActions.moveCodeToTop(code));
+ dispatch(AnnoActions.moveCodeToTop(codeId));
}
},
},
@@ -93,10 +91,10 @@ function SentenceAnnotationComparison({
};
const handleCodeSelectorClose = (reason?: "backdropClick" | "escapeKeyDown") => {
// i clicked away because i like the annotation as is
- if (selectedSentences.length > 0 && reason === "backdropClick" && mostRecentCode) {
+ if (selectedSentences.length > 0 && reason === "backdropClick" && mostRecentCodeId) {
createMutation.mutate({
requestBody: {
- code_id: mostRecentCode.id,
+ code_id: mostRecentCodeId,
sdoc_id: sdocData.id,
sentence_id_start: selectedSentences[0],
sentence_id_end: selectedSentences[selectedSentences.length - 1],
@@ -359,7 +357,7 @@ function SentenceAnnotationComparison({
sentenceId={sentId}
sentence={sentence}
isSelected={selectedSentences.includes(sentId)}
- selectedCode={mostRecentCode}
+ selectedCodeId={mostRecentCodeId}
onSentenceMouseDown={handleSentenceMouseDown}
onSentenceMouseEnter={handleSentenceMouseEnter}
onAnnotationClick={handleAnnotationClick}
diff --git a/frontend/src/views/annotation/TextAnnotator/TextAnnotator.tsx b/frontend/src/views/annotation/TextAnnotator/TextAnnotator.tsx
index 75fc6c716..5feb9c197 100644
--- a/frontend/src/views/annotation/TextAnnotator/TextAnnotator.tsx
+++ b/frontend/src/views/annotation/TextAnnotator/TextAnnotator.tsx
@@ -3,20 +3,19 @@ import React, { MouseEvent, useRef, useState } from "react";
import { QueryKey } from "../../../api/QueryKey.ts";
import SpanAnnotationHooks, { FAKE_ANNOTATION_ID } from "../../../api/SpanAnnotationHooks.ts";
-import { CodeRead } from "../../../api/openapi/models/CodeRead.ts";
import { SourceDocumentDataRead } from "../../../api/openapi/models/SourceDocumentDataRead.ts";
import { SpanAnnotationCreate } from "../../../api/openapi/models/SpanAnnotationCreate.ts";
import { SpanAnnotationRead } from "../../../api/openapi/models/SpanAnnotationRead.ts";
+import { useAuth } from "../../../auth/useAuth.ts";
import ConfirmationAPI from "../../../components/ConfirmationDialog/ConfirmationAPI.ts";
import { useOpenSnackbar } from "../../../components/SnackbarDialog/useOpenSnackbar.ts";
import { useAppDispatch, useAppSelector } from "../../../plugins/ReduxHooks.ts";
+import { SYSTEM_USER_ID } from "../../../utils/GlobalConstants.ts";
import { Annotation } from "../Annotation.ts";
import AnnotationMenu, { CodeSelectorHandle } from "../AnnotationMenu/AnnotationMenu.tsx";
import DocumentRenderer from "../DocumentRenderer/DocumentRenderer.tsx";
import useComputeTokenData from "../DocumentRenderer/useComputeTokenData.ts";
-import { ICode } from "../ICode.ts";
import { AnnoActions, TagStyle } from "../annoSlice.ts";
-
const selectionIsEmpty = (selection: Selection): boolean => {
return selection.toString().trim().length === 0;
};
@@ -26,13 +25,15 @@ interface TextAnnotatorProps {
}
function TextAnnotator({ sdocData }: TextAnnotatorProps) {
+ const { user } = useAuth();
+
// local state
const spanMenuRef = useRef(null);
const [fakeAnnotation, setFakeAnnotation] = useState(undefined);
// global client state (redux)
const visibleUserId = useAppSelector((state) => state.annotations.visibleUserId);
- const mostRecentCode = useAppSelector((state) => state.annotations.mostRecentCode);
+ const mostRecentCodeId = useAppSelector((state) => state.annotations.mostRecentCodeId);
const selectedCodeId = useAppSelector((state) => state.annotations.selectedCodeId);
const tagStyle = useAppSelector((state) => state.annotations.tagStyle);
const dispatch = useAppDispatch();
@@ -108,7 +109,7 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) {
}
// the selection is valid
- if (!mostRecentCode && !selectedCodeId) {
+ if (!mostRecentCodeId && !selectedCodeId) {
openSnackbar({
severity: "warning",
text: "Select a code in the Code Explorer (left) first!",
@@ -147,7 +148,7 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) {
.join(" ");
const requestBody: SpanAnnotationCreate = {
- code_id: mostRecentCode?.id || selectedCodeId || -1,
+ code_id: mostRecentCodeId || selectedCodeId || -1,
sdoc_id: sdocData.id,
begin: tokenData[begin_token].beginChar,
end: tokenData[end_token].endChar,
@@ -175,7 +176,7 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) {
code_id: requestBody.code_id,
created: "",
updated: "",
- user_id: 0,
+ user_id: user?.id || SYSTEM_USER_ID,
group_ids: [],
memo_ids: [],
};
@@ -209,32 +210,32 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) {
},
});
};
- const handleCodeSelectorEditCode = (annotation: Annotation, code: ICode) => {
+ const handleCodeSelectorEditCode = (annotation: Annotation, codeId: number) => {
updateMutation.mutate({
spanAnnotationToUpdate: annotation as SpanAnnotationRead,
requestBody: {
- code_id: code.id,
+ code_id: codeId,
},
});
};
- const handleCodeSelectorAddCode = (code: CodeRead, isNewCode: boolean) => {
+ const handleCodeSelectorAddCode = (codeId: number, isNewCode: boolean) => {
if (!fakeAnnotation) return;
createMutation.mutate(
{
...fakeAnnotation,
- code_id: code.id,
+ code_id: codeId,
},
{
onSuccess: () => {
if (!isNewCode) {
// if we use an existing code to annotate, we move it to the top
- dispatch(AnnoActions.moveCodeToTop(code));
+ dispatch(AnnoActions.moveCodeToTop(codeId));
}
},
},
);
};
- const handleCodeSelectorDuplicateAnnotation = (annotation: Annotation, code: CodeRead) => {
+ const handleCodeSelectorDuplicateAnnotation = (annotation: Annotation, codeId: number) => {
if ("id" in annotation && "begin_token" in annotation && "end_token" in annotation) {
const fakeAnnotation: SpanAnnotationCreate = {
begin: annotation.begin,
@@ -243,11 +244,11 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) {
end_token: annotation.end_token,
span_text: annotation.text,
sdoc_id: annotation.sdoc_id,
- code_id: code.id,
+ code_id: codeId,
};
createMutation.mutate(fakeAnnotation, {
onSuccess: () => {
- dispatch(AnnoActions.moveCodeToTop(code));
+ dispatch(AnnoActions.moveCodeToTop(codeId));
},
});
}
@@ -258,7 +259,14 @@ function TextAnnotator({ sdocData }: TextAnnotatorProps) {
// i clicked away because i like the annotation as is
if (reason === "backdropClick") {
// add the annotation as is
- createMutation.mutate({ ...fakeAnnotation });
+ createMutation.mutate(
+ { ...fakeAnnotation },
+ {
+ onSuccess: () => {
+ dispatch(AnnoActions.moveCodeToTop(fakeAnnotation.code_id));
+ },
+ },
+ );
}
// i clicked escape because i want to cancel the annotation
if (reason === "escapeKeyDown") {
diff --git a/frontend/src/views/annotation/annoSlice.ts b/frontend/src/views/annotation/annoSlice.ts
index c221452eb..f0f484cf9 100644
--- a/frontend/src/views/annotation/annoSlice.ts
+++ b/frontend/src/views/annotation/annoSlice.ts
@@ -1,7 +1,6 @@
import { createSlice, PayloadAction } from "@reduxjs/toolkit";
import { persistReducer } from "redux-persist";
import storage from "redux-persist/lib/storage";
-import { CodeRead } from "../../api/openapi/models/CodeRead.ts";
import { ProjectActions } from "../../components/Project/projectSlice.ts";
import { RootState } from "../../store/store.ts";
import AnnotationMode from "./AnnotationMode.ts";
@@ -16,7 +15,7 @@ export interface AnnoState {
selectedAnnotationId: number | undefined; // the annotation selected in the annotation explorer.
selectedCodeId: number | undefined; // the code selected in the code explorer, used to compute which codes are shown in the annotation menu.
hoveredCodeId: number | undefined; // the code hovered in the code explorer, used to compute highlightings.
- mostRecentCode: CodeRead | undefined; // the most recently applied code, it is always at the top of the annotation menu and the default code for new annotations.
+ mostRecentCodeId: number | undefined; // the most recently applied code, it is always at the top of the annotation menu and the default code for new annotations.
expandedCodeIds: string[]; // the code ids of the expanded codes in the code explorer.
hiddenCodeIds: number[]; // the code ids of the hidden codes. Hidden codes are shown in the CodeExplorer, but are not rendered in the Annotator.
visibleUserId: number | undefined; // the user id of the user whose annotations are shown in the Annotator.
@@ -32,7 +31,7 @@ const initialState: AnnoState = {
selectedAnnotationId: undefined,
selectedCodeId: undefined,
hoveredCodeId: undefined,
- mostRecentCode: undefined,
+ mostRecentCodeId: undefined,
expandedCodeIds: [],
hiddenCodeIds: [],
visibleUserId: undefined,
@@ -102,8 +101,8 @@ export const annoSlice = createSlice({
}
state.visibleUserId = action.payload;
},
- moveCodeToTop: (state, action: PayloadAction) => {
- state.mostRecentCode = action.payload;
+ moveCodeToTop: (state, action: PayloadAction) => {
+ state.mostRecentCodeId = action.payload;
},
onSetAnnotatorTagStyle: (state, action: PayloadAction) => {
if (action.payload !== undefined && action.payload !== null) {
@@ -127,6 +126,25 @@ export const annoSlice = createSlice({
state.compareWithUserId = undefined;
state.isCompareMode = false;
},
+ onDeleteCode: (state, action: PayloadAction) => {
+ // remove references to deleted code
+ if (state.selectedCodeId === action.payload) {
+ state.selectedCodeId = undefined;
+ }
+ if (state.mostRecentCodeId === action.payload) {
+ state.mostRecentCodeId = undefined;
+ }
+ if (state.hoveredCodeId === action.payload) {
+ state.hoveredCodeId = undefined;
+ }
+ if (state.expandedCodeIds.length > 0) {
+ state.expandedCodeIds = state.expandedCodeIds.filter((id) => parseInt(id, 10) !== action.payload);
+ }
+ if (state.hiddenCodeIds.length > 0) {
+ state.hiddenCodeIds = state.hiddenCodeIds.filter((id) => id !== action.payload);
+ }
+ state.selectedAnnotationId = undefined;
+ },
},
extraReducers: (builder) => {
builder.addCase(ProjectActions.changeProject, (state) => {
@@ -134,7 +152,7 @@ export const annoSlice = createSlice({
state.selectedAnnotationId = initialState.selectedAnnotationId;
state.selectedCodeId = initialState.selectedCodeId;
state.hoveredCodeId = initialState.hoveredCodeId;
- state.mostRecentCode = initialState.mostRecentCode;
+ state.mostRecentCodeId = initialState.mostRecentCodeId;
state.expandedCodeIds = initialState.expandedCodeIds;
state.hiddenCodeIds = initialState.hiddenCodeIds;
state.visibleUserId = initialState.visibleUserId;
diff --git a/frontend/src/views/search/DocumentSearch/SearchDocumentTable.tsx b/frontend/src/views/search/DocumentSearch/SearchDocumentTable.tsx
index 48c88e28f..b845fa6b6 100644
--- a/frontend/src/views/search/DocumentSearch/SearchDocumentTable.tsx
+++ b/frontend/src/views/search/DocumentSearch/SearchDocumentTable.tsx
@@ -15,7 +15,7 @@ import {
MRT_Updater,
useMaterialReactTable,
} from "material-react-table";
-import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { useCallback, useEffect, useMemo, useRef } from "react";
import { useNavigate } from "react-router-dom";
import { FolderMap } from "../../../api/FolderHooks.ts";
import { QueryKey } from "../../../api/QueryKey.ts";
@@ -54,18 +54,12 @@ import { useTableFetchMoreOnScroll } from "../../../utils/useTableInfiniteScroll
import { useInitSearchFilterSlice } from "../useInitSearchFilterSlice.ts";
import OpenInTabsButton from "./OpenInTabsButton.tsx";
import SearchOptionsMenu from "./SearchOptionsMenu.tsx";
-import { SearchActions } from "./searchSlice.ts";
+import { FolderSelection, SearchActions } from "./searchSlice.ts";
// this has to match Search.tsx!
const filterStateSelector = (state: RootState) => state.search;
const filterName = "root";
-enum FolderSelection {
- FOLDER = "FOLDER",
- SDOC = "SDOC",
- UNKNOWN = "UNKNOWN",
-}
-
const rowSelection = (fs: FolderSelection) => (row: MRT_Row) => {
switch (fs) {
case FolderSelection.FOLDER:
@@ -91,7 +85,7 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable
const dispatch = useAppDispatch();
// custom row selection state (it distinguishes between folders and source documents)
- const [folderSelectionType, setFolderSelectionType] = useState(FolderSelection.UNKNOWN);
+ const folderSelectionType = useAppSelector((state) => state.search.folderSelectionType);
const rowSelectionModel = useAppSelector((state) => state.search.rowSelectionModel);
const setRowSelectionModel = useCallback(
(updater: MRT_Updater) => {
@@ -100,7 +94,7 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable
// if it contains any numbers, it's SDOC, otherwise it's FOLDER
if (Object.keys(rowSelectionModel).length === 0) {
if (Object.keys(newState).some((key) => !isNaN(Number(key)))) {
- setFolderSelectionType(FolderSelection.SDOC);
+ dispatch(SearchActions.onFolderSelectionChange(FolderSelection.SDOC));
// remove all keys that are not numbers
Object.keys(newState).forEach((key) => {
if (isNaN(Number(key))) {
@@ -108,12 +102,12 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable
}
});
} else {
- setFolderSelectionType(FolderSelection.FOLDER);
+ dispatch(SearchActions.onFolderSelectionChange(FolderSelection.FOLDER));
}
}
// if new state is empty, reset the folder selection type
if (Object.keys(newState).length === 0) {
- setFolderSelectionType(FolderSelection.UNKNOWN);
+ dispatch(SearchActions.onFolderSelectionChange(FolderSelection.UNKNOWN));
}
dispatch(SearchActions.onRowSelectionChange(newState));
},
@@ -133,6 +127,10 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable
(state) => state.search.columnVisibilityModel,
SearchActions.onColumnVisibilityChange,
);
+ const [expandedModel, setExpandedModel] = useReduxConnector(
+ (state) => state.search.expandedModel,
+ SearchActions.onExpandedChange,
+ );
const [columnSizingModel, setColumnSizingModel] = useReduxConnector(
(state) => state.search.columnSizingModel,
SearchActions.onColumnSizingChange,
@@ -313,6 +311,7 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable
// if showFolders is true, we need to merge the sub_rows
const hits: Record = {};
+ const sortedHitIds: number[] = [];
data.pages.forEach((page) => {
page.hits.forEach((hit) => {
// do the merging here!
@@ -321,9 +320,14 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable
} else {
hits[hit.id] = JSON.parse(JSON.stringify(hit)); // deep clone to avoid mutating the original hit
}
+
+ // keep track of the order
+ if (!sortedHitIds.includes(hit.id)) {
+ sortedHitIds.push(hit.id);
+ }
});
});
- const flatData = Object.values(hits);
+ const flatData = sortedHitIds.map((id) => hits[id]);
return {
flatData,
totalFetchedSdocs: flatData.reduce((acc, hit) => acc + hit.sub_rows.length, 0),
@@ -349,9 +353,11 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable
columns: columns,
getRowId: (row) => (row.is_folder ? `folder-${row.id}` : `${row.id}`),
// sub rows / folders
- enableExpanding: showFolders,
getSubRows: showFolders ? (originalRow) => originalRow.sub_rows : undefined, //default, can customize
rowCount: folderSelectionType === FolderSelection.FOLDER ? totalFetchedFolders : totalFetchedSdocs,
+ //expansion
+ enableExpanding: showFolders,
+ onExpandedChange: setExpandedModel,
// state
state: {
globalFilter: searchQuery,
@@ -364,6 +370,7 @@ function SearchDocumentTable({ projectId, onSearchResultsChange }: DocumentTable
showAlertBanner: isError,
showProgressBars: isFetching,
showGlobalFilter: true,
+ expanded: expandedModel,
},
// search query
autoResetAll: false,
diff --git a/frontend/src/views/search/DocumentSearch/searchSlice.ts b/frontend/src/views/search/DocumentSearch/searchSlice.ts
index 00e426d93..52f7d2c22 100644
--- a/frontend/src/views/search/DocumentSearch/searchSlice.ts
+++ b/frontend/src/views/search/DocumentSearch/searchSlice.ts
@@ -26,6 +26,12 @@ import { ProjectActions } from "../../../components/Project/projectSlice.ts";
import { TableState, initialTableState, resetProjectTableState, tableReducer } from "../../../components/tableSlice.ts";
import { getValue } from "../metadataUtils.ts";
+export enum FolderSelection {
+ FOLDER = "FOLDER",
+ SDOC = "SDOC",
+ UNKNOWN = "UNKNOWN",
+}
+
interface SearchState {
// project state:
selectedDocumentId: number | undefined; // the id of the selected document. Used to highlight the selected document in the table, and to show the document information (tags, metadata etc.).
@@ -35,6 +41,7 @@ interface SearchState {
selectedFolderId: number; // the id of the selected folder. (the root folder is -1)
showFolders: boolean; // whether the folders are shown in search table.
scrollPosition: number; // the scroll position of the document table, used to restore position when returning to the table
+ folderSelectionType: FolderSelection; // whether a folder or a document is selected
// app state:
expertSearchMode: boolean; // whether the expert search mode is enabled.
sortStatsByGlobal: boolean; // whether the search statistics are sorted by the global frequency or the "local" ().
@@ -58,6 +65,7 @@ const initialState: FilterState & TableState & SearchState = {
selectedFolderId: -1, // the root folder is -1
showFolders: true,
scrollPosition: 0,
+ folderSelectionType: FolderSelection.UNKNOWN,
// app state:
expertSearchMode: false,
sortStatsByGlobal: false,
@@ -118,6 +126,10 @@ export const searchSlice = createSlice({
delete state.rowSelectionModel[`${sdocId}`];
}
},
+ // folder selection type
+ onFolderSelectionChange: (state, action: PayloadAction) => {
+ state.folderSelectionType = action.payload;
+ },
// tag explorer
setExpandedTagIds: (state, action: PayloadAction) => {
state.expandedTagIds = action.payload;
@@ -241,6 +253,7 @@ export const searchSlice = createSlice({
state.scrollPosition = initialState.scrollPosition;
state.expandedFolderIds = initialState.expandedFolderIds;
state.selectedFolderId = initialState.selectedFolderId;
+ state.folderSelectionType = initialState.folderSelectionType;
resetProjectTableState(state);
resetProjectFilterState({ state, defaultFilterExpression, projectId: action.payload, sliceName: "search" });
})
diff --git a/frontend/src/views/whiteboard/WhiteboardFlow.tsx b/frontend/src/views/whiteboard/WhiteboardFlow.tsx
index 888323dab..1c5ebf974 100644
--- a/frontend/src/views/whiteboard/WhiteboardFlow.tsx
+++ b/frontend/src/views/whiteboard/WhiteboardFlow.tsx
@@ -481,7 +481,7 @@ function WhiteboardFlow({ whiteboard }: WhiteboardFlowProps) {
}}
>
-
+
-
+
+
+
+
+
{
switch (attachedObjectType) {
@@ -210,7 +211,7 @@ function MemoNode(props: NodeProps) {
// global server state (react-query)
const memo = MemoHooks.useGetMemo(props.data.memoId);
- const attachedObject = useGetMemosAttachedObject(memo.data?.attached_object_type)(memo.data?.attached_object_id);
+ const attachedObject = useGetMemosAttachedObject(memo.data?.attached_object_type, memo.data?.attached_object_id);
useEffect(() => {
if (!memo.data || !attachedObject.data) return;
@@ -285,7 +286,7 @@ function MemoNode(props: NodeProps) {
<>
} />
- {memo.data.content}
+ {memo.data.content}
>
) : memo.isError ? (