diff --git a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
index d9a3a30f..517eb1da 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/exception/ErrorCode.java
@@ -15,11 +15,6 @@ public enum ErrorCode {
ALIAS_NOT_FOUND,
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
INVALID_PERSON_TYPE,
- /** A concurrent edit on a referenced transcription block prevented the rename
- * from committing (optimistic-lock conflict). The whole rename rolls back; the
- * client should refetch and retry. 409 */
- PERSON_RENAME_CONFLICT,
-
// --- Documents ---
/** A document with the given ID does not exist. 404 */
DOCUMENT_NOT_FOUND,
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java
deleted file mode 100644
index 1b748c1a..00000000
--- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonDisplayNameChangedEvent.java
+++ /dev/null
@@ -1,23 +0,0 @@
-package org.raddatz.familienarchiv.model;
-
-import java.util.UUID;
-
-/**
- * Published by PersonService when a save changes Person.getDisplayName() — i.e.
- * any mutation to the fields that DisplayNameFormatter consumes (title,
- * firstName, lastName). Listeners on the transcription side rewrite block text
- * and sidecar entries that reference the old name.
- *
- *
This is the first custom application event in the codebase. The previous
- * only listener (OcrTrainingService.recoverOrphanedRuns) listens to Spring's
- * built-in ApplicationReadyEvent. Future cross-domain decoupling should follow
- * the same shape: record-typed event in model/, listener in the consuming
- * domain's service/ package, synchronous @EventListener inside the publisher's
- * transaction unless the workload genuinely needs to defer.
- */
-public record PersonDisplayNameChangedEvent(
- UUID personId,
- String oldDisplayName,
- String newDisplayName
-) {
-}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java
index 79b232d6..2ca2033e 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/model/PersonMention.java
@@ -26,5 +26,6 @@ public class PersonMention {
@Size(max = 200)
@Column(name = "display_name", nullable = false, length = 200)
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
+ // Archival: the text the transcriber typed after @. Never updated on person rename.
private String displayName;
}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java
deleted file mode 100644
index a3c03206..00000000
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListener.java
+++ /dev/null
@@ -1,77 +0,0 @@
-package org.raddatz.familienarchiv.service;
-
-import lombok.RequiredArgsConstructor;
-import lombok.extern.slf4j.Slf4j;
-import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
-import org.raddatz.familienarchiv.model.PersonMention;
-import org.raddatz.familienarchiv.model.TranscriptionBlock;
-import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
-import org.springframework.context.event.EventListener;
-import org.springframework.stereotype.Service;
-import org.springframework.transaction.annotation.Transactional;
-
-import java.util.List;
-import java.util.regex.Matcher;
-import java.util.regex.Pattern;
-
-/**
- * Transcription-domain consumer of {@link PersonDisplayNameChangedEvent}. When
- * Person.getDisplayName() flips during a rename, this listener rewrites every
- * transcription block whose sidecar references the renamed person — both the
- * literal "@OldName" inside block.text and the displayName carried in the
- * {@link PersonMention} entries.
- *
- *
Synchronous on purpose: the rename and the propagation must commit as one
- * transaction so a half-applied rewrite never reaches the archive. If the
- * archive grows past tens of thousands of blocks, switch to
- * {@code @TransactionalEventListener(AFTER_COMMIT) + @Async} — one annotation
- * change.
- */
-@Service
-@RequiredArgsConstructor
-@Slf4j
-public class PersonMentionPropagationListener {
-
- private final TranscriptionBlockRepository blockRepository;
-
- @EventListener
- @Transactional // Joins publisher's transaction — async switch requires @TransactionalEventListener(AFTER_COMMIT)
- public void onPersonDisplayNameChanged(PersonDisplayNameChangedEvent event) {
- List blocks =
- blockRepository.findByPersonIdWithMentionsFetched(event.personId());
- if (blocks.isEmpty()) {
- return;
- }
-
- String oldNeedle = "@" + event.oldDisplayName();
- String newNeedle = "@" + event.newDisplayName();
- Pattern boundary = Pattern.compile(
- Pattern.quote(oldNeedle) + "(?![\\p{L}0-9\\-]| (?=\\p{Lu}))");
- String replacement = Matcher.quoteReplacement(newNeedle);
-
- for (TranscriptionBlock block : blocks) {
- rewriteBlockText(block, boundary, replacement);
- for (PersonMention mention : block.getMentionedPersons()) {
- if (mention.getPersonId().equals(event.personId())) {
- mention.setDisplayName(event.newDisplayName());
- }
- }
- }
-
- blockRepository.saveAllAndFlush(blocks);
-
- log.info("Propagated rename {} → {} across {} block(s) for person {}",
- event.oldDisplayName(), event.newDisplayName(), blocks.size(), event.personId());
- }
-
- // Match @OldName only at a token boundary: not followed by a letter/digit/hyphen
- // (catches @Hans-Peter when renaming Hans) AND not followed by " "
- // (catches @Hans Müller when renaming the single-name @Hans). False negatives —
- // e.g. "@Hans Bekam" where Bekam is sentence-initial — are accepted as the
- // conservative trade-off; the alternative (corruption) is irrecoverable.
- private void rewriteBlockText(TranscriptionBlock block, Pattern boundary, String replacement) {
- if (block.getText() != null) {
- block.setText(boundary.matcher(block.getText()).replaceAll(replacement));
- }
- }
-}
diff --git a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
index 33873459..e1adbdba 100644
--- a/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
+++ b/backend/src/main/java/org/raddatz/familienarchiv/service/PersonService.java
@@ -1,7 +1,6 @@
package org.raddatz.familienarchiv.service;
import java.util.List;
-import java.util.Objects;
import java.util.Optional;
import java.util.UUID;
@@ -13,14 +12,11 @@ import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
-import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
-import org.springframework.context.ApplicationEventPublisher;
-import org.springframework.dao.OptimisticLockingFailureException;
import org.springframework.http.HttpStatus;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
@@ -34,7 +30,6 @@ public class PersonService {
private final PersonRepository personRepository;
private final PersonNameAliasRepository aliasRepository;
- private final ApplicationEventPublisher eventPublisher;
public List findAll(String q) {
if (q == null) {
@@ -161,7 +156,6 @@ public class PersonService {
validateYears(dto.getBirthYear(), dto.getDeathYear());
Person person = personRepository.findById(id)
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
- String oldDisplayName = person.getDisplayName();
person.setPersonType(dto.getPersonType());
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
person.setFirstName(dto.getFirstName());
@@ -170,17 +164,7 @@ public class PersonService {
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
person.setBirthYear(dto.getBirthYear());
person.setDeathYear(dto.getDeathYear());
- Person saved = personRepository.save(person);
- String newDisplayName = saved.getDisplayName();
- if (!Objects.equals(oldDisplayName, newDisplayName)) {
- try {
- eventPublisher.publishEvent(new PersonDisplayNameChangedEvent(id, oldDisplayName, newDisplayName));
- } catch (OptimisticLockingFailureException e) {
- throw DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT,
- "A referenced transcription block was modified concurrently — rename rolled back");
- }
- }
- return saved;
+ return personRepository.save(person);
}
@Transactional
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java
index 41a4a0ac..9de8a3a1 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/controller/PersonControllerTest.java
@@ -333,21 +333,6 @@ class PersonControllerTest {
.andExpect(jsonPath("$.lastName").value("Müller"));
}
- @Test
- @WithMockUser(authorities = "WRITE_ALL")
- void updatePerson_returns409_whenRenameConflict() throws Exception {
- UUID id = UUID.randomUUID();
- when(personService.updatePerson(eq(id), any()))
- .thenThrow(DomainException.conflict(ErrorCode.PERSON_RENAME_CONFLICT,
- "Concurrent block edit during rename"));
-
- mockMvc.perform(put("/api/persons/{id}", id)
- .contentType(MediaType.APPLICATION_JSON)
- .content("{\"firstName\":\"Augusta\",\"lastName\":\"Raddatz\",\"personType\":\"PERSON\"}"))
- .andExpect(status().isConflict())
- .andExpect(jsonPath("$.code").value("PERSON_RENAME_CONFLICT"));
- }
-
// ─── POST /api/persons/{id}/merge ─────────────────────────────────────────
@Test
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java
deleted file mode 100644
index c6b89716..00000000
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonMentionPropagationListenerTest.java
+++ /dev/null
@@ -1,227 +0,0 @@
-package org.raddatz.familienarchiv.service;
-
-import jakarta.persistence.EntityManager;
-import org.junit.jupiter.api.BeforeEach;
-import org.junit.jupiter.api.Test;
-import org.raddatz.familienarchiv.PostgresContainerConfig;
-import org.raddatz.familienarchiv.config.FlywayConfig;
-import org.raddatz.familienarchiv.model.Document;
-import org.raddatz.familienarchiv.model.DocumentAnnotation;
-import org.raddatz.familienarchiv.model.DocumentStatus;
-import org.raddatz.familienarchiv.model.Person;
-import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
-import org.raddatz.familienarchiv.model.PersonMention;
-import org.raddatz.familienarchiv.model.TranscriptionBlock;
-import org.raddatz.familienarchiv.repository.AnnotationRepository;
-import org.raddatz.familienarchiv.repository.DocumentRepository;
-import org.raddatz.familienarchiv.repository.PersonRepository;
-import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
-import org.springframework.beans.factory.annotation.Autowired;
-import org.springframework.boot.jdbc.test.autoconfigure.AutoConfigureTestDatabase;
-import org.springframework.boot.data.jpa.test.autoconfigure.DataJpaTest;
-import org.springframework.context.annotation.Import;
-
-import java.util.ArrayList;
-import java.util.List;
-import java.util.UUID;
-
-import static org.assertj.core.api.Assertions.assertThat;
-import static org.assertj.core.api.Assertions.assertThatCode;
-
-@DataJpaTest
-@AutoConfigureTestDatabase(replace = AutoConfigureTestDatabase.Replace.NONE)
-@Import({PostgresContainerConfig.class, FlywayConfig.class})
-class PersonMentionPropagationListenerTest {
-
- @Autowired TranscriptionBlockRepository blockRepository;
- @Autowired DocumentRepository documentRepository;
- @Autowired AnnotationRepository annotationRepository;
- @Autowired PersonRepository personRepository;
- @Autowired EntityManager em;
-
- private PersonMentionPropagationListener listener;
-
- private UUID documentId;
- private UUID annotationId;
-
- @BeforeEach
- void setUp() {
- listener = new PersonMentionPropagationListener(blockRepository);
-
- Document doc = documentRepository.save(Document.builder()
- .title("Letter").originalFilename("letter.pdf")
- .status(DocumentStatus.UPLOADED).build());
- documentId = doc.getId();
- DocumentAnnotation annotation = annotationRepository.save(DocumentAnnotation.builder()
- .documentId(documentId).pageNumber(1)
- .x(0.1).y(0.2).width(0.3).height(0.4)
- .color("#00C7B1").build());
- annotationId = annotation.getId();
- }
-
- private TranscriptionBlock saveBlock(String text, List mentions) {
- return blockRepository.saveAndFlush(TranscriptionBlock.builder()
- .annotationId(annotationId).documentId(documentId)
- .text(text).sortOrder(0)
- .mentionedPersons(mentions).build());
- }
-
- private UUID savedPersonId(String firstName, String lastName) {
- Person p = personRepository.save(Person.builder()
- .firstName(firstName).lastName(lastName).build());
- return p.getId();
- }
-
- @Test
- void rewritesTextAndSidecar_whenSingleBlockReferencesRenamedPerson() {
- UUID personId = savedPersonId("Auguste", "Raddatz");
- TranscriptionBlock saved = saveBlock(
- "Liebe Tante @Auguste Raddatz, danke für den Brief.",
- List.of(new PersonMention(personId, "Auguste Raddatz")));
- em.clear();
-
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
- blockRepository.flush();
- em.clear();
-
- TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
- assertThat(reloaded.getText()).isEqualTo("Liebe Tante @Augusta Raddatz, danke für den Brief.");
- assertThat(reloaded.getMentionedPersons())
- .extracting(PersonMention::getDisplayName)
- .containsExactly("Augusta Raddatz");
- }
-
- @Test
- void doesNotMatchPartialName_whenAnotherMentionShares_a_substring_with_renamed_person() {
- UUID hansPeterId = savedPersonId("Hans-Peter", "Müller");
- UUID hansId = savedPersonId("Hans", "Müller");
- TranscriptionBlock saved = saveBlock(
- "Heute hat @Hans-Peter Müller wieder mit @Hans Müller gesprochen.",
- List.of(
- new PersonMention(hansPeterId, "Hans-Peter Müller"),
- new PersonMention(hansId, "Hans Müller")));
- em.clear();
-
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(hansId, "Hans Müller", "Hans Schmidt"));
- blockRepository.flush();
- em.clear();
-
- TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
- assertThat(reloaded.getText())
- .isEqualTo("Heute hat @Hans-Peter Müller wieder mit @Hans Schmidt gesprochen.");
- assertThat(reloaded.getMentionedPersons())
- .extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
- .containsExactlyInAnyOrder(
- org.assertj.core.groups.Tuple.tuple(hansPeterId, "Hans-Peter Müller"),
- org.assertj.core.groups.Tuple.tuple(hansId, "Hans Schmidt"));
- }
-
- @Test
- void doesNotCorruptCompositeMention_whenRenamingSingleWordPerson() {
- UUID hansMüllerId = savedPersonId("Hans", "Müller");
- UUID hansId = savedPersonId(null, "Hans");
- TranscriptionBlock saved = saveBlock(
- "@Hans Müller schrieb. Auch @Hans hat geschrieben.",
- List.of(
- new PersonMention(hansMüllerId, "Hans Müller"),
- new PersonMention(hansId, "Hans")));
- em.clear();
-
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(hansId, "Hans", "Henry"));
- blockRepository.flush();
- em.clear();
-
- TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
- assertThat(reloaded.getText())
- .isEqualTo("@Hans Müller schrieb. Auch @Henry hat geschrieben.");
- assertThat(reloaded.getMentionedPersons())
- .extracting(PersonMention::getPersonId, PersonMention::getDisplayName)
- .containsExactlyInAnyOrder(
- org.assertj.core.groups.Tuple.tuple(hansMüllerId, "Hans Müller"),
- org.assertj.core.groups.Tuple.tuple(hansId, "Henry"));
- }
-
- @Test
- void rewritesAllOccurrences_whenSameMentionAppearsTwiceInBlock() {
- UUID personId = savedPersonId("Auguste", "Raddatz");
- TranscriptionBlock saved = saveBlock(
- "Heute hat @Auguste Raddatz geschrieben, dann hat @Auguste Raddatz nochmal geschrieben.",
- List.of(new PersonMention(personId, "Auguste Raddatz")));
- em.clear();
-
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
- blockRepository.flush();
- em.clear();
-
- TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
- assertThat(reloaded.getText())
- .isEqualTo("Heute hat @Augusta Raddatz geschrieben, dann hat @Augusta Raddatz nochmal geschrieben.");
- assertThat(reloaded.getMentionedPersons())
- .extracting(PersonMention::getDisplayName)
- .containsExactly("Augusta Raddatz");
- }
-
- @Test
- void propagatesAcross200Blocks_inUnderFiveSeconds_latencyFloor() {
- UUID personId = savedPersonId("Auguste", "Raddatz");
- List blockIds = new ArrayList<>();
- for (int i = 0; i < 200; i++) {
- TranscriptionBlock saved = blockRepository.save(TranscriptionBlock.builder()
- .annotationId(annotationId).documentId(documentId)
- .text("Block " + i + " mentions @Auguste Raddatz here.")
- .sortOrder(i)
- .mentionedPersons(List.of(new PersonMention(personId, "Auguste Raddatz")))
- .build());
- blockIds.add(saved.getId());
- }
- blockRepository.flush();
- em.clear();
-
- long start = System.nanoTime();
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
- blockRepository.flush();
- long elapsedMs = (System.nanoTime() - start) / 1_000_000;
-
- assertThat(elapsedMs)
- .as("Propagation across 200 blocks must stay under 5s — merge-blocking regression floor")
- .isLessThan(5000L);
-
- em.clear();
- TranscriptionBlock first = blockRepository.findById(blockIds.get(0)).orElseThrow();
- assertThat(first.getText()).contains("@Augusta Raddatz");
- }
-
- @Test
- void doesNotThrow_whenBlockTextIsNull() {
- UUID personId = savedPersonId("Auguste", "Raddatz");
- saveBlock(null, List.of(new PersonMention(personId, "Auguste Raddatz")));
- em.clear();
-
- assertThatCode(() -> listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz")))
- .doesNotThrowAnyException();
- }
-
- @Test
- void leavesUnrelatedBlockUntouched_whenNoSidecarReferencesPerson() {
- UUID personId = savedPersonId("Auguste", "Raddatz");
- TranscriptionBlock saved = saveBlock(
- "Plain text without any mentions.",
- List.of());
- em.clear();
-
- listener.onPersonDisplayNameChanged(
- new PersonDisplayNameChangedEvent(personId, "Auguste Raddatz", "Augusta Raddatz"));
- blockRepository.flush();
- em.clear();
-
- TranscriptionBlock reloaded = blockRepository.findById(saved.getId()).orElseThrow();
- assertThat(reloaded.getText()).isEqualTo("Plain text without any mentions.");
- assertThat(reloaded.getMentionedPersons()).isEmpty();
- }
-}
diff --git a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
index b502ac7c..24827177 100644
--- a/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
+++ b/backend/src/test/java/org/raddatz/familienarchiv/service/PersonServiceTest.java
@@ -2,7 +2,6 @@ package org.raddatz.familienarchiv.service;
import org.junit.jupiter.api.Test;
import org.junit.jupiter.api.extension.ExtendWith;
-import org.mockito.ArgumentCaptor;
import org.mockito.InjectMocks;
import org.mockito.Mock;
import org.mockito.junit.jupiter.MockitoExtension;
@@ -10,22 +9,14 @@ import org.raddatz.familienarchiv.dto.PersonNameAliasDTO;
import org.raddatz.familienarchiv.dto.PersonSummaryDTO;
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
import org.raddatz.familienarchiv.exception.DomainException;
-import org.raddatz.familienarchiv.exception.ErrorCode;
import org.raddatz.familienarchiv.model.Person;
-import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
-import org.raddatz.familienarchiv.model.PersonMention;
import org.raddatz.familienarchiv.model.PersonNameAlias;
import org.raddatz.familienarchiv.model.PersonNameAliasType;
import org.raddatz.familienarchiv.model.PersonType;
-import org.raddatz.familienarchiv.model.TranscriptionBlock;
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
import org.raddatz.familienarchiv.repository.PersonRepository;
-import org.raddatz.familienarchiv.repository.TranscriptionBlockRepository;
-import org.springframework.context.ApplicationEventPublisher;
-import org.springframework.orm.ObjectOptimisticLockingFailureException;
import org.springframework.web.server.ResponseStatusException;
-import java.util.ArrayList;
import java.util.List;
import java.util.Optional;
import java.util.UUID;
@@ -33,14 +24,16 @@ import java.util.UUID;
import static org.assertj.core.api.Assertions.assertThat;
import static org.assertj.core.api.Assertions.assertThatThrownBy;
import static org.mockito.ArgumentMatchers.any;
-import static org.mockito.Mockito.*;
+import static org.mockito.ArgumentMatchers.argThat;
+import static org.mockito.Mockito.never;
+import static org.mockito.Mockito.verify;
+import static org.mockito.Mockito.when;
@ExtendWith(MockitoExtension.class)
class PersonServiceTest {
@Mock PersonRepository personRepository;
@Mock PersonNameAliasRepository aliasRepository;
- @Mock ApplicationEventPublisher eventPublisher;
@InjectMocks PersonService personService;
// ─── getById ─────────────────────────────────────────────────────────────
@@ -252,121 +245,6 @@ class PersonServiceTest {
assertThat(result.getAlias()).isEqualTo("Anna Alt");
}
- // ─── updatePerson (display-name change event) ────────────────────────────
-
- @Test
- void updatePerson_publishesEvent_whenTitleChanges() {
- UUID id = UUID.randomUUID();
- Person existing = Person.builder()
- .id(id).title("Herr").firstName("Auguste").lastName("Raddatz")
- .personType(PersonType.PERSON).build();
- String oldName = existing.getDisplayName();
-
- when(personRepository.findById(id)).thenReturn(Optional.of(existing));
- when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
-
- PersonUpdateDTO dto = new PersonUpdateDTO();
- dto.setPersonType(PersonType.PERSON);
- dto.setTitle("Frau"); dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
-
- personService.updatePerson(id, dto);
-
- ArgumentCaptor captor =
- ArgumentCaptor.forClass(PersonDisplayNameChangedEvent.class);
- verify(eventPublisher).publishEvent(captor.capture());
-
- PersonDisplayNameChangedEvent event = captor.getValue();
- assertThat(event.personId()).isEqualTo(id);
- assertThat(event.oldDisplayName()).isEqualTo(oldName);
- assertThat(event.newDisplayName())
- .isNotEqualTo(oldName)
- .contains("Frau");
- }
-
- @Test
- void updatePerson_doesNotPublishEvent_whenDisplayNameFieldsUnchanged() {
- UUID id = UUID.randomUUID();
- Person existing = Person.builder()
- .id(id).firstName("Auguste").lastName("Raddatz")
- .personType(PersonType.PERSON).alias("old alias").build();
-
- when(personRepository.findById(id)).thenReturn(Optional.of(existing));
- when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
-
- PersonUpdateDTO dto = new PersonUpdateDTO();
- dto.setPersonType(PersonType.PERSON);
- dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
- dto.setAlias("new alias");
-
- personService.updatePerson(id, dto);
-
- verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
- }
-
- @Test
- void updatePerson_throwsConflict_whenBlockSaveAllAndFlushHitsOptimisticLock() {
- // Wire a real PersonMentionPropagationListener with a mocked block repository
- // that throws on saveAllAndFlush. The publisher mock routes events to the
- // listener so the catch path traverses the same call chain as production:
- // PersonService → publishEvent → listener → saveAllAndFlush throws → catch.
- UUID id = UUID.randomUUID();
- Person existing = Person.builder()
- .id(id).firstName("Auguste").lastName("Raddatz")
- .personType(PersonType.PERSON).build();
-
- TranscriptionBlock referencingBlock = TranscriptionBlock.builder()
- .id(UUID.randomUUID()).documentId(UUID.randomUUID()).annotationId(UUID.randomUUID())
- .text("Brief von @Auguste Raddatz").sortOrder(0)
- .mentionedPersons(new ArrayList<>(List.of(new PersonMention(id, "Auguste Raddatz"))))
- .build();
-
- TranscriptionBlockRepository blockRepo = mock(TranscriptionBlockRepository.class);
- when(blockRepo.findByPersonIdWithMentionsFetched(id))
- .thenReturn(List.of(referencingBlock));
- when(blockRepo.saveAllAndFlush(any()))
- .thenThrow(new ObjectOptimisticLockingFailureException(
- TranscriptionBlock.class, referencingBlock.getId()));
-
- PersonMentionPropagationListener realListener =
- new PersonMentionPropagationListener(blockRepo);
-
- when(personRepository.findById(id)).thenReturn(Optional.of(existing));
- when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
- doAnswer(inv -> {
- realListener.onPersonDisplayNameChanged(inv.getArgument(0));
- return null;
- }).when(eventPublisher).publishEvent(any(PersonDisplayNameChangedEvent.class));
-
- PersonUpdateDTO dto = new PersonUpdateDTO();
- dto.setPersonType(PersonType.PERSON);
- dto.setFirstName("Augusta"); dto.setLastName("Raddatz");
-
- assertThatThrownBy(() -> personService.updatePerson(id, dto))
- .isInstanceOf(DomainException.class)
- .matches(e -> ((DomainException) e).getCode() == ErrorCode.PERSON_RENAME_CONFLICT)
- .matches(e -> ((DomainException) e).getStatus().value() == 409);
- }
-
- @Test
- void updatePerson_doesNotPublishEvent_whenOnlyNotesChanges() {
- UUID id = UUID.randomUUID();
- Person existing = Person.builder()
- .id(id).firstName("Auguste").lastName("Raddatz")
- .personType(PersonType.PERSON).notes("first note").build();
-
- when(personRepository.findById(id)).thenReturn(Optional.of(existing));
- when(personRepository.save(any())).thenAnswer(inv -> inv.getArgument(0));
-
- PersonUpdateDTO dto = new PersonUpdateDTO();
- dto.setPersonType(PersonType.PERSON);
- dto.setFirstName("Auguste"); dto.setLastName("Raddatz");
- dto.setNotes("revised note");
-
- personService.updatePerson(id, dto);
-
- verify(eventPublisher, never()).publishEvent(any(PersonDisplayNameChangedEvent.class));
- }
-
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
@Test
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index 8ef11f81..bad61019 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -427,6 +427,8 @@
"person_mention_popup_empty": "Keine Personen gefunden",
"person_mention_btn_label": "Person verlinken",
"person_mention_create_new": "Neue Person anlegen",
+ "transcription_editor_aria_label": "Transkriptionstext",
+ "person_born_name_prefix": "geb.",
"page_title_home": "Archiv",
"page_title_persons": "Personen",
"page_title_admin": "Administration",
@@ -549,7 +551,6 @@
"person_alias_btn_delete": "Entfernen",
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
- "error_person_rename_conflict": "Eine andere Bearbeitung hat einen verknüpften Transkriptionsblock gleichzeitig geändert. Bitte erneut versuchen.",
"validation_last_name_required": "Nachname ist Pflichtfeld.",
"validation_first_name_required": "Vorname ist Pflichtfeld.",
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index c0909263..ca99ea8f 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -427,6 +427,8 @@
"person_mention_popup_empty": "No persons found",
"person_mention_btn_label": "Link person",
"person_mention_create_new": "Create new person",
+ "transcription_editor_aria_label": "Transcription text",
+ "person_born_name_prefix": "née",
"page_title_home": "Archive",
"page_title_persons": "Persons",
"page_title_admin": "Administration",
@@ -549,7 +551,6 @@
"person_alias_btn_delete": "Remove",
"error_alias_not_found": "The name alias was not found.",
"error_invalid_person_type": "The specified person type is not valid.",
- "error_person_rename_conflict": "Another edit modified a referenced transcription block at the same time. Please try again.",
"validation_last_name_required": "Last name is required.",
"validation_first_name_required": "First name is required.",
"error_ocr_service_unavailable": "The OCR service is not available.",
diff --git a/frontend/messages/es.json b/frontend/messages/es.json
index 4b2fcdaf..3630d405 100644
--- a/frontend/messages/es.json
+++ b/frontend/messages/es.json
@@ -427,6 +427,8 @@
"person_mention_popup_empty": "No se encontraron personas",
"person_mention_btn_label": "Vincular persona",
"person_mention_create_new": "Crear nueva persona",
+ "transcription_editor_aria_label": "Texto de transcripción",
+ "person_born_name_prefix": "n.",
"page_title_home": "Archivo",
"page_title_persons": "Personas",
"page_title_admin": "Administración",
@@ -549,7 +551,6 @@
"person_alias_btn_delete": "Eliminar",
"error_alias_not_found": "No se encontro el alias de nombre.",
"error_invalid_person_type": "El tipo de persona especificado no es válido.",
- "error_person_rename_conflict": "Otra edición modificó un bloque de transcripción referenciado al mismo tiempo. Por favor, inténtalo de nuevo.",
"validation_last_name_required": "El apellido es obligatorio.",
"validation_first_name_required": "El nombre es obligatorio.",
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
diff --git a/frontend/package-lock.json b/frontend/package-lock.json
index 01031480..e39be73d 100644
--- a/frontend/package-lock.json
+++ b/frontend/package-lock.json
@@ -8,6 +8,9 @@
"name": "frontend",
"version": "0.0.1",
"dependencies": {
+ "@tiptap/core": "3.22.5",
+ "@tiptap/extension-mention": "3.22.5",
+ "@tiptap/starter-kit": "3.22.5",
"diff": "^8.0.3",
"openapi-fetch": "^0.13.5",
"pdfjs-dist": "^5.5.207"
@@ -2188,6 +2191,403 @@
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
}
},
+ "node_modules/@tiptap/core": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz",
+ "integrity": "sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/pm": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-blockquote": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz",
+ "integrity": "sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-bold": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz",
+ "integrity": "sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-bullet-list": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz",
+ "integrity": "sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-code": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz",
+ "integrity": "sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-code-block": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz",
+ "integrity": "sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5",
+ "@tiptap/pm": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-document": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz",
+ "integrity": "sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-dropcursor": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz",
+ "integrity": "sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-gapcursor": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz",
+ "integrity": "sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extensions": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-hard-break": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz",
+ "integrity": "sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-heading": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz",
+ "integrity": "sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-horizontal-rule": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz",
+ "integrity": "sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5",
+ "@tiptap/pm": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-italic": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz",
+ "integrity": "sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-link": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz",
+ "integrity": "sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==",
+ "license": "MIT",
+ "dependencies": {
+ "linkifyjs": "^4.3.2"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5",
+ "@tiptap/pm": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-list": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz",
+ "integrity": "sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5",
+ "@tiptap/pm": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-list-item": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz",
+ "integrity": "sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-list-keymap": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz",
+ "integrity": "sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-mention": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.22.5.tgz",
+ "integrity": "sha512-rGTbTjyxLc5C/6QjfbQF53nMbxjVgJU1VK6Si1i1J2c5DU09COgEFlYvi4YHjb3xz39SprPfG+GTtgD96eg7Ww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5",
+ "@tiptap/pm": "3.22.5",
+ "@tiptap/suggestion": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-ordered-list": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz",
+ "integrity": "sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/extension-list": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-paragraph": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz",
+ "integrity": "sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-strike": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz",
+ "integrity": "sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-text": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz",
+ "integrity": "sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extension-underline": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz",
+ "integrity": "sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/extensions": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz",
+ "integrity": "sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==",
+ "license": "MIT",
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5",
+ "@tiptap/pm": "3.22.5"
+ }
+ },
+ "node_modules/@tiptap/pm": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz",
+ "integrity": "sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-changeset": "^2.3.0",
+ "prosemirror-commands": "^1.6.2",
+ "prosemirror-dropcursor": "^1.8.1",
+ "prosemirror-gapcursor": "^1.3.2",
+ "prosemirror-history": "^1.4.1",
+ "prosemirror-keymap": "^1.2.2",
+ "prosemirror-model": "^1.24.1",
+ "prosemirror-schema-list": "^1.5.0",
+ "prosemirror-state": "^1.4.3",
+ "prosemirror-tables": "^1.6.4",
+ "prosemirror-transform": "^1.10.2",
+ "prosemirror-view": "^1.38.1"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/starter-kit": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz",
+ "integrity": "sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==",
+ "license": "MIT",
+ "dependencies": {
+ "@tiptap/core": "^3.22.5",
+ "@tiptap/extension-blockquote": "^3.22.5",
+ "@tiptap/extension-bold": "^3.22.5",
+ "@tiptap/extension-bullet-list": "^3.22.5",
+ "@tiptap/extension-code": "^3.22.5",
+ "@tiptap/extension-code-block": "^3.22.5",
+ "@tiptap/extension-document": "^3.22.5",
+ "@tiptap/extension-dropcursor": "^3.22.5",
+ "@tiptap/extension-gapcursor": "^3.22.5",
+ "@tiptap/extension-hard-break": "^3.22.5",
+ "@tiptap/extension-heading": "^3.22.5",
+ "@tiptap/extension-horizontal-rule": "^3.22.5",
+ "@tiptap/extension-italic": "^3.22.5",
+ "@tiptap/extension-link": "^3.22.5",
+ "@tiptap/extension-list": "^3.22.5",
+ "@tiptap/extension-list-item": "^3.22.5",
+ "@tiptap/extension-list-keymap": "^3.22.5",
+ "@tiptap/extension-ordered-list": "^3.22.5",
+ "@tiptap/extension-paragraph": "^3.22.5",
+ "@tiptap/extension-strike": "^3.22.5",
+ "@tiptap/extension-text": "^3.22.5",
+ "@tiptap/extension-underline": "^3.22.5",
+ "@tiptap/extensions": "^3.22.5",
+ "@tiptap/pm": "^3.22.5"
+ },
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ }
+ },
+ "node_modules/@tiptap/suggestion": {
+ "version": "3.22.5",
+ "resolved": "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.5.tgz",
+ "integrity": "sha512-Uv79Ht/o4mx1GWIT65jeQTE67LMrA+K7d8p51XOe9PJw0H0fS3iCdeMJ8tAo3h6QrMJFejdsB7z8jJL9UbAnhA==",
+ "license": "MIT",
+ "peer": true,
+ "funding": {
+ "type": "github",
+ "url": "https://github.com/sponsors/ueberdosis"
+ },
+ "peerDependencies": {
+ "@tiptap/core": "3.22.5",
+ "@tiptap/pm": "3.22.5"
+ }
+ },
"node_modules/@types/chai": {
"version": "5.2.3",
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
@@ -4270,6 +4670,12 @@
"node": ">=10"
}
},
+ "node_modules/linkifyjs": {
+ "version": "4.3.2",
+ "resolved": "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz",
+ "integrity": "sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==",
+ "license": "MIT"
+ },
"node_modules/locate-character": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
@@ -4499,6 +4905,12 @@
"node": ">= 0.8.0"
}
},
+ "node_modules/orderedmap": {
+ "version": "2.1.1",
+ "resolved": "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz",
+ "integrity": "sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==",
+ "license": "MIT"
+ },
"node_modules/p-limit": {
"version": "3.1.0",
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
@@ -4934,6 +5346,135 @@
}
}
},
+ "node_modules/prosemirror-changeset": {
+ "version": "2.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz",
+ "integrity": "sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-transform": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-commands": {
+ "version": "1.7.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz",
+ "integrity": "sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.10.2"
+ }
+ },
+ "node_modules/prosemirror-dropcursor": {
+ "version": "1.8.2",
+ "resolved": "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz",
+ "integrity": "sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0",
+ "prosemirror-view": "^1.1.0"
+ }
+ },
+ "node_modules/prosemirror-gapcursor": {
+ "version": "1.4.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz",
+ "integrity": "sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.0.0",
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-view": "^1.0.0"
+ }
+ },
+ "node_modules/prosemirror-history": {
+ "version": "1.5.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz",
+ "integrity": "sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.2.2",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.31.0",
+ "rope-sequence": "^1.3.0"
+ }
+ },
+ "node_modules/prosemirror-keymap": {
+ "version": "1.2.3",
+ "resolved": "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz",
+ "integrity": "sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-state": "^1.0.0",
+ "w3c-keyname": "^2.2.0"
+ }
+ },
+ "node_modules/prosemirror-model": {
+ "version": "1.25.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz",
+ "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==",
+ "license": "MIT",
+ "dependencies": {
+ "orderedmap": "^2.0.0"
+ }
+ },
+ "node_modules/prosemirror-schema-list": {
+ "version": "1.5.1",
+ "resolved": "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz",
+ "integrity": "sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.7.3"
+ }
+ },
+ "node_modules/prosemirror-state": {
+ "version": "1.4.4",
+ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz",
+ "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.0.0",
+ "prosemirror-transform": "^1.0.0",
+ "prosemirror-view": "^1.27.0"
+ }
+ },
+ "node_modules/prosemirror-tables": {
+ "version": "1.8.5",
+ "resolved": "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz",
+ "integrity": "sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-keymap": "^1.2.3",
+ "prosemirror-model": "^1.25.4",
+ "prosemirror-state": "^1.4.4",
+ "prosemirror-transform": "^1.10.5",
+ "prosemirror-view": "^1.41.4"
+ }
+ },
+ "node_modules/prosemirror-transform": {
+ "version": "1.12.0",
+ "resolved": "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz",
+ "integrity": "sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.21.0"
+ }
+ },
+ "node_modules/prosemirror-view": {
+ "version": "1.41.8",
+ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz",
+ "integrity": "sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==",
+ "license": "MIT",
+ "dependencies": {
+ "prosemirror-model": "^1.20.0",
+ "prosemirror-state": "^1.0.0",
+ "prosemirror-transform": "^1.1.0"
+ }
+ },
"node_modules/punycode": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
@@ -5044,6 +5585,12 @@
"fsevents": "~2.3.2"
}
},
+ "node_modules/rope-sequence": {
+ "version": "1.3.4",
+ "resolved": "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz",
+ "integrity": "sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==",
+ "license": "MIT"
+ },
"node_modules/sade": {
"version": "1.8.1",
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
@@ -5761,6 +6308,12 @@
"vitest": "^4.0.0"
}
},
+ "node_modules/w3c-keyname": {
+ "version": "2.2.8",
+ "resolved": "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz",
+ "integrity": "sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==",
+ "license": "MIT"
+ },
"node_modules/webpack-virtual-modules": {
"version": "0.6.2",
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
diff --git a/frontend/package.json b/frontend/package.json
index e7170513..542411e8 100644
--- a/frontend/package.json
+++ b/frontend/package.json
@@ -21,6 +21,9 @@
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
},
"dependencies": {
+ "@tiptap/core": "3.22.5",
+ "@tiptap/extension-mention": "3.22.5",
+ "@tiptap/starter-kit": "3.22.5",
"diff": "^8.0.3",
"openapi-fetch": "^0.13.5",
"pdfjs-dist": "^5.5.207"
diff --git a/frontend/src/lib/components/MentionDropdown.svelte b/frontend/src/lib/components/MentionDropdown.svelte
new file mode 100644
index 00000000..d86e912a
--- /dev/null
+++ b/frontend/src/lib/components/MentionDropdown.svelte
@@ -0,0 +1,170 @@
+
+
+
+
+ {#if model.items.length === 0}
+
+ {m.person_mention_popup_empty()}
+
+
+
e.preventDefault()}
+ >
+ {m.person_mention_create_new()}
+ →
+
+ {:else}
+ {#each model.items as person, i (person.id)}
+
{
+ // Prevent blur on the editor before the selection fires.
+ e.preventDefault();
+ selectItem(person);
+ }}
+ >
+ {person.displayName}
+ {#if formatLifeDateRange(person.birthYear, person.deathYear)}
+
+ {formatLifeDateRange(person.birthYear, person.deathYear)}
+
+ {/if}
+
+ {/each}
+ {/if}
+
diff --git a/frontend/src/lib/components/PersonHoverCard.svelte b/frontend/src/lib/components/PersonHoverCard.svelte
index 36a4989d..aac4f883 100644
--- a/frontend/src/lib/components/PersonHoverCard.svelte
+++ b/frontend/src/lib/components/PersonHoverCard.svelte
@@ -109,7 +109,10 @@ const ariaBusy = $derived(state.status === 'loading');
{dateRange}
{/if}
{#if state.person.alias}
- geb. {state.person.alias}
+
+ {m.person_born_name_prefix()}
+ {state.person.alias}
+
{/if}
{#if familyChips.length > 0}
diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte b/frontend/src/lib/components/PersonMentionEditor.svelte
index df686334..3a9188c1 100644
--- a/frontend/src/lib/components/PersonMentionEditor.svelte
+++ b/frontend/src/lib/components/PersonMentionEditor.svelte
@@ -1,263 +1,279 @@
-
-
-
- {#if popupOpen}
-
- {#if loading}
-
{m.comp_typeahead_loading()}
- {:else if results.length === 0}
-
- {:else}
- {#each results as person, i (person.id)}
-
{
- e.preventDefault();
- selectPerson(person);
- }}
- >
- {person.displayName}
- {#if formatLifeDateRange(person.birthYear, person.deathYear)}
-
- {formatLifeDateRange(person.birthYear, person.deathYear)}
-
- {/if}
-
- {/each}
- {/if}
-
- {/if}
-
+
diff --git a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
index ea8df7fe..bf4b59ea 100644
--- a/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
+++ b/frontend/src/lib/components/PersonMentionEditor.svelte.spec.ts
@@ -1,26 +1,19 @@
-import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
+/**
+ * PersonMentionEditor — Tiptap-based component tests.
+ *
+ * All old tests used document.querySelector('textarea') which is dead after
+ * the Tiptap migration. These tests drive the contenteditable via
+ * userEvent.type() and inspect the serialized output from the test host.
+ */
+import { describe, it, expect, vi, afterEach } from 'vitest';
import { cleanup, render } from 'vitest-browser-svelte';
-import { page } from 'vitest/browser';
+import { page, userEvent } from 'vitest/browser';
import PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
import type { components } from '$lib/generated/api';
type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention'];
-// Editor's internal search debounce is 200ms — drive it via fake timers
-// so tests are deterministic and fast (Tester #5506 §1).
-const DEBOUNCE_MS = 200;
-
-async function flushDebounce() {
- await vi.advanceTimersByTimeAsync(DEBOUNCE_MS);
- // Let the awaited fetch resolve and the resulting state assignments flush.
- await vi.runAllTimersAsync();
-}
-
-async function tick() {
- await vi.advanceTimersByTimeAsync(0);
-}
-
const AUGUSTE: Person = {
id: 'p-aug',
firstName: 'Auguste',
@@ -39,39 +32,24 @@ const ANNA: Person = {
} as unknown as Person;
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
- const fetchMock = vi
- .fn()
- .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) });
- vi.stubGlobal('fetch', fetchMock);
- return fetchMock;
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
+ );
}
function mockFetchEmpty() {
- const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
- vi.stubGlobal('fetch', fetchMock);
- return fetchMock;
-}
-
-function mockFetchRejects() {
- const fetchMock = vi.fn().mockRejectedValue(new TypeError('Failed to fetch'));
- vi.stubGlobal('fetch', fetchMock);
- return fetchMock;
-}
-
-function getTextarea(): HTMLTextAreaElement {
- return document.querySelector('textarea')!;
-}
-
-function clickOption(personId: string) {
- const opt = document.querySelector(
- `[role="option"][data-test-person-id="${personId}"]`
- ) as HTMLElement;
- opt.dispatchEvent(new MouseEvent('mousedown', { bubbles: true, cancelable: true }));
+ vi.stubGlobal(
+ 'fetch',
+ vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
+ );
}
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
-function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[] } = {}) {
+function renderHost(
+ initial: { value?: string; mentionedPersons?: PersonMention[]; disabled?: boolean } = {}
+) {
let snapshot: Snapshot = {
value: initial.value ?? '',
mentionedPersons: initial.mentionedPersons ?? []
@@ -79,6 +57,7 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[
render(PersonMentionEditorHost, {
initialValue: initial.value ?? '',
initialMentions: initial.mentionedPersons ?? [],
+ disabled: initial.disabled ?? false,
onChange: (snap: Snapshot) => {
snapshot = snap;
}
@@ -90,279 +69,300 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[
};
}
-beforeEach(() => {
- vi.useFakeTimers({ shouldAdvanceTime: true });
-});
-
afterEach(() => {
cleanup();
vi.unstubAllGlobals();
- vi.useRealTimers();
});
// ─── Rendering ────────────────────────────────────────────────────────────────
describe('PersonMentionEditor — rendering', () => {
- it('renders the textarea with placeholder', async () => {
+ it('renders the editor as a textbox (ARIA role from editorProps)', async () => {
render(PersonMentionEditorHost, {
initialValue: '',
initialMentions: [],
- placeholder: 'Transkription…',
onChange: () => {}
});
- await expect.element(page.getByPlaceholder('Transkription…')).toBeInTheDocument();
+ await expect.element(page.getByRole('textbox')).toBeInTheDocument();
});
- it('reflects bound initial value', async () => {
+ it('reflects bound initial value as visible text', async () => {
render(PersonMentionEditorHost, {
initialValue: 'Hallo Welt',
initialMentions: [],
onChange: () => {}
});
- await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt');
+ await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
});
});
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
describe('PersonMentionEditor — typeahead', () => {
- it('opens the popup when typing @ + query and shows results', async () => {
+ it('opens the dropdown when typing @ + query and shows results', async () => {
mockFetchWithPersons();
renderHost();
- const ta = getTextarea();
- ta.focus();
- ta.value = '@Aug';
- ta.selectionStart = 4;
- ta.selectionEnd = 4;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await userEvent.type(page.getByRole('textbox'), '@Aug');
- await flushDebounce();
-
- await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
+ await vi.waitFor(async () => {
+ await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
+ });
});
it('hits /api/persons?q= with the typed query', async () => {
- const fetchMock = mockFetchWithPersons();
+ const fetchMock = vi
+ .fn()
+ .mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([AUGUSTE]) });
+ vi.stubGlobal('fetch', fetchMock);
renderHost();
- const ta = getTextarea();
- ta.focus();
- ta.value = '@Aug';
- ta.selectionStart = 4;
- ta.selectionEnd = 4;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
+ await userEvent.type(page.getByRole('textbox'), '@Aug');
- await flushDebounce();
-
- expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
+ await vi.waitFor(() => {
+ expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
+ });
});
it('shows life dates next to the name in the dropdown', async () => {
mockFetchWithPersons();
renderHost();
- const ta = getTextarea();
- ta.focus();
- ta.value = '@Aug';
- ta.selectionStart = 4;
- ta.selectionEnd = 4;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
+ await userEvent.type(page.getByRole('textbox'), '@Aug');
- await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
+ await vi.waitFor(async () => {
+ await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
+ });
});
it('shows empty state when no persons match', async () => {
mockFetchEmpty();
renderHost();
- const ta = getTextarea();
- ta.focus();
- ta.value = '@xyz';
- ta.selectionStart = 4;
- ta.selectionEnd = 4;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
+ await userEvent.type(page.getByRole('textbox'), '@xyz');
- await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
+ await vi.waitFor(async () => {
+ await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
+ });
});
- it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => {
- mockFetchRejects();
+ it('offers a "create new person" link in the empty state', async () => {
+ mockFetchEmpty();
renderHost();
- const ta = getTextarea();
- ta.focus();
- ta.value = '@Aug';
- ta.selectionStart = 4;
- ta.selectionEnd = 4;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
+ await userEvent.type(page.getByRole('textbox'), '@xyz');
- await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
- });
-
- it('keeps the popup open when the query has a trailing space (multi-word names)', async () => {
- mockFetchWithPersons();
- renderHost();
-
- const ta = getTextarea();
- ta.focus();
- ta.value = '@Auguste ';
- ta.selectionStart = 9;
- ta.selectionEnd = 9;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
-
- await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
+ await vi.waitFor(async () => {
+ const link = page.getByRole('link', { name: /Neue Person anlegen/ });
+ await expect.element(link).toBeVisible();
+ await expect.element(link).toHaveAttribute('href', '/persons/new');
+ });
});
});
-// ─── Selection writes text + sidecar ─────────────────────────────────────────
+// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
-describe('PersonMentionEditor — selecting a person', () => {
- it('inserts @DisplayName followed by a trailing space into the textarea', async () => {
+describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
+ it('stores the typed query as displayName, not the person DB name', async () => {
mockFetchWithPersons();
const host = renderHost();
- const ta = getTextarea();
- ta.focus();
- ta.value = '@Aug';
- ta.selectionStart = 4;
- ta.selectionEnd = 4;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
+ // User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
+ await userEvent.type(page.getByRole('textbox'), '@Aug');
+ await vi.waitFor(async () => {
+ await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
+ });
- clickOption('p-aug');
- await tick();
+ await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
- expect(host.snapshot.value).toBe('@Auguste Raddatz ');
+ await vi.waitFor(() => {
+ expect(host.snapshot.mentionedPersons).toHaveLength(1);
+ expect(host.snapshot.mentionedPersons[0]).toEqual({
+ personId: 'p-aug',
+ displayName: 'Aug' // typed text, not "Auguste Raddatz"
+ });
+ });
});
- it('pushes {personId, displayName} into the bound mentionedPersons array', async () => {
+ it('regression: text value contains the typed query, not the full DB name', async () => {
mockFetchWithPersons();
const host = renderHost();
- const ta = getTextarea();
- ta.focus();
- ta.value = '@Aug';
- ta.selectionStart = 4;
- ta.selectionEnd = 4;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
+ await userEvent.type(page.getByRole('textbox'), '@Aug');
+ await vi.waitFor(async () => {
+ await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
+ });
- clickOption('p-aug');
- await tick();
+ await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
- expect(host.snapshot.mentionedPersons).toEqual([
- { personId: 'p-aug', displayName: 'Auguste Raddatz' }
- ]);
+ await vi.waitFor(() => {
+ // Text should contain "@Aug " (typed text + space), not "@Auguste Raddatz "
+ expect(host.snapshot.value).toContain('@Aug');
+ expect(host.snapshot.value).not.toContain('@Auguste Raddatz');
+ });
+ });
+
+ it('pushes {personId, displayName} into mentionedPersons sidecar', async () => {
+ mockFetchWithPersons();
+ const host = renderHost();
+
+ await userEvent.type(page.getByRole('textbox'), '@Aug');
+ await vi.waitFor(async () => {
+ await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
+ });
+
+ await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
+
+ await vi.waitFor(() => {
+ expect(host.snapshot.mentionedPersons).toEqual([{ personId: 'p-aug', displayName: 'Aug' }]);
+ });
});
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
mockFetchWithPersons();
const host = renderHost({
- value: '@Auguste Raddatz ',
- mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
+ value: '@Aug ',
+ mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
});
- const ta = getTextarea();
- ta.focus();
- ta.value = '@Auguste Raddatz @Aug';
- ta.selectionStart = ta.value.length;
- ta.selectionEnd = ta.value.length;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
+ await userEvent.type(page.getByRole('textbox'), '@Aug');
+ await vi.waitFor(async () => {
+ await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
+ });
- clickOption('p-aug');
- await tick();
+ await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
- expect(host.snapshot.mentionedPersons).toHaveLength(1);
+ await vi.waitFor(() => {
+ expect(host.snapshot.mentionedPersons).toHaveLength(1);
+ });
});
});
-// ─── Keyboard navigation (B11b) ──────────────────────────────────────────────
+// ─── Keyboard navigation ──────────────────────────────────────────────────────
-describe('PersonMentionEditor — keyboard navigation (B11b)', () => {
- it('ArrowDown / ArrowUp cycle the highlighted result', async () => {
+describe('PersonMentionEditor — keyboard navigation', () => {
+ it('Enter selects the highlighted result', async () => {
+ mockFetchWithPersons();
+ const host = renderHost();
+
+ await userEvent.type(page.getByRole('textbox'), '@A');
+
+ await vi.waitFor(async () => {
+ await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
+ });
+
+ await userEvent.keyboard('{Enter}');
+
+ await vi.waitFor(() => {
+ expect(host.snapshot.mentionedPersons).toHaveLength(1);
+ });
+ });
+
+ it('ArrowDown moves the highlight to the next result', async () => {
mockFetchWithPersons();
renderHost();
- const ta = getTextarea();
- ta.focus();
- ta.value = '@A';
- ta.selectionStart = 2;
- ta.selectionEnd = 2;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
+ await userEvent.type(page.getByRole('textbox'), '@A');
- const optAuguste = document.querySelector(
- '[role="option"][data-test-person-id="p-aug"]'
- ) as HTMLElement;
- const optAnna = document.querySelector(
- '[role="option"][data-test-person-id="p-anna"]'
- ) as HTMLElement;
+ await vi.waitFor(async () => {
+ await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
+ });
- expect(optAuguste.getAttribute('aria-selected')).toBe('true');
- expect(optAnna.getAttribute('aria-selected')).toBe('false');
+ await userEvent.keyboard('{ArrowDown}');
- ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
- await tick();
-
- expect(optAuguste.getAttribute('aria-selected')).toBe('false');
- expect(optAnna.getAttribute('aria-selected')).toBe('true');
-
- ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowUp', bubbles: true }));
- await tick();
-
- expect(optAuguste.getAttribute('aria-selected')).toBe('true');
- expect(optAnna.getAttribute('aria-selected')).toBe('false');
+ await vi.waitFor(async () => {
+ const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
+ await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
+ });
});
- it('Enter selects the currently highlighted result', async () => {
+ it('Escape closes the dropdown without inserting', async () => {
mockFetchWithPersons();
const host = renderHost();
- const ta = getTextarea();
- ta.focus();
- ta.value = '@A';
- ta.selectionStart = 2;
- ta.selectionEnd = 2;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
+ await userEvent.type(page.getByRole('textbox'), '@Aug');
- ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
- await tick();
- ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
- await tick();
+ await vi.waitFor(async () => {
+ await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
+ });
- expect(host.snapshot.mentionedPersons).toEqual([
- { personId: 'p-anna', displayName: 'Anna Schmidt' }
- ]);
- });
+ await userEvent.keyboard('{Escape}');
- it('Escape closes the popup without inserting anything', async () => {
- mockFetchWithPersons();
- const host = renderHost();
+ await vi.waitFor(async () => {
+ await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
+ });
- const ta = getTextarea();
- ta.focus();
- ta.value = '@Aug';
- ta.selectionStart = 4;
- ta.selectionEnd = 4;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
-
- ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Escape', bubbles: true }));
- await tick();
-
- await expect.element(page.getByText('Auguste Raddatz')).not.toBeInTheDocument();
- expect(host.snapshot.value).toBe('@Aug');
expect(host.snapshot.mentionedPersons).toEqual([]);
});
});
+// ─── Disabled state (WCAG 2.1.1 — keyboard users) ────────────────────────────
+
+describe('PersonMentionEditor — disabled state', () => {
+ it('sets contenteditable=false on the editor when disabled', async () => {
+ renderHost({ value: 'Bestehender Text', disabled: true });
+
+ await vi.waitFor(() => {
+ const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
+ expect(textbox).not.toBeNull();
+ expect(textbox!.getAttribute('contenteditable')).toBe('false');
+ });
+ });
+
+ it('exposes aria-disabled=true on the editor wrapper when disabled', async () => {
+ renderHost({ disabled: true });
+
+ await vi.waitFor(() => {
+ const wrapper = document.querySelector('[aria-disabled="true"]');
+ expect(wrapper).not.toBeNull();
+ });
+ });
+
+ it('keeps the editor editable (contenteditable=true) when not disabled', async () => {
+ renderHost({ disabled: false });
+
+ await vi.waitFor(() => {
+ const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
+ expect(textbox).not.toBeNull();
+ expect(textbox!.getAttribute('contenteditable')).toBe('true');
+ });
+ });
+});
+
+// ─── Security — XSS in displayName (CWE-79) ──────────────────────────────────
+
+describe('PersonMentionEditor — XSS resistance', () => {
+ it('renders a malicious displayName as text, not as HTML elements', async () => {
+ // A historical sidecar entry whose displayName contains an HTML payload
+ // that would execute if interpolated as raw HTML. Tiptap's renderHTML
+ // returns the @-prefixed string as the third tuple entry, which
+ // ProseMirror's DOMSerializer treats as a Text node — escaping it.
+ const maliciousMention: PersonMention = {
+ personId: '00000000-0000-0000-0000-000000000001',
+ displayName: '
'
+ };
+
+ renderHost({
+ value: '@
',
+ mentionedPersons: [maliciousMention]
+ });
+
+ await vi.waitFor(() => {
+ const textbox = document.querySelector('[role="textbox"]') as HTMLElement | null;
+ expect(textbox).not.toBeNull();
+ // No element from the malicious payload should have appeared as a real
+ // DOM node. (Tiptap inserts its own ProseMirror-separator
in empty
+ // paragraphs — that is internal markup and never carries user attrs;
+ // guard against the injection by checking the user-controlled attrs.)
+ expect(textbox!.querySelector('img[onerror]')).toBeNull();
+ expect(textbox!.querySelector('img[src="x"]')).toBeNull();
+ expect(textbox!.querySelector('script')).toBeNull();
+ // The payload should appear as visible text content instead.
+ expect(textbox!.textContent ?? '').toContain('
');
+ });
+ });
+});
+
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
describe('PersonMentionEditor — touch target', () => {
@@ -370,13 +370,11 @@ describe('PersonMentionEditor — touch target', () => {
mockFetchWithPersons();
renderHost();
- const ta = getTextarea();
- ta.focus();
- ta.value = '@Aug';
- ta.selectionStart = 4;
- ta.selectionEnd = 4;
- ta.dispatchEvent(new Event('input', { bubbles: true }));
- await flushDebounce();
+ await userEvent.type(page.getByRole('textbox'), '@Aug');
+
+ await vi.waitFor(async () => {
+ await expect.element(page.getByRole('option').first()).toBeVisible();
+ });
const option = document.querySelector('[role="option"]') as HTMLElement;
expect(option).not.toBeNull();
diff --git a/frontend/src/lib/components/PersonMentionEditor.test-host.svelte b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte
index e608d694..cdc31df0 100644
--- a/frontend/src/lib/components/PersonMentionEditor.test-host.svelte
+++ b/frontend/src/lib/components/PersonMentionEditor.test-host.svelte
@@ -9,10 +9,17 @@ type Props = {
initialValue?: string;
initialMentions?: PersonMention[];
placeholder?: string;
+ disabled?: boolean;
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
};
-let { initialValue = '', initialMentions = [], placeholder, onChange }: Props = $props();
+let {
+ initialValue = '',
+ initialMentions = [],
+ placeholder,
+ disabled = false,
+ onChange
+}: Props = $props();
// initial* props seed mount-time state; reading them inside untrack signals
// the intentional one-shot capture and silences state_referenced_locally.
@@ -28,4 +35,5 @@ $effect(() => {
bind:value={value}
bind:mentionedPersons={mentionedPersons}
placeholder={placeholder}
+ disabled={disabled}
/>
diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte b/frontend/src/lib/components/TranscriptionBlock.svelte
index 982b43ff..417c3fbe 100644
--- a/frontend/src/lib/components/TranscriptionBlock.svelte
+++ b/frontend/src/lib/components/TranscriptionBlock.svelte
@@ -79,17 +79,6 @@ let leftBorderClass = $derived(
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
);
-// Single source of truth for the editor's textarea — stored on attach so
-// we can read selection bounds for quote selection without re-querying the DOM.
-let textareaEl: HTMLTextAreaElement | null = null;
-
-function captureTextarea(node: HTMLTextAreaElement) {
- textareaEl = node;
- return () => {
- textareaEl = null;
- };
-}
-
function emitChange() {
onTextChange(localText, localMentions);
}
@@ -101,17 +90,6 @@ async function handleDelete() {
});
if (confirmed) onDeleteClick();
}
-
-function handleTextareaMouseUp() {
- if (!textareaEl) return;
- const start = textareaEl.selectionStart;
- const end = textareaEl.selectionEnd;
- if (start !== end) {
- selectedQuote = localText.substring(start, end);
- } else {
- selectedQuote = null;
- }
-}
-
-
-
localText,
- (v) => {
- localText = v;
- emitChange();
- }}
- bind:mentionedPersons={() => localMentions,
- (next) => {
- localMentions = next;
- emitChange();
- }}
- placeholder={m.transcription_block_placeholder()}
- onfocus={onFocus}
- captureTextarea={captureTextarea}
- />
-
+
+
localText,
+ (v) => {
+ localText = v;
+ emitChange();
+ }}
+ bind:mentionedPersons={() => localMentions,
+ (next) => {
+ localMentions = next;
+ emitChange();
+ }}
+ placeholder={m.transcription_block_placeholder()}
+ onfocus={onFocus}
+ onSelectionChange={(text) => (selectedQuote = text)}
+ />
{#if selectedQuote}
{m.transcription_block_quote_hint()}
diff --git a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts
index c05a8c88..85961e75 100644
--- a/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts
+++ b/frontend/src/lib/components/TranscriptionBlock.svelte.spec.ts
@@ -41,8 +41,7 @@ describe('TranscriptionBlock — rendering', () => {
it('renders text in textarea', async () => {
renderBlock();
- const textarea = page.getByRole('textbox');
- await expect.element(textarea).toHaveValue('Liebe Mutter,');
+ await expect.element(page.getByText('Liebe Mutter,')).toBeInTheDocument();
});
it('renders optional label when provided', async () => {
@@ -226,14 +225,18 @@ describe('TranscriptionBlock — delete confirmation', () => {
// ─── Quote selection ─────────────────────────────────────────────────────────
describe('TranscriptionBlock — quote selection', () => {
- it('shows quote hint after text is selected in textarea', async () => {
+ it('shows quote hint after text is selected in the editor', async () => {
renderBlock({ text: 'Breslau, den 12. August' });
await page.getByRole('textbox').click();
- // Select text and fire mouseup via native DOM — locator.selectText/dispatchEvent not available
- const el = document.querySelector('textarea') as HTMLTextAreaElement;
- el.focus();
- el.setSelectionRange(0, el.value.length);
- el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
+ // Select all text in the contenteditable via the native Selection API.
+ // Tiptap fires selectionUpdate which the block forwards as onSelectionChange.
+ const editorEl = document.querySelector('[role="textbox"]') as HTMLElement;
+ const range = document.createRange();
+ range.selectNodeContents(editorEl);
+ const selection = window.getSelection()!;
+ selection.removeAllRanges();
+ selection.addRange(range);
+ editorEl.dispatchEvent(new Event('input', { bubbles: true }));
await expect.element(page.getByText(/Zitat/)).toBeInTheDocument();
});
});
diff --git a/frontend/src/lib/errors.ts b/frontend/src/lib/errors.ts
index 3c80a1da..e57f212e 100644
--- a/frontend/src/lib/errors.ts
+++ b/frontend/src/lib/errors.ts
@@ -8,7 +8,6 @@ export type ErrorCode =
| 'PERSON_NOT_FOUND'
| 'ALIAS_NOT_FOUND'
| 'INVALID_PERSON_TYPE'
- | 'PERSON_RENAME_CONFLICT'
| 'DOCUMENT_NOT_FOUND'
| 'DOCUMENT_NO_FILE'
| 'FILE_NOT_FOUND'
@@ -80,8 +79,6 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
return m.error_alias_not_found();
case 'INVALID_PERSON_TYPE':
return m.error_invalid_person_type();
- case 'PERSON_RENAME_CONFLICT':
- return m.error_person_rename_conflict();
case 'DOCUMENT_NOT_FOUND':
return m.error_document_not_found();
case 'DOCUMENT_NO_FILE':
diff --git a/frontend/src/lib/utils/mentionSerializer.spec.ts b/frontend/src/lib/utils/mentionSerializer.spec.ts
new file mode 100644
index 00000000..19b4c4a2
--- /dev/null
+++ b/frontend/src/lib/utils/mentionSerializer.spec.ts
@@ -0,0 +1,138 @@
+import { describe, it, expect } from 'vitest';
+import { deserialize, serialize } from './mentionSerializer';
+import type { PersonMention } from '$lib/types';
+
+// ─── deserialize ─────────────────────────────────────────────────────────────
+
+describe('deserialize', () => {
+ it('returns a valid Tiptap doc for an empty string', () => {
+ const doc = deserialize('', []);
+ expect(doc.type).toBe('doc');
+ expect(doc.content).toHaveLength(1);
+ expect(doc.content![0].type).toBe('paragraph');
+ });
+
+ it('returns plain text node for text with no mentions', () => {
+ const doc = deserialize('Hallo Welt', []);
+ const para = doc.content![0];
+ expect(para.content).toHaveLength(1);
+ expect(para.content![0]).toEqual({ type: 'text', text: 'Hallo Welt' });
+ });
+
+ it('places a mention node at the correct position in the paragraph', () => {
+ const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
+ const doc = deserialize('Hallo @Clara Welt', sidecar);
+ const nodes = doc.content![0].content!;
+ expect(nodes).toHaveLength(3);
+ expect(nodes[0]).toEqual({ type: 'text', text: 'Hallo ' });
+ expect(nodes[1]).toMatchObject({
+ type: 'mention',
+ attrs: { displayName: 'Clara', personId: 'uuid-x' }
+ });
+ expect(nodes[2]).toEqual({ type: 'text', text: ' Welt' });
+ });
+
+ it('prefers the longer displayName when two sidecar entries share a prefix', () => {
+ const sidecar: PersonMention[] = [
+ { personId: 'uuid-short', displayName: 'Auguste' },
+ { personId: 'uuid-long', displayName: 'Auguste Raddatz' }
+ ];
+ const doc = deserialize('@Auguste Raddatz', sidecar);
+ const nodes = doc.content![0].content!;
+ expect(nodes).toHaveLength(1);
+ expect(nodes[0]).toMatchObject({
+ type: 'mention',
+ attrs: { personId: 'uuid-long', displayName: 'Auguste Raddatz' }
+ });
+ });
+
+ it('splits multiple paragraphs on newline', () => {
+ const doc = deserialize('Zeile 1\nZeile 2', []);
+ expect(doc.content).toHaveLength(2);
+ expect(doc.content![0].content![0]).toEqual({ type: 'text', text: 'Zeile 1' });
+ expect(doc.content![1].content![0]).toEqual({ type: 'text', text: 'Zeile 2' });
+ });
+});
+
+// ─── serialize ───────────────────────────────────────────────────────────────
+
+describe('serialize', () => {
+ it('serializes plain text unchanged', () => {
+ const doc = deserialize('Hallo Welt', []);
+ const { text, mentionedPersons } = serialize(doc);
+ expect(text).toBe('Hallo Welt');
+ expect(mentionedPersons).toEqual([]);
+ });
+
+ it('serializes mention node back to @displayName', () => {
+ const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
+ const doc = deserialize('Hallo @Clara Welt', sidecar);
+ const { text, mentionedPersons } = serialize(doc);
+ expect(text).toBe('Hallo @Clara Welt');
+ expect(mentionedPersons).toEqual([{ personId: 'uuid-x', displayName: 'Clara' }]);
+ });
+
+ it('joins multi-paragraph doc back with newlines', () => {
+ const doc = deserialize('Zeile 1\nZeile 2', []);
+ const { text } = serialize(doc);
+ expect(text).toBe('Zeile 1\nZeile 2');
+ });
+
+ it('de-duplicates repeated mentions in the sidecar', () => {
+ const sidecar: PersonMention[] = [{ personId: 'uuid-x', displayName: 'Clara' }];
+ const doc = deserialize('@Clara und @Clara', sidecar);
+ const { mentionedPersons } = serialize(doc);
+ expect(mentionedPersons).toHaveLength(1);
+ expect(mentionedPersons[0].personId).toBe('uuid-x');
+ });
+});
+
+// ─── Round-trip invariant ─────────────────────────────────────────────────────
+
+describe('round-trip invariant', () => {
+ it('text is preserved exactly through deserialize → serialize', () => {
+ const cases = [
+ ['', []],
+ ['Hallo Welt', []],
+ ['@Clara schreibt', [{ personId: 'uuid-x', displayName: 'Clara' }]],
+ ['Zeile 1\nZeile 2', []],
+ ['Sehr geehrte @Auguste Raddatz,', [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }]]
+ ] as const;
+
+ for (const [text, sidecar] of cases) {
+ const doc = deserialize(text, sidecar as PersonMention[]);
+ const { text: out } = serialize(doc);
+ expect(out, `round-trip failed for: "${text}"`).toBe(text);
+ }
+ });
+});
+
+// ─── Backward compatibility (AC-6) ────────────────────────────────────────────
+
+describe('backward compatibility', () => {
+ it('old-format full-name sidecar entry still round-trips correctly', () => {
+ // Before this issue, displayName stored the person's full DB name.
+ // renderTranscriptionBody already handles this — so does the serializer.
+ const oldSidecar: PersonMention[] = [{ personId: 'uuid-aug', displayName: 'Auguste Raddatz' }];
+ const text = 'Brief von @Auguste Raddatz';
+ const doc = deserialize(text, oldSidecar);
+ const { text: out, mentionedPersons } = serialize(doc);
+ expect(out).toBe(text);
+ expect(mentionedPersons).toEqual(oldSidecar);
+ });
+});
+
+// ─── Security ─────────────────────────────────────────────────────────────────
+
+describe('security', () => {
+ it('displayName containing HTML special chars is preserved as a string, not injected', () => {
+ const sidecar: PersonMention[] = [
+ { personId: 'uuid-x', displayName: '' }
+ ];
+ const text = '@';
+ const doc = deserialize(text, sidecar);
+ const { mentionedPersons } = serialize(doc);
+ // The displayName is stored verbatim — HTML escaping is the renderer's job
+ expect(mentionedPersons[0].displayName).toBe('');
+ });
+});
diff --git a/frontend/src/lib/utils/mentionSerializer.ts b/frontend/src/lib/utils/mentionSerializer.ts
new file mode 100644
index 00000000..b6213f25
--- /dev/null
+++ b/frontend/src/lib/utils/mentionSerializer.ts
@@ -0,0 +1,113 @@
+import type { JSONContent } from '@tiptap/core';
+import type { PersonMention } from '$lib/types';
+
+/**
+ * Converts stored block text + sidecar into a Tiptap ProseMirror document.
+ *
+ * The text is split by "\n" into paragraphs. Within each paragraph, sidecar
+ * entries are matched against "@displayName" tokens (longest first) and
+ * converted to mention nodes. Unmatched text becomes plain text nodes.
+ *
+ * Round-trip invariant: serialize(deserialize(text, sidecar)).text === text
+ */
+export function deserialize(text: string, sidecar: PersonMention[]): JSONContent {
+ const lines = text === '' ? [''] : text.split('\n');
+
+ // Sort sidecar by displayName length descending so longer names shadow
+ // shorter prefix matches (same heuristic as renderTranscriptionBody).
+ const sorted = [...sidecar].sort((a, b) => b.displayName.length - a.displayName.length);
+
+ return {
+ type: 'doc',
+ content: lines.map((line) => ({
+ type: 'paragraph',
+ content: parseLine(line, sorted)
+ }))
+ };
+}
+
+function parseLine(text: string, sidecar: PersonMention[]): JSONContent[] {
+ if (text === '') return [];
+
+ if (sidecar.length === 0) {
+ return [{ type: 'text', text }];
+ }
+
+ // Build a list of mention ranges: { start, end, mention }
+ const ranges: Array<{ start: number; end: number; mention: PersonMention }> = [];
+
+ for (const mention of sidecar) {
+ const needle = `@${mention.displayName}`;
+ let idx = 0;
+ while (idx < text.length) {
+ const pos = text.indexOf(needle, idx);
+ if (pos === -1) break;
+ // Check that the range doesn't overlap an already-found range
+ const end = pos + needle.length;
+ const overlaps = ranges.some((r) => r.start < end && r.end > pos);
+ if (!overlaps) {
+ ranges.push({ start: pos, end, mention });
+ }
+ idx = pos + 1;
+ }
+ }
+
+ if (ranges.length === 0) {
+ return [{ type: 'text', text }];
+ }
+
+ // Sort by position
+ ranges.sort((a, b) => a.start - b.start);
+
+ const nodes: JSONContent[] = [];
+ let cursor = 0;
+
+ for (const { start, end, mention } of ranges) {
+ if (start > cursor) {
+ nodes.push({ type: 'text', text: text.slice(cursor, start) });
+ }
+ nodes.push({
+ type: 'mention',
+ attrs: { displayName: mention.displayName, personId: mention.personId }
+ });
+ cursor = end;
+ }
+
+ if (cursor < text.length) {
+ nodes.push({ type: 'text', text: text.slice(cursor) });
+ }
+
+ return nodes;
+}
+
+/**
+ * Converts a Tiptap ProseMirror document back to stored block text + sidecar.
+ *
+ * Paragraphs are joined with "\n". Mention nodes are emitted as "@displayName"
+ * and collected into mentionedPersons (de-duplicated by personId).
+ */
+export function serialize(doc: JSONContent): { text: string; mentionedPersons: PersonMention[] } {
+ const paragraphs = doc.content ?? [];
+ const mentionedPersons: PersonMention[] = [];
+ const seenIds = new Set();
+ const lines: string[] = [];
+
+ for (const para of paragraphs) {
+ let line = '';
+ for (const node of para.content ?? []) {
+ if (node.type === 'text') {
+ line += node.text ?? '';
+ } else if (node.type === 'mention') {
+ const { displayName, personId } = node.attrs ?? {};
+ line += `@${displayName}`;
+ if (!seenIds.has(personId)) {
+ seenIds.add(personId);
+ mentionedPersons.push({ personId, displayName });
+ }
+ }
+ }
+ lines.push(line);
+ }
+
+ return { text: lines.join('\n'), mentionedPersons };
+}
diff --git a/frontend/src/lib/utils/personMention.spec.ts b/frontend/src/lib/utils/personMention.spec.ts
deleted file mode 100644
index 7101c27c..00000000
--- a/frontend/src/lib/utils/personMention.spec.ts
+++ /dev/null
@@ -1,65 +0,0 @@
-import { describe, it, expect } from 'vitest';
-import { detectPersonMention } from './personMention';
-
-describe('detectPersonMention', () => {
- it('returns null when text has no @', () => {
- expect(detectPersonMention('hello world', 11)).toBeNull();
- });
-
- it('returns null when @ is preceded by a non-whitespace character (email pattern)', () => {
- expect(detectPersonMention('user@example', 12)).toBeNull();
- });
-
- it('returns query for @ at the very start of string', () => {
- expect(detectPersonMention('@Aug', 4)).toBe('Aug');
- });
-
- it('returns empty string immediately after @', () => {
- expect(detectPersonMention('@', 1)).toBe('');
- });
-
- it('returns single-word query', () => {
- expect(detectPersonMention('hi @Auguste', 11)).toBe('Auguste');
- });
-
- it('keeps the trigger active when the query has a trailing space', () => {
- expect(detectPersonMention('hi @Auguste ', 12)).toBe('Auguste ');
- });
-
- it('returns multi-word query (spaces allowed)', () => {
- expect(detectPersonMention('hi @Auguste Raddatz', 19)).toBe('Auguste Raddatz');
- });
-
- it('returns single-character query', () => {
- expect(detectPersonMention('@M', 2)).toBe('M');
- });
-
- it('returns null when the query crosses a newline', () => {
- expect(detectPersonMention('@Aug\nfoo', 8)).toBeNull();
- });
-
- it('returns null when a second @ appears in the query (next mention starts)', () => {
- expect(detectPersonMention('@Aug@bar', 8)).toBeNull();
- });
-
- it('uses the most recent @ when separated by whitespace', () => {
- // '@Aug @Bert' with cursor at end — the second @ is the trigger.
- expect(detectPersonMention('@Aug @Bert', 10)).toBe('Bert');
- });
-
- it('returns the query when the cursor sits exactly at a newline boundary', () => {
- // '@Aug\nfoo' with cursor at index 4 — right at the newline before it
- // is consumed. The query is still 'Aug' because nothing past the cursor
- // counts.
- expect(detectPersonMention('@Aug\nfoo', 4)).toBe('Aug');
- });
-
- it('returns null when cursor is before the @', () => {
- expect(detectPersonMention('@Hans', 0)).toBeNull();
- });
-
- it('uses the most recent @ in the text', () => {
- // cursor is just after the second @ + a few chars
- expect(detectPersonMention('hi @Anna and @Bert', 18)).toBe('Bert');
- });
-});
diff --git a/frontend/src/lib/utils/personMention.ts b/frontend/src/lib/utils/personMention.ts
deleted file mode 100644
index 7611c89f..00000000
--- a/frontend/src/lib/utils/personMention.ts
+++ /dev/null
@@ -1,23 +0,0 @@
-/**
- * Given the current textarea value and cursor position, returns the
- * @-person-mention query being typed (the text after the last triggering @),
- * or null if no person-mention is active.
- *
- * Rules — distinct from comment-mentions in `mention.ts`:
- * - @ must be at the start of the string or preceded by whitespace
- * - The query may contain spaces (historical persons commonly have multi-word
- * display names — "Auguste Raddatz", "Maria von Müller-Schultz")
- * - The query stops at a newline or at a second @ (the next mention starts)
- */
-export function detectPersonMention(text: string, cursorPos: number): string | null {
- const before = text.slice(0, cursorPos);
- const atIndex = before.lastIndexOf('@');
- if (atIndex === -1) return null;
-
- if (atIndex > 0 && !/\s/.test(before[atIndex - 1])) return null;
-
- const query = before.slice(atIndex + 1);
- if (query.includes('\n') || query.includes('@')) return null;
-
- return query;
-}
diff --git a/frontend/src/routes/layout.css b/frontend/src/routes/layout.css
index e4d9cce0..f44f295c 100644
--- a/frontend/src/routes/layout.css
+++ b/frontend/src/routes/layout.css
@@ -416,6 +416,23 @@
}
}
+/* ─── Tiptap editor ─────────────────────────────────────────────────────────── */
+
+/* Placeholder: shown on the inner contenteditable when the editor is empty
+ and unfocused. Uses the data-placeholder attribute set via editorProps. */
+.tiptap-editor-inner[data-placeholder]:not(:focus)::before {
+ content: attr(data-placeholder);
+ color: var(--c-ink-3);
+ pointer-events: none;
+ float: left;
+ height: 0;
+}
+
+/* Remove default ProseMirror focus ring — the outer wrapper has its own. */
+.tiptap-editor-inner:focus {
+ outline: none;
+}
+
@keyframes slide {
0% {
transform: translateX(-100%);
diff --git a/frontend/yarn.lock b/frontend/yarn.lock
index 0c70bf19..91d23d4b 100644
--- a/frontend/yarn.lock
+++ b/frontend/yarn.lock
@@ -493,6 +493,181 @@
resolved "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz"
integrity sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==
+"@tiptap/core@^3.22.5", "@tiptap/core@3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/core/-/core-3.22.5.tgz"
+ integrity sha512-L1lhWz6ujGny8LduTJ7MBWYhzigwOvfUJUrJ7IzOJSuy3+OAzisdGDD1GV7LEO/hU0Hr2Mkm1wajRIHExvS9HQ==
+
+"@tiptap/extension-blockquote@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-blockquote/-/extension-blockquote-3.22.5.tgz"
+ integrity sha512-ajyP5W8fG5Hrru47T/eF3xMKOpNvWofgNJqBTeNuGl02sYxsy9a4EunyFxudsaZP9WW3VOD4SaIWr5+MqpbnOQ==
+
+"@tiptap/extension-bold@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bold/-/extension-bold-3.22.5.tgz"
+ integrity sha512-l/uDtpJISiFFyfctvnODNWBN/XPZI1jVZRacTRDDnSn8+x6KQ7G2qgFYueU7KvVJGDFVT39Iio56mcFRG/Pozg==
+
+"@tiptap/extension-bullet-list@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-bullet-list/-/extension-bullet-list-3.22.5.tgz"
+ integrity sha512-cf54fG9AybU8NgPMv1TOcoqAkELeRc/VpnSCt/rIJZphWQx9nsFmrtkrlCatrIcCaGtNZYwlHlMnC5LVVMu0uA==
+
+"@tiptap/extension-code-block@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.22.5.tgz"
+ integrity sha512-d123kCfLdJTi4fue1m0+TNFztDkmIRSZGZmGu6H9KqwG5Q7IzjT9o8lzRsz+pXxYqHvqgYmXoEpM6srbzXx/Ag==
+
+"@tiptap/extension-code@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-code/-/extension-code-3.22.5.tgz"
+ integrity sha512-mwDNOJC9rYbDu/JcqrN4dbUQRklJU8Fuk2raxD/IvFw9qUIcPCmxQ2XT9UTKmZz/Ju7Kdy72fss6XpgWv6gLAQ==
+
+"@tiptap/extension-document@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-document/-/extension-document-3.22.5.tgz"
+ integrity sha512-8NJERd+pCtvSuEP4C4WMGYmRRCV12ePZL7bC+QUdFlbdXg+kNZS0zZ7hh879tYA0Kidbi8rWWD1Tx+H2ezkmMw==
+
+"@tiptap/extension-dropcursor@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-dropcursor/-/extension-dropcursor-3.22.5.tgz"
+ integrity sha512-Mp40DaFrY3sEUVtFqmxrR0BmU4G3k8GCYYNGqNa9OqWv7BrcFDC03V2n3okESDKt4MKkzhQQmypq+ouLy8dLfA==
+
+"@tiptap/extension-gapcursor@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-gapcursor/-/extension-gapcursor-3.22.5.tgz"
+ integrity sha512-4WkMu7qqjbsm8hCQS+8X+la1wjriN0SKoRdvpfKH33qM50MB34tYJuGLAO+y7TTh4MMMco3AZCKPBL5JVMqNIg==
+
+"@tiptap/extension-hard-break@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-hard-break/-/extension-hard-break-3.22.5.tgz"
+ integrity sha512-n0R2mUVYZU2AVbJhg/WcY9+zx690wVwvsItHJf0DrYbf1tCYHx+PRHUt/AoXk6u8BSmnkb8/FDziS8m3mjfpSg==
+
+"@tiptap/extension-heading@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-heading/-/extension-heading-3.22.5.tgz"
+ integrity sha512-hjyEG4947PAhMBfP1G6B0QAh6+y9mp2C5BQmNjprA05/lQzDAT7KFZzNh8ZVp3ol6aICKq/N1gFOW9Dc/9FUOw==
+
+"@tiptap/extension-horizontal-rule@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-horizontal-rule/-/extension-horizontal-rule-3.22.5.tgz"
+ integrity sha512-vUV0/ugIbXOc8SJib0h8UMhgcqZXWu/dkEhlswZN4VVven1o5enkfxEiDw+OyIJHi5rUkrdhsQ/KTxG/Xb7X8A==
+
+"@tiptap/extension-italic@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-italic/-/extension-italic-3.22.5.tgz"
+ integrity sha512-4T8baSiLkeIymTgEwirxDFt5YgYofkP3m1+MGYdGy2HKcOK+1vpvlPhEO1X5qtZngtJW5S4+njKjinRg52A4PA==
+
+"@tiptap/extension-link@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-link/-/extension-link-3.22.5.tgz"
+ integrity sha512-d671MvF3GPKoS2OVxjIlQ7hIE7MS3hREdR+d4cvnnoiLLD+ZJ6KgDnxmWqF0a1s4qxLWK2KxKRSOIfYGE31QWQ==
+ dependencies:
+ linkifyjs "^4.3.2"
+
+"@tiptap/extension-list-item@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list-item/-/extension-list-item-3.22.5.tgz"
+ integrity sha512-W7uTmyKLhlsvuTPLv+8WwnsY+mlikBFIoLSvVcBaFt4MwpsZ+DeB6KQg02Y7tbtaAnG7rXu9Fvw2QORh2P728A==
+
+"@tiptap/extension-list-keymap@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list-keymap/-/extension-list-keymap-3.22.5.tgz"
+ integrity sha512-cGUnxJ0y515e1bVHNjUmbx7oWHoEon59w6BA5N2KwV9iW2mZZchlTX4yxJSOX+ixeVRChsa7YwC3Z1jUZ6AMEg==
+
+"@tiptap/extension-list@^3.22.5", "@tiptap/extension-list@3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.22.5.tgz"
+ integrity sha512-cVO3ZHCgxAWZ4zrFSs81FO2nyCk1wb2EHkpLpW98FzbJLkN9rDkazhW99P3HRWy/CvUldOT+8ecI1YrQtBojMg==
+
+"@tiptap/extension-mention@3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-mention/-/extension-mention-3.22.5.tgz"
+ integrity sha512-rGTbTjyxLc5C/6QjfbQF53nMbxjVgJU1VK6Si1i1J2c5DU09COgEFlYvi4YHjb3xz39SprPfG+GTtgD96eg7Ww==
+
+"@tiptap/extension-ordered-list@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-ordered-list/-/extension-ordered-list-3.22.5.tgz"
+ integrity sha512-OXdh4k4CNrukwiSdWdEQ49uvgnqvR0Z9aNSP4HI5/kZQ/Te1NtRtYCpUrzWyO/7CtjcCisXHti0o9C/TV8YMbQ==
+
+"@tiptap/extension-paragraph@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-paragraph/-/extension-paragraph-3.22.5.tgz"
+ integrity sha512-52KCto4+XKpnBWpIufspWLyq4UWxAWC72ANPdGuIhbi72NRTabiTbTVN40uwGSPkyakeESG0/vKdWJCVvB4f0g==
+
+"@tiptap/extension-strike@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-strike/-/extension-strike-3.22.5.tgz"
+ integrity sha512-42WrrFK5gOom/0znH85x12Mw5IQ/6O6DWdyUWoRIrNA/qJpuHtU8oVU+bIgU2tuomMGHruRjIzgBQv5sBjEtww==
+
+"@tiptap/extension-text@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-text/-/extension-text-3.22.5.tgz"
+ integrity sha512-bzpDOdAEo1JeoVZDIyV0oY0jGXkEG+AzF70SzHoRSjOvFDtKWunyXf9eO1OnOr2/fmMcckT2qwUBNBMQplWBzw==
+
+"@tiptap/extension-underline@^3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extension-underline/-/extension-underline-3.22.5.tgz"
+ integrity sha512-9ut09rJD0iEbS6sk7yd2j6IwuFDLTNmDEGTDLodvqAfi+bq7ddsTDv0YviXoZaA9sdHAdTEVr2ITy2m6WK5jpA==
+
+"@tiptap/extensions@^3.22.5", "@tiptap/extensions@3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.22.5.tgz"
+ integrity sha512-Ifg4MzKCj3uRqe3ieTwYnomu2y4p7EXr2avVSKZYfh12i2dyWe2Gkn1KuZDREANVE+gHqFlQjJRYzhJFwzSCrg==
+
+"@tiptap/pm@^3.22.5", "@tiptap/pm@3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/pm/-/pm-3.22.5.tgz"
+ integrity sha512-Cr9Mv4igxvI2tKMiahw48sZxva3PfDzypErH8IB82N+9qa9n9ygVMt0BOaDg53hLKxEEVeYr2S/wCcJIVFgBTw==
+ dependencies:
+ prosemirror-changeset "^2.3.0"
+ prosemirror-commands "^1.6.2"
+ prosemirror-dropcursor "^1.8.1"
+ prosemirror-gapcursor "^1.3.2"
+ prosemirror-history "^1.4.1"
+ prosemirror-keymap "^1.2.2"
+ prosemirror-model "^1.24.1"
+ prosemirror-schema-list "^1.5.0"
+ prosemirror-state "^1.4.3"
+ prosemirror-tables "^1.6.4"
+ prosemirror-transform "^1.10.2"
+ prosemirror-view "^1.38.1"
+
+"@tiptap/starter-kit@3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/starter-kit/-/starter-kit-3.22.5.tgz"
+ integrity sha512-LZ/LYbwH6rnDi5DnRyagkuNsYAVyhM+yJvvz+ZuYA0JkPiTXJV86J5PWSKew8M0gVfMHcNVtKjfQCvViFCeIgw==
+ dependencies:
+ "@tiptap/core" "^3.22.5"
+ "@tiptap/extension-blockquote" "^3.22.5"
+ "@tiptap/extension-bold" "^3.22.5"
+ "@tiptap/extension-bullet-list" "^3.22.5"
+ "@tiptap/extension-code" "^3.22.5"
+ "@tiptap/extension-code-block" "^3.22.5"
+ "@tiptap/extension-document" "^3.22.5"
+ "@tiptap/extension-dropcursor" "^3.22.5"
+ "@tiptap/extension-gapcursor" "^3.22.5"
+ "@tiptap/extension-hard-break" "^3.22.5"
+ "@tiptap/extension-heading" "^3.22.5"
+ "@tiptap/extension-horizontal-rule" "^3.22.5"
+ "@tiptap/extension-italic" "^3.22.5"
+ "@tiptap/extension-link" "^3.22.5"
+ "@tiptap/extension-list" "^3.22.5"
+ "@tiptap/extension-list-item" "^3.22.5"
+ "@tiptap/extension-list-keymap" "^3.22.5"
+ "@tiptap/extension-ordered-list" "^3.22.5"
+ "@tiptap/extension-paragraph" "^3.22.5"
+ "@tiptap/extension-strike" "^3.22.5"
+ "@tiptap/extension-text" "^3.22.5"
+ "@tiptap/extension-underline" "^3.22.5"
+ "@tiptap/extensions" "^3.22.5"
+ "@tiptap/pm" "^3.22.5"
+
+"@tiptap/suggestion@3.22.5":
+ version "3.22.5"
+ resolved "https://registry.npmjs.org/@tiptap/suggestion/-/suggestion-3.22.5.tgz"
+ integrity sha512-Uv79Ht/o4mx1GWIT65jeQTE67LMrA+K7d8p51XOe9PJw0H0fS3iCdeMJ8tAo3h6QrMJFejdsB7z8jJL9UbAnhA==
+
"@types/chai@^5.2.2":
version "5.2.3"
resolved "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz"
@@ -1508,6 +1683,11 @@ lilconfig@^2.0.5:
resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz"
integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
+linkifyjs@^4.3.2:
+ version "4.3.2"
+ resolved "https://registry.npmjs.org/linkifyjs/-/linkifyjs-4.3.2.tgz"
+ integrity sha512-NT1CJtq3hHIreOianA8aSXn6Cw0JzYOuDQbOrSPe7gqFnCpKP++MQe3ODgO3oh2GJFORkAAdqredOa60z63GbA==
+
locate-character@^3.0.0:
version "3.0.0"
resolved "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz"
@@ -1645,6 +1825,11 @@ optionator@^0.9.3:
type-check "^0.4.0"
word-wrap "^1.2.5"
+orderedmap@^2.0.0:
+ version "2.1.1"
+ resolved "https://registry.npmjs.org/orderedmap/-/orderedmap-2.1.1.tgz"
+ integrity sha512-TvAWxi0nDe1j/rtMcWcIj94+Ffe6n7zhow33h40SKxmsmozs6dz/e+EajymfoFcHd7sxNn8yHM8839uixMOV6g==
+
p-limit@^3.0.2:
version "3.1.0"
resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"
@@ -1800,6 +1985,111 @@ prettier@^3.0, prettier@^3.0.0, prettier@^3.6.2:
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz"
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
+prosemirror-changeset@^2.3.0:
+ version "2.4.1"
+ resolved "https://registry.npmjs.org/prosemirror-changeset/-/prosemirror-changeset-2.4.1.tgz"
+ integrity sha512-96WBLhOaYhJ+kPhLg3uW359Tz6I/MfcrQfL4EGv4SrcqKEMC1gmoGrXHecPE8eOwTVCJ4IwgfzM8fFad25wNfw==
+ dependencies:
+ prosemirror-transform "^1.0.0"
+
+prosemirror-commands@^1.6.2:
+ version "1.7.1"
+ resolved "https://registry.npmjs.org/prosemirror-commands/-/prosemirror-commands-1.7.1.tgz"
+ integrity sha512-rT7qZnQtx5c0/y/KlYaGvtG411S97UaL6gdp6RIZ23DLHanMYLyfGBV5DtSnZdthQql7W+lEVbpSfwtO8T+L2w==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.10.2"
+
+prosemirror-dropcursor@^1.8.1:
+ version "1.8.2"
+ resolved "https://registry.npmjs.org/prosemirror-dropcursor/-/prosemirror-dropcursor-1.8.2.tgz"
+ integrity sha512-CCk6Gyx9+Tt2sbYk5NK0nB1ukHi2ryaRgadV/LvyNuO3ena1payM2z6Cg0vO1ebK8cxbzo41ku2DE5Axj1Zuiw==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+ prosemirror-view "^1.1.0"
+
+prosemirror-gapcursor@^1.3.2:
+ version "1.4.1"
+ resolved "https://registry.npmjs.org/prosemirror-gapcursor/-/prosemirror-gapcursor-1.4.1.tgz"
+ integrity sha512-pMdYaEnjNMSwl11yjEGtgTmLkR08m/Vl+Jj443167p9eB3HVQKhYCc4gmHVDsLPODfZfjr/MmirsdyZziXbQKw==
+ dependencies:
+ prosemirror-keymap "^1.0.0"
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-view "^1.0.0"
+
+prosemirror-history@^1.4.1:
+ version "1.5.0"
+ resolved "https://registry.npmjs.org/prosemirror-history/-/prosemirror-history-1.5.0.tgz"
+ integrity sha512-zlzTiH01eKA55UAf1MEjtssJeHnGxO0j4K4Dpx+gnmX9n+SHNlDqI2oO1Kv1iPN5B1dm5fsljCfqKF9nFL6HRg==
+ dependencies:
+ prosemirror-state "^1.2.2"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.31.0"
+ rope-sequence "^1.3.0"
+
+prosemirror-keymap@^1.0.0, prosemirror-keymap@^1.2.2, prosemirror-keymap@^1.2.3:
+ version "1.2.3"
+ resolved "https://registry.npmjs.org/prosemirror-keymap/-/prosemirror-keymap-1.2.3.tgz"
+ integrity sha512-4HucRlpiLd1IPQQXNqeo81BGtkY8Ai5smHhKW9jjPKRc2wQIxksg7Hl1tTI2IfT2B/LgX6bfYvXxEpJl7aKYKw==
+ dependencies:
+ prosemirror-state "^1.0.0"
+ w3c-keyname "^2.2.0"
+
+prosemirror-model@^1.0.0, prosemirror-model@^1.20.0, prosemirror-model@^1.21.0, prosemirror-model@^1.24.1, prosemirror-model@^1.25.4:
+ version "1.25.4"
+ resolved "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz"
+ integrity sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==
+ dependencies:
+ orderedmap "^2.0.0"
+
+prosemirror-schema-list@^1.5.0:
+ version "1.5.1"
+ resolved "https://registry.npmjs.org/prosemirror-schema-list/-/prosemirror-schema-list-1.5.1.tgz"
+ integrity sha512-927lFx/uwyQaGwJxLWCZRkjXG0p48KpMj6ueoYiu4JX05GGuGcgzAy62dfiV8eFZftgyBUvLx76RsMe20fJl+Q==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.7.3"
+
+prosemirror-state@^1.0.0, prosemirror-state@^1.2.2, prosemirror-state@^1.4.3, prosemirror-state@^1.4.4:
+ version "1.4.4"
+ resolved "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz"
+ integrity sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==
+ dependencies:
+ prosemirror-model "^1.0.0"
+ prosemirror-transform "^1.0.0"
+ prosemirror-view "^1.27.0"
+
+prosemirror-tables@^1.6.4:
+ version "1.8.5"
+ resolved "https://registry.npmjs.org/prosemirror-tables/-/prosemirror-tables-1.8.5.tgz"
+ integrity sha512-V/0cDCsHKHe/tfWkeCmthNUcEp1IVO3p6vwN8XtwE9PZQLAZJigbw3QoraAdfJPir4NKJtNvOB8oYGKRl+t0Dw==
+ dependencies:
+ prosemirror-keymap "^1.2.3"
+ prosemirror-model "^1.25.4"
+ prosemirror-state "^1.4.4"
+ prosemirror-transform "^1.10.5"
+ prosemirror-view "^1.41.4"
+
+prosemirror-transform@^1.0.0, prosemirror-transform@^1.1.0, prosemirror-transform@^1.10.2, prosemirror-transform@^1.10.5, prosemirror-transform@^1.7.3:
+ version "1.12.0"
+ resolved "https://registry.npmjs.org/prosemirror-transform/-/prosemirror-transform-1.12.0.tgz"
+ integrity sha512-GxboyN4AMIsoHNtz5uf2r2Ru551i5hWeCMD6E2Ib4Eogqoub0NflniaBPVQ4MrGE5yZ8JV9tUHg9qcZTTrcN4w==
+ dependencies:
+ prosemirror-model "^1.21.0"
+
+prosemirror-view@^1.0.0, prosemirror-view@^1.1.0, prosemirror-view@^1.27.0, prosemirror-view@^1.31.0, prosemirror-view@^1.38.1, prosemirror-view@^1.41.4:
+ version "1.41.8"
+ resolved "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.8.tgz"
+ integrity sha512-TnKDdohEatgyZNGCDWIdccOHXhYloJwbwU+phw/a23KBvJIR9lWQWW7WHHK3vBdOLDNuF7TaX98GObUZOWkOnA==
+ dependencies:
+ prosemirror-model "^1.20.0"
+ prosemirror-state "^1.0.0"
+ prosemirror-transform "^1.1.0"
+
punycode@^2.1.0:
version "2.3.1"
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
@@ -1863,6 +2153,11 @@ rollup@^1.20.0||^2.0.0||^3.0.0||^4.0.0, rollup@^2.68.0||^3.0.0||^4.0.0, rollup@^
"@rollup/rollup-win32-x64-msvc" "4.59.0"
fsevents "~2.3.2"
+rope-sequence@^1.3.0:
+ version "1.3.4"
+ resolved "https://registry.npmjs.org/rope-sequence/-/rope-sequence-1.3.4.tgz"
+ integrity sha512-UT5EDe2cu2E/6O4igUr5PSFs23nvvukicWHx6GnOPlHAiiYbzNuCRQCuiUdHJQcqKalLKlrYJnjY0ySGsXNQXQ==
+
sade@^1.7.4:
version "1.8.1"
resolved "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz"
@@ -2178,6 +2473,11 @@ vitest@^4.0.0, vitest@^4.0.10, vitest@4.1.0:
vite "^6.0.0 || ^7.0.0 || ^8.0.0-0"
why-is-node-running "^2.3.0"
+w3c-keyname@^2.2.0:
+ version "2.2.8"
+ resolved "https://registry.npmjs.org/w3c-keyname/-/w3c-keyname-2.2.8.tgz"
+ integrity sha512-dpojBhNsCNN7T82Tm7k26A6G9ML3NkhDsnw9n/eoxSRlVBB4CEtIQ/KTCLI2Fwf3ataSXRhYFkQi3SlnFwPvPQ==
+
webpack-virtual-modules@^0.6.2:
version "0.6.2"
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
diff --git a/renovate.json b/renovate.json
new file mode 100644
index 00000000..bcb6238b
--- /dev/null
+++ b/renovate.json
@@ -0,0 +1,10 @@
+{
+ "$schema": "https://docs.renovatebot.com/renovate-schema.json",
+ "packageRules": [
+ {
+ "matchPackagePatterns": ["^@tiptap/"],
+ "groupName": "tiptap",
+ "automerge": false
+ }
+ ]
+}