diff --git a/.github/workflows/merge-to-protected.yml b/.github/workflows/merge-to-protected.yml index 951568021..fb31a2824 100644 --- a/.github/workflows/merge-to-protected.yml +++ b/.github/workflows/merge-to-protected.yml @@ -24,7 +24,7 @@ jobs: - name: Use Node.js uses: actions/setup-node@v4 with: - node-version: "16.x" + node-version: "20.x" - name: Install run: npm install - name: Prettier Check diff --git a/Dockerfile b/Dockerfile index c708a6c5a..91d61460f 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,6 +1,6 @@ # BASE STAGE # Prepare node, copy package.json -FROM node:16-alpine AS base +FROM node:20-alpine AS base WORKDIR /usr/src/app COPY package.json package-lock.json ./ diff --git a/README.md b/README.md index ad7b60596..9a6246630 100644 --- a/README.md +++ b/README.md @@ -17,7 +17,7 @@ Documentation for developers and system administrators is in the [doc folder](do ## Running TermIt UI -NodeJS and npm are required to build and run TermIt UI. To run the TermIt UI, it is necessary to provide a value for +NodeJS 20 and npm 10 are required to build and run TermIt UI. To run the TermIt UI, it is necessary to provide a value for `REACT_APP_SERVER_URL` representing the URL of the backend to connect to. Typically, this is done at build time. See the documentation for more details and other configuration options. diff --git a/doc/setup.md b/doc/setup.md index d12ee5766..dde5b060d 100644 --- a/doc/setup.md +++ b/doc/setup.md @@ -6,8 +6,8 @@ This guide provides information on how to build and deploy TermIt UI. ### System Requirements -- NodeJS 12.x or later -- npm 6.x or later +- NodeJS 20.x or later +- npm 10.x or later ### Setup @@ -27,7 +27,7 @@ The following parameters can be configured for the build: ### Example -1. `npm install` +1. `npm install --legacy-peer-deps` 2. `REACT_APP_SERVER_URL=https://kbss.felk.cvut.cz/termit-server-dev REACT_APP_DEPLOYMENT_NAME=dev REACT_APP_ADMIN_REGISTRATION_ONLY=true npm run build-prod` ## Deployment diff --git a/package.json b/package.json index 6f6222da8..e2e4550b0 100644 --- a/package.json +++ b/package.json @@ -17,7 +17,6 @@ "dom-serializer": "^1.3.2", "domhandler": "^4.3.1", "easymde": "2.18.0", - "marked": "^9.1.6", "html-to-react": "1.5.0", "htmlparser2": "^4.1.0", "intelligent-tree-select": "0.11.4", @@ -29,6 +28,7 @@ "ld-query": "^2.6.1", "lodash": "^4.17.21", "luxon": "^3.3.0", + "marked": "^9.1.6", "object-assign": "4.1.1", "oidc-client": "^1.11.5", "promise": "8.3.0", @@ -57,10 +57,10 @@ "redux-logger": "^3.0.6", "redux-thunk": "^2.4.2", "resolve": "1.22.2", + "simple-xpath-position": "^2.0.2", "uuid": "^9.0.0", "whatwg-fetch": "3.6.2", - "whatwg-mimetype": "3.0.0", - "xpath-range": "^1.1.1" + "whatwg-mimetype": "3.0.0" }, "scripts": { "start": "react-scripts start", diff --git a/src/@types/simple-xpath-position/index.d.ts b/src/@types/simple-xpath-position/index.d.ts new file mode 100644 index 000000000..898a5d16a --- /dev/null +++ b/src/@types/simple-xpath-position/index.d.ts @@ -0,0 +1,11 @@ +/** + * Type declarations for simple-xpath-position library. + */ +declare module "simple-xpath-position" { + export function fromNode(node: Node, root: Node = null): string; + export function toNode( + path: string, + root: Node, + resolver: any = null + ): Node | null; +} diff --git a/src/@types/xpath-range/index.d.ts b/src/@types/xpath-range/index.d.ts deleted file mode 100644 index 2fd3d0059..000000000 --- a/src/@types/xpath-range/index.d.ts +++ /dev/null @@ -1,20 +0,0 @@ -/** - * Type declarations for xpath-range library. - */ -declare module "xpath-range" { - interface XPathRange { - start: string; - end: string; - startOffset: number; - endOffset: number; - } - - export function toRange( - startPath: string, - startOffset: number, - endPath: string, - endOffset: number, - root: Node - ): Range; - export function fromRange(range: Range, root: Node): XPathRange; -} diff --git a/src/__tests__/environment/TestUtil.ts b/src/__tests__/environment/TestUtil.ts index 1374fb601..615188adc 100644 --- a/src/__tests__/environment/TestUtil.ts +++ b/src/__tests__/environment/TestUtil.ts @@ -59,7 +59,7 @@ export function routingProps(): RouteComponentProps { } /** - * Changes value attribute of inputElement and simulates change event on it + * Changes value attribute of inputElement and simulates change event on it, should be executed in "act"! * @param inputElement * @param value */ diff --git a/src/action/ActionType.ts b/src/action/ActionType.ts index ad6530c0f..45f1258d7 100644 --- a/src/action/ActionType.ts +++ b/src/action/ActionType.ts @@ -76,6 +76,7 @@ export interface BreadcrumbAction extends Action { export interface AnnotatorLegendFilterAction extends Action { annotationClass: AnnotationClass; annotationOrigin: AnnotationOrigin; + enabled?: boolean; } const ActionType = { @@ -111,6 +112,8 @@ const ActionType = { LOAD_RELATED_VOCABULARIES: "LOAD_RELATED_VOCABULARIES", REMOVE_VOCABULARY: "REMOVE_VOCABULARY", CREATE_VOCABULARY_SNAPSHOT: "CREATE_VOCABULARY_SNAPSHOT", + GET_VOCABULARY_TERMS_RELATIONS: "GET_VOCABULARY_TERMS_RELATIONS", + GET_VOCABULARY_RELATIONS: "GET_VOCABULARY_RELATIONS", LOAD_VOCABULARY_HISTORY: "LOAD_VOCABULARY_HISTORY", LOAD_VOCABULARY_CONTENT_HISTORY: "LOAD_VOCABULARY_CONTENT_HISTORY", @@ -141,6 +144,7 @@ const ActionType = { REMOVE_TERM_DEFINITION_SOURCE: "REMOVE_TERM_DEFINITION_SOURCE", TOGGLE_ANNOTATOR_LEGEND_FILTER: "TOGGLE_ANNOTATOR_LEGEND_FILTER", + SET_ANNOTATOR_LEGEND_FILTER: "SET_ANNOTATOR_LEGEND_FILTER", LOAD_TERM_HISTORY: "LOAD_TERM_HISTORY", @@ -237,7 +241,8 @@ const ActionType = { REACT_TO_COMMENT: "REACT_TO_COMMENT", REMOVE_COMMENT_REACTION: "REMOVE_COMMENT_REACTION", - IMPORT_SKOS: "IMPORT_SKOS", + IMPORT_VOCABULARY: "IMPORT_VOCABULARY", + LOAD_EXCEL_TEMPLATE: "LOAD_EXCEL_TEMPLATE", OPEN_CONTEXTS_FOR_EDITING: "OPEN_CONTEXTS_FOR_EDITING", diff --git a/src/action/AsyncActions.ts b/src/action/AsyncActions.ts index 062e89b19..3a48ef59a 100644 --- a/src/action/AsyncActions.ts +++ b/src/action/AsyncActions.ts @@ -474,7 +474,7 @@ export function loadVocabularies() { if (isActionRequestPending(getState(), action)) { return Promise.resolve({}); } - dispatch(asyncActionRequest(action)); + dispatch(asyncActionRequest(action, true)); return Ajax.get(`${getApiPrefix(getState())}/vocabularies`) .then((data: object[]) => data.length !== 0 @@ -579,15 +579,18 @@ export function genericLoadTerms( }; } -export function loadTermByIri(termIri: IRI) { +export function loadTermByIri( + termIri: IRI, + abortController: AbortController = new AbortController() +) { const action = { type: ActionType.LOAD_TERM_BY_IRI, }; return (dispatch: ThunkDispatch, getState: GetStoreState) => { - dispatch(asyncActionRequest(action, true)); + dispatch(asyncActionRequest(action, true, abortController)); return Ajax.get( `${getApiPrefix(getState())}/terms/${termIri.fragment}`, - param("namespace", termIri.namespace) + param("namespace", termIri.namespace).signal(abortController) ) .then((data: object) => JsonLdUtils.compactAndResolveReferences(data, TERM_CONTEXT) diff --git a/src/action/AsyncAnnotatorActions.ts b/src/action/AsyncAnnotatorActions.ts index 20af06239..ba903a20e 100644 --- a/src/action/AsyncAnnotatorActions.ts +++ b/src/action/AsyncAnnotatorActions.ts @@ -58,30 +58,46 @@ export function loadAllTerms( } // Cache of pending term fetches, used to prevent repeated concurrent attempts at fetching the same term -const pendingTermFetches: { [key: string]: Promise } = {}; +const pendingTermFetches: { + [key: string]: { + promise: Promise; + abortController: AbortController; + }; +} = {}; -export function loadTermByIri(termIri: string) { +export function loadTermByIri( + termIri: string, + abortController: AbortController = new AbortController() +) { const action = { type: ActionType.ANNOTATOR_LOAD_TERM }; return (dispatch: ThunkDispatch, getState: GetStoreState) => { + abortController.signal.throwIfAborted(); if (getState().annotatorTerms[termIri]) { return Promise.resolve(getState().annotatorTerms[termIri]); } - if (pendingTermFetches[termIri] !== undefined) { - return pendingTermFetches[termIri]; + if ( + pendingTermFetches[termIri] !== undefined && + !pendingTermFetches[termIri].abortController.signal.aborted + ) { + return pendingTermFetches[termIri].promise; } const promise = dispatch( - loadTermByIriBase(VocabularyUtils.create(termIri)) + loadTermByIriBase(VocabularyUtils.create(termIri), abortController) ); - pendingTermFetches[termIri] = promise; - return promise.then((t) => { - delete pendingTermFetches[termIri]; - if (t) { - // No hierarchy for on-demand loaded terms in annotator. We cannot load children anyway - t.subTerms = []; - dispatch(asyncActionSuccessWithPayload(action, t)); - } - return t; - }); + pendingTermFetches[termIri] = { promise, abortController }; + return promise + .then((t) => { + abortController.signal.throwIfAborted(); + if (t) { + // No hierarchy for on-demand loaded terms in annotator. We cannot load children anyway + t.subTerms = []; + dispatch(asyncActionSuccessWithPayload(action, t)); + } + return t; + }) + .finally(() => { + delete pendingTermFetches[termIri]; + }); }; } diff --git a/src/action/AsyncImportActions.ts b/src/action/AsyncImportActions.ts index f165a9394..49f34831d 100644 --- a/src/action/AsyncImportActions.ts +++ b/src/action/AsyncImportActions.ts @@ -4,7 +4,7 @@ import { asyncActionRequest, asyncActionSuccess, } from "./SyncActions"; -import Ajax, { contentType } from "../util/Ajax"; +import Ajax, { contentType, responseType } from "../util/Ajax"; import { ThunkDispatch } from "../util/Types"; import Constants from "../util/Constants"; import { ErrorData } from "../model/ErrorInfo"; @@ -14,12 +14,10 @@ import { IRI } from "../util/VocabularyUtils"; import ActionType from "./ActionType"; import { Action } from "redux"; import { loadVocabulary } from "./AsyncActions"; +import Utils from "../util/Utils"; -export function importSkosIntoExistingVocabulary( - vocabularyIri: IRI, - data: File -) { - const action = { type: ActionType.IMPORT_SKOS }; +export function importIntoExistingVocabulary(vocabularyIri: IRI, data: File) { + const action = { type: ActionType.IMPORT_VOCABULARY }; const formData = new FormData(); formData.append("file", data, "thesaurus"); formData.append("namespace", vocabularyIri.namespace!); @@ -36,7 +34,7 @@ export function importSkosIntoExistingVocabulary( } export function importSkosAsNewVocabulary(data: File, rename: Boolean) { - const action = { type: ActionType.IMPORT_SKOS }; + const action = { type: ActionType.IMPORT_VOCABULARY }; const formData = new FormData(); formData.append("file", data, "thesaurus"); formData.append("rename", rename.toString()); @@ -83,3 +81,23 @@ const processError = SyncActions.publishMessage(new Message(error, MessageType.ERROR)) ); }; + +export function downloadExcelTemplate() { + return (dispatch: ThunkDispatch) => { + const action = { type: ActionType.LOAD_EXCEL_TEMPLATE }; + dispatch(asyncActionRequest(action, true)); + return Ajax.getRaw( + `${Constants.API_PREFIX}/vocabularies/import/template`, + responseType("arraybuffer") + ) + .then((response) => { + Utils.fileDownload( + response.data, + "termit-import.xlsx", + Constants.EXCEL_MIME_TYPE + ); + dispatch(asyncActionSuccess(action)); + }) + .catch((error) => dispatch(asyncActionFailure(action, error))); + }; +} diff --git a/src/action/AsyncVocabularyActions.ts b/src/action/AsyncVocabularyActions.ts index 9ae5e6480..7a620f40b 100644 --- a/src/action/AsyncVocabularyActions.ts +++ b/src/action/AsyncVocabularyActions.ts @@ -26,6 +26,7 @@ import { getApiPrefix } from "./ActionUtils"; import SnapshotData, { CONTEXT as SNAPSHOT_CONTEXT } from "../model/Snapshot"; import NotificationType from "../model/NotificationType"; import ExportConfig from "../model/local/ExportConfig"; +import RDFStatement, { RDFSTATEMENT_CONTEXT } from "../model/RDFStatement"; export function loadTermCount(vocabularyIri: IRI) { const action = { type: ActionType.LOAD_TERM_COUNT, vocabularyIri }; @@ -218,3 +219,59 @@ export function loadVocabularySnapshots(vocabularyIri: IRI) { }); }; } + +export function getVocabularyRelations( + vocabularyIri: IRI, + abortController: AbortController = new AbortController() +) { + const action = { type: ActionType.GET_VOCABULARY_RELATIONS }; + return (dispatch: ThunkDispatch) => { + dispatch(asyncActionRequest(action, false)); + return Ajax.get( + `${Constants.API_PREFIX}/vocabularies/${vocabularyIri.fragment}/relations`, + param("namespace", vocabularyIri.namespace).signal(abortController) + ) + .then((data) => + JsonLdUtils.compactAndResolveReferencesAsArray( + data, + RDFSTATEMENT_CONTEXT + ) + ) + .then((statements: RDFStatement[]) => { + dispatch(asyncActionSuccess(action)); + return statements; + }) + .catch((error: ErrorData) => { + dispatch(asyncActionFailure(action, error)); + return []; + }); + }; +} + +export function getVocabularyTermsRelations( + vocabularyIri: IRI, + abortController: AbortController = new AbortController() +) { + const action = { type: ActionType.GET_VOCABULARY_TERMS_RELATIONS }; + return (dispatch: ThunkDispatch) => { + dispatch(asyncActionRequest(action, false)); + return Ajax.get( + `${Constants.API_PREFIX}/vocabularies/${vocabularyIri.fragment}/terms/relations`, + param("namespace", vocabularyIri.namespace).signal(abortController) + ) + .then((data) => + JsonLdUtils.compactAndResolveReferencesAsArray( + data, + RDFSTATEMENT_CONTEXT + ) + ) + .then((statements: RDFStatement[]) => { + dispatch(asyncActionSuccess(action)); + return statements; + }) + .catch((error: ErrorData) => { + dispatch(asyncActionFailure(action, error)); + return []; + }); + }; +} diff --git a/src/action/SyncActions.ts b/src/action/SyncActions.ts index eed90c60a..aa38607a0 100644 --- a/src/action/SyncActions.ts +++ b/src/action/SyncActions.ts @@ -200,3 +200,16 @@ export function toggleAnnotatorLegendFilter( annotationOrigin, }; } + +export function setAnnotatorLegendFilter( + annotationClass: AnnotationClass, + annotationOrigin: AnnotationOrigin = AnnotationOrigin.SELECTED, + enabled: boolean +): AnnotatorLegendFilterAction { + return { + type: ActionType.SET_ANNOTATOR_LEGEND_FILTER, + annotationClass, + annotationOrigin, + enabled, + }; +} diff --git a/src/action/__tests__/AsyncAnnotatorActions.test.ts b/src/action/__tests__/AsyncAnnotatorActions.test.ts index d9d5a16ac..06d9c341e 100644 --- a/src/action/__tests__/AsyncAnnotatorActions.test.ts +++ b/src/action/__tests__/AsyncAnnotatorActions.test.ts @@ -21,6 +21,13 @@ jest.mock("../../util/Ajax", () => ({ formData: jest.requireActual("../../util/Ajax").formData, })); +// Mock implementation for throwIfAborted which is missing in jsdom < 22.1.0 +AbortSignal.prototype.throwIfAborted = function () { + if (this.aborted) { + throw this.reason; + } +}; + const mockStore = configureMockStore([thunk]); describe("AsyncAnnotatorActions", () => { diff --git a/src/action/__tests__/AsyncVocabularyActions.test.ts b/src/action/__tests__/AsyncVocabularyActions.test.ts index a74d335eb..30a149dd2 100644 --- a/src/action/__tests__/AsyncVocabularyActions.test.ts +++ b/src/action/__tests__/AsyncVocabularyActions.test.ts @@ -9,6 +9,8 @@ import { ThunkDispatch } from "../../util/Types"; import ActionType from "../ActionType"; import { exportGlossary, + getVocabularyRelations, + getVocabularyTermsRelations, loadTermCount, loadVocabularyContentChanges, loadVocabularySnapshots, @@ -314,4 +316,50 @@ describe("AsyncTermActions", () => { }); }); }); + + describe("getVocabularyRelations", () => { + it("returns vocabulary relations", () => { + Ajax.get = jest.fn().mockResolvedValue({}); + const vocabulary = Generator.generateVocabulary(); + vocabulary.iri = namespace + vocabularyName; + return Promise.resolve( + (store.dispatch as ThunkDispatch)( + getVocabularyRelations( + VocabularyUtils.create(vocabulary.iri), + new AbortController() + ) + ) + ).then(() => { + expect(Ajax.get).toHaveBeenCalled(); + const args = (Ajax.get as jest.Mock).mock.calls[0]; + expect(args[0]).toEqual( + `${Constants.API_PREFIX}/vocabularies/${vocabularyName}/relations` + ); + expect(args[1].getParams().namespace).toEqual(namespace); + }); + }); + }); + + describe("getVocabularyTermsRelations", () => { + it("returns vocabulary terms relations", () => { + Ajax.get = jest.fn().mockResolvedValue({}); + const vocabulary = Generator.generateVocabulary(); + vocabulary.iri = namespace + vocabularyName; + return Promise.resolve( + (store.dispatch as ThunkDispatch)( + getVocabularyTermsRelations( + VocabularyUtils.create(vocabulary.iri), + new AbortController() + ) + ) + ).then(() => { + expect(Ajax.get).toHaveBeenCalled(); + const args = (Ajax.get as jest.Mock).mock.calls[0]; + expect(args[0]).toEqual( + `${Constants.API_PREFIX}/vocabularies/${vocabularyName}/terms/relations` + ); + expect(args[1].getParams().namespace).toEqual(namespace); + }); + }); + }); }); diff --git a/src/component/annotator/Annotation.tsx b/src/component/annotator/Annotation.tsx index c3b0ee5cf..f7fd899c0 100644 --- a/src/component/annotator/Annotation.tsx +++ b/src/component/annotator/Annotation.tsx @@ -31,7 +31,10 @@ interface AnnotationProps extends AnnotationSpanProps { onUpdate: (annotation: AnnotationSpanProps, term: Term | null) => void; onCreateTerm: (label: string, annotation: AnnotationSpanProps) => void; term?: Term; - onFetchTerm: (termIri: string) => Promise; + onFetchTerm: ( + termIri: string, + abortController: AbortController + ) => Promise; onResetSticky: () => void; // Resets sticky annotation status accessLevel: AccessLevel; highlight?: boolean; @@ -42,6 +45,7 @@ interface AnnotationState { detailOpened: boolean; term: Term | null | undefined; termFetchFinished: boolean; + abortController: AbortController; } export function isDefinitionAnnotation(type: string) { @@ -63,6 +67,7 @@ export class Annotation extends React.Component< detailOpened: false, term: resourceAssigned ? props.term : null, termFetchFinished: false, + abortController: new AbortController(), }; } @@ -109,27 +114,40 @@ export class Annotation extends React.Component< ); } + componentWillUnmount() { + this.state.abortController.abort(); + } + private fetchTerm = () => { if (this.state.term) { this.setState({ termFetchFinished: true }); return; } if (this.props.resource) { - this.setState({ termFetchFinished: false }); + this.setState({ + termFetchFinished: false, + abortController: new AbortController(), + }); this.props - .onFetchTerm(this.props.resource) - .then((t) => + .onFetchTerm(this.props.resource, this.state.abortController) + .then((t) => { + if (this.state.abortController.signal.aborted) { + return; + } this.setState({ term: t, termFetchFinished: true, - }) - ) - .catch(() => + }); + }) + .catch(() => { + if (this.state.abortController.signal.aborted) { + return; + } this.setState({ term: undefined, termFetchFinished: true, - }) - ); + }); + }); } else { this.setState({ term: null, @@ -177,7 +195,9 @@ export class Annotation extends React.Component< }; private onClick = () => { - this.toggleOpenDetail(); + if (!this.isHidden(this.props)) { + this.toggleOpenDetail(); + } }; public onCreateTerm = () => { @@ -251,8 +271,12 @@ export class Annotation extends React.Component< : {}; const Tag = this.props.tag; - if (this.isHidden(this.props)) { - return this.props.children; + let className = classNames(termClassName, termCreatorClassName, { + "annotator-highlighted-annotation": this.props.highlight, + }); + + if (this.isHidden(this.props) && !this.props.highlight) { + className = ""; } return ( @@ -265,9 +289,7 @@ export class Annotation extends React.Component< {...contentProps} typeof={this.props.typeof} {...scoreProps} - className={classNames(termClassName, termCreatorClassName, { - "annotator-highlighted-annotation": this.props.highlight, - })} + className={className} > {this.props.children} {this.props.typeof === AnnotationType.DEFINITION @@ -324,7 +346,8 @@ export default connect( }, (dispatch: ThunkDispatch) => { return { - onFetchTerm: (iri: string) => dispatch(loadTermByIri(iri)), + onFetchTerm: (iri: string, abortController: AbortController) => + dispatch(loadTermByIri(iri, abortController)), }; } )(Annotation); diff --git a/src/component/annotator/Annotator.tsx b/src/component/annotator/Annotator.tsx index e76db0b88..2aae447f9 100644 --- a/src/component/annotator/Annotator.tsx +++ b/src/component/annotator/Annotator.tsx @@ -14,7 +14,10 @@ import SelectionPurposeDialog from "./SelectionPurposeDialog"; import { connect } from "react-redux"; import { ThunkDispatch } from "../../util/Types"; import Message from "../../model/Message"; -import { publishMessage } from "../../action/SyncActions"; +import { + publishMessage, + setAnnotatorLegendFilter, +} from "../../action/SyncActions"; import MessageType from "../../model/MessageType"; import TermOccurrence, { TextQuoteSelector } from "../../model/TermOccurrence"; import { @@ -49,6 +52,10 @@ import { } from "./AnnotatorUtil"; import { saveOccurrence } from "../../action/AsyncAnnotatorActions"; import HighlightTermOccurrencesButton from "./HighlightTermOccurrencesButton"; +import { + AnnotationClass, + AnnotationOrigin, +} from "../../model/AnnotatorLegendFilter"; interface AnnotatorProps extends HasI18n { fileIri: IRI; @@ -60,6 +67,11 @@ interface AnnotatorProps extends HasI18n { vocabulary: Vocabulary; onUpdate: (newHtml: string) => void; + setAnnotatorLegendFilter: ( + annotationClass: AnnotationClass, + annotationOrigin: AnnotationOrigin, + enabled: boolean + ) => void; publishMessage: (message: Message) => void; setTermDefinitionSource: (src: TermOccurrence, term: Term) => Promise; @@ -231,6 +243,7 @@ export class Annotator extends React.Component { ) => { // Make a shallow copy to force re-render if changes to an annotation are really made const dom = [...this.state.internalHtml]; + this.filterShowTermOccurence(); const ann = AnnotationDomHelper.findAnnotation( dom, annotationSpan.about!, @@ -266,6 +279,13 @@ export class Annotator extends React.Component { this.props.fileIri ); this.props.approveTermOccurrence({ iri }); + + // reset filter for existing terms + this.props.setAnnotatorLegendFilter( + AnnotationClass.ASSIGNED_OCCURRENCE, + AnnotationOrigin.SELECTED, + true + ); } /** @@ -308,6 +328,7 @@ export class Annotator extends React.Component { } public onSaveTermDefinition = (term: Term) => { + this.filterShowDefinitionOccurence(); return this.setTermDefinitionSource( term, this.state.existingTermDefinitionAnnotationElement! @@ -358,6 +379,7 @@ export class Annotator extends React.Component { if (this.createNewTermDialog.current) { this.createNewTermDialog.current.setLabel(label); } + this.filterShowTermOccurence(); }; public onCloseCreate = () => { @@ -397,6 +419,7 @@ export class Annotator extends React.Component { public assignNewTerm = (newTerm: Term) => { const dom = [...this.state.internalHtml]; + this.filterShowTermOccurence(); if (this.state.newTermLabelAnnotation) { const ann = AnnotationDomHelper.findAnnotation( dom, @@ -440,7 +463,8 @@ export class Annotator extends React.Component { return; } if (this.containerElement.current) { - HtmlDomUtils.extendSelectionToWords(); + HtmlDomUtils.extendSelectionToWords(this.containerElement.current); + const range = HtmlDomUtils.getSelectionRange(); if (range && !HtmlDomUtils.isInPopup(range)) { if (this.state.newTermLabelAnnotation) { @@ -469,6 +493,7 @@ export class Annotator extends React.Component { property: VocabularyUtils.IS_OCCURRENCE_OF_TERM, }); } + this.filterShowTermOccurence(); }; public createTermOccurrence = (preventSticky: boolean = false) => { @@ -480,6 +505,7 @@ export class Annotator extends React.Component { about, AnnotationType.OCCURRENCE ); + this.filterShowTermOccurence(); if (annotationResult != null) { this.setState({ internalHtml: HtmlParserUtils.html2dom( @@ -494,6 +520,7 @@ export class Annotator extends React.Component { public markTermDefinition = () => { this.closeSelectionPurposeDialog(); + this.filterShowDefinitionOccurence(); const annotation = this.annotateDefinition(); if (annotation) { if (this.state.newTermLabelAnnotation) { @@ -514,6 +541,32 @@ export class Annotator extends React.Component { } }; + private filterShowDefinitionOccurence() { + this.props.setAnnotatorLegendFilter( + AnnotationClass.DEFINITION, + AnnotationOrigin.SELECTED, + true + ); + this.props.setAnnotatorLegendFilter( + AnnotationClass.PENDING_DEFINITION, + AnnotationOrigin.SELECTED, + true + ); + } + + private filterShowTermOccurence() { + this.props.setAnnotatorLegendFilter( + AnnotationClass.SUGGESTED_OCCURRENCE, + AnnotationOrigin.SELECTED, + true + ); + this.props.setAnnotatorLegendFilter( + AnnotationClass.ASSIGNED_OCCURRENCE, + AnnotationOrigin.SELECTED, + true + ); + } + private annotateDefinition() { const about = JsonLdUtils.generateBlankNodeId(); const annotationResult = this.annotateSelection( @@ -686,11 +739,13 @@ export class Annotator extends React.Component { about: string, annotationType: string ): { container: HTMLElement; annotation: Element } | null { - const range = HtmlDomUtils.getSelectionRange(); + const range = HtmlDomUtils.getSelectionRange()?.cloneRange(); if (!range) { return null; } - HtmlDomUtils.extendRangeToPreventNodeCrossing(range); + if (annotationType === AnnotationType.DEFINITION) { + HtmlDomUtils.extendRangeToPreventNodeCrossing(range); + } const rangeContent = HtmlDomUtils.getRangeContent(range); const newAnnotationNode = AnnotationDomHelper.createNewAnnotation( about, @@ -726,6 +781,14 @@ export default connect( dispatch(saveOccurrence(occurrence)), removeTermOccurrence: (occurrence: AssetData) => dispatch(removeOccurrence(occurrence, true)), + setAnnotatorLegendFilter: ( + annotationClass: AnnotationClass, + annotationOrigin: AnnotationOrigin, + enabled: boolean + ) => + dispatch( + setAnnotatorLegendFilter(annotationClass, annotationOrigin, enabled) + ), }; } )(injectIntl(withI18n(Annotator))); diff --git a/src/component/annotator/HtmlDomUtils.ts b/src/component/annotator/HtmlDomUtils.ts index 2bd6465f3..4deba3a28 100644 --- a/src/component/annotator/HtmlDomUtils.ts +++ b/src/component/annotator/HtmlDomUtils.ts @@ -2,10 +2,11 @@ import { Node as DomHandlerNode } from "domhandler"; import Utils from "../../util/Utils"; import { TextQuoteSelector } from "../../model/TermOccurrence"; import { AnnotationType } from "./AnnotationDomHelper"; -import { fromRange, toRange } from "xpath-range"; +import { fromNode, toNode } from "simple-xpath-position"; import * as React from "react"; +import TextSelection from "./TextSelection"; -const BLOCK_ELEMENTS = [ +export const BLOCK_ELEMENTS = [ "address", "article", "aside", @@ -41,31 +42,6 @@ const BLOCK_ELEMENTS = [ "ul", ]; -const PUNCTUATION_CHARS = [".", ",", "!", "?", ":", ";"]; - -function calculatePathLength(node: Node, ancestor: Node): number { - let parent = node.parentNode; - let length = 0; - while (parent && parent !== ancestor) { - length++; - parent = parent.parentNode; - } - return length; -} - -/** - * Detects if the specified selection is backwards. - * @param sel Selection to check - */ -function isBackwards(sel: Selection): boolean { - const range = document.createRange(); - range.setStart(sel.anchorNode!, sel.anchorOffset); - range.setEnd(sel.focusNode!, sel.focusOffset); - const backwards = range.collapsed; - range.detach(); - return backwards; -} - export interface HtmlSplit { prefix: string; body: string; @@ -134,62 +110,22 @@ const HtmlDomUtils = { }; }, - extendSelectionToWords() { + /** + * Extends and trims the selection so as not to contain leading/trailing spaces or punctuation characters. + * @param container + */ + extendSelectionToWords(container: Node) { const sel = window.getSelection(); - if ( - sel && - !sel.isCollapsed && - sel.anchorNode && - sel.focusNode && - // @ts-ignore - sel.modify - ) { - const backwards = isBackwards(sel); - // modify() works on the focus of the selection - const endNode = sel.focusNode; - const endOffset = Math.max(0, sel.focusOffset - 1); - sel.collapse( - sel.anchorNode, - backwards ? sel.anchorOffset : sel.anchorOffset + 1 - ); - if (backwards) { - // Note that we are not using sel.modify by word due to issues with Firefox messing up the modification/extension - // in certain situations (Bug #1610) - const anchorText = sel.anchorNode.textContent || ""; - while ( - anchorText.charAt(sel.anchorOffset).trim().length !== 0 && - sel.anchorOffset < anchorText.length - ) { - // @ts-ignore - sel.modify("move", "forward", "character"); - } - const text = endNode.textContent || ""; - let index = endOffset; - while (text.charAt(index).trim().length !== 0 && index >= 0) { - index--; - } - sel.extend(endNode, index + 1); - } else { - // @ts-ignore - sel.modify("move", "backward", "word"); - const text = endNode.textContent || ""; - let index = endOffset; - while ( - !this.isWhitespaceOrPunctuation(text.charAt(index)) && - index < text.length - ) { - index++; - } - sel.extend(endNode, index); - } - } - }, + if (sel && !sel.isCollapsed && sel.rangeCount > 0) { + const selectionModifier = new TextSelection(sel, container); - isWhitespaceOrPunctuation(character: string) { - return ( - character.trim().length === 0 || - PUNCTUATION_CHARS.indexOf(character) !== -1 - ); + selectionModifier.adjustStart(); + selectionModifier.adjustEnd(); + selectionModifier.restoreSelection(); + + // Note that we are not using sel.modify by word due to issues with Firefox messing up the modification/extension + // in certain situations (Bug #1610) + } }, /** @@ -200,12 +136,15 @@ const HtmlDomUtils = { */ doesRangeSpanMultipleElements(range: Range): boolean { return ( - range.startContainer !== range.endContainer && - calculatePathLength( - range.startContainer, - range.commonAncestorContainer - ) !== - calculatePathLength(range.endContainer, range.commonAncestorContainer) + // either they have the same parent node + // and they are different nodes + (range.startContainer.parentNode === range.endContainer.parentNode && + range.startContainer !== range.endContainer && + // and one of them is not a text node + (range.startContainer.nodeType !== Node.TEXT_NODE || + range.endContainer.nodeType !== Node.TEXT_NODE)) || + // or they have different parent + range.startContainer.parentNode !== range.endContainer.parentNode ); }, @@ -214,12 +153,38 @@ const HtmlDomUtils = { * * This extension should handle situations when the range starts in one element and ends in another, which would * prevent its replacement/annotation due to invalid element boundary crossing. This method attempts to fix this by - * extending the range to be the contents of the closest common ancestor of the range's start and end containers. + * extending the range to contain both starting and ending elements. * @param range Range to fix */ - extendRangeToPreventNodeCrossing(range: Range) { + extendRangeToPreventNodeCrossing(range: Range): void { if (this.doesRangeSpanMultipleElements(range)) { - range.selectNodeContents(range.commonAncestorContainer); + const startingChild = findLastParentBefore( + range.startContainer, + range.commonAncestorContainer + ); + const endingChild = findLastParentBefore( + range.endContainer, + range.commonAncestorContainer + ); + + if (startingChild !== range.commonAncestorContainer) { + range.setStartBefore(startingChild); + } else { + range.setStart(range.commonAncestorContainer, 0); + } + if (endingChild !== range.commonAncestorContainer) { + range.setEndAfter(endingChild); + } else { + let offset = 0; + // just to be sure + if (range.commonAncestorContainer.hasChildNodes()) { + offset = range.commonAncestorContainer.childNodes.length - 1; + } else { + const text = range.commonAncestorContainer.textContent || ""; + offset = text.length - 1; + } + range.setEnd(range.commonAncestorContainer, offset); + } } }, @@ -234,22 +199,21 @@ const HtmlDomUtils = { range: Range, surroundingElementHtml: string ): HTMLElement { - const xpathRange = fromRange(range, rootElement); + const startXpath = fromNode(range.startContainer, rootElement) || "."; + const endXpath = fromNode(range.endContainer, rootElement) || "."; const clonedElement = rootElement.cloneNode(true) as HTMLElement; - const newRange = toRange( - xpathRange.start, - xpathRange.startOffset, - xpathRange.end, - range.endContainer.nodeType === Node.TEXT_NODE ? xpathRange.endOffset : 0, - clonedElement - ); - // This works around the issue that the toRange considers the offsets as textual characters, but if the end container is - // not a text node, the offset represents the number of elements before it and thus the offset in the newRange would be incorrect - // See https://developer.mozilla.org/en-US/docs/Web/API/Range/endOffset and in contrast the docs to toRange - if (range.endContainer.nodeType !== Node.TEXT_NODE) { - newRange.setEnd(newRange.endContainer, range.endOffset); + + const startElement: Node | null = toNode(startXpath, clonedElement); + const endElement: Node | null = toNode(endXpath, clonedElement); + + if (!startElement || !endElement) { + throw new Error("Unable to resolve selected range"); } + const newRange = new Range(); + newRange.setStart(startElement, range.startOffset); + newRange.setEnd(endElement, range.endOffset); + const doc = clonedElement.ownerDocument; const template = doc!.createElement("template"); template.innerHTML = surroundingElementHtml; @@ -394,6 +358,14 @@ const HtmlDomUtils = { }, }; +function findLastParentBefore(child: Node, parent: Node) { + let node = child; + while (node && node.parentNode && node.parentNode !== parent) { + node = node.parentNode; + } + return node; +} + function prefixMatch(selector: TextQuoteSelector, element: Element) { return ( selector.prefix && diff --git a/src/component/annotator/TextSelection.ts b/src/component/annotator/TextSelection.ts new file mode 100644 index 000000000..d9387d972 --- /dev/null +++ b/src/component/annotator/TextSelection.ts @@ -0,0 +1,366 @@ +export const PUNCTUATION_CHARS = [".", ",", "!", "?", ":", ";"]; +const PUNCTUATION_REGEX = PUNCTUATION_CHARS.map(escapeRegExp).join(""); + +/** + * Allows manipulation with the first {@link Range} in the {@link Selection}.
+ *

+ * Every action manipulates the inner range, to apply the changes to the selection + * use {@link TextSelection#restoreSelection()} method. + *

+ * Using the Range has the advantage that there is no need to deal with the direction of selection, + * the beginning of the range is always before the end of the range. + * @see https://javascript.info/selection-range#selection + */ +export default class TextSelection { + private readonly selection: Selection; + private readonly range: Range; + private readonly container: Node; + + /** + * @param selection The {@link Selection} + * @param container the selection never extends beyond it + * @see the Class {@link TextSelection} + * @throws Error when selection is undefined, there is nothing selected (no range inside the selection) or the selection is collapsed + */ + constructor(selection: Selection, container: Node) { + if (!selection || selection.rangeCount === 0 || selection.isCollapsed) { + throw new Error("Invalid selection"); + } + + this.selection = selection; + this.container = container; + // currently no browser other than firefox supports multiple range selection + // and in termit it does not make any sense + this.range = selection.getRangeAt(0).cloneRange(); + } + + /** + * Removes whitespace and {@link PUNCTUATION_CHARS} characters from the string start + */ + private trimStringLeft(str: string) { + return str + .trimStart() + .replace(new RegExp(`^[${PUNCTUATION_REGEX}\\s)]+`, "g"), ""); + } + + /** + * Removes whitespace and {@link PUNCTUATION_CHARS} characters from the string end + */ + private trimStringRight(str: string) { + return str + .trimEnd() + .replace(new RegExp(`[${PUNCTUATION_REGEX}\\s(]+$`, "g"), ""); + } + + /** + * Clears the selection and applies the range inside this object. + */ + restoreSelection() { + this.selection.removeAllRanges(); + this.selection.addRange(this.range.cloneRange()); + } + + /** + * Sets {@link #range#startContainer} to a text node when possible + */ + private diveStartToTextNode() { + const it = createIteratorAtChild(this.container, this.range.startContainer); + let node; + do { + node = it.nextNode(); + } while (node && node.nodeType !== Node.TEXT_NODE); + if (node?.nodeType === Node.TEXT_NODE) { + let offset = this.range.startOffset; + if (node !== this.range.startContainer) { + offset = 0; + } + this.range.setStart(node, offset); + } + } + + /** + * Sets {@link #range#endContainer} to a text node when possible + */ + private diveEndToTextNode() { + const it = createIteratorAtChild(this.container, this.range.endContainer); + let node; + it.nextNode(); // include current node + do { + node = it.previousNode(); + } while (node && node.nodeType !== Node.TEXT_NODE); + if (node?.nodeType === Node.TEXT_NODE) { + let offset = this.range.endOffset; + if (node !== this.range.endContainer) { + offset = node.textContent?.length || 0; + } + this.range.setEnd(node, offset); + } + } + + /** + * Moves {@link #range#startContainer} to the next text node, + * if there is no text node available, the node is unchanged + * @returns true if node was changed, false otherwise + */ + private moveStartToNextTextNode() { + const it = createIteratorAtChild(this.container, this.range.startContainer); + it.nextNode(); // skip current child (startContainer) + let node; + do { + node = it.nextNode(); + } while (node && node.nodeType !== Node.TEXT_NODE); + if (node?.nodeType === Node.TEXT_NODE) { + this.range.setStart(node, 0); + return true; + } + return false; + } + + /** + * Moves {@link #range#endContainer} to the next text node, + * if there is no text node available, the node is unchanged + * @returns true if node was changed, false otherwise + */ + private moveEndToNextTextNode() { + const it = createIteratorAtChild(this.container, this.range.endContainer); + it.nextNode(); // skip current child (endContainer) + let node; + do { + node = it.nextNode(); + } while (node && node.nodeType !== Node.TEXT_NODE); + if (node?.nodeType === Node.TEXT_NODE) { + this.range.setEnd(node, 0); + return true; + } + return false; + } + + /** + * Moves {@link #range#startContainer} to the prev text node, + * if there is no text node available, the node is unchanged + * @returns true if node was changed, false otherwise + */ + private moveStartToPrevTextNode() { + const it = createIteratorAtChild(this.container, this.range.startContainer); + let node; + do { + node = it.previousNode(); + } while (node && node.nodeType !== Node.TEXT_NODE); + if (node?.nodeType === Node.TEXT_NODE) { + const offset = node.textContent?.length || 0; + this.range.setStart(node, offset); + return true; + } + return false; + } + + /** + * Moves {@link #range#endContainer} to the prev text node, + * if there is no text node available, the node is unchanged + * @returns true if node was changed, false otherwise + */ + private moveEndToPrevTextNode() { + const it = createIteratorAtChild(this.container, this.range.endContainer); + let node; + do { + node = it.previousNode(); + } while (node && node.nodeType !== Node.TEXT_NODE); + if (node?.nodeType === Node.TEXT_NODE) { + const offset = node.textContent?.length || 0; + this.range.setEnd(node, offset); + return true; + } + return false; + } + + /** + * @returns string A part of the text in {@link #range#startContainer} respecting the {@link #range#startOffset} + */ + private getStartOffsetText() { + const { startContainer, startOffset } = this.range; + const text = startContainer.textContent || ""; + return text.slice(startOffset); + } + + /** + * @returns string A whole text in {@link #range#startContainer} or empty string + */ + private getStartText() { + return this.range.startContainer.textContent || ""; + } + + /** + * @returns string A whole text in {@link #range#endContainer} or empty string + */ + private getEndText() { + return this.range.endContainer.textContent || ""; + } + + /** + * @returns string A part of the text in {@link #range#endContainer} respecting the {@link #range#endOffset} + */ + private getEndOffsetText() { + const { endContainer, endOffset } = this.range; + const text = endContainer.textContent || ""; + return text.slice(0, endOffset); + } + + /** + * Moves the start of the {@link #range} to the left as long as there is no whitespace/punctuation + * @see the trim method {@link #trimStringLeft} + */ + private extendStart() { + this.diveStartToTextNode(); + let oldContainer = this.range.startContainer; + let oldOffset = this.range.startOffset; + while ( + this.getStartOffsetText().length === + this.trimStringLeft(this.getStartOffsetText()).length + ) { + oldContainer = this.range.startContainer; + oldOffset = this.range.startOffset; + const { startContainer, startOffset } = this.range; + const newOffset = startOffset - 1; + if (newOffset < 0) { + if (this.moveStartToPrevTextNode()) { + continue; + } else { + break; + } + } + this.range.setStart(startContainer, newOffset); + } + // restore last action + this.range.setStart(oldContainer, oldOffset); + } + + /** + * Moves the end of the {@link #range} to the right as long as there is no whitespace/punctuation + * @see the trim method {@link #trimStringRight} + */ + private extendEnd() { + this.diveEndToTextNode(); + let oldContainer = this.range.endContainer; + let oldOffset = this.range.endOffset; + while ( + this.getEndOffsetText().length === + this.trimStringRight(this.getEndOffsetText()).length + ) { + oldContainer = this.range.endContainer; + oldOffset = this.range.endOffset; + const { endContainer, endOffset } = this.range; + const newOffset = endOffset + 1; + if (newOffset >= this.getEndText().length) { + if (this.moveEndToNextTextNode()) { + continue; + } else { + break; + } + } + this.range.setEnd(endContainer, newOffset); + } + // restore last action + this.range.setEnd(oldContainer, oldOffset); + } + + /** + * Moves the start of the {@link #range} to the right as long as there is a whitespace/punctuation + * @see the trim method {@link #trimStringLeft} + */ + private trimStart() { + this.diveStartToTextNode(); + while ( + this.trimStringLeft(this.getStartOffsetText()).length !== + this.getStartOffsetText().length || + this.getStartOffsetText().length === 0 + ) { + const { startContainer, startOffset } = this.range; + const newOffset = startOffset + 1; + if (newOffset >= this.getStartText().length) { + if (this.moveStartToNextTextNode()) { + continue; + } else { + break; + } + } + this.range.setStart(startContainer, newOffset); + } + } + + /** + * Moves the end of the {@link #range} to the right as long as there is a whitespace/punctuation + * @see the trim method {@link #trimStringRight} + */ + private trimEnd() { + this.diveEndToTextNode(); + while ( + this.trimStringRight(this.getEndOffsetText()).length !== + this.getEndOffsetText().length || + this.getEndOffsetText().length === 0 + ) { + const { endContainer, endOffset } = this.range; + const newOffset = endOffset - 1; + if (newOffset < 0) { + if (this.moveEndToPrevTextNode()) { + continue; + } else { + break; + } + } + this.range.setEnd(endContainer, newOffset); + } + } + + /** + * Trims or extends the start of the range to remove leading spaces/punctuation, or to contain whole word + * @see punctuation constant {@link PUNCTUATION_CHARS} + */ + adjustStart() { + if ( + this.trimStringLeft(this.getStartOffsetText()).length !== + this.getStartOffsetText().length || + this.getStartOffsetText().length === 0 + ) { + this.trimStart(); + } else { + this.extendStart(); + this.trimStart(); + } + } + + /** + * Trims or extends the end of the range to remove trailing spaces/punctuation, or to contain whole word + * @see punctuation constant {@link PUNCTUATION_CHARS} + */ + adjustEnd() { + if ( + this.trimStringRight(this.getEndOffsetText()).length !== + this.getEndOffsetText().length || + this.getEndOffsetText().length === 0 + ) { + this.trimEnd(); + } else { + this.extendEnd(); + this.trimEnd(); + } + } +} + +/** + * @returns an iterator {@link NodeIterator} at the child position (calling nextNode will return the child) + * @param parent parent node of the child + * @param child Node that is a child of the parent + */ +function createIteratorAtChild(parent: Node, child: Node) { + const it = document.createNodeIterator(parent); + let node; + do { + node = it.nextNode(); + } while (node && node !== child); + it.previousNode(); + return it; +} + +function escapeRegExp(str: string): string { + return str.replace(/[.*+?^${}()|[\]\\]/g, "\\$&"); // $& means the whole matched string +} diff --git a/src/component/annotator/__tests__/Annotation.test.tsx b/src/component/annotator/__tests__/Annotation.test.tsx index 2a9d163dd..e167793c0 100644 --- a/src/component/annotator/__tests__/Annotation.test.tsx +++ b/src/component/annotator/__tests__/Annotation.test.tsx @@ -12,6 +12,7 @@ import TermOccurrenceAnnotation from "../TermOccurrenceAnnotation"; import { MemoryRouter } from "react-router-dom"; import Generator from "../../../__tests__/environment/Generator"; import { langString } from "../../../model/MultilingualString"; +import AnnotatorLegendFilter from "../../../model/AnnotatorLegendFilter"; function assumeProps( wrapper: ReactWrapper, @@ -48,11 +49,15 @@ describe("Annotation", () => { text, }; let assignedOccProps: any; + let filter: AnnotatorLegendFilter; // @ts-ignore const popupComponentClass: ComponentClass = SimplePopupWithActions; let mockedFunctions: { - onFetchTerm: (termIri: string) => Promise; + onFetchTerm: ( + termIri: string, + abortController: AbortController + ) => Promise; onCreateTerm: (label: string, annotation: AnnotationSpanProps) => void; onResetSticky: () => void; onUpdate: (annotation: AnnotationSpanProps, term: Term | null) => void; @@ -69,6 +74,7 @@ describe("Annotation", () => { onResetSticky: jest.fn(), onUpdate: jest.fn(), }; + filter = new AnnotatorLegendFilter(); }); /* --- recognizes occurrence --- */ @@ -78,6 +84,7 @@ describe("Annotation", () => { {...mockedFunctions} {...intlFunctions()} {...suggestedOccProps} + filter={filter} /> ); @@ -107,7 +114,8 @@ describe("Annotation", () => { /> ); expect(mockedFunctions.onFetchTerm).toHaveBeenCalledWith( - assignedOccProps.resource + assignedOccProps.resource, + expect.any(AbortController) ); }); @@ -122,7 +130,10 @@ describe("Annotation", () => { const newResource = Generator.generateUri(); wrapper.setProps({ resource: newResource }); wrapper.update(); - expect(mockedFunctions.onFetchTerm).toHaveBeenCalledWith(newResource); + expect(mockedFunctions.onFetchTerm).toHaveBeenCalledWith( + newResource, + expect.any(AbortController) + ); }); it("recognizes invalid occurrence", () => { diff --git a/src/component/annotator/__tests__/Annotator.test.tsx b/src/component/annotator/__tests__/Annotator.test.tsx index 9a20ffb8f..abf0dce2b 100644 --- a/src/component/annotator/__tests__/Annotator.test.tsx +++ b/src/component/annotator/__tests__/Annotator.test.tsx @@ -30,6 +30,11 @@ import Vocabulary from "../../../model/Vocabulary"; import AccessLevel from "../../../model/acl/AccessLevel"; import { MemoryRouter } from "react-router"; import { AssetData } from "../../../model/Asset"; +import { + AnnotationClass, + AnnotationOrigin, +} from "../../../model/AnnotatorLegendFilter"; +import { AnnotatorLegendFilterAction } from "../../../action/ActionType"; jest.mock("../../misc/AssetIriLink", () => () => AssetIriLink); jest.mock("../HighlightTermOccurrencesButton", () => () => ( @@ -54,6 +59,11 @@ describe("Annotator", () => { approveTermOccurrence: (occurrence: AssetData) => Promise; removeTermOccurrence: (occurrence: AssetData) => Promise; saveTermOccurrence: (occurrence: TermOccurrence) => Promise; + setAnnotatorLegendFilter: ( + annotationClass: AnnotationClass, + annotationOrigin: AnnotationOrigin, + enabled: boolean + ) => AnnotatorLegendFilterAction; }; let user: User; let file: File; @@ -73,6 +83,7 @@ describe("Annotator", () => { approveTermOccurrence: jest.fn().mockResolvedValue({}), removeTermOccurrence: jest.fn().mockResolvedValue({}), saveTermOccurrence: jest.fn().mockResolvedValue({}), + setAnnotatorLegendFilter: jest.fn().mockResolvedValue({}), }; user = Generator.generateUser(); file = new File({ @@ -476,7 +487,19 @@ describe("Annotator", () => { startContainer: container, endContainer: container, commonAncestorContainer: container, + cloneRange: function () { + return Object.assign({}, this); + }, + setStart: function (node: Node, offset: number) { + this.startContainer = node; + this.startOffset = offset; + }, + setEnd: function (node: Node, offset: number) { + this.endContainer = node; + this.endOffset = offset; + }, }; + HtmlDomUtils.getSelectionRange = jest.fn().mockReturnValue(range); }); @@ -485,6 +508,8 @@ describe("Annotator", () => { isCollapsed: false, rangeCount: 1, getRangeAt: () => range, + removeAllRanges: () => null, + addRange: (r: Range) => (range = r), }); window.getComputedStyle = jest.fn().mockReturnValue({ getPropertyValue: () => "16px", @@ -573,6 +598,9 @@ describe("Annotator", () => { startContainer: container, endContainer: container, commonAncestorContainer: container, + cloneRange: function () { + return Object.assign({}, this); + }, }; }); @@ -621,6 +649,9 @@ describe("Annotator", () => { startContainer: container, endContainer: container, commonAncestorContainer: container, + cloneRange: function () { + return Object.assign({}, this); + }, }; }); diff --git a/src/component/annotator/__tests__/HtmlDomUtils.test.ts b/src/component/annotator/__tests__/HtmlDomUtils.test.ts index dc975d27b..7facc5cb5 100644 --- a/src/component/annotator/__tests__/HtmlDomUtils.test.ts +++ b/src/component/annotator/__tests__/HtmlDomUtils.test.ts @@ -1,5 +1,3 @@ -// @ts-ignore -import { fromRange, toRange, XPathRange } from "xpath-range"; import HtmlDomUtils from "../HtmlDomUtils"; import { mockWindowSelection } from "../../../__tests__/environment/Environment"; import VocabularyUtils from "../../../util/VocabularyUtils"; @@ -7,10 +5,12 @@ import { TextQuoteSelector } from "../../../model/TermOccurrence"; import Generator from "../../../__tests__/environment/Generator"; import { NodeWithChildren, Text as DomHandlerText } from "domhandler"; import { ElementType } from "domelementtype"; +// @ts-ignore +import { fromNode, toNode } from "simple-xpath-position"; -jest.mock("xpath-range", () => ({ - fromRange: jest.fn(), - toRange: jest.fn(), +jest.mock("simple-xpath-position", () => ({ + toNode: jest.fn(), + fromNode: jest.fn(), })); describe("Html dom utils", () => { @@ -19,7 +19,7 @@ describe("Html dom utils", () => { const sampleDivContent = "before div

before spansample text pointer in spanafter span
after div"; const surroundingElementHtml = "text pointer"; - const xpathTextPointerRange: XPathRange = { + const xpathTextPointerRange = { start: "/div[1]/span[1]/text()[1]", end: "/div[1]/span[1]/text()[1]", startOffset: 7, @@ -32,6 +32,8 @@ describe("Html dom utils", () => { let cloneContents: () => DocumentFragment; let textPointerRange: any; beforeEach(() => { + jest.resetAllMocks(); + // // @ts-ignore window.getSelection = jest.fn().mockImplementation(() => { return { @@ -119,58 +121,66 @@ describe("Html dom utils", () => { describe("replace range", () => { it("returns clone of input element", () => { let ret: HTMLElement | null; - (fromRange as jest.Mock).mockImplementation(() => { - return xpathTextPointerRange; - }); + // start and end element is the same span node + (fromNode as jest.Mock).mockReturnValue(xpathTextPointerRange.start); - (toRange as jest.Mock).mockImplementation(() => { - return textPointerRange; + (toNode as jest.Mock).mockImplementation((path: string, root: Node) => { + return root.childNodes[1].childNodes[1].childNodes[0]; // span }); - textPointerRange.endContainer = { - nodeType: Node.TEXT_NODE, - }; + + textPointerRange = Object.assign( + { startContainer: {}, endContainer: {} }, + xpathTextPointerRange, + textPointerRange + ); + ret = HtmlDomUtils.replaceRange( sampleDiv, textPointerRange, surroundingElementHtml ); - expect(fromRange).toHaveBeenCalledWith(expect.any(Object), sampleDiv); - expect(toRange).toHaveBeenCalledWith( - xpathTextPointerRange.start, - xpathTextPointerRange.startOffset, - xpathTextPointerRange.end, - xpathTextPointerRange.endOffset, - expect.any(Object) + + expect(fromNode).toHaveBeenNthCalledWith( + 1, + textPointerRange.startContainer, + sampleDiv + ); + expect(fromNode).toHaveBeenNthCalledWith( + 2, + textPointerRange.endContainer, + sampleDiv ); + expect(fromNode).toHaveBeenCalledTimes(2); + + expect(toNode).toHaveBeenCalled(); expect(ret).not.toBe(sampleDiv); - expect(ret.children[0].childNodes[0].nodeValue).toEqual( - sampleDiv.children[0].childNodes[0].nodeValue + expect((ret.children[0].childNodes[1] as HTMLElement).innerHTML).toEqual( + "sample text pointer in span" ); }); - // Bug #1564 - it("uses original range end offset to work around offsetting issues when range end container is not a text node", () => { - (fromRange as jest.Mock).mockImplementation(() => { - return xpathTextPointerRange; + it("detects when a node has childrens and uses the offset correctly", () => { + (fromNode as jest.Mock).mockReturnValue(xpathTextPointerRange.start); + (toNode as jest.Mock).mockImplementation((path: string, root: Node) => { + return root.childNodes[1]; // div }); - (toRange as jest.Mock).mockImplementation(() => { - return textPointerRange; - }); - const originalRange: any = { - endContainer: { - nodeType: Node.ELEMENT_NODE, - }, - endOffset: 10, - }; - HtmlDomUtils.replaceRange( + const originalRange = new Range(); + // a div element, range staring before second div child (span) + originalRange.setStart(sampleDiv.children[0], 1); + // a div element, range ending before third div child (text node after the span) + originalRange.setEnd(sampleDiv.children[0], 2); + + const ret = HtmlDomUtils.replaceRange( sampleDiv, originalRange, surroundingElementHtml ); - expect(textPointerRange.setEnd).toHaveBeenCalledWith( - textPointerRange.endContainer, - originalRange.endOffset + + expect(toNode).toHaveBeenCalledTimes(2); + expect(fromNode).toHaveBeenCalledTimes(2); + expect(ret.children[0].innerHTML).toEqual( + "before spantext pointerafter span" ); }); }); @@ -202,6 +212,23 @@ describe("Html dom utils", () => { ).toBeFalsy(); }); + it("returns true for range spanning between two nested siblings", () => { + const container = document.createElement("div"); + container.innerHTML = + "before all
before firstfirst spanafterFirst
middle
before secondsecond spanafter second
after all"; + + const range: any = { + startContainer: container.children[0].childNodes[1], + startOffset: 0, + endContainer: container.children[1].childNodes[1], + endOffset: 5, + commonAncestorContainer: container, + }; + expect( + HtmlDomUtils.doesRangeSpanMultipleElements(range as Range) + ).toBeTruthy(); + }); + it("returns true for range spanning two elements", () => { const range: any = { startContainer: diff --git a/src/component/asset/RemoveAssetDialog.tsx b/src/component/asset/RemoveAssetDialog.tsx index 1f75e5bd7..26f6e59c0 100644 --- a/src/component/asset/RemoveAssetDialog.tsx +++ b/src/component/asset/RemoveAssetDialog.tsx @@ -35,6 +35,7 @@ const RemoveAssetDialog: React.FC = (props) => { label: props.asset.getLabel(), })} confirmKey="remove" + confirmColor="outline-danger" >