feat: decouple person-mention display text from person name (#372) #373
@@ -15,11 +15,6 @@ public enum ErrorCode {
|
|||||||
ALIAS_NOT_FOUND,
|
ALIAS_NOT_FOUND,
|
||||||
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
/** The submitted personType value is not allowed (e.g. SKIP is import-only). 400 */
|
||||||
INVALID_PERSON_TYPE,
|
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 ---
|
// --- Documents ---
|
||||||
/** A document with the given ID does not exist. 404 */
|
/** A document with the given ID does not exist. 404 */
|
||||||
DOCUMENT_NOT_FOUND,
|
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)
|
@Size(max = 200)
|
||||||
@Column(name = "display_name", nullable = false, length = 200)
|
@Column(name = "display_name", nullable = false, length = 200)
|
||||||
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
@Schema(requiredMode = Schema.RequiredMode.REQUIRED)
|
||||||
|
// Archival: the text the transcriber typed after @. Never updated on person rename.
|
||||||
private String displayName;
|
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;
|
package org.raddatz.familienarchiv.service;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Objects;
|
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
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.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
import org.raddatz.familienarchiv.exception.ErrorCode;
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
import org.raddatz.familienarchiv.model.Person;
|
||||||
import org.raddatz.familienarchiv.model.PersonDisplayNameChangedEvent;
|
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
import org.raddatz.familienarchiv.model.PersonNameAlias;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||||
import org.raddatz.familienarchiv.model.PersonType;
|
import org.raddatz.familienarchiv.model.PersonType;
|
||||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
import org.raddatz.familienarchiv.repository.PersonRepository;
|
||||||
import org.springframework.context.ApplicationEventPublisher;
|
|
||||||
import org.springframework.dao.OptimisticLockingFailureException;
|
|
||||||
import org.springframework.http.HttpStatus;
|
import org.springframework.http.HttpStatus;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
@@ -34,7 +30,6 @@ public class PersonService {
|
|||||||
|
|
||||||
private final PersonRepository personRepository;
|
private final PersonRepository personRepository;
|
||||||
private final PersonNameAliasRepository aliasRepository;
|
private final PersonNameAliasRepository aliasRepository;
|
||||||
private final ApplicationEventPublisher eventPublisher;
|
|
||||||
|
|
||||||
public List<PersonSummaryDTO> findAll(String q) {
|
public List<PersonSummaryDTO> findAll(String q) {
|
||||||
if (q == null) {
|
if (q == null) {
|
||||||
@@ -161,7 +156,6 @@ public class PersonService {
|
|||||||
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
validateYears(dto.getBirthYear(), dto.getDeathYear());
|
||||||
Person person = personRepository.findById(id)
|
Person person = personRepository.findById(id)
|
||||||
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
.orElseThrow(() -> DomainException.notFound(ErrorCode.PERSON_NOT_FOUND, "Person not found: " + id));
|
||||||
String oldDisplayName = person.getDisplayName();
|
|
||||||
person.setPersonType(dto.getPersonType());
|
person.setPersonType(dto.getPersonType());
|
||||||
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
person.setTitle(dto.getTitle() == null || dto.getTitle().isBlank() ? null : dto.getTitle().trim());
|
||||||
person.setFirstName(dto.getFirstName());
|
person.setFirstName(dto.getFirstName());
|
||||||
@@ -170,17 +164,7 @@ public class PersonService {
|
|||||||
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
person.setNotes(dto.getNotes() == null || dto.getNotes().isBlank() ? null : dto.getNotes().trim());
|
||||||
person.setBirthYear(dto.getBirthYear());
|
person.setBirthYear(dto.getBirthYear());
|
||||||
person.setDeathYear(dto.getDeathYear());
|
person.setDeathYear(dto.getDeathYear());
|
||||||
Person saved = personRepository.save(person);
|
return 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;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@Transactional
|
@Transactional
|
||||||
|
|||||||
@@ -333,21 +333,6 @@ class PersonControllerTest {
|
|||||||
.andExpect(jsonPath("$.lastName").value("Müller"));
|
.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 ─────────────────────────────────────────
|
// ─── POST /api/persons/{id}/merge ─────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@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.Test;
|
||||||
import org.junit.jupiter.api.extension.ExtendWith;
|
import org.junit.jupiter.api.extension.ExtendWith;
|
||||||
import org.mockito.ArgumentCaptor;
|
|
||||||
import org.mockito.InjectMocks;
|
import org.mockito.InjectMocks;
|
||||||
import org.mockito.Mock;
|
import org.mockito.Mock;
|
||||||
import org.mockito.junit.jupiter.MockitoExtension;
|
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.PersonSummaryDTO;
|
||||||
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
import org.raddatz.familienarchiv.dto.PersonUpdateDTO;
|
||||||
import org.raddatz.familienarchiv.exception.DomainException;
|
import org.raddatz.familienarchiv.exception.DomainException;
|
||||||
import org.raddatz.familienarchiv.exception.ErrorCode;
|
|
||||||
import org.raddatz.familienarchiv.model.Person;
|
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.PersonNameAlias;
|
||||||
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
import org.raddatz.familienarchiv.model.PersonNameAliasType;
|
||||||
import org.raddatz.familienarchiv.model.PersonType;
|
import org.raddatz.familienarchiv.model.PersonType;
|
||||||
import org.raddatz.familienarchiv.model.TranscriptionBlock;
|
|
||||||
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
import org.raddatz.familienarchiv.repository.PersonNameAliasRepository;
|
||||||
import org.raddatz.familienarchiv.repository.PersonRepository;
|
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 org.springframework.web.server.ResponseStatusException;
|
||||||
|
|
||||||
import java.util.ArrayList;
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Optional;
|
import java.util.Optional;
|
||||||
import java.util.UUID;
|
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.assertThat;
|
||||||
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
import static org.assertj.core.api.Assertions.assertThatThrownBy;
|
||||||
import static org.mockito.ArgumentMatchers.any;
|
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)
|
@ExtendWith(MockitoExtension.class)
|
||||||
class PersonServiceTest {
|
class PersonServiceTest {
|
||||||
|
|
||||||
@Mock PersonRepository personRepository;
|
@Mock PersonRepository personRepository;
|
||||||
@Mock PersonNameAliasRepository aliasRepository;
|
@Mock PersonNameAliasRepository aliasRepository;
|
||||||
@Mock ApplicationEventPublisher eventPublisher;
|
|
||||||
@InjectMocks PersonService personService;
|
@InjectMocks PersonService personService;
|
||||||
|
|
||||||
// ─── getById ─────────────────────────────────────────────────────────────
|
// ─── getById ─────────────────────────────────────────────────────────────
|
||||||
@@ -252,121 +245,6 @@ class PersonServiceTest {
|
|||||||
assertThat(result.getAlias()).isEqualTo("Anna Alt");
|
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 ─────────────────────────────────────────────────
|
// ─── findOrCreateByAlias ─────────────────────────────────────────────────
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|||||||
@@ -427,6 +427,8 @@
|
|||||||
"person_mention_popup_empty": "Keine Personen gefunden",
|
"person_mention_popup_empty": "Keine Personen gefunden",
|
||||||
"person_mention_btn_label": "Person verlinken",
|
"person_mention_btn_label": "Person verlinken",
|
||||||
"person_mention_create_new": "Neue Person anlegen",
|
"person_mention_create_new": "Neue Person anlegen",
|
||||||
|
"transcription_editor_aria_label": "Transkriptionstext",
|
||||||
|
"person_born_name_prefix": "geb.",
|
||||||
"page_title_home": "Archiv",
|
"page_title_home": "Archiv",
|
||||||
"page_title_persons": "Personen",
|
"page_title_persons": "Personen",
|
||||||
"page_title_admin": "Administration",
|
"page_title_admin": "Administration",
|
||||||
@@ -549,7 +551,6 @@
|
|||||||
"person_alias_btn_delete": "Entfernen",
|
"person_alias_btn_delete": "Entfernen",
|
||||||
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
"error_alias_not_found": "Der Namensalias wurde nicht gefunden.",
|
||||||
"error_invalid_person_type": "Der angegebene Personentyp ist ungültig.",
|
"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_last_name_required": "Nachname ist Pflichtfeld.",
|
||||||
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
"validation_first_name_required": "Vorname ist Pflichtfeld.",
|
||||||
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
"error_ocr_service_unavailable": "Der OCR-Dienst ist nicht verfügbar.",
|
||||||
|
|||||||
@@ -427,6 +427,8 @@
|
|||||||
"person_mention_popup_empty": "No persons found",
|
"person_mention_popup_empty": "No persons found",
|
||||||
"person_mention_btn_label": "Link person",
|
"person_mention_btn_label": "Link person",
|
||||||
"person_mention_create_new": "Create new 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_home": "Archive",
|
||||||
"page_title_persons": "Persons",
|
"page_title_persons": "Persons",
|
||||||
"page_title_admin": "Administration",
|
"page_title_admin": "Administration",
|
||||||
@@ -549,7 +551,6 @@
|
|||||||
"person_alias_btn_delete": "Remove",
|
"person_alias_btn_delete": "Remove",
|
||||||
"error_alias_not_found": "The name alias was not found.",
|
"error_alias_not_found": "The name alias was not found.",
|
||||||
"error_invalid_person_type": "The specified person type is not valid.",
|
"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_last_name_required": "Last name is required.",
|
||||||
"validation_first_name_required": "First name is required.",
|
"validation_first_name_required": "First name is required.",
|
||||||
"error_ocr_service_unavailable": "The OCR service is not available.",
|
"error_ocr_service_unavailable": "The OCR service is not available.",
|
||||||
|
|||||||
@@ -427,6 +427,8 @@
|
|||||||
"person_mention_popup_empty": "No se encontraron personas",
|
"person_mention_popup_empty": "No se encontraron personas",
|
||||||
"person_mention_btn_label": "Vincular persona",
|
"person_mention_btn_label": "Vincular persona",
|
||||||
"person_mention_create_new": "Crear nueva 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_home": "Archivo",
|
||||||
"page_title_persons": "Personas",
|
"page_title_persons": "Personas",
|
||||||
"page_title_admin": "Administración",
|
"page_title_admin": "Administración",
|
||||||
@@ -549,7 +551,6 @@
|
|||||||
"person_alias_btn_delete": "Eliminar",
|
"person_alias_btn_delete": "Eliminar",
|
||||||
"error_alias_not_found": "No se encontro el alias de nombre.",
|
"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_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_last_name_required": "El apellido es obligatorio.",
|
||||||
"validation_first_name_required": "El nombre es obligatorio.",
|
"validation_first_name_required": "El nombre es obligatorio.",
|
||||||
"error_ocr_service_unavailable": "El servicio OCR no está disponible.",
|
"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",
|
"name": "frontend",
|
||||||
"version": "0.0.1",
|
"version": "0.0.1",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/extension-mention": "3.22.5",
|
||||||
|
"@tiptap/starter-kit": "3.22.5",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"pdfjs-dist": "^5.5.207"
|
||||||
@@ -2188,6 +2191,403 @@
|
|||||||
"svelte": "^3 || ^4 || ^5 || ^5.0.0-next.0"
|
"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": {
|
"node_modules/@types/chai": {
|
||||||
"version": "5.2.3",
|
"version": "5.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz",
|
||||||
@@ -4270,6 +4670,12 @@
|
|||||||
"node": ">=10"
|
"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": {
|
"node_modules/locate-character": {
|
||||||
"version": "3.0.0",
|
"version": "3.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz",
|
||||||
@@ -4499,6 +4905,12 @@
|
|||||||
"node": ">= 0.8.0"
|
"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": {
|
"node_modules/p-limit": {
|
||||||
"version": "3.1.0",
|
"version": "3.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz",
|
"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": {
|
"node_modules/punycode": {
|
||||||
"version": "2.3.1",
|
"version": "2.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz",
|
||||||
@@ -5044,6 +5585,12 @@
|
|||||||
"fsevents": "~2.3.2"
|
"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": {
|
"node_modules/sade": {
|
||||||
"version": "1.8.1",
|
"version": "1.8.1",
|
||||||
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
"resolved": "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz",
|
||||||
@@ -5761,6 +6308,12 @@
|
|||||||
"vitest": "^4.0.0"
|
"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": {
|
"node_modules/webpack-virtual-modules": {
|
||||||
"version": "0.6.2",
|
"version": "0.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz",
|
"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"
|
"generate:api": "openapi-typescript http://localhost:8080/v3/api-docs -o ./src/lib/generated/api.ts"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@tiptap/core": "3.22.5",
|
||||||
|
"@tiptap/extension-mention": "3.22.5",
|
||||||
|
"@tiptap/starter-kit": "3.22.5",
|
||||||
"diff": "^8.0.3",
|
"diff": "^8.0.3",
|
||||||
"openapi-fetch": "^0.13.5",
|
"openapi-fetch": "^0.13.5",
|
||||||
"pdfjs-dist": "^5.5.207"
|
"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>
|
<div class="dates" data-testid="person-hover-card-dates">{dateRange}</div>
|
||||||
{/if}
|
{/if}
|
||||||
{#if state.person.alias}
|
{#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}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
{#if familyChips.length > 0}
|
{#if familyChips.length > 0}
|
||||||
|
|||||||
@@ -1,263 +1,279 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, tick } from 'svelte';
|
import { onMount, onDestroy, mount, unmount } from 'svelte';
|
||||||
import { detectPersonMention } from '$lib/utils/personMention';
|
import { Editor } from '@tiptap/core';
|
||||||
import { formatLifeDateRange } from '$lib/utils/personLifeDates';
|
import StarterKit from '@tiptap/starter-kit';
|
||||||
|
import { Mention } from '@tiptap/extension-mention';
|
||||||
import { m } from '$lib/paraglide/messages.js';
|
import { m } from '$lib/paraglide/messages.js';
|
||||||
import type { components } from '$lib/generated/api';
|
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 Person = components['schemas']['Person'];
|
||||||
type PersonMention = components['schemas']['PersonMention'];
|
|
||||||
|
|
||||||
type Props = {
|
type Props = {
|
||||||
value: string;
|
value: string;
|
||||||
mentionedPersons: PersonMention[];
|
mentionedPersons: PersonMention[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
rows?: number;
|
|
||||||
disabled?: boolean;
|
disabled?: boolean;
|
||||||
onfocus?: () => void;
|
onfocus?: () => void;
|
||||||
onblur?: () => void;
|
onblur?: () => void;
|
||||||
// Optional escape hatch: lets the parent observe the underlying textarea node
|
onSelectionChange?: (text: string | null) => void;
|
||||||
// (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);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
let {
|
let {
|
||||||
value = $bindable(''),
|
value = $bindable(''),
|
||||||
mentionedPersons = $bindable([]),
|
mentionedPersons = $bindable([]),
|
||||||
placeholder = '',
|
placeholder = '',
|
||||||
rows = 1,
|
|
||||||
disabled = false,
|
disabled = false,
|
||||||
onfocus,
|
onfocus,
|
||||||
onblur,
|
onblur,
|
||||||
captureTextarea
|
onSelectionChange
|
||||||
}: Props = $props();
|
}: Props = $props();
|
||||||
|
|
||||||
let query: string | null = $state(null);
|
let editorEl: HTMLDivElement;
|
||||||
let results: Person[] = $state([]);
|
let editor: Editor | null = null;
|
||||||
let highlightedIndex = $state(0);
|
|
||||||
let mentionStart = $state(0);
|
|
||||||
let loading = $state(false);
|
|
||||||
|
|
||||||
let textarea: HTMLTextAreaElement | null = null;
|
// Single reactive state object shared with MentionDropdown. Mutating these
|
||||||
let debounceTimer: ReturnType<typeof setTimeout> | undefined;
|
// fields propagates to the mounted dropdown via Svelte's $state proxy —
|
||||||
|
// this is required because Svelte 5's `mount()` does NOT return prop
|
||||||
function attachTextarea(node: HTMLTextAreaElement) {
|
// accessors; setting `instance.items = ...` does not update the component.
|
||||||
textarea = node;
|
let dropdownState = $state<{
|
||||||
resizeTextarea();
|
items: Person[];
|
||||||
const parentCleanup = captureTextarea?.(node);
|
command: (item: Person) => void;
|
||||||
return () => {
|
clientRect: (() => DOMRect | null) | null;
|
||||||
parentCleanup?.();
|
}>({
|
||||||
textarea = null;
|
items: [],
|
||||||
};
|
command: () => {},
|
||||||
}
|
clientRect: 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();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
function handleInput() {
|
type DropdownExports = {
|
||||||
if (!textarea) return;
|
onKeyDown: (event: KeyboardEvent) => boolean;
|
||||||
const cursorPos = textarea.selectionStart;
|
};
|
||||||
const detected = detectPersonMention(value, cursorPos);
|
|
||||||
|
|
||||||
if (detected === null) {
|
onMount(() => {
|
||||||
closePopup();
|
// Custom Mention node: uses personId / displayName instead of the
|
||||||
return;
|
// 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);
|
editor = new Editor({
|
||||||
mentionStart = before.lastIndexOf('@');
|
element: editorEl,
|
||||||
|
// Initial editable state honors the `disabled` prop. The reactive
|
||||||
if (query !== detected) {
|
// $effect below keeps it in sync if the prop flips after mount —
|
||||||
query = detected;
|
// without this, a keyboard user can tab into the contenteditable
|
||||||
highlightedIndex = 0;
|
// even when the wrapper has pointer-events-none (WCAG 2.1.1).
|
||||||
scheduleSearch(detected);
|
editable: !disabled,
|
||||||
}
|
extensions: [
|
||||||
}
|
StarterKit.configure({
|
||||||
|
heading: false,
|
||||||
function scheduleSearch(q: string) {
|
bold: false,
|
||||||
clearTimeout(debounceTimer);
|
italic: false,
|
||||||
if (!q.trim()) {
|
strike: false,
|
||||||
// Empty query: keep popup open with last results so the user can browse,
|
code: false,
|
||||||
// but don't fire a backend call until they actually type something.
|
blockquote: false,
|
||||||
results = [];
|
codeBlock: false,
|
||||||
loading = false;
|
bulletList: false,
|
||||||
return;
|
orderedList: false,
|
||||||
}
|
hardBreak: false,
|
||||||
loading = true;
|
horizontalRule: false
|
||||||
debounceTimer = setTimeout(async () => {
|
}),
|
||||||
|
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 {
|
try {
|
||||||
// SECURITY: relies on the SvelteKit Vite proxy injecting the auth_token
|
const res = await fetch(`/api/persons?q=${encodeURIComponent(query)}`);
|
||||||
// cookie as the Authorization header (vite.config.ts) and on the
|
if (!res.ok) return [];
|
||||||
// browser's same-origin policy for the /api/* path. Mounted in
|
return ((await res.json()) as Person[]).slice(0, 5);
|
||||||
// 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 = [];
|
|
||||||
}
|
|
||||||
} catch {
|
} catch {
|
||||||
results = [];
|
return [];
|
||||||
} finally {
|
|
||||||
loading = false;
|
|
||||||
}
|
}
|
||||||
}, 200);
|
},
|
||||||
|
// 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;
|
||||||
|
|
||||||
|
// 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;
|
||||||
|
};
|
||||||
|
|
||||||
|
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
async function selectPerson(person: Person) {
|
|
||||||
if (!textarea) return;
|
|
||||||
|
|
||||||
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 }];
|
|
||||||
}
|
}
|
||||||
|
};
|
||||||
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
|
content: deserialize(value, mentionedPersons),
|
||||||
// popup unmounts. Without this, clicking a result on the way out would
|
editorProps: {
|
||||||
// race with blur and lose the selection.
|
attributes: {
|
||||||
setTimeout(() => closePopup(), 150);
|
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(' ')
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onUpdate({ editor: ed }) {
|
||||||
|
const { text, mentionedPersons: mp } = serialize(ed.getJSON());
|
||||||
|
value = text;
|
||||||
|
mentionedPersons = mp;
|
||||||
|
},
|
||||||
|
onFocus() {
|
||||||
|
onfocus?.();
|
||||||
|
},
|
||||||
|
onBlur() {
|
||||||
onblur?.();
|
onblur?.();
|
||||||
|
},
|
||||||
|
onSelectionUpdate({ editor: ed }) {
|
||||||
|
const { from, to } = ed.state.selection;
|
||||||
|
onSelectionChange?.(from !== to ? ed.state.doc.textBetween(from, to) : null);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
function handleKeydown(e: KeyboardEvent) {
|
onDestroy(() => {
|
||||||
if (query === null) return;
|
editor?.destroy();
|
||||||
|
});
|
||||||
|
|
||||||
if (e.key === 'Escape') {
|
// Keep editor in sync with the reactive `disabled` prop. Tiptap's setEditable
|
||||||
e.preventDefault();
|
// flips contenteditable on the inner DOM and stops accepting input — matches
|
||||||
closePopup();
|
// the textarea's old `disabled` semantics for keyboard users (WCAG 2.1.1).
|
||||||
return;
|
//
|
||||||
|
// 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);
|
||||||
}
|
}
|
||||||
|
});
|
||||||
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>
|
</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
|
<div
|
||||||
class="absolute z-20 mt-1 w-72 overflow-hidden rounded-sm border border-line bg-surface shadow-lg"
|
class="relative rounded-sm border border-transparent focus-within:border-brand-mint focus-within:ring-2 focus-within:ring-brand-mint/40"
|
||||||
role="listbox"
|
class:opacity-50={disabled}
|
||||||
aria-label={m.person_mention_btn_label()}
|
class:pointer-events-none={disabled}
|
||||||
>
|
aria-disabled={disabled ? 'true' : undefined}
|
||||||
{#if loading}
|
bind:this={editorEl}
|
||||||
<p class="px-3 py-2 font-sans text-sm text-ink-3">{m.comp_typeahead_loading()}</p>
|
></div>
|
||||||
{: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>
|
|
||||||
|
|||||||
@@ -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 { 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 PersonMentionEditorHost from './PersonMentionEditor.test-host.svelte';
|
||||||
import type { components } from '$lib/generated/api';
|
import type { components } from '$lib/generated/api';
|
||||||
|
|
||||||
type Person = components['schemas']['Person'];
|
type Person = components['schemas']['Person'];
|
||||||
type PersonMention = components['schemas']['PersonMention'];
|
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 = {
|
const AUGUSTE: Person = {
|
||||||
id: 'p-aug',
|
id: 'p-aug',
|
||||||
firstName: 'Auguste',
|
firstName: 'Auguste',
|
||||||
@@ -39,39 +32,24 @@ const ANNA: Person = {
|
|||||||
} as unknown as Person;
|
} as unknown as Person;
|
||||||
|
|
||||||
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
|
function mockFetchWithPersons(persons: Person[] = [AUGUSTE, ANNA]) {
|
||||||
const fetchMock = vi
|
vi.stubGlobal(
|
||||||
.fn()
|
'fetch',
|
||||||
.mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) });
|
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue(persons) })
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
);
|
||||||
return fetchMock;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function mockFetchEmpty() {
|
function mockFetchEmpty() {
|
||||||
const fetchMock = vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) });
|
vi.stubGlobal(
|
||||||
vi.stubGlobal('fetch', fetchMock);
|
'fetch',
|
||||||
return fetchMock;
|
vi.fn().mockResolvedValue({ ok: true, json: vi.fn().mockResolvedValue([]) })
|
||||||
}
|
);
|
||||||
|
|
||||||
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 }));
|
|
||||||
}
|
}
|
||||||
|
|
||||||
type Snapshot = { value: string; mentionedPersons: PersonMention[] };
|
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 = {
|
let snapshot: Snapshot = {
|
||||||
value: initial.value ?? '',
|
value: initial.value ?? '',
|
||||||
mentionedPersons: initial.mentionedPersons ?? []
|
mentionedPersons: initial.mentionedPersons ?? []
|
||||||
@@ -79,6 +57,7 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[
|
|||||||
render(PersonMentionEditorHost, {
|
render(PersonMentionEditorHost, {
|
||||||
initialValue: initial.value ?? '',
|
initialValue: initial.value ?? '',
|
||||||
initialMentions: initial.mentionedPersons ?? [],
|
initialMentions: initial.mentionedPersons ?? [],
|
||||||
|
disabled: initial.disabled ?? false,
|
||||||
onChange: (snap: Snapshot) => {
|
onChange: (snap: Snapshot) => {
|
||||||
snapshot = snap;
|
snapshot = snap;
|
||||||
}
|
}
|
||||||
@@ -90,279 +69,300 @@ function renderHost(initial: { value?: string; mentionedPersons?: PersonMention[
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
beforeEach(() => {
|
|
||||||
vi.useFakeTimers({ shouldAdvanceTime: true });
|
|
||||||
});
|
|
||||||
|
|
||||||
afterEach(() => {
|
afterEach(() => {
|
||||||
cleanup();
|
cleanup();
|
||||||
vi.unstubAllGlobals();
|
vi.unstubAllGlobals();
|
||||||
vi.useRealTimers();
|
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Rendering ────────────────────────────────────────────────────────────────
|
// ─── Rendering ────────────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('PersonMentionEditor — 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, {
|
render(PersonMentionEditorHost, {
|
||||||
initialValue: '',
|
initialValue: '',
|
||||||
initialMentions: [],
|
initialMentions: [],
|
||||||
placeholder: 'Transkription…',
|
|
||||||
onChange: () => {}
|
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, {
|
render(PersonMentionEditorHost, {
|
||||||
initialValue: 'Hallo Welt',
|
initialValue: 'Hallo Welt',
|
||||||
initialMentions: [],
|
initialMentions: [],
|
||||||
onChange: () => {}
|
onChange: () => {}
|
||||||
});
|
});
|
||||||
await expect.element(page.getByRole('textbox')).toHaveValue('Hallo Welt');
|
await expect.element(page.getByText('Hallo Welt')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
|
// ─── Typeahead opens on @ ─────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('PersonMentionEditor — typeahead', () => {
|
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();
|
mockFetchWithPersons();
|
||||||
renderHost();
|
renderHost();
|
||||||
|
|
||||||
const ta = getTextarea();
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
ta.focus();
|
|
||||||
ta.value = '@Aug';
|
|
||||||
ta.selectionStart = 4;
|
|
||||||
ta.selectionEnd = 4;
|
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('hits /api/persons?q= with the typed query', async () => {
|
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();
|
renderHost();
|
||||||
|
|
||||||
const ta = getTextarea();
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
ta.focus();
|
|
||||||
ta.value = '@Aug';
|
|
||||||
ta.selectionStart = 4;
|
|
||||||
ta.selectionEnd = 4;
|
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
expect(fetchMock).toHaveBeenCalledWith(expect.stringContaining('/api/persons?q=Aug'));
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('shows life dates next to the name in the dropdown', async () => {
|
it('shows life dates next to the name in the dropdown', async () => {
|
||||||
mockFetchWithPersons();
|
mockFetchWithPersons();
|
||||||
renderHost();
|
renderHost();
|
||||||
|
|
||||||
const ta = getTextarea();
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
ta.focus();
|
|
||||||
ta.value = '@Aug';
|
|
||||||
ta.selectionStart = 4;
|
|
||||||
ta.selectionEnd = 4;
|
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
await expect.element(page.getByText('* 1882 – † 1944')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('shows empty state when no persons match', async () => {
|
it('shows empty state when no persons match', async () => {
|
||||||
mockFetchEmpty();
|
mockFetchEmpty();
|
||||||
renderHost();
|
renderHost();
|
||||||
|
|
||||||
const ta = getTextarea();
|
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||||||
ta.focus();
|
|
||||||
ta.value = '@xyz';
|
|
||||||
ta.selectionStart = 4;
|
|
||||||
ta.selectionEnd = 4;
|
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
await expect.element(page.getByText('Keine Personen gefunden')).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
|
});
|
||||||
|
|
||||||
it('falls back to the empty state when the typeahead fetch rejects (network error)', async () => {
|
it('offers a "create new person" link in the empty state', async () => {
|
||||||
mockFetchRejects();
|
mockFetchEmpty();
|
||||||
renderHost();
|
renderHost();
|
||||||
|
|
||||||
const ta = getTextarea();
|
await userEvent.type(page.getByRole('textbox'), '@xyz');
|
||||||
ta.focus();
|
|
||||||
ta.value = '@Aug';
|
|
||||||
ta.selectionStart = 4;
|
|
||||||
ta.selectionEnd = 4;
|
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
await expect.element(page.getByText('Keine Personen gefunden')).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');
|
||||||
});
|
});
|
||||||
|
|
||||||
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();
|
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Selection writes text + sidecar ─────────────────────────────────────────
|
// ─── AC-1: typed text becomes displayName, not DB name ───────────────────────
|
||||||
|
|
||||||
describe('PersonMentionEditor — selecting a person', () => {
|
describe('PersonMentionEditor — AC-1: typed text as displayName', () => {
|
||||||
it('inserts @DisplayName followed by a trailing space into the textarea', async () => {
|
it('stores the typed query as displayName, not the person DB name', async () => {
|
||||||
mockFetchWithPersons();
|
mockFetchWithPersons();
|
||||||
const host = renderHost();
|
const host = renderHost();
|
||||||
|
|
||||||
const ta = getTextarea();
|
// User types "@Aug" (not the full "Auguste Raddatz") and selects Auguste Raddatz
|
||||||
ta.focus();
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
ta.value = '@Aug';
|
await vi.waitFor(async () => {
|
||||||
ta.selectionStart = 4;
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
ta.selectionEnd = 4;
|
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
clickOption('p-aug');
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
expect(host.snapshot.value).toBe('@Auguste Raddatz ');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('pushes {personId, displayName} into the bound mentionedPersons array', async () => {
|
await userEvent.click(page.getByRole('option', { name: /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('regression: text value contains the typed query, not the full DB name', async () => {
|
||||||
mockFetchWithPersons();
|
mockFetchWithPersons();
|
||||||
const host = renderHost();
|
const host = renderHost();
|
||||||
|
|
||||||
const ta = getTextarea();
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
ta.focus();
|
await vi.waitFor(async () => {
|
||||||
ta.value = '@Aug';
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
ta.selectionStart = 4;
|
});
|
||||||
ta.selectionEnd = 4;
|
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
clickOption('p-aug');
|
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||||
await tick();
|
|
||||||
|
|
||||||
expect(host.snapshot.mentionedPersons).toEqual([
|
await vi.waitFor(() => {
|
||||||
{ personId: 'p-aug', displayName: 'Auguste Raddatz' }
|
// 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 () => {
|
it('does not duplicate the sidecar entry when the same person is selected twice', async () => {
|
||||||
mockFetchWithPersons();
|
mockFetchWithPersons();
|
||||||
const host = renderHost({
|
const host = renderHost({
|
||||||
value: '@Auguste Raddatz ',
|
value: '@Aug ',
|
||||||
mentionedPersons: [{ personId: 'p-aug', displayName: 'Auguste Raddatz' }]
|
mentionedPersons: [{ personId: 'p-aug', displayName: 'Aug' }]
|
||||||
});
|
});
|
||||||
|
|
||||||
const ta = getTextarea();
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
ta.focus();
|
await vi.waitFor(async () => {
|
||||||
ta.value = '@Auguste Raddatz @Aug';
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
ta.selectionStart = ta.value.length;
|
});
|
||||||
ta.selectionEnd = ta.value.length;
|
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
clickOption('p-aug');
|
await userEvent.click(page.getByRole('option', { name: /Auguste Raddatz/ }));
|
||||||
await tick();
|
|
||||||
|
|
||||||
|
await vi.waitFor(() => {
|
||||||
|
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// ─── Keyboard navigation ──────────────────────────────────────────────────────
|
||||||
|
|
||||||
|
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);
|
expect(host.snapshot.mentionedPersons).toHaveLength(1);
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
// ─── Keyboard navigation (B11b) ──────────────────────────────────────────────
|
it('ArrowDown moves the highlight to the next result', async () => {
|
||||||
|
|
||||||
describe('PersonMentionEditor — keyboard navigation (B11b)', () => {
|
|
||||||
it('ArrowDown / ArrowUp cycle the highlighted result', async () => {
|
|
||||||
mockFetchWithPersons();
|
mockFetchWithPersons();
|
||||||
renderHost();
|
renderHost();
|
||||||
|
|
||||||
const ta = getTextarea();
|
await userEvent.type(page.getByRole('textbox'), '@A');
|
||||||
ta.focus();
|
|
||||||
ta.value = '@A';
|
|
||||||
ta.selectionStart = 2;
|
|
||||||
ta.selectionEnd = 2;
|
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
const optAuguste = document.querySelector(
|
await vi.waitFor(async () => {
|
||||||
'[role="option"][data-test-person-id="p-aug"]'
|
await expect.element(page.getByRole('option', { name: /Auguste Raddatz/ })).toBeVisible();
|
||||||
) as HTMLElement;
|
|
||||||
const optAnna = document.querySelector(
|
|
||||||
'[role="option"][data-test-person-id="p-anna"]'
|
|
||||||
) as HTMLElement;
|
|
||||||
|
|
||||||
expect(optAuguste.getAttribute('aria-selected')).toBe('true');
|
|
||||||
expect(optAnna.getAttribute('aria-selected')).toBe('false');
|
|
||||||
|
|
||||||
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');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Enter selects the currently highlighted result', async () => {
|
await userEvent.keyboard('{ArrowDown}');
|
||||||
|
|
||||||
|
await vi.waitFor(async () => {
|
||||||
|
const annaOption = page.getByRole('option', { name: /Anna Schmidt/ });
|
||||||
|
await expect.element(annaOption).toHaveAttribute('aria-selected', 'true');
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('Escape closes the dropdown without inserting', async () => {
|
||||||
mockFetchWithPersons();
|
mockFetchWithPersons();
|
||||||
const host = renderHost();
|
const host = renderHost();
|
||||||
|
|
||||||
const ta = getTextarea();
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
ta.focus();
|
|
||||||
ta.value = '@A';
|
|
||||||
ta.selectionStart = 2;
|
|
||||||
ta.selectionEnd = 2;
|
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'ArrowDown', bubbles: true }));
|
await vi.waitFor(async () => {
|
||||||
await tick();
|
await expect.element(page.getByText('Auguste Raddatz')).toBeInTheDocument();
|
||||||
ta.dispatchEvent(new KeyboardEvent('keydown', { key: 'Enter', bubbles: true }));
|
|
||||||
await tick();
|
|
||||||
|
|
||||||
expect(host.snapshot.mentionedPersons).toEqual([
|
|
||||||
{ personId: 'p-anna', displayName: 'Anna Schmidt' }
|
|
||||||
]);
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('Escape closes the popup without inserting anything', async () => {
|
await userEvent.keyboard('{Escape}');
|
||||||
mockFetchWithPersons();
|
|
||||||
const host = renderHost();
|
|
||||||
|
|
||||||
const ta = getTextarea();
|
await vi.waitFor(async () => {
|
||||||
ta.focus();
|
await expect.element(page.getByRole('listbox')).not.toBeInTheDocument();
|
||||||
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([]);
|
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) ──────────────────────────────────────────────
|
// ─── Touch target (WCAG 2.2 AA) ──────────────────────────────────────────────
|
||||||
|
|
||||||
describe('PersonMentionEditor — touch target', () => {
|
describe('PersonMentionEditor — touch target', () => {
|
||||||
@@ -370,13 +370,11 @@ describe('PersonMentionEditor — touch target', () => {
|
|||||||
mockFetchWithPersons();
|
mockFetchWithPersons();
|
||||||
renderHost();
|
renderHost();
|
||||||
|
|
||||||
const ta = getTextarea();
|
await userEvent.type(page.getByRole('textbox'), '@Aug');
|
||||||
ta.focus();
|
|
||||||
ta.value = '@Aug';
|
await vi.waitFor(async () => {
|
||||||
ta.selectionStart = 4;
|
await expect.element(page.getByRole('option').first()).toBeVisible();
|
||||||
ta.selectionEnd = 4;
|
});
|
||||||
ta.dispatchEvent(new Event('input', { bubbles: true }));
|
|
||||||
await flushDebounce();
|
|
||||||
|
|
||||||
const option = document.querySelector('[role="option"]') as HTMLElement;
|
const option = document.querySelector('[role="option"]') as HTMLElement;
|
||||||
expect(option).not.toBeNull();
|
expect(option).not.toBeNull();
|
||||||
|
|||||||
@@ -9,10 +9,17 @@ type Props = {
|
|||||||
initialValue?: string;
|
initialValue?: string;
|
||||||
initialMentions?: PersonMention[];
|
initialMentions?: PersonMention[];
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
|
disabled?: boolean;
|
||||||
onChange: (snapshot: { value: string; mentionedPersons: PersonMention[] }) => void;
|
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
|
// initial* props seed mount-time state; reading them inside untrack signals
|
||||||
// the intentional one-shot capture and silences state_referenced_locally.
|
// the intentional one-shot capture and silences state_referenced_locally.
|
||||||
@@ -28,4 +35,5 @@ $effect(() => {
|
|||||||
bind:value={value}
|
bind:value={value}
|
||||||
bind:mentionedPersons={mentionedPersons}
|
bind:mentionedPersons={mentionedPersons}
|
||||||
placeholder={placeholder}
|
placeholder={placeholder}
|
||||||
|
disabled={disabled}
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -79,17 +79,6 @@ let leftBorderClass = $derived(
|
|||||||
saveState === 'error' ? 'border-l-2 border-error' : active ? 'border-l-2 border-turquoise' : ''
|
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() {
|
function emitChange() {
|
||||||
onTextChange(localText, localMentions);
|
onTextChange(localText, localMentions);
|
||||||
}
|
}
|
||||||
@@ -101,17 +90,6 @@ async function handleDelete() {
|
|||||||
});
|
});
|
||||||
if (confirmed) onDeleteClick();
|
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>
|
</script>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
@@ -176,8 +154,7 @@ function handleTextareaMouseUp() {
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Textarea (now powered by PersonMentionEditor for @-mention typeahead) -->
|
<!-- Editor powered by PersonMentionEditor (Tiptap) for @-mention typeahead -->
|
||||||
<div onmouseup={handleTextareaMouseUp} role="presentation">
|
|
||||||
<PersonMentionEditor
|
<PersonMentionEditor
|
||||||
bind:value={() => localText,
|
bind:value={() => localText,
|
||||||
(v) => {
|
(v) => {
|
||||||
@@ -191,9 +168,8 @@ function handleTextareaMouseUp() {
|
|||||||
}}
|
}}
|
||||||
placeholder={m.transcription_block_placeholder()}
|
placeholder={m.transcription_block_placeholder()}
|
||||||
onfocus={onFocus}
|
onfocus={onFocus}
|
||||||
captureTextarea={captureTextarea}
|
onSelectionChange={(text) => (selectedQuote = text)}
|
||||||
/>
|
/>
|
||||||
</div>
|
|
||||||
|
|
||||||
{#if selectedQuote}
|
{#if selectedQuote}
|
||||||
<p class="mt-1 text-xs text-ink-3">{m.transcription_block_quote_hint()}</p>
|
<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 () => {
|
it('renders text in textarea', async () => {
|
||||||
renderBlock();
|
renderBlock();
|
||||||
const textarea = page.getByRole('textbox');
|
await expect.element(page.getByText('Liebe Mutter,')).toBeInTheDocument();
|
||||||
await expect.element(textarea).toHaveValue('Liebe Mutter,');
|
|
||||||
});
|
});
|
||||||
|
|
||||||
it('renders optional label when provided', async () => {
|
it('renders optional label when provided', async () => {
|
||||||
@@ -226,14 +225,18 @@ describe('TranscriptionBlock — delete confirmation', () => {
|
|||||||
// ─── Quote selection ─────────────────────────────────────────────────────────
|
// ─── Quote selection ─────────────────────────────────────────────────────────
|
||||||
|
|
||||||
describe('TranscriptionBlock — 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' });
|
renderBlock({ text: 'Breslau, den 12. August' });
|
||||||
await page.getByRole('textbox').click();
|
await page.getByRole('textbox').click();
|
||||||
// Select text and fire mouseup via native DOM — locator.selectText/dispatchEvent not available
|
// Select all text in the contenteditable via the native Selection API.
|
||||||
const el = document.querySelector('textarea') as HTMLTextAreaElement;
|
// Tiptap fires selectionUpdate which the block forwards as onSelectionChange.
|
||||||
el.focus();
|
const editorEl = document.querySelector('[role="textbox"]') as HTMLElement;
|
||||||
el.setSelectionRange(0, el.value.length);
|
const range = document.createRange();
|
||||||
el.dispatchEvent(new MouseEvent('mouseup', { bubbles: true }));
|
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();
|
await expect.element(page.getByText(/Zitat/)).toBeInTheDocument();
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -8,7 +8,6 @@ export type ErrorCode =
|
|||||||
| 'PERSON_NOT_FOUND'
|
| 'PERSON_NOT_FOUND'
|
||||||
| 'ALIAS_NOT_FOUND'
|
| 'ALIAS_NOT_FOUND'
|
||||||
| 'INVALID_PERSON_TYPE'
|
| 'INVALID_PERSON_TYPE'
|
||||||
| 'PERSON_RENAME_CONFLICT'
|
|
||||||
| 'DOCUMENT_NOT_FOUND'
|
| 'DOCUMENT_NOT_FOUND'
|
||||||
| 'DOCUMENT_NO_FILE'
|
| 'DOCUMENT_NO_FILE'
|
||||||
| 'FILE_NOT_FOUND'
|
| 'FILE_NOT_FOUND'
|
||||||
@@ -80,8 +79,6 @@ export function getErrorMessage(code: ErrorCode | string | undefined): string {
|
|||||||
return m.error_alias_not_found();
|
return m.error_alias_not_found();
|
||||||
case 'INVALID_PERSON_TYPE':
|
case 'INVALID_PERSON_TYPE':
|
||||||
return m.error_invalid_person_type();
|
return m.error_invalid_person_type();
|
||||||
case 'PERSON_RENAME_CONFLICT':
|
|
||||||
return m.error_person_rename_conflict();
|
|
||||||
case 'DOCUMENT_NOT_FOUND':
|
case 'DOCUMENT_NOT_FOUND':
|
||||||
return m.error_document_not_found();
|
return m.error_document_not_found();
|
||||||
case 'DOCUMENT_NO_FILE':
|
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 {
|
@keyframes slide {
|
||||||
0% {
|
0% {
|
||||||
transform: translateX(-100%);
|
transform: translateX(-100%);
|
||||||
|
|||||||
@@ -493,6 +493,181 @@
|
|||||||
resolved "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz"
|
resolved "https://registry.npmjs.org/@testing-library/svelte-core/-/svelte-core-1.0.0.tgz"
|
||||||
integrity sha512-VkUePoLV6oOYwSUvX6ShA8KLnJqZiYMIbP2JW2t0GLWLkJxKGvuH5qrrZBV/X7cXFnLGuFQEC7RheYiZOW68KQ==
|
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":
|
"@types/chai@^5.2.2":
|
||||||
version "5.2.3"
|
version "5.2.3"
|
||||||
resolved "https://registry.npmjs.org/@types/chai/-/chai-5.2.3.tgz"
|
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"
|
resolved "https://registry.npmjs.org/lilconfig/-/lilconfig-2.1.0.tgz"
|
||||||
integrity sha512-utWOt/GHzuUxnLKxB6dk81RoOeoNeHgbrXiuGk4yyF5qlRz+iIVWu56E2fqGHFrXz0QNUhLB/8nKqvRH66JKGQ==
|
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:
|
locate-character@^3.0.0:
|
||||||
version "3.0.0"
|
version "3.0.0"
|
||||||
resolved "https://registry.npmjs.org/locate-character/-/locate-character-3.0.0.tgz"
|
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"
|
type-check "^0.4.0"
|
||||||
word-wrap "^1.2.5"
|
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:
|
p-limit@^3.0.2:
|
||||||
version "3.1.0"
|
version "3.1.0"
|
||||||
resolved "https://registry.npmjs.org/p-limit/-/p-limit-3.1.0.tgz"
|
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"
|
resolved "https://registry.npmjs.org/prettier/-/prettier-3.8.1.tgz"
|
||||||
integrity sha512-UOnG6LftzbdaHZcKoPFtOcCKztrQ57WkHDeRD9t/PTQtmT0NHSeWWepj6pS0z/N7+08BHFDQVUrfmfMRcZwbMg==
|
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:
|
punycode@^2.1.0:
|
||||||
version "2.3.1"
|
version "2.3.1"
|
||||||
resolved "https://registry.npmjs.org/punycode/-/punycode-2.3.1.tgz"
|
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"
|
"@rollup/rollup-win32-x64-msvc" "4.59.0"
|
||||||
fsevents "~2.3.2"
|
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:
|
sade@^1.7.4:
|
||||||
version "1.8.1"
|
version "1.8.1"
|
||||||
resolved "https://registry.npmjs.org/sade/-/sade-1.8.1.tgz"
|
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"
|
vite "^6.0.0 || ^7.0.0 || ^8.0.0-0"
|
||||||
why-is-node-running "^2.3.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:
|
webpack-virtual-modules@^0.6.2:
|
||||||
version "0.6.2"
|
version "0.6.2"
|
||||||
resolved "https://registry.npmjs.org/webpack-virtual-modules/-/webpack-virtual-modules-0.6.2.tgz"
|
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