feat: decouple person-mention display text from person name (#372) #373
@@ -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,
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
}
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
@@ -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.",
|
||||
|
||||
553
frontend/package-lock.json
generated
553
frontend/package-lock.json
generated
@@ -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",
|
||||
|
||||
@@ -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"
|
||||
|
||||
170
frontend/src/lib/components/MentionDropdown.svelte
Normal file
170
frontend/src/lib/components/MentionDropdown.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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}
|
||||
/>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
|
||||
@@ -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':
|
||||
|
||||
138
frontend/src/lib/utils/mentionSerializer.spec.ts
Normal file
138
frontend/src/lib/utils/mentionSerializer.spec.ts
Normal 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>');
|
||||
});
|
||||
});
|
||||
113
frontend/src/lib/utils/mentionSerializer.ts
Normal file
113
frontend/src/lib/utils/mentionSerializer.ts
Normal 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 };
|
||||
}
|
||||
@@ -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');
|
||||
});
|
||||
});
|
||||
@@ -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;
|
||||
}
|
||||
@@ -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%);
|
||||
|
||||
@@ -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
10
renovate.json
Normal file
@@ -0,0 +1,10 @@
|
||||
{
|
||||
"$schema": "https://docs.renovatebot.com/renovate-schema.json",
|
||||
"packageRules": [
|
||||
{
|
||||
"matchPackagePatterns": ["^@tiptap/"],
|
||||
"groupName": "tiptap",
|
||||
"automerge": false
|
||||
}
|
||||
]
|
||||
}
|
||||
Reference in New Issue
Block a user