feat: decouple person-mention display text from person name (#372) #373

Merged
marcel merged 17 commits from feat/person-mentions-issue-362-frontend-b2 into main 2026-04-29 16:55:53 +02:00
28 changed files with 1814 additions and 1078 deletions

View File

@@ -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,

View File

@@ -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.
*
* <p>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
) {
}

View File

@@ -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;
}

View File

@@ -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.
*
* <p>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<TranscriptionBlock> 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 " <Uppercase>"
// (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));
}
}
}

View File

@@ -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<PersonSummaryDTO> 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

View File

@@ -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

View File

@@ -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<PersonMention> 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<UUID> 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();
}
}

View File

@@ -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<PersonDisplayNameChangedEvent> 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

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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.",

View File

@@ -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",

View File

@@ -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"

View File

@@ -0,0 +1,170 @@
<script lang="ts">
import type { components } from '$lib/generated/api';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import { m } from '$lib/paraglide/messages.js';
type Person = components['schemas']['Person'];
// The dropdown receives a single reactive state object. PersonMentionEditor
// mutates fields on this object (model.items = ..., etc.) and Svelte's $state
// proxy reactivity propagates the change here. This is the supported way to
// update an imperatively-mounted Svelte 5 component — `mount` does not return
// settable prop accessors.
type DropdownState = {
items: Person[];
command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null;
};
let { model }: { model: DropdownState } = $props();
// highlightedIndex must be both writable (keyboard handler mutates it) and
// reset when `items` changes (so it never points past the end of a new list).
// A pure $derived is read-only and cannot serve both needs, so $state + $effect
// is the correct pattern here. The autofixer suggestion to use $derived does not
// apply: the mutation in onKeyDown is not a derivation.
let highlightedIndex = $state(0);
$effect(() => {
// Read model.items to subscribe; reset index whenever the list is replaced.
void model.items;
highlightedIndex = 0;
});
// ---------------------------------------------------------------------------
// Positioning — flip strategy: open upward when there is not enough room
// below the cursor to show the dropdown without clipping the viewport.
// ---------------------------------------------------------------------------
type Position = {
top: string | null;
bottom: string | null;
left: string;
};
const DROPDOWN_CLEARANCE_PX = 200;
const position = $derived.by<Position>(() => {
const cr = model.clientRect;
if (!cr) return { top: '0px', bottom: null, left: '0px' };
const rect = cr();
if (!rect) return { top: '0px', bottom: null, left: '0px' };
// Some editors report a caret DOMRect with zero width; fall back to rect.x.
const left = `${rect.width === 0 ? rect.x : rect.left}px`;
if (window.innerHeight - rect.bottom < DROPDOWN_CLEARANCE_PX) {
// Not enough space below — anchor bottom of dropdown to top of caret.
return {
top: null,
bottom: `${window.innerHeight - rect.top}px`,
left
};
}
return { top: `${rect.bottom}px`, bottom: null, left };
});
// ---------------------------------------------------------------------------
// Keyboard handler — exported so Tiptap's render() can forward events.
// Returns true when the event is consumed (prevents the editor's default).
// ---------------------------------------------------------------------------
export function onKeyDown(event: KeyboardEvent): boolean {
const len = model.items.length;
if (event.key === 'ArrowDown') {
highlightedIndex = (highlightedIndex + 1) % Math.max(len, 1);
return true;
}
if (event.key === 'ArrowUp') {
highlightedIndex = (highlightedIndex - 1 + Math.max(len, 1)) % Math.max(len, 1);
return true;
}
if (event.key === 'Enter') {
const selected = model.items[highlightedIndex];
if (selected) {
model.command(selected);
}
return true;
}
// Escape: let the suggestion plugin handle it (return false = not consumed).
return false;
}
function selectItem(item: Person) {
model.command(item);
}
</script>
<!--
Mounted imperatively to document.body by the Tiptap suggestion plugin.
Positioned absolutely relative to the viewport using inline styles derived
from the Tiptap clientRect() callback.
SECURITY: This component receives pre-filtered Person[] items from the
parent — it does NOT fetch. The parent's fetch relies on the SvelteKit Vite
proxy injecting the auth_token cookie as the Authorization header.
Mounted in transcribe mode behind WRITE_ALL — never reachable to
unauthenticated users.
-->
<div
class="fixed z-20 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.person_mention_btn_label()}
style:top={position.top}
style:bottom={position.bottom}
style:left={position.left}
>
{#if model.items.length === 0}
<p class="px-3 py-2.5 font-sans text-sm text-ink-3">
{m.person_mention_popup_empty()}
</p>
<!--
Empty-state escape hatch — without it the transcriber has to close
the dropdown, navigate to /persons/new, come back, and re-type the
query. target=_blank keeps the document and editor state intact;
rel=noopener prevents reverse-tabnabbing on the new tab. Leonie #5621.
-->
<a
href="/persons/new"
target="_blank"
rel="noopener"
class="flex min-h-[44px] items-center gap-2 border-t border-line px-3 py-2.5 font-sans text-sm font-medium text-brand-navy hover:bg-canvas focus:bg-canvas focus:outline-none"
onmousedown={(e) => e.preventDefault()}
>
{m.person_mention_create_new()}
<span aria-hidden="true"></span>
</a>
{:else}
{#each model.items as person, i (person.id)}
<div
class={[
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
// brand-mint ring (≈2.5:1 on white) fails WCAG 1.4.11 Non-Text
// Contrast for a meaningful keyboard-highlight indicator. brand-navy
// gives ~14.5:1 against the bg-brand-mint/20 row. Leonie #5621.
i === highlightedIndex && 'bg-brand-mint/20 ring-2 ring-brand-navy ring-inset'
]}
role="option"
aria-selected={i === highlightedIndex}
data-test-person-id={person.id}
tabindex="-1"
onmousedown={(e) => {
// Prevent blur on the editor before the selection fires.
e.preventDefault();
selectItem(person);
}}
>
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
<span class="truncate font-sans text-xs text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</span>
{/if}
</div>
{/each}
{/if}
</div>

View File

@@ -109,7 +109,10 @@ const ariaBusy = $derived(state.status === 'loading');
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
{/if}
{#if state.person.alias}
<div class="maiden" data-testid="person-hover-card-maiden">geb. {state.person.alias}</div>
<div class="maiden" data-testid="person-hover-card-maiden">
{m.person_born_name_prefix()}
{state.person.alias}
</div>
{/if}
</div>
{#if familyChips.length > 0}

View File

@@ -1,263 +1,279 @@
<script lang="ts">
import { onDestroy, tick } from 'svelte';
import { detectPersonMention } from '$lib/utils/personMention';
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
import { onMount, onDestroy, mount, unmount } from 'svelte';
import { Editor } from '@tiptap/core';
import StarterKit from '@tiptap/starter-kit';
import { Mention } from '@tiptap/extension-mention';
import { m } from '$lib/paraglide/messages.js';
import type { components } from '$lib/generated/api';
import type { PersonMention } from '$lib/types';
import { deserialize, serialize } from '$lib/utils/mentionSerializer';
import MentionDropdown from './MentionDropdown.svelte';
type Person = components['schemas']['Person'];
type PersonMention = components['schemas']['PersonMention'];
type Props = {
value: string;
mentionedPersons: PersonMention[];
placeholder?: string;
rows?: number;
disabled?: boolean;
onfocus?: () => void;
onblur?: () => void;
// Optional escape hatch: lets the parent observe the underlying textarea node
// (e.g. to read selection bounds for quote-selection features). Returning a
// cleanup function from the parent is not required.
captureTextarea?: (node: HTMLTextAreaElement) => void | (() => void);
onSelectionChange?: (text: string | null) => void;
};
let {
value = $bindable(''),
mentionedPersons = $bindable([]),
placeholder = '',
rows = 1,
disabled = false,
onfocus,
onblur,
captureTextarea
onSelectionChange
}: Props = $props();
let query: string | null = $state(null);
let results: Person[] = $state([]);
let highlightedIndex = $state(0);
let mentionStart = $state(0);
let loading = $state(false);
let editorEl: HTMLDivElement;
let editor: Editor | null = null;
let textarea: HTMLTextAreaElement | null = null;
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
function attachTextarea(node: HTMLTextAreaElement) {
textarea = node;
resizeTextarea();
const parentCleanup = captureTextarea?.(node);
return () => {
parentCleanup?.();
textarea = null;
};
}
function resizeTextarea() {
if (!textarea) return;
textarea.style.height = 'auto';
textarea.style.height = `${textarea.scrollHeight}px`;
}
// Autoresize on every value change — read `value` so this $effect
// re-runs whenever the bound prop is reassigned.
$effect(() => {
void value;
resizeTextarea();
// Single reactive state object shared with MentionDropdown. Mutating these
// fields propagates to the mounted dropdown via Svelte's $state proxy —
// this is required because Svelte 5's `mount()` does NOT return prop
// accessors; setting `instance.items = ...` does not update the component.
let dropdownState = $state<{
items: Person[];
command: (item: Person) => void;
clientRect: (() => DOMRect | null) | null;
}>({
items: [],
command: () => {},
clientRect: null
});
function handleInput() {
if (!textarea) return;
const cursorPos = textarea.selectionStart;
const detected = detectPersonMention(value, cursorPos);
type DropdownExports = {
onKeyDown: (event: KeyboardEvent) => boolean;
};
if (detected === null) {
closePopup();
return;
}
onMount(() => {
// Custom Mention node: uses personId / displayName instead of the
// default id / label attribute names so the mentionSerializer can
// round-trip correctly without attribute remapping.
const CustomMention = Mention.extend({
addAttributes() {
return {
personId: {
default: null,
parseHTML: (el) => el.getAttribute('data-person-id'),
renderHTML: (attrs) => ({ 'data-person-id': attrs.personId })
},
displayName: {
default: null,
parseHTML: (el) => el.getAttribute('data-display-name'),
renderHTML: (attrs) => ({ 'data-display-name': attrs.displayName })
},
mentionSuggestionChar: {
default: '@',
parseHTML: (el) => el.getAttribute('data-mention-suggestion-char'),
renderHTML: (attrs) => ({
'data-mention-suggestion-char': attrs.mentionSuggestionChar
})
}
};
}
});
const before = value.slice(0, cursorPos);
mentionStart = before.lastIndexOf('@');
editor = new Editor({
element: editorEl,
// Initial editable state honors the `disabled` prop. The reactive
// $effect below keeps it in sync if the prop flips after mount —
// without this, a keyboard user can tab into the contenteditable
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
editable: !disabled,
extensions: [
StarterKit.configure({
heading: false,
bold: false,
italic: false,
strike: false,
code: false,
blockquote: false,
codeBlock: false,
bulletList: false,
orderedList: false,
hardBreak: false,
horizontalRule: false
}),
CustomMention.configure({
renderHTML({ node }) {
// Underline color matches the read-mode .person-mention rule
// (ink at ~50% alpha) — brand-mint on white fails WCAG 1.4.11
// Non-Text Contrast (≈1.7:1, needs 3:1). Leonie #5621.
return [
'span',
{
'data-type': 'mention',
'data-person-id': node.attrs.personId,
'data-display-name': node.attrs.displayName,
class:
'mention-token underline decoration-ink/50 underline-offset-2 text-brand-navy font-medium'
},
`@${node.attrs.displayName}`
];
},
renderText({ node }) {
return `@${node.attrs.displayName}`;
},
suggestion: {
char: '@',
// ─────────────────────────────────────────────────────────────
// EXCEPTION to frontend/CLAUDE.md "no client-side API fetch":
// Tiptap's suggestion plugin lives entirely on the client and
// fires on every keystroke after `@`. Routing each query through
// a SvelteKit form action would round-trip through SSR for a
// dropdown that needs to feel instantaneous, and a +server.ts
// endpoint would only proxy the same call. Auth flows through
// the Vite proxy in dev and Caddy in prod (cookie-based), so the
// network surface is identical to a server-driven call.
// Markus #5616: an ADR will formalise this. Open follow-up:
// "ADR: client-side fetch exception for editor suggestion plugins."
// Nora #5618 #3 — separate issue tracks the GET /api/persons
// response-shape audit (PersonSummaryDTO leaks `notes`).
// ─────────────────────────────────────────────────────────────
items: async ({ query }: { query: string }) => {
if (!query) return [];
try {
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
if (!res.ok) return [];
return ((await res.json()) as Person[]).slice(0, 5);
} catch {
return [];
}
},
// AC-1 fix: insert the typed query as displayName, not person.displayName.
command({ editor: ed, range, props }) {
const p = props as unknown as { personId: string; displayName: string };
const nodeAfter = ed.view.state.selection.$to.nodeAfter;
if (nodeAfter?.text?.startsWith(' ')) range.to += 1;
ed.chain()
.focus()
.insertContentAt(range, [
{
type: 'mention',
attrs: { personId: p.personId, displayName: p.displayName }
},
{ type: 'text', text: ' ' }
])
.run();
},
render() {
let component: object | null = null;
let exports: DropdownExports | null = null;
if (query !== detected) {
query = detected;
highlightedIndex = 0;
scheduleSearch(detected);
}
}
// Tiptap's SuggestionProps types `command` against the default
// MentionNodeAttrs (id/label). Our custom Mention extension uses
// personId/displayName, so we cast the renderProps locally.
type LooseRenderProps = {
items: unknown;
command: (props: { personId: string; displayName: string }) => void;
query: string;
clientRect?: (() => DOMRect | null) | null;
};
function scheduleSearch(q: string) {
clearTimeout(debounceTimer);
if (!q.trim()) {
// Empty query: keep popup open with last results so the user can browse,
// but don't fire a backend call until they actually type something.
results = [];
loading = false;
return;
}
loading = true;
debounceTimer = setTimeout(async () => {
try {
// SECURITY: relies on the SvelteKit Vite proxy injecting the auth_token
// cookie as the Authorization header (vite.config.ts) and on the
// browser's same-origin policy for the /api/* path. Mounted in
// transcribe mode behind WRITE_ALL — never reachable to unauthenticated
// users.
const res = await fetch(`/api/persons?q=${encodeURIComponent(q)}`);
if (res.ok) {
const data: Person[] = await res.json();
results = data.slice(0, 5);
} else {
results = [];
const updateState = (renderProps: LooseRenderProps) => {
dropdownState.items = renderProps.items as Person[];
// AC-1: pass typed query as displayName, not person.displayName
dropdownState.command = (item: Person) =>
renderProps.command({
personId: item.id,
displayName: renderProps.query
});
dropdownState.clientRect = renderProps.clientRect ?? null;
};
return {
onStart(renderProps) {
updateState(renderProps as unknown as LooseRenderProps);
const mounted = mount(MentionDropdown, {
target: document.body,
props: { model: dropdownState }
});
component = mounted as object;
exports = mounted as unknown as DropdownExports;
},
onUpdate(renderProps) {
updateState(renderProps as unknown as LooseRenderProps);
},
onKeyDown({ event }) {
// Escape is handled by the suggestion plugin itself.
if (event.key === 'Escape') return false;
return exports?.onKeyDown(event) ?? false;
},
onExit() {
if (component) {
unmount(component);
component = null;
exports = null;
}
}
};
}
}
})
],
content: deserialize(value, mentionedPersons),
editorProps: {
attributes: {
role: 'textbox',
'aria-multiline': 'true',
'aria-label': m.transcription_editor_aria_label(),
...(placeholder ? { 'data-placeholder': placeholder } : {}),
class: [
'min-h-[120px] px-1 py-2.5',
'font-serif text-base leading-relaxed text-ink',
'focus:outline-none',
'tiptap-editor-inner'
].join(' ')
}
} catch {
results = [];
} finally {
loading = false;
},
onUpdate({ editor: ed }) {
const { text, mentionedPersons: mp } = serialize(ed.getJSON());
value = text;
mentionedPersons = mp;
},
onFocus() {
onfocus?.();
},
onBlur() {
onblur?.();
},
onSelectionUpdate({ editor: ed }) {
const { from, to } = ed.state.selection;
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
}
}, 200);
}
});
});
async function selectPerson(person: Person) {
if (!textarea) return;
onDestroy(() => {
editor?.destroy();
});
const displayName = person.displayName ?? '';
const replacement = `@${displayName} `;
const cursorPos = textarea.selectionStart;
const before = value.slice(0, mentionStart);
const after = value.slice(cursorPos);
value = before + replacement + after;
if (!mentionedPersons.some((existing) => existing.personId === person.id)) {
mentionedPersons = [...mentionedPersons, { personId: person.id!, displayName }];
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
// flips contenteditable on the inner DOM and stops accepting input — matches
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
//
// Guard: setEditable triggers a ProseMirror transaction which fires onUpdate;
// onUpdate writes through bind:value / bind:mentionedPersons. Without this
// idempotence check, the effect would loop on every prop pass-through.
$effect(() => {
const shouldBeEditable = !disabled;
if (editor && editor.isEditable !== shouldBeEditable) {
editor.setEditable(shouldBeEditable);
}
closePopup();
await tick();
if (!textarea) return;
const pos = mentionStart + replacement.length;
textarea.selectionStart = pos;
textarea.selectionEnd = pos;
textarea.focus();
}
function closePopup() {
query = null;
results = [];
highlightedIndex = 0;
loading = false;
clearTimeout(debounceTimer);
}
function handleBlur() {
// Small delay so an option's onmousedown can fire and select before the
// popup unmounts. Without this, clicking a result on the way out would
// race with blur and lose the selection.
setTimeout(() => closePopup(), 150);
onblur?.();
}
function handleKeydown(e: KeyboardEvent) {
if (query === null) return;
if (e.key === 'Escape') {
e.preventDefault();
closePopup();
return;
}
if (e.key === 'ArrowDown') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex + 1) % results.length;
}
return;
}
if (e.key === 'ArrowUp') {
e.preventDefault();
if (results.length > 0) {
highlightedIndex = (highlightedIndex - 1 + results.length) % results.length;
}
return;
}
if (e.key === 'Enter' && results.length > 0) {
e.preventDefault();
selectPerson(results[highlightedIndex]);
return;
}
}
onDestroy(() => clearTimeout(debounceTimer));
const popupOpen = $derived(query !== null);
});
</script>
<div class="relative">
<textarea
{@attach attachTextarea}
class="block min-h-[44px] w-full resize-none rounded-sm border border-transparent bg-transparent px-1 py-2.5 font-serif text-base leading-relaxed text-ink placeholder:text-ink-3 focus-visible:border-brand-mint focus-visible:ring-2 focus-visible:ring-brand-mint/40 focus-visible:outline-none"
rows={rows}
placeholder={placeholder}
disabled={disabled}
bind:value={value}
oninput={handleInput}
onkeydown={handleKeydown}
onfocus={onfocus}
onblur={handleBlur}
></textarea>
{#if popupOpen}
<div
class="absolute z-20 mt-1 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
role="listbox"
aria-label={m.person_mention_btn_label()}
>
{#if loading}
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.comp_typeahead_loading()}</p>
{:else if results.length === 0}
<div class="flex flex-col gap-2 px-3 py-2.5">
<p class="font-sans text-sm text-ink-3">{m.person_mention_popup_empty()}</p>
<a
href="/persons/new?name={encodeURIComponent(query ?? '')}"
target="_blank"
rel="noopener"
class="font-sans text-sm font-medium text-brand-navy underline-offset-2 hover:underline"
>
{m.person_mention_create_new()}
</a>
</div>
{:else}
{#each results as person, i (person.id)}
<div
class={[
'flex min-h-[44px] cursor-pointer flex-col gap-1 px-3 py-2.5 text-left hover:bg-canvas',
// Keyboard-highlighted row gets a stronger token than hover so
// keyboard users (and tablet stylus users sweeping over rows)
// can tell the cursor position apart from a hover (Leonie #5507 §3,
// WCAG 1.4.11 Non-Text Contrast).
i === highlightedIndex &&
'bg-brand-mint/20 ring-2 ring-brand-mint ring-inset'
]}
role="option"
aria-selected={i === highlightedIndex}
data-test-person-id={person.id}
tabindex="-1"
onmousedown={(e) => {
e.preventDefault();
selectPerson(person);
}}
>
<span class="truncate font-serif text-base text-ink">{person.displayName}</span>
{#if formatLifeDateRange(person.birthYear, person.deathYear)}
<span class="truncate font-sans text-xs text-ink-3">
{formatLifeDateRange(person.birthYear, person.deathYear)}
</span>
{/if}
</div>
{/each}
{/if}
</div>
{/if}
</div>
<div
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
class:opacity-50={disabled}
class:pointer-events-none={disabled}
aria-disabled={disabled ? 'true' : undefined}
bind:this={editorEl}
></div>

View File

@@ -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: '<img src=x onerror=alert(1)>'
};
renderHost({
value: '@<img src=x onerror=alert(1)>',
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 <img> 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('<img src=x onerror=alert(1)>');
});
});
});
// ─── 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();

View File

@@ -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}
/>

View File

@@ -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;
}
}
</script>
<div
@@ -176,24 +154,22 @@ function handleTextareaMouseUp() {
{/if}
</div>
<!-- Textarea (now powered by PersonMentionEditor for @-mention typeahead) -->
<div onmouseup={handleTextareaMouseUp} role="presentation">
<PersonMentionEditor
bind:value={() => localText,
(v) => {
localText = v;
emitChange();
}}
bind:mentionedPersons={() => localMentions,
(next) => {
localMentions = next;
emitChange();
}}
placeholder={m.transcription_block_placeholder()}
onfocus={onFocus}
captureTextarea={captureTextarea}
/>
</div>
<!-- Editor powered by PersonMentionEditor (Tiptap) for @-mention typeahead -->
<PersonMentionEditor
bind:value={() => 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}
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>

View File

@@ -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();
});
});

View File

@@ -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':

View File

@@ -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: '<script>alert(1)</script>' }
];
const text = '@<script>alert(1)</script>';
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('<script>alert(1)</script>');
});
});

View File

@@ -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<string>();
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 };
}

View File

@@ -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');
});
});

View File

@@ -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;
}

View File

@@ -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%);

View File

@@ -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"

10
renovate.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
"packageRules": [
{
"matchPackagePatterns": ["^@tiptap/"],
"groupName": "tiptap",
"automerge": false
}
]
}