├── .gitignore ├── .npmignore ├── .travis.yml ├── package-lock.json ├── package.json ├── readme.md ├── src ├── Action │ ├── Connection.ts │ ├── Content.ts │ ├── IAction.ts │ └── Label.ts ├── Annotator.ts ├── Config.ts ├── Demo │ └── vue │ │ └── demo │ │ ├── .browserslistrc │ │ ├── .gitignore │ │ ├── README.md │ │ ├── babel.config.js │ │ ├── package-lock.json │ │ ├── package.json │ │ ├── postcss.config.js │ │ ├── public │ │ ├── favicon.ico │ │ ├── index.html │ │ └── prism.css │ │ ├── src │ │ ├── App.vue │ │ ├── assets │ │ │ └── default.json │ │ ├── main.ts │ │ ├── plugins │ │ │ └── vuetify.ts │ │ ├── router.ts │ │ ├── shims-tsx.d.ts │ │ ├── shims-vue.d.ts │ │ └── views │ │ │ ├── Annotate.vue │ │ │ └── demo.js │ │ ├── tsconfig.json │ │ ├── tslint.json │ │ ├── vue.config.js │ │ └── yarn.lock ├── Develop │ ├── dev.ts │ ├── index.html │ └── test.json ├── Infrastructure │ ├── Array.ts │ ├── Assert.ts │ ├── Color.ts │ ├── Option.ts │ ├── Range.ts │ ├── Repository.ts │ └── SVGNS.ts ├── Store │ ├── Connection.ts │ ├── ConnectionCategory.ts │ ├── Label.ts │ ├── LabelCategory.ts │ └── Store.ts ├── View │ ├── Entities │ │ ├── ConnectionView │ │ │ ├── ConnectionCategoryElement.ts │ │ │ └── ConnectionView.ts │ │ ├── ContentEditor │ │ │ └── ContentEditor.ts │ │ ├── LabelView │ │ │ ├── LabelCategoryElement.ts │ │ │ └── LabelView.ts │ │ └── Line │ │ │ ├── Line.ts │ │ │ └── TopContext │ │ │ ├── TopContext.ts │ │ │ └── TopContextUser.ts │ ├── EventHandler │ │ ├── TextSelectionHandler.ts │ │ └── TwoLabelsClickedHandler.ts │ ├── Font.ts │ └── View.ts └── index.ts ├── tsconfig.json ├── webpack.anal.js ├── webpack.dev.js ├── webpack.prod.js ├── webpack.test.js └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### Linux template 3 | *~ 4 | 5 | # temporary files which can be created if a process still has a handle open of a deleted file 6 | .fuse_hidden* 7 | 8 | # KDE directory preferences 9 | .directory 10 | 11 | # Linux trash folder which might appear on any partition or disk 12 | .Trash-* 13 | 14 | # .nfs files are created when an open file is removed but is still being accessed 15 | .nfs* 16 | ### Windows template 17 | # Windows thumbnail cache files 18 | Thumbs.db 19 | ehthumbs.db 20 | ehthumbs_vista.db 21 | 22 | # Dump file 23 | *.stackdump 24 | 25 | # Folder config file 26 | [Dd]esktop.ini 27 | 28 | # Recycle Bin used on file shares 29 | $RECYCLE.BIN/ 30 | 31 | # Windows Installer files 32 | *.cab 33 | *.msi 34 | *.msix 35 | *.msm 36 | *.msp 37 | 38 | # Windows shortcuts 39 | *.lnk 40 | ### JetBrains template 41 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 42 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 43 | 44 | # User-specific stuff 45 | .idea/**/workspace.xml 46 | .idea/**/tasks.xml 47 | .idea/**/usage.statistics.xml 48 | .idea/**/dictionaries 49 | .idea/**/shelf 50 | 51 | # Sensitive or high-churn files 52 | .idea/**/dataSources/ 53 | .idea/**/dataSources.ids 54 | .idea/**/dataSources.local.xml 55 | .idea/**/sqlDataSources.xml 56 | .idea/**/dynamic.xml 57 | .idea/**/uiDesigner.xml 58 | .idea/**/dbnavigator.xml 59 | 60 | # Gradle 61 | .idea/**/gradle.xml 62 | .idea/**/libraries 63 | 64 | # Gradle and Maven with auto-import 65 | # When using Gradle or Maven with auto-import, you should exclude module files, 66 | # since they will be recreated, and may cause churn. Uncomment if using 67 | # auto-import. 68 | # .idea/modules.xml 69 | # .idea/*.iml 70 | # .idea/modules 71 | 72 | # CMake 73 | cmake-build-*/ 74 | 75 | # Mongo Explorer plugin 76 | .idea/**/mongoSettings.xml 77 | 78 | # File-based project format 79 | *.iws 80 | 81 | # IntelliJ 82 | out/ 83 | 84 | # mpeltonen/sbt-idea plugin 85 | .idea_modules/ 86 | 87 | # JIRA plugin 88 | atlassian-ide-plugin.xml 89 | 90 | # Cursive Clojure plugin 91 | .idea/replstate.xml 92 | 93 | # Crashlytics plugin (for Android Studio and IntelliJ) 94 | com_crashlytics_export_strings.xml 95 | crashlytics.properties 96 | crashlytics-build.properties 97 | fabric.properties 98 | 99 | # Editor-based Rest Client 100 | .idea/httpRequests 101 | ### macOS template 102 | # General 103 | .DS_Store 104 | .AppleDouble 105 | .LSOverride 106 | 107 | # Icon must end with two \r 108 | Icon 109 | 110 | # Thumbnails 111 | ._* 112 | 113 | # Files that might appear in the root of a volume 114 | .DocumentRevisions-V100 115 | .fseventsd 116 | .Spotlight-V100 117 | .TemporaryItems 118 | .Trashes 119 | .VolumeIcon.icns 120 | .com.apple.timemachine.donotpresent 121 | 122 | # Directories potentially created on remote AFP share 123 | .AppleDB 124 | .AppleDesktop 125 | Network Trash Folder 126 | Temporary Items 127 | .apdisk 128 | ### Vim template 129 | # Swap 130 | [._]*.s[a-v][a-z] 131 | [._]*.sw[a-p] 132 | [._]s[a-rt-v][a-z] 133 | [._]ss[a-gi-z] 134 | [._]sw[a-p] 135 | 136 | # Session 137 | Session.vim 138 | 139 | # Temporary 140 | .netrwhist 141 | # Auto-generated tag files 142 | tags 143 | # Persistent undo 144 | [._]*.un~ 145 | ### Node template 146 | # Logs 147 | logs 148 | *.log 149 | npm-debug.log* 150 | yarn-debug.log* 151 | yarn-error.log* 152 | 153 | # Runtime data 154 | pids 155 | *.pid 156 | *.seed 157 | *.pid.lock 158 | 159 | # Directory for instrumented libs generated by jscoverage/JSCover 160 | lib-cov 161 | 162 | # Coverage directory used by tools like istanbul 163 | coverage 164 | 165 | # nyc test coverage 166 | .nyc_output 167 | 168 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 169 | .grunt 170 | 171 | # Bower dependency directory (https://bower.io/) 172 | bower_components 173 | 174 | # node-waf configuration 175 | .lock-wscript 176 | 177 | # Compiled binary addons (https://nodejs.org/api/addons.html) 178 | build/Release 179 | 180 | # Dependency directories 181 | node_modules/ 182 | jspm_packages/ 183 | 184 | # TypeScript v1 declaration files 185 | typings/ 186 | 187 | # Optional npm cache directory 188 | .npm 189 | 190 | # Optional eslint cache 191 | .eslintcache 192 | 193 | # Optional REPL history 194 | .node_repl_history 195 | 196 | # Output of 'npm pack' 197 | *.tgz 198 | 199 | # Yarn Integrity file 200 | .yarn-integrity 201 | 202 | # dotenv environment variables file 203 | .env 204 | 205 | # parcel-bundler cache (https://parceljs.org/) 206 | .cache 207 | 208 | # next.js build output 209 | .next 210 | 211 | # nuxt.js build output 212 | .nuxt 213 | 214 | # vuepress build output 215 | .vuepress/dist 216 | 217 | # Serverless directories 218 | .serverless 219 | ### VisualStudioCode template 220 | .vscode/* 221 | !.vscode/settings.json 222 | !.vscode/tasks.json 223 | !.vscode/launch.json 224 | !.vscode/extensions.json 225 | .idea 226 | *.js 227 | !/webpack.dev.js 228 | !/webpack.prod.js 229 | !/webpack.anal.js 230 | !/webpack.test.js 231 | !src/Demo/**/*.js 232 | /dist/ 233 | -------------------------------------------------------------------------------- /.npmignore: -------------------------------------------------------------------------------- 1 | # Created by .ignore support plugin (hsz.mobi) 2 | ### VisualStudioCode template 3 | .vscode/* 4 | !.vscode/settings.json 5 | !.vscode/tasks.json 6 | !.vscode/launch.json 7 | !.vscode/extensions.json 8 | ### JetBrains template 9 | # Covers JetBrains IDEs: IntelliJ, RubyMine, PhpStorm, AppCode, PyCharm, CLion, Android Studio and WebStorm 10 | # Reference: https://intellij-support.jetbrains.com/hc/en-us/articles/206544839 11 | 12 | # User-specific stuff 13 | .idea/**/workspace.xml 14 | .idea/**/tasks.xml 15 | .idea/**/usage.statistics.xml 16 | .idea/**/dictionaries 17 | .idea/**/shelf 18 | 19 | # Sensitive or high-churn files 20 | .idea/**/dataSources/ 21 | .idea/**/dataSources.ids 22 | .idea/**/dataSources.local.xml 23 | .idea/**/sqlDataSources.xml 24 | .idea/**/dynamic.xml 25 | .idea/**/uiDesigner.xml 26 | .idea/**/dbnavigator.xml 27 | 28 | # Gradle 29 | .idea/**/gradle.xml 30 | .idea/**/libraries 31 | 32 | # Gradle and Maven with auto-import 33 | # When using Gradle or Maven with auto-import, you should exclude module files, 34 | # since they will be recreated, and may cause churn. Uncomment if using 35 | # auto-import. 36 | # .idea/modules.xml 37 | # .idea/*.iml 38 | # .idea/modules 39 | 40 | # CMake 41 | cmake-build-*/ 42 | 43 | # Mongo Explorer plugin 44 | .idea/**/mongoSettings.xml 45 | 46 | # File-based project format 47 | *.iws 48 | 49 | # IntelliJ 50 | out/ 51 | 52 | # mpeltonen/sbt-idea plugin 53 | .idea_modules/ 54 | 55 | # JIRA plugin 56 | atlassian-ide-plugin.xml 57 | 58 | # Cursive Clojure plugin 59 | .idea/replstate.xml 60 | 61 | # Crashlytics plugin (for Android Studio and IntelliJ) 62 | com_crashlytics_export_strings.xml 63 | crashlytics.properties 64 | crashlytics-build.properties 65 | fabric.properties 66 | 67 | # Editor-based Rest Client 68 | .idea/httpRequests 69 | ### Windows template 70 | # Windows thumbnail cache files 71 | Thumbs.db 72 | ehthumbs.db 73 | ehthumbs_vista.db 74 | 75 | # Dump file 76 | *.stackdump 77 | 78 | # Folder config file 79 | [Dd]esktop.ini 80 | 81 | # Recycle Bin used on file shares 82 | $RECYCLE.BIN/ 83 | 84 | # Windows Installer files 85 | *.cab 86 | *.msi 87 | *.msix 88 | *.msm 89 | *.msp 90 | 91 | # Windows shortcuts 92 | *.lnk 93 | ### Linux template 94 | *~ 95 | 96 | # temporary files which can be created if a process still has a handle open of a deleted file 97 | .fuse_hidden* 98 | 99 | # KDE directory preferences 100 | .directory 101 | 102 | # Linux trash folder which might appear on any partition or disk 103 | .Trash-* 104 | 105 | # .nfs files are created when an open file is removed but is still being accessed 106 | .nfs* 107 | ### Node template 108 | # Logs 109 | logs 110 | *.log 111 | npm-debug.log* 112 | yarn-debug.log* 113 | yarn-error.log* 114 | 115 | # Runtime data 116 | pids 117 | *.pid 118 | *.seed 119 | *.pid.lock 120 | 121 | # Directory for instrumented libs generated by jscoverage/JSCover 122 | lib-cov 123 | 124 | # Coverage directory used by tools like istanbul 125 | coverage 126 | 127 | # nyc test coverage 128 | .nyc_output 129 | 130 | # Grunt intermediate storage (http://gruntjs.com/creating-plugins#storing-task-files) 131 | .grunt 132 | 133 | # Bower dependency directory (https://bower.io/) 134 | bower_components 135 | 136 | # node-waf configuration 137 | .lock-wscript 138 | 139 | # Compiled binary addons (https://nodejs.org/api/addons.html) 140 | build/Release 141 | 142 | # Dependency directories 143 | node_modules/ 144 | jspm_packages/ 145 | 146 | # TypeScript v1 declaration files 147 | typings/ 148 | 149 | # Optional npm cache directory 150 | .npm 151 | 152 | # Optional eslint cache 153 | .eslintcache 154 | 155 | # Optional REPL history 156 | .node_repl_history 157 | 158 | # Output of 'npm pack' 159 | *.tgz 160 | 161 | # Yarn Integrity file 162 | .yarn-integrity 163 | 164 | # dotenv environment variables file 165 | .env 166 | 167 | # parcel-bundler cache (https://parceljs.org/) 168 | .cache 169 | 170 | # next.js build output 171 | .next 172 | 173 | # nuxt.js build output 174 | .nuxt 175 | 176 | # vuepress build output 177 | .vuepress/dist 178 | 179 | # Serverless directories 180 | .serverless 181 | ### macOS template 182 | # General 183 | .DS_Store 184 | .AppleDouble 185 | .LSOverride 186 | 187 | # Icon must end with two \r 188 | Icon 189 | 190 | # Thumbnails 191 | ._* 192 | 193 | # Files that might appear in the root of a volume 194 | .DocumentRevisions-V100 195 | .fseventsd 196 | .Spotlight-V100 197 | .TemporaryItems 198 | .Trashes 199 | .VolumeIcon.icns 200 | .com.apple.timemachine.donotpresent 201 | 202 | # Directories potentially created on remote AFP share 203 | .AppleDB 204 | .AppleDesktop 205 | Network Trash Folder 206 | Temporary Items 207 | .apdisk 208 | 209 | .idea 210 | 211 | webpack.dev.js 212 | webpack.prod.js 213 | webpack.test.js 214 | webpack.anal.js 215 | yarn.lock 216 | tsconfig.json 217 | .gitignore 218 | .npmignore 219 | /doc/ 220 | /src/ 221 | /dist/Demo 222 | /dist/Develop 223 | /dist/Test 224 | .travis.yml 225 | -------------------------------------------------------------------------------- /.travis.yml: -------------------------------------------------------------------------------- 1 | jobs: 2 | include: 3 | - stage: build 4 | language: node_js 5 | node_js: 6 | - node 7 | script: 8 | - chmod +x ./src/Test/test.sh 9 | - npm test 10 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "wm-annotation-utils", 3 | "version": "1.0.0", 4 | "description": "", 5 | "main": "dist/index.js", 6 | "types": "dist/index.d.ts", 7 | "typings": "dist/index.d.ts", 8 | "repository": "", 9 | "author":"coder-zhou", 10 | "license": "", 11 | "scripts": { 12 | "start:dev": "webpack-dev-server --config webpack.dev.js", 13 | "start:test": "webpack-dev-server --config webpack.test.js", 14 | "test": "bash -c ./src/Test/test.sh", 15 | "build": "webpack -p --config webpack.prod.js", 16 | }, 17 | "dependencies": { 18 | "events": "^3.0.0" 19 | }, 20 | "devDependencies": { 21 | "@types/chai": "^4.2.0", 22 | "@types/events": "^3.0.0", 23 | "@types/mocha": "^5.2.7", 24 | "@types/puppeteer": "^1.19.1", 25 | "chai": "^4.2.0", 26 | "html-loader": "^0.5.5", 27 | "html-webpack-plugin": "^3.2.0", 28 | "mocha": "^6.2.0", 29 | "puppeteer": "^1.19.0", 30 | "ts-loader": "^6.0.4", 31 | "ts-node": "^8.3.0", 32 | "typescript": "^3.7.4", 33 | "uglifyjs-webpack-plugin": "^2.1.3", 34 | "webpack": "^4.36.1", 35 | "webpack-bundle-analyzer": "^3.3.2", 36 | "webpack-cli": "^3.3.6", 37 | "webpack-dev-server": "^3.7.2" 38 | } 39 | } 40 | -------------------------------------------------------------------------------- /readme.md: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/engineer-cv/annotation-utils/72ca525a28d39313efd8c01e4e8a6da080402cd0/readme.md -------------------------------------------------------------------------------- /src/Action/Connection.ts: -------------------------------------------------------------------------------- 1 | import {IAction} from "./IAction"; 2 | import {Store} from "../Store/Store"; 3 | import {Connection as ConnectionModel} from "../Store/Connection"; 4 | 5 | export namespace Connection { 6 | export class CreateConnectionAction implements IAction { 7 | constructor( 8 | public readonly categoryId: number, 9 | public readonly fromId: number, 10 | public readonly toId: number) { 11 | } 12 | 13 | apply(store: Store) { 14 | store.connectionRepo.add(new ConnectionModel.Entity(null, this.categoryId, this.fromId, this.toId, store)); 15 | } 16 | } 17 | 18 | export function Create(categoryId: number, fromId: number, toId: number) { 19 | return new CreateConnectionAction(categoryId, fromId, toId); 20 | } 21 | 22 | export class DeleteConnectionAction implements IAction { 23 | constructor(public id: number) { 24 | } 25 | 26 | apply(store: Store) { 27 | store.connectionRepo.delete(this.id); 28 | }; 29 | } 30 | 31 | export function Delete(id: number) { 32 | return new DeleteConnectionAction(id); 33 | } 34 | 35 | export class UpdateConnectionAction implements IAction { 36 | constructor(public connectionId: number, public categoryId: number) { 37 | } 38 | 39 | apply(store: Store) { 40 | const oldConnection = store.connectionRepo.get(this.connectionId); 41 | Delete(this.connectionId).apply(store); 42 | store.connectionRepo.add(new ConnectionModel.Entity(this.connectionId, this.categoryId, oldConnection.fromId, oldConnection.toId, store)); 43 | } 44 | } 45 | 46 | export function Update(labelId: number, categoryId: number) { 47 | return new UpdateConnectionAction(labelId, categoryId); 48 | } 49 | } 50 | -------------------------------------------------------------------------------- /src/Action/Content.ts: -------------------------------------------------------------------------------- 1 | import {IAction} from "./IAction"; 2 | import {Store} from "../Store/Store"; 3 | 4 | export namespace Content { 5 | export class SpliceAction implements IAction { 6 | constructor( 7 | public readonly startIndex: number, 8 | public readonly removeLength: number, 9 | public readonly insert: string) { 10 | } 11 | 12 | apply(store: Store) { 13 | if (!store.config.contentEditable) { 14 | throw Error("Content edition is not on!") 15 | } else { 16 | store.spliceContent(this.startIndex, this.removeLength, this.insert); 17 | } 18 | } 19 | } 20 | 21 | export function Splice(startIndex: number, removeLength: number, insert: string) { 22 | return new SpliceAction(startIndex, removeLength, insert); 23 | } 24 | } 25 | -------------------------------------------------------------------------------- /src/Action/IAction.ts: -------------------------------------------------------------------------------- 1 | import {Store} from "../Store/Store"; 2 | 3 | export interface IAction { 4 | apply: (store: Store) => void; 5 | } 6 | -------------------------------------------------------------------------------- /src/Action/Label.ts: -------------------------------------------------------------------------------- 1 | import {IAction} from "./IAction"; 2 | import {Store} from "../Store/Store"; 3 | import {Label as LabelModel} from "../Store/Label"; 4 | 5 | export namespace Label { 6 | export class CreateLabelAction implements IAction { 7 | constructor( 8 | public readonly categoryId: number, 9 | public readonly startIndex: number, 10 | public readonly endIndex: number) { 11 | } 12 | 13 | apply(store: Store) { 14 | if (store.content.slice(this.startIndex, this.endIndex).includes("\n")) { 15 | // todo: support this? 16 | throw Error("Insert label across hard line is not supported now! Please remove the \\n in content first!"); 17 | } 18 | store.labelRepo.add(new LabelModel.Entity(null, this.categoryId, this.startIndex, this.endIndex, store)); 19 | } 20 | } 21 | 22 | export function Create(categoryId: number, startIndex: number, endIndex: number) { 23 | return new CreateLabelAction(categoryId, startIndex, endIndex); 24 | } 25 | 26 | export class DeleteLabelAction implements IAction { 27 | constructor(public id: number) { 28 | } 29 | 30 | apply(store: Store) { 31 | for (let connection of store.labelRepo.get(this.id).allConnections) { 32 | store.connectionRepo.delete(connection); 33 | } 34 | store.labelRepo.delete(this.id); 35 | }; 36 | } 37 | 38 | export function Delete(id: number) { 39 | return new DeleteLabelAction(id); 40 | } 41 | 42 | export class UpdateLabelAction implements IAction { 43 | constructor(public labelId: number, public categoryId: number) { 44 | } 45 | 46 | apply(store: Store) { 47 | const oldLabel = store.labelRepo.get(this.labelId); 48 | const connections = oldLabel.allConnections; 49 | Delete(this.labelId).apply(store); 50 | store.labelRepo.add(new LabelModel.Entity(this.labelId, this.categoryId, oldLabel.startIndex, oldLabel.endIndex, store)); 51 | connections.forEach(it => store.connectionRepo.add(it)); 52 | } 53 | } 54 | 55 | export function Update(labelId: number, categoryId: number) { 56 | return new UpdateLabelAction(labelId, categoryId); 57 | } 58 | } 59 | -------------------------------------------------------------------------------- /src/Annotator.ts: -------------------------------------------------------------------------------- 1 | import {EventEmitter} from "events"; 2 | import {JSON as StoreJSON, Store} from "./Store/Store"; 3 | import {View} from "./View/View"; 4 | import {SVGNS} from "./Infrastructure/SVGNS"; 5 | import {ConfigInput, parseInput} from "./Config"; 6 | import {TextSelectionHandler} from "./View/EventHandler/TextSelectionHandler"; 7 | import {TwoLabelsClickedHandler} from "./View/EventHandler/TwoLabelsClickedHandler"; 8 | import {IAction} from "./Action/IAction"; 9 | 10 | 11 | export class Annotator extends EventEmitter { 12 | readonly store: Store; 13 | readonly view: View; 14 | readonly textSelectionHandler: TextSelectionHandler; 15 | readonly twoLabelsClickedHandler: TwoLabelsClickedHandler; 16 | 17 | constructor( 18 | data: string | object, 19 | private containerElement: HTMLElement, 20 | public readonly configInput?: ConfigInput 21 | ) { 22 | super(); 23 | const config = parseInput(configInput || {}); //解析字符串,返回整数 24 | this.store = new Store(config); 25 | this.store.json = typeof data === "string" ? JSON.parse(data) : (data as StoreJSON); //解码字符串 26 | const svgElement = document.createElementNS(SVGNS, 'svg'); //创建svg元素 27 | svgElement.setAttribute("xmlns", SVGNS); //给svg元素设置属性 28 | containerElement.appendChild(svgElement); //添加到dom树中去 29 | this.view = new View(this, svgElement, config); 30 | this.textSelectionHandler = new TextSelectionHandler(this, config); // 创建选取文字的实例 31 | this.twoLabelsClickedHandler = new TwoLabelsClickedHandler(this, config); //创建点击事件的实例 32 | } 33 | 34 | //暴露事件 35 | public applyAction(action: IAction) { 36 | action.apply(this.store); 37 | } 38 | 39 | public export(): string { 40 | this.view.contentEditor.hide(); 41 | // bad for Safari again 42 | const result = this.view.svgElement.outerHTML.replace(/ /, " "); 43 | this.view.contentEditor.show(); 44 | return result; 45 | } 46 | 47 | //移除svg 48 | public remove() { 49 | this.view.svgElement.remove(); 50 | this.store.config.contentEditable && this.view.contentEditor.remove(); 51 | } 52 | } 53 | -------------------------------------------------------------------------------- /src/Config.ts: -------------------------------------------------------------------------------- 1 | import {Config as ViewConfig} from "./View/View"; 2 | import {Config as StoreConfig} from "./Store/Store"; 3 | import {Config as TextSelectionHandlerConfig} from "./View/EventHandler/TextSelectionHandler"; 4 | import {Config as TwoLabelsClickedHandlerConfig} from "./View/EventHandler/TwoLabelsClickedHandler"; 5 | 6 | export interface ConfigInput { 7 | readonly contentClasses?: Array; 8 | readonly labelClasses?: Array; 9 | readonly connectionClasses?: Array; 10 | readonly labelPadding?: number; 11 | readonly lineHeight?: number; 12 | readonly topContextMargin?: number; 13 | readonly bracketWidth?: number; 14 | readonly allowMultipleLabel?: "notAllowed" | "differentCategory" | "allowed"; 15 | readonly allowMultipleConnection?: "notAllowed" | "differentCategory" | "allowed"; 16 | readonly connectionWidthCalcMethod?: "text" | "line"; 17 | readonly labelWidthCalcMethod?: "max" | "label"; 18 | readonly labelOpacity?: number; 19 | readonly selectingAreaStrip?: RegExp | null | undefined; 20 | readonly unconnectedLineStyle?: "none" | "straight" | "curve"; 21 | readonly contentEditable?: boolean 22 | } 23 | 24 | export interface Config extends ViewConfig, StoreConfig, TextSelectionHandlerConfig, TwoLabelsClickedHandlerConfig { 25 | } 26 | 27 | const defaultValues: Config = { 28 | contentClasses: ['poplar-annotation-content'], 29 | labelClasses: ['poplar-annotation-label'], 30 | connectionClasses: ['poplar-annotation-connection'], 31 | labelPadding: 2, 32 | lineHeight: 1.5, 33 | topContextMargin: 3, 34 | bracketWidth: 8, 35 | allowMultipleLabel: "differentCategory", 36 | allowMultipleConnection: "differentCategory", 37 | labelWidthCalcMethod: "max", 38 | connectionWidthCalcMethod: "line", 39 | labelOpacity: 90, 40 | defaultLabelColor: "#ff9d61", 41 | selectingAreaStrip: /[\n ]/, 42 | unconnectedLineStyle: "curve", 43 | contentEditable: true 44 | }; 45 | 46 | export function parseInput(input: ConfigInput): Config { 47 | let result = {}; 48 | for (let entry in defaultValues) { 49 | // @ts-ignore 50 | // noinspection JSUnfilteredForInLoop 51 | result[entry] = input[entry] !== undefined ? input[entry] : defaultValues[entry]; 52 | } 53 | return result as Config; 54 | } 55 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/.browserslistrc: -------------------------------------------------------------------------------- 1 | > 1% 2 | last 2 versions 3 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_Store 2 | node_modules 3 | /dist 4 | 5 | # local env files 6 | .env.local 7 | .env.*.local 8 | 9 | # Log files 10 | npm-debug.log* 11 | yarn-debug.log* 12 | yarn-error.log* 13 | 14 | # Editor directories and files 15 | .idea 16 | .vscode 17 | *.suo 18 | *.ntvs* 19 | *.njsproj 20 | *.sln 21 | *.sw? 22 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/README.md: -------------------------------------------------------------------------------- 1 | # demo 2 | 3 | ## Project setup 4 | ``` 5 | yarn install 6 | ``` 7 | 8 | ### Compiles and hot-reloads for development 9 | ``` 10 | yarn run serve 11 | ``` 12 | 13 | ### Compiles and minifies for production 14 | ``` 15 | yarn run build 16 | ``` 17 | 18 | ### Run your tests 19 | ``` 20 | yarn run test 21 | ``` 22 | 23 | ### Lints and fixes files 24 | ``` 25 | yarn run lint 26 | ``` 27 | 28 | ### Customize configuration 29 | See [Configuration Reference](https://cli.vuejs.org/config/). 30 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@vue/app' 4 | ] 5 | } 6 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "demo", 3 | "version": "0.1.0", 4 | "private": true, 5 | "scripts": { 6 | "serve": "vue-cli-service serve", 7 | "build": "vue-cli-service build", 8 | "lint": "vue-cli-service lint", 9 | "i18n:report": "vue-cli-service i18n:report --src './src/**/*.?(js|vue)' --locales './src/locales/**/*.json'" 10 | }, 11 | "dependencies": { 12 | "@mdi/font": "^3.6.95", 13 | "core-js": "^2.6.5", 14 | "element-ui": "^2.15.10", 15 | "poplar-annotation": "^2.0.3", 16 | "prismjs": "^1.21.0", 17 | "roboto-fontface": "*", 18 | "vue": "^2.6.10", 19 | "vue-class-component": "^7.0.2", 20 | "vue-i18n": "^8.14.0", 21 | "vue-property-decorator": "^8.1.0", 22 | "vue-router": "^3.0.3", 23 | "vuetify": "^2.0.0", 24 | "wm-annotation": "^1.0.1", 25 | "wm-annotation-utils": "^1.0.0" 26 | }, 27 | "devDependencies": { 28 | "@kazupon/vue-i18n-loader": "^0.3.0", 29 | "@types/prismjs": "^1.16.0", 30 | "@types/webpack": "^4.4.0", 31 | "@vue/cli-plugin-babel": "^3.11.0", 32 | "@vue/cli-plugin-typescript": "^3.11.0", 33 | "@vue/cli-service": "^3.11.0", 34 | "sass": "^1.17.4", 35 | "sass-loader": "^7.1.0", 36 | "stylus": "^0.54.5", 37 | "stylus-loader": "^3.0.2", 38 | "typescript": "^3.4.3", 39 | "vue-cli-plugin-i18n": "^0.6.0", 40 | "vue-cli-plugin-vuetify": "^0.6.3", 41 | "vue-template-compiler": "^2.6.10", 42 | "vuetify-loader": "^1.2.2" 43 | } 44 | } 45 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | autoprefixer: {} 4 | } 5 | } 6 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/public/favicon.ico: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/engineer-cv/annotation-utils/72ca525a28d39313efd8c01e4e8a6da080402cd0/src/Demo/vue/demo/public/favicon.ico -------------------------------------------------------------------------------- /src/Demo/vue/demo/public/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | demo 9 | 10 | 11 | 14 |
15 | 16 | 17 | 18 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/public/prism.css: -------------------------------------------------------------------------------- 1 | /* PrismJS 1.17.1 2 | https://prismjs.com/download.html#themes=prism-dark&languages=clike+javascript */ 3 | /** 4 | * prism.js Dark theme for JavaScript, CSS and HTML 5 | * Based on the slides of the talk “/Reg(exp){2}lained/” 6 | * @author Lea Verou 7 | */ 8 | 9 | code[class*="language-"], 10 | pre[class*="language-"] { 11 | color: white; 12 | background: none; 13 | text-shadow: 0 -.1em .2em black; 14 | font-family: Consolas, Monaco, 'Andale Mono', 'Ubuntu Mono', monospace; 15 | font-size: 1em; 16 | text-align: left; 17 | white-space: pre; 18 | word-spacing: normal; 19 | word-break: normal; 20 | word-wrap: normal; 21 | line-height: 1.5; 22 | 23 | -moz-tab-size: 4; 24 | -o-tab-size: 4; 25 | tab-size: 4; 26 | 27 | -webkit-hyphens: none; 28 | -moz-hyphens: none; 29 | -ms-hyphens: none; 30 | hyphens: none; 31 | } 32 | 33 | @media print { 34 | code[class*="language-"], 35 | pre[class*="language-"] { 36 | text-shadow: none; 37 | } 38 | } 39 | 40 | pre[class*="language-"], 41 | :not(pre) > code[class*="language-"] { 42 | background: hsl(30, 20%, 25%); 43 | } 44 | 45 | /* Code blocks */ 46 | pre[class*="language-"] { 47 | padding: 1em; 48 | margin: .5em 0; 49 | overflow: auto; 50 | border: .3em solid hsl(30, 20%, 40%); 51 | border-radius: .5em; 52 | box-shadow: 1px 1px .5em black inset; 53 | } 54 | 55 | /* Inline code */ 56 | :not(pre) > code[class*="language-"] { 57 | padding: .15em .2em .05em; 58 | border-radius: .3em; 59 | border: .13em solid hsl(30, 20%, 40%); 60 | box-shadow: 1px 1px .3em -.1em black inset; 61 | white-space: normal; 62 | } 63 | 64 | .token.comment, 65 | .token.prolog, 66 | .token.doctype, 67 | .token.cdata { 68 | color: hsl(30, 20%, 50%); 69 | } 70 | 71 | .token.punctuation { 72 | opacity: .7; 73 | } 74 | 75 | .namespace { 76 | opacity: .7; 77 | } 78 | 79 | .token.property, 80 | .token.tag, 81 | .token.boolean, 82 | .token.number, 83 | .token.constant, 84 | .token.symbol { 85 | color: hsl(350, 40%, 70%); 86 | } 87 | 88 | .token.selector, 89 | .token.attr-name, 90 | .token.string, 91 | .token.char, 92 | .token.builtin, 93 | .token.inserted { 94 | color: hsl(75, 70%, 60%); 95 | } 96 | 97 | .token.operator, 98 | .token.entity, 99 | .token.url, 100 | .language-css .token.string, 101 | .style .token.string, 102 | .token.variable { 103 | color: hsl(40, 90%, 60%); 104 | } 105 | 106 | .token.atrule, 107 | .token.attr-value, 108 | .token.keyword { 109 | color: hsl(350, 40%, 70%); 110 | } 111 | 112 | .token.regex, 113 | .token.important { 114 | color: #e90; 115 | } 116 | 117 | .token.important, 118 | .token.bold { 119 | font-weight: bold; 120 | } 121 | .token.italic { 122 | font-style: italic; 123 | } 124 | 125 | .token.entity { 126 | cursor: help; 127 | } 128 | 129 | .token.deleted { 130 | color: red; 131 | } 132 | 133 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/src/App.vue: -------------------------------------------------------------------------------- 1 | 8 | 9 | 15 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/src/assets/default.json: -------------------------------------------------------------------------------- 1 | { 2 | "content": "1、JavaScript有哪些垃圾回收机制?有以下垃圾回收机制。标记清除( mark and sweep)这是 JavaScript最常见的垃圾回收方式。当变量进入执行环境的时候,比如在函数中声明一个变量,垃圾回收器将其标记为“进入环境”。当变量离开环境的时候(函数执行结束),将其标记为“离开环境”。垃圾回收器会在运行的时候给存储在内存中的所有变量加上标记,然后去掉环境中的变量,以及被环境中变量所引用的变量(闭包)的标记。在完成这些之后仍然存在的标记就是要删除的变量。引用计数( reference counting)在低版本的E中经常会发生内存泄漏,很多时候就是因为它采用引用计数的方式进行垃圾回收。引用计数的策略是跟踪记录每个值被使用的次数。当声明了一个变量并将个引用类型赋值给该变量的时候,这个值的引用次数就加1.如果该变量的值变成了另外一个,则这个值的引用次数减1.当这个值的引用次数变为0的时候,说明没有变量在使用,这个值没法被访问。因此,可以将它占用的空间回收,这样垃圾回收器会在运行的时候清理引用次数为0的值占用的空间在正中虽然 JavaScript对象通过标记清除的方式进行垃圾回收,但是BOM与DOM对象是用引用计数的方式回收垃圾的。也就是说,只要涉及BOM和DOM,就会出现循环引用问题2、列举几种类型的DOM节点有以下几类DOM节点。整个文档是一个文档( Document)节点。每个HTML标签是一个元素( Element)节点。每一个HTML属性是一个属性( Attribute)节点。包含在HTML元素中的文本是文本(Text)节点。3、谈谈 script标签中 defer和 async属性的区别。区别如下。(1) defer属性规定是否延迟执行脚本,直到页面加载为止, async属性规定脚本一旦可用,就异步执行。(2) defer并行加载 JavaScript文件,会按照页面上 script标签的顺序执行, async并行加载 JavaScript文件,下载完成立即执行,不会按照页面上 script标签的顺序执行。4、说说你对闭包的理解。使用闭包主要是为了设计私有的方法和变量。闭包的优点是可以避免全局变量的污染;缺点是闭包会常驻内存,增加内存使用量,使用不当很容易造成内存泄漏。在JavaScript中,函数即闭包,只有函数才会产生作用域闭包有3个特性(1)函数嵌套函数。(2)在函数内部可以引用外部的参数和变量(3)参数和变量不会以垃圾回收机制回收\n", 3 | "labelCategories": [ 4 | { 5 | "id": 0, 6 | "text": "名词", 7 | "color": "#eac0a2", 8 | "borderColor": "#a38671" 9 | }, 10 | { 11 | "id": 1, 12 | "text": "动词", 13 | "color": "#619dff", 14 | "borderColor": "#436db2" 15 | }, 16 | { 17 | "id": 2, 18 | "text": "形容词", 19 | "color": "#9d61ff", 20 | "borderColor": "#6d43b2" 21 | }, 22 | { 23 | "id": 123, 24 | "text": "副词", 25 | "color": "#ff9d61", 26 | "borderColor": "#b26d43" 27 | } 28 | ], 29 | "labels": [ 30 | { 31 | "id": 29293, 32 | "categoryId": 0, 33 | "startIndex": 0, 34 | "endIndex": 4 35 | }, 36 | { 37 | "id": 1, 38 | "categoryId": 0, 39 | "startIndex": 32, 40 | "endIndex": 33 41 | }, 42 | { 43 | "id": 7, 44 | "categoryId": 1, 45 | "startIndex": 46, 46 | "endIndex": 47 47 | }, 48 | { 49 | "id": 9, 50 | "categoryId": 1, 51 | "startIndex": 64, 52 | "endIndex": 65 53 | }, 54 | { 55 | "id": 10, 56 | "categoryId": 0, 57 | "startIndex": 217, 58 | "endIndex": 218 59 | }, 60 | { 61 | "id": 11, 62 | "categoryId": 0, 63 | "startIndex": 220, 64 | "endIndex": 221 65 | }, 66 | { 67 | "id": 12, 68 | "categoryId": 2, 69 | "startIndex": 218, 70 | "endIndex": 219 71 | }, 72 | { 73 | "id": 13, 74 | "categoryId": 2, 75 | "startIndex": 221, 76 | "endIndex": 222 77 | }, 78 | { 79 | "id": 14, 80 | "categoryId": 0, 81 | "startIndex": 79, 82 | "endIndex": 81 83 | }, 84 | { 85 | "id": 15, 86 | "categoryId": 2, 87 | "startIndex": 84, 88 | "endIndex": 86 89 | } 90 | ], 91 | "connectionCategories": [ 92 | { 93 | "id": 1231231, 94 | "text": "修饰" 95 | }, 96 | { 97 | "id": 1, 98 | "text": "限定" 99 | }, 100 | { 101 | "id": 2, 102 | "text": "是...的动作" 103 | } 104 | ], 105 | "connections": [ 106 | { 107 | "id": 123, 108 | "categoryId": 2, 109 | "fromId": 29293, 110 | "toId": 14 111 | } 112 | ] 113 | } 114 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/src/main.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import App from "./App.vue"; 3 | import router from "./router"; 4 | import vuetify from "./plugins/vuetify"; 5 | import "roboto-fontface/css/roboto/roboto-fontface.css"; 6 | import "@mdi/font/css/materialdesignicons.css"; 7 | 8 | Vue.config.productionTip = false; 9 | Vue.prototype.$eventbus = new Vue(); 10 | new Vue({ 11 | router, 12 | 13 | // @ts-ignore 14 | vuetify, 15 | 16 | render: (h) => h(App), 17 | }).$mount("#app"); 18 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/src/plugins/vuetify.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | // @ts-ignore 3 | import Vuetify from "vuetify/lib"; 4 | import zhHans from "vuetify/src/locale/zh-Hans"; 5 | 6 | Vue.use(Vuetify); 7 | 8 | export default new Vuetify({ 9 | lang: { 10 | locales: {zhHans}, 11 | current: "zh-Hans", 12 | }, 13 | icons: { 14 | iconfont: "mdi", 15 | }, 16 | }); 17 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/src/router.ts: -------------------------------------------------------------------------------- 1 | import Vue from "vue"; 2 | import Router from "vue-router"; 3 | import Annotate from "@/views/Annotate.vue"; 4 | 5 | Vue.use(Router); 6 | 7 | export default new Router({ 8 | mode: "history", 9 | base: process.env.BASE_URL, 10 | routes: [ 11 | { 12 | path: "/", 13 | name: "annotate", 14 | component: Annotate, 15 | }, 16 | ], 17 | }); 18 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/src/shims-tsx.d.ts: -------------------------------------------------------------------------------- 1 | import Vue, { VNode } from 'vue'; 2 | 3 | declare global { 4 | namespace JSX { 5 | // tslint:disable no-empty-interface 6 | interface Element extends VNode {} 7 | // tslint:disable no-empty-interface 8 | interface ElementClass extends Vue {} 9 | interface IntrinsicElements { 10 | [elem: string]: any; 11 | } 12 | } 13 | } 14 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/src/shims-vue.d.ts: -------------------------------------------------------------------------------- 1 | declare module '*.vue' { 2 | import Vue from 'vue'; 3 | export default Vue; 4 | } 5 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/src/views/Annotate.vue: -------------------------------------------------------------------------------- 1 | 61 | 62 | 199 | 232 | 246 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/src/views/demo.js: -------------------------------------------------------------------------------- 1 | let A = [1,2,3,4,5] 2 | let B = [2,3,4,5,7,8] 3 | 4 | 5 | A.forEach((item,index) => { 6 | if(B.indexOf(item) === -1) { 7 | A.splice(index,1) 8 | } 9 | }) 10 | 11 | B.forEach(item => { 12 | if (A.indexOf(item) === -1) { 13 | A.push(item) 14 | } 15 | }) 16 | 17 | console.log(A); -------------------------------------------------------------------------------- /src/Demo/vue/demo/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "target": "esnext", 4 | "module": "esnext", 5 | "strict": false, 6 | "jsx": "preserve", 7 | "importHelpers": true, 8 | "moduleResolution": "node", 9 | "experimentalDecorators": true, 10 | "esModuleInterop": true, 11 | "allowSyntheticDefaultImports": true, 12 | "sourceMap": true, 13 | "resolveJsonModule": true, 14 | "baseUrl": ".", 15 | "types": [ 16 | "webpack-env", 17 | "webpack" 18 | ], 19 | "paths": { 20 | "@/*": [ 21 | "src/*" 22 | ] 23 | }, 24 | "lib": [ 25 | "esnext", 26 | "dom", 27 | "dom.iterable", 28 | "scripthost" 29 | ] 30 | }, 31 | "include": [ 32 | "src/**/*.ts", 33 | "src/**/*.tsx", 34 | "src/**/*.vue", 35 | "tests/**/*.ts", 36 | "tests/**/*.tsx" 37 | ], 38 | "exclude": [ 39 | "node_modules", 40 | "*.d.ts" 41 | ] 42 | } 43 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/tslint.json: -------------------------------------------------------------------------------- 1 | { 2 | "defaultSeverity": "warning", 3 | "extends": [ 4 | "tslint:recommended" 5 | ], 6 | "linterOptions": { 7 | "exclude": [ 8 | "node_modules/**" 9 | ] 10 | }, 11 | "rules": { 12 | "indent": [ 13 | true, 14 | "spaces", 15 | 2 16 | ], 17 | "interface-name": false, 18 | "no-consecutive-blank-lines": false, 19 | "object-literal-sort-keys": false, 20 | "ordered-imports": false, 21 | "quotemark": [ 22 | true, 23 | "double" 24 | ], 25 | "trailing-comma": false 26 | } 27 | } 28 | -------------------------------------------------------------------------------- /src/Demo/vue/demo/vue.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | publicPath: '/coder', 3 | pluginOptions: { 4 | i18n: { 5 | locale: 'zh', 6 | fallbackLocale: 'en', 7 | localeDir: 'locales', 8 | enableInSFC: true 9 | } 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Develop/dev.ts: -------------------------------------------------------------------------------- 1 | import {Annotator} from "../Annotator"; 2 | // @ts-ignore 3 | import * as data from "./test.json"; 4 | import {EventEmitter} from "events"; 5 | import {Label} from "../Action/Label"; 6 | import {Connection} from "../Action/Connection"; 7 | import {Content} from "../Action/Content"; 8 | 9 | window.onload = function () { 10 | (window as any).annotator = new Annotator(data, document.getElementById("container")!, { 11 | connectionWidthCalcMethod: "line" 12 | }); 13 | ((window as any).annotator as EventEmitter).on('textSelected', (startIndex: number, endIndex: number) => { 14 | console.log(startIndex, endIndex); 15 | (window as any).annotator.applyAction(Label.Create(2, startIndex, endIndex)); 16 | }); 17 | ((window as any).annotator as EventEmitter).on('labelClicked', (labelId: number) => { 18 | console.log(labelId); 19 | }); 20 | ((window as any).annotator as EventEmitter).on('twoLabelsClicked', (fromLabelId: number, toLabelId: number) => { 21 | if (fromLabelId === toLabelId) { 22 | (window as any).annotator.applyAction(Label.Update(fromLabelId, 2)); 23 | } else { 24 | (window as any).annotator.applyAction(Connection.Create(0, fromLabelId, toLabelId)); 25 | console.log(fromLabelId, toLabelId); 26 | } 27 | }); 28 | ((window as any).annotator as EventEmitter).on('labelRightClicked', (labelId: number, event: MouseEvent) => { 29 | (window as any).annotator.applyAction(Label.Delete(labelId)); 30 | console.log(event.x, event.y); 31 | }); 32 | ((window as any).annotator as EventEmitter).on('connectionRightClicked', (connectionId: number, event: MouseEvent) => { 33 | (window as any).annotator.applyAction(Connection.Delete(connectionId)); 34 | console.log(event.x, event.y); 35 | }); 36 | ((window as any).annotator as EventEmitter).on('contentInput', (position: number, value: string) => { 37 | (window as any).annotator.applyAction(Content.Splice(position, 0, value)); 38 | }); 39 | ((window as any).annotator as EventEmitter).on('contentDelete', (position: number, length: number) => { 40 | (window as any).annotator.applyAction(Content.Splice(position, length, "")); 41 | }); 42 | }; 43 | -------------------------------------------------------------------------------- /src/Develop/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | poplar 6 | 41 | 42 | 43 |
44 | 45 | 46 | 47 | -------------------------------------------------------------------------------- /src/Develop/test.json: -------------------------------------------------------------------------------- 1 | { 2 | "content": "The aim of the presented study was to characterize the anticonvulsant effects of levetiracetam in combination with various antiepileptic drugs (carbamazepine, phenytoin, topiramate and vigabatrin) in the mouse 6Hz psychomotor seizure model. Limbic (psychomotor) seizure activity was evoked in albino Swiss mice by a current (32mA, 6Hz, 3s stimulus duration) delivered via ocular electrodes; type II isobolographic analysis was used to characterize the consequent anticonvulsant interactions between the various drug combinations for fixed-ratios of 1:1, 1:2, 1:5 and 1:10. With type II isobolographic analysis, the combinations of levetiracetam with carbamazepine and phenytoin for the fixed-ratios of 1:5 and 1:10 were supra-additive (synergistic; P<0.01) in terms of seizure suppression, while the combinations for the fixed-ratios of 1:1 and 1:2 were additive. Levetiracetam combined with topiramate and vigabatrin for the fixed-ratio of 1:10 exerted supra-additive interaction (P<0.05), and simultaneously, the two-drug combinations for the fixed-ratios of 1:1, 1:2 and 1:5 produced additive interaction in the mouse 6Hz psychomotor seizure model. The combinations of levetiracetam with carbamazepine and phenytoin for the fixed-ratios of 1:5 and 1:10, as well as the combinations of levetiracetam with topiramate and vigabatrin for the fixed-ratio of 1:10 appear to be particularly favorable combinations exerting supra-additive interaction in the mouse 6Hz psychomotor seizure model. Finally, it may be concluded that because of the synergistic interactions between levetiracetam and carbamazepine, phenytoin, topiramate and vigabatrin, the combinations might be useful in clinical practice.", 3 | "labelCategories": [ 4 | { 5 | "id": 0, 6 | "text": "名词", 7 | "color": "#eac0a2", 8 | "borderColor": "#a38671" 9 | }, 10 | { 11 | "id": 8, 12 | "text": "动词", 13 | "color": "#619dff", 14 | "borderColor": "#436db2" 15 | }, 16 | { 17 | "id": 2, 18 | "text": "超长标签超长标签超长标签", 19 | "color": "#9d61ff", 20 | "borderColor": "#6d43b2" 21 | }, 22 | { 23 | "id": 99, 24 | "text": "副词", 25 | "color": "#ff9d61", 26 | "borderColor": "#b26d43" 27 | } 28 | ], 29 | "labels": [ 30 | ], 31 | "connectionCategories": [ 32 | { 33 | "id": 0, 34 | "text": "修饰" 35 | }, 36 | { 37 | "id": 1, 38 | "text": "限定" 39 | }, 40 | { 41 | "id": 2, 42 | "text": "是...的动作" 43 | } 44 | ], 45 | "connections": [] 46 | } 47 | -------------------------------------------------------------------------------- /src/Infrastructure/Array.ts: -------------------------------------------------------------------------------- 1 | import {none, Option, some} from "./Option"; 2 | 3 | export function lookup(array: Array, index: number): Option { 4 | if (array.length <= index) { 5 | return none; 6 | } else { 7 | return some(array[index]); 8 | } 9 | } 10 | 11 | export function takeWhile(array: Array, pred: (value: T) => boolean): Array { 12 | let i: number; 13 | for (i = 0; i < array.length && pred(array[i]); ++i) { 14 | } 15 | return array.slice(0, i); 16 | } 17 | -------------------------------------------------------------------------------- /src/Infrastructure/Assert.ts: -------------------------------------------------------------------------------- 1 | declare let process: any; 2 | 3 | export function assert(condition: boolean, message?: string) { 4 | if (process.env.NODE_ENV === 'development') { 5 | if (condition === false) { 6 | throw Error('Assertion failed' + (message === undefined) ? (':' + message) : '') 7 | } 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /src/Infrastructure/Color.ts: -------------------------------------------------------------------------------- 1 | 2 | export function shadeColor(color: string, percent: number): string { 3 | const num = parseInt(color.slice(1), 16); 4 | const r = Math.min((num >> 16) + (num >> 16) * percent / 100, 255); 5 | const b = Math.min(((num >> 8) & 0xFF) + ((num >> 8) & 0x00FF) * percent / 100, 255); 6 | const g = Math.min((num & 0x0000FF) + (num & 0x0000FF) * percent / 100, 255); 7 | const newColor = g | (b << 8) | (r << 16); 8 | return "#" + newColor.toString(16); 9 | } 10 | 11 | export function addAlpha(color: string, alpha: number): string { 12 | const f = parseInt(color.slice(1), 16); 13 | const R = f >> 16, G = f >> 8 & 0x00FF, B = f & 0x0000FF; 14 | return `rgba(${R},${G},${B},${alpha}%)`; 15 | } 16 | -------------------------------------------------------------------------------- /src/Infrastructure/Option.ts: -------------------------------------------------------------------------------- 1 | export class Option { 2 | constructor(private value: T | null) { 3 | } 4 | 5 | get isSome(): boolean { 6 | return this.value !== null; 7 | } 8 | 9 | map(func: (from: T) => U) { 10 | if (this.value === null) { 11 | return new Option(null); 12 | } else { 13 | return new Option(func(this.value)); 14 | } 15 | } 16 | 17 | flatMap(func: (from: T) => Option) { 18 | if (this.value === null) { 19 | return new Option(null); 20 | } else { 21 | return fromNullable(func(this.value).toNullable()); 22 | } 23 | } 24 | 25 | orElse(defaultValue: T): T { 26 | return this.value === null ? defaultValue : this.value; 27 | } 28 | 29 | toNullable(): T | null { 30 | return this.value; 31 | } 32 | 33 | match(whenSome: U, whenNone: U): U { 34 | if (this.isSome) { 35 | return whenSome; 36 | } else { 37 | return whenNone; 38 | } 39 | } 40 | } 41 | 42 | export const none = new Option(null as any); 43 | 44 | export function some(value: T): Option { 45 | return new Option(value); 46 | } 47 | 48 | export function fromNullable(value: T | undefined | null): Option { 49 | if (value === null || value === undefined) { 50 | return none; 51 | } else { 52 | return some(value); 53 | } 54 | } 55 | 56 | export function fromTry(value: () => T): Option { 57 | try { 58 | const result = value(); 59 | return some(result); 60 | } catch (e) { 61 | return none; 62 | } 63 | } 64 | 65 | -------------------------------------------------------------------------------- /src/Infrastructure/Range.ts: -------------------------------------------------------------------------------- 1 | export function range(start: number, end: number): Array { 2 | let result = []; 3 | for (let i = start; i < end; ++i) { 4 | result.push(i); 5 | } 6 | return result; 7 | } 8 | -------------------------------------------------------------------------------- /src/Infrastructure/Repository.ts: -------------------------------------------------------------------------------- 1 | import {assert} from "./Assert"; 2 | import {EventEmitter} from "events"; 3 | 4 | export namespace Base { 5 | export class Repository extends EventEmitter { 6 | protected entities = new Map(); 7 | private nextId = 0; 8 | 9 | get json(): Array { 10 | let result = []; 11 | for (const entity of this.values()) { 12 | if ('json' in entity) { 13 | result.push((entity as any).json); 14 | } else { 15 | result.push(JSON.parse(JSON.stringify(entity))); 16 | } 17 | } 18 | return result; 19 | } 20 | 21 | get length(): number { 22 | return this.entities.size; 23 | } 24 | 25 | get(key: number): T { 26 | assert(this.has(key), `There's no Entity which id=${key} in repo!`); 27 | return this.entities.get(key)!; 28 | } 29 | 30 | has(key: number): boolean { 31 | return this.entities.has(key); 32 | } 33 | 34 | set(key: number, value: T): this { 35 | const alreadyHas = this.has(key); 36 | this.entities.set(key, value); 37 | if (!alreadyHas) { 38 | if (this.nextId < key + 1) { 39 | this.nextId = key + 1; 40 | } 41 | this.emit('created', value); 42 | } else { 43 | this.emit('updated', value); 44 | } 45 | return this; 46 | } 47 | 48 | add(value: T): number { 49 | if ('id' in value) { 50 | let id: number = (value as any).id; 51 | assert(!this.has(id), `reAdd ${id}!`); 52 | if (id !== null) { 53 | this.set(id, value); 54 | } else { 55 | (value as any).id = this.nextId; 56 | return this.add(value); 57 | } 58 | return id; 59 | } 60 | const key = this.nextId; 61 | this.set(key, value); 62 | return key; 63 | } 64 | 65 | [Symbol.iterator](): Iterator<[number, T]> { 66 | return this.entities[Symbol.iterator](); 67 | } 68 | 69 | delete(key: number | T): boolean { 70 | if (typeof key === 'number' && this.has(key)) { 71 | const theDeleted = this.get(key); 72 | this.entities.delete(key); 73 | this.emit('removed', theDeleted); 74 | return true; 75 | } else if (typeof key !== 'number' && 'id' in key) { 76 | return this.delete((key as any).id); 77 | } 78 | return false; 79 | } 80 | 81 | values(): IterableIterator { 82 | return this.entities.values(); 83 | } 84 | } 85 | } 86 | -------------------------------------------------------------------------------- /src/Infrastructure/SVGNS.ts: -------------------------------------------------------------------------------- 1 | export const SVGNS = "http://www.w3.org/2000/svg"; 2 | -------------------------------------------------------------------------------- /src/Store/Connection.ts: -------------------------------------------------------------------------------- 1 | import {Base} from "../Infrastructure/Repository"; 2 | import {Store} from "./Store"; 3 | import {ConnectionCategory} from "./ConnectionCategory"; 4 | import {Label} from "./Label"; 5 | 6 | export namespace Connection { 7 | export interface JSON { 8 | id: number; 9 | categoryId: number; 10 | fromId: number; 11 | toId: number; 12 | } 13 | 14 | export class Entity { 15 | constructor( 16 | public readonly id: number | null, 17 | public readonly categoryId: number, 18 | public readonly fromId: number, 19 | public readonly toId: number, 20 | private readonly root: Store 21 | ) { 22 | } 23 | 24 | get category(): ConnectionCategory.Entity { 25 | return this.root.connectionCategoryRepo.get(this.categoryId); 26 | } 27 | 28 | get from(): Label.Entity { 29 | return this.root.labelRepo.get(this.fromId); 30 | } 31 | 32 | get to(): Label.Entity { 33 | return this.root.labelRepo.get(this.toId); 34 | } 35 | 36 | get priorLabel(): Label.Entity { 37 | if (this.from.startIndex < this.to.startIndex) { 38 | return this.from; 39 | } else { 40 | return this.to; 41 | } 42 | } 43 | 44 | get posteriorLabel(): Label.Entity { 45 | if (this.from.startIndex >= this.to.startIndex) { 46 | return this.from; 47 | } else { 48 | return this.to; 49 | } 50 | } 51 | 52 | get json(): JSON { 53 | return { 54 | id: this.id!, 55 | categoryId: this.categoryId, 56 | fromId: this.fromId, 57 | toId: this.toId 58 | } 59 | } 60 | } 61 | 62 | export interface Config { 63 | readonly allowMultipleConnection: "notAllowed" | "differentCategory" | "allowed" 64 | } 65 | 66 | export class Repository extends Base.Repository { 67 | constructor(private config: Config) { 68 | super(); 69 | } 70 | 71 | set(key: number, value: Entity): this { 72 | if (!this.againstMultipleConnectionRuleWith(value)) { 73 | super.set(key, value); 74 | } else { 75 | console.warn("try set a label against the checkMultipleLabel rule!"); 76 | } 77 | return this; 78 | } 79 | 80 | add(value: Entity): number { 81 | if (!this.againstMultipleConnectionRuleWith(value)) { 82 | return super.add(value); 83 | } else { 84 | console.warn("try add a label against the checkMultipleLabel rule!"); 85 | } 86 | return -1; 87 | } 88 | 89 | private againstMultipleConnectionRuleWith(other: Entity): boolean { 90 | const sameFromToCheck = (entityA: Entity, entityB: Entity) => entityA.from === entityB.from && entityA.to === entityB.to; 91 | const sameFromToCategoryCheck = (entityA: Entity, entityB: Entity) => sameFromToCheck(entityA, entityB) && entityA.categoryId == entityB.categoryId; 92 | const sameCheck = this.config.allowMultipleConnection === "notAllowed" ? sameFromToCheck : sameFromToCategoryCheck; 93 | return Array.from(this.values()).some(it => sameCheck(it, other)); 94 | } 95 | } 96 | 97 | export namespace Factory { 98 | export function create(json: JSON, root: Store): Entity { 99 | return new Entity(json.id, json.categoryId, json.fromId, json.toId, root); 100 | } 101 | 102 | export function createAll(json: Array, root: Store): Array { 103 | return json.map(it => create(it, root)); 104 | } 105 | } 106 | } 107 | -------------------------------------------------------------------------------- /src/Store/ConnectionCategory.ts: -------------------------------------------------------------------------------- 1 | import {Base} from "../Infrastructure/Repository"; 2 | 3 | export namespace ConnectionCategory { 4 | export interface Entity { 5 | readonly id: number; 6 | readonly text: string; 7 | } 8 | 9 | export class Repository extends Base.Repository { 10 | } 11 | } 12 | -------------------------------------------------------------------------------- /src/Store/Label.ts: -------------------------------------------------------------------------------- 1 | import {Base} from "../Infrastructure/Repository"; 2 | import {Store} from "./Store"; 3 | import {Connection} from "./Connection"; 4 | import {LabelCategory} from "./LabelCategory"; 5 | 6 | export namespace Label { 7 | export interface JSON { 8 | readonly id: number; 9 | readonly categoryId: number; 10 | readonly startIndex: number; 11 | readonly endIndex: number; 12 | } 13 | 14 | export class Entity { 15 | constructor( 16 | public readonly id: number | null, 17 | public readonly categoryId: number, 18 | private _startIndex: number, 19 | private _endIndex: number, 20 | private readonly root: Store 21 | ) { 22 | } 23 | 24 | get startIndex() { 25 | return this._startIndex; 26 | } 27 | 28 | get endIndex() { 29 | return this._endIndex; 30 | } 31 | 32 | move(offset: number) { 33 | this._startIndex += offset; 34 | this._endIndex += offset; 35 | } 36 | 37 | get category(): LabelCategory.Entity { 38 | return this.root.labelCategoryRepo.get(this.categoryId); 39 | } 40 | 41 | get json(): JSON { 42 | return { 43 | id: this.id!, 44 | categoryId: this.categoryId, 45 | startIndex: this.startIndex, 46 | endIndex: this.endIndex 47 | } 48 | } 49 | 50 | get sameLineConnections(): Array { 51 | let result = []; 52 | for (let entity of this.root.connectionRepo.values()) { 53 | if (entity.priorLabel === this) { 54 | result.push(entity); 55 | } 56 | } 57 | return result; 58 | } 59 | 60 | get connectionsFrom(): Set { 61 | let result = new Set(); 62 | for (let entity of this.root.connectionRepo.values()) { 63 | if (entity.from === this) { 64 | result.add(entity); 65 | } 66 | } 67 | return result; 68 | } 69 | 70 | get connectionsTo(): Set { 71 | let result = new Set(); 72 | for (let entity of this.root.connectionRepo.values()) { 73 | if (entity.to === this) { 74 | result.add(entity); 75 | } 76 | } 77 | return result; 78 | } 79 | 80 | get allConnections(): Set { 81 | return new Set(Array.prototype.concat( 82 | Array.from(this.connectionsFrom), 83 | Array.from(this.connectionsTo) 84 | )); 85 | } 86 | } 87 | 88 | export interface Config { 89 | readonly allowMultipleLabel: "notAllowed" | "differentCategory" | "allowed" 90 | } 91 | 92 | export class Repository extends Base.Repository { 93 | constructor(private config: Config) { 94 | super(); 95 | } 96 | 97 | set(key: number, value: Entity): this { 98 | if (!this.againstMultipleLabelWith(value)) { 99 | super.set(key, value); 100 | } else { 101 | console.warn("try set a label against the againstMultipleLabelWith rule!"); 102 | } 103 | return this; 104 | } 105 | 106 | add(value: Label.Entity): number { 107 | if (!this.againstMultipleLabelWith(value)) { 108 | return super.add(value); 109 | } else { 110 | console.warn("try add a label against the againstMultipleLabelWith rule!"); 111 | } 112 | return -1; 113 | } 114 | 115 | private againstMultipleLabelWith(other: Entity): boolean { 116 | const sameStartEndCheck = (entityA: Entity, entityB: Entity) => entityA.startIndex === entityB.startIndex && entityA.endIndex === entityB.endIndex; 117 | const sameFromToCategoryCheck = (entityA: Entity, entityB: Entity) => sameStartEndCheck(entityA, entityB) && entityA.categoryId == entityB.categoryId; 118 | const sameCheck = this.config.allowMultipleLabel === "notAllowed" ? sameStartEndCheck : sameFromToCategoryCheck; 119 | return Array.from(this.values()).some(it => sameCheck(it, other)); 120 | } 121 | 122 | getEntitiesInRange(startIndex: number, endIndex: number): Array { 123 | return Array.from(this.entities.values()) 124 | .filter(entity => startIndex <= entity.startIndex && entity.endIndex <= endIndex); 125 | } 126 | 127 | getEntitiesCross(index: number): Array { 128 | return Array.from(this.entities.values()) 129 | .filter(entity => entity.startIndex <= index && index < entity.endIndex); 130 | } 131 | } 132 | 133 | export namespace Factory { 134 | export function create(json: JSON, root: Store): Entity { 135 | return new Entity(json.id, json.categoryId, json.startIndex, json.endIndex, root); 136 | } 137 | 138 | export function createAll(json: Array, root: Store): Array { 139 | return json.map(it => create(it, root)); 140 | } 141 | } 142 | } 143 | -------------------------------------------------------------------------------- /src/Store/LabelCategory.ts: -------------------------------------------------------------------------------- 1 | import {Base} from "../Infrastructure/Repository"; 2 | import {shadeColor} from "../Infrastructure/Color"; 3 | 4 | export namespace LabelCategory { 5 | export interface JSON { 6 | readonly id: number; 7 | readonly text: string; 8 | readonly color?: string; 9 | readonly borderColor?: string; 10 | readonly "border-color"?: string; 11 | } 12 | 13 | export interface Entity { 14 | readonly id: number; 15 | readonly text: string; 16 | readonly color: string; 17 | readonly borderColor: string; 18 | } 19 | 20 | export class Repository extends Base.Repository { 21 | } 22 | 23 | export interface Config { 24 | readonly defaultLabelColor: string 25 | } 26 | 27 | export namespace Factory { 28 | //给label添加color,borderColor属性,并返回label 29 | export function create(json: JSON, defaultColor: string): Entity { 30 | let borderColor = json.borderColor; 31 | let color = json.color; 32 | if (!(json.borderColor) && json["border-color"]) { 33 | borderColor = json["border-color"]; 34 | } 35 | if (!(json.color)) { 36 | color = defaultColor; 37 | } 38 | if (!(json.borderColor)) { 39 | borderColor = shadeColor(color!, -30); 40 | } 41 | return { 42 | id: json.id, 43 | text: json.text, 44 | color: color!, 45 | borderColor: borderColor! 46 | }; 47 | } 48 | 49 | export function createAll(json: Array, config: Config): Array { 50 | return json.map(it => create(it, config.defaultLabelColor)); 51 | } 52 | } 53 | } 54 | -------------------------------------------------------------------------------- /src/Store/Store.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 3 | * 创建各个对象的repo,以便于取值 4 | * 5 | * */ 6 | 7 | import {LabelCategory} from "./LabelCategory"; 8 | import {Label} from "./Label"; 9 | import {ConnectionCategory} from "./ConnectionCategory"; 10 | import {Connection} from "./Connection"; 11 | import {EventEmitter} from "events"; //使用events模块中的EventEmitter进行跨组件通信 12 | 13 | export interface Config extends LabelCategory.Config, Label.Config, Connection.Config { 14 | readonly contentEditable: boolean; 15 | } 16 | 17 | export interface JSON { 18 | readonly content: string; 19 | 20 | readonly labelCategories: Array; 21 | readonly labels: Array; 22 | 23 | readonly connectionCategories: Array; 24 | readonly connections: Array; 25 | } 26 | 27 | export class Store extends EventEmitter { 28 | readonly labelCategoryRepo: LabelCategory.Repository; 29 | readonly labelRepo: Label.Repository; 30 | readonly connectionCategoryRepo: ConnectionCategory.Repository; 31 | readonly connectionRepo: Connection.Repository; 32 | readonly config: Config; 33 | private _content: string = ''; //标注的文字 34 | 35 | constructor(config: Config) { 36 | super(); 37 | this.config = config; 38 | this.labelCategoryRepo = new LabelCategory.Repository(); 39 | this.labelRepo = new Label.Repository(config); 40 | this.connectionCategoryRepo = new ConnectionCategory.Repository(); 41 | this.connectionRepo = new Connection.Repository(config); 42 | } 43 | 44 | get content() { 45 | return this._content; 46 | } 47 | 48 | set json(json: JSON) { 49 | // 判断当前字符串是否以\n结束 50 | this._content = json.content.endsWith('\n') ? json.content : (json.content + '\n'); 51 | LabelCategory.Factory.createAll(json.labelCategories, this.config).map(it => this.labelCategoryRepo.add(it)); 52 | Label.Factory.createAll(json.labels, this).map(it => this.labelRepo.add(it)); 53 | json.connectionCategories.map(it => this.connectionCategoryRepo.add(it)); 54 | Connection.Factory.createAll(json.connections, this).map(it => this.connectionRepo.add(it)); 55 | } 56 | 57 | contentSlice(startIndex: number, endIndex: number): string { 58 | return this.content.slice(startIndex, endIndex); 59 | } 60 | 61 | private moveLabels(startFromIndex: number, distance: number) { 62 | Array.from(this.labelRepo.values()) 63 | .filter(it => it.startIndex >= startFromIndex) 64 | .map(it => it.move(distance)); 65 | } 66 | 67 | get json(): JSON { 68 | return { 69 | content: this._content, 70 | labelCategories: this.labelCategoryRepo.json as Array, 71 | labels: this.labelRepo.json as Array, 72 | connectionCategories: this.connectionCategoryRepo.json as Array, 73 | connections: this.connectionRepo.json as Array 74 | } 75 | } 76 | 77 | spliceContent(start: number, removeLength: number, ...inserts: Array) { 78 | const removeEnd = start + removeLength; 79 | if (removeLength === 0 || Array.from(this.labelRepo.values()) 80 | .find((label: Label.Entity) => 81 | (label.startIndex <= start && start < label.endIndex) || 82 | (label.startIndex < removeEnd && removeEnd < label.endIndex) 83 | ) === undefined) { 84 | const notTouchedFirstPart = this.content.slice(0, start); 85 | const removed = this.content.slice(start, start + removeLength); 86 | const inserted = inserts.join(''); 87 | const notTouchedSecondPart = this.content.slice(start + removeLength); 88 | this._content = notTouchedFirstPart + inserted + notTouchedSecondPart; 89 | this.moveLabels(start + removeLength, inserted.length - removed.length); 90 | this.emit('contentSpliced', start, removed, inserted); 91 | } 92 | } 93 | } 94 | -------------------------------------------------------------------------------- /src/View/Entities/ConnectionView/ConnectionCategoryElement.ts: -------------------------------------------------------------------------------- 1 | /** 2 | * 某一 "种" connection 的文字部分是一样的 3 | * 只需预先构造,而后在使用时 cloneNode 即可 4 | */ 5 | import {Base} from '../../../Infrastructure/Repository'; 6 | import {SVGNS} from '../../../Infrastructure/SVGNS'; 7 | import {ConnectionCategory} from '../../../Store/ConnectionCategory'; 8 | import {Font} from '../../Font'; 9 | import {View} from '../../View'; 10 | 11 | export namespace ConnectionCategoryElement { 12 | class Factory { 13 | private svgElement: SVGGElement; 14 | 15 | constructor( 16 | private store: ConnectionCategory.Entity, font: Font.ValueObject, 17 | classes: Array) { 18 | this.svgElement = document.createElementNS(SVGNS, 'g') as SVGGElement; 19 | const rectElement = document.createElementNS(SVGNS, 'rect') as SVGRectElement; 20 | rectElement.setAttribute('fill', '#ffffff'); 21 | rectElement.setAttribute('width', (font.widthOf(store.text)).toString()); 22 | rectElement.setAttribute('height', (font.lineHeight).toString()); 23 | const textElement = 24 | document.createElementNS(SVGNS, 'text') as SVGTextElement; 25 | textElement.classList.add(...classes); 26 | textElement.textContent = store.text; 27 | textElement.setAttribute('dy', `${font.topToBaseLine}px`); 28 | this.svgElement.appendChild(rectElement); 29 | this.svgElement.appendChild(textElement); 30 | } 31 | 32 | get id() { 33 | return this.store.id; 34 | } 35 | 36 | public create(): SVGGElement { 37 | return this.svgElement.cloneNode(true) as SVGGElement; 38 | } 39 | } 40 | 41 | export interface Config { 42 | readonly connectionClasses: Array 43 | } 44 | 45 | export class FactoryRepository extends Base.Repository { 46 | constructor(view: View, private config: Config) { 47 | super(); 48 | for (let entity of view.store.connectionCategoryRepo.values()) { 49 | super.add( 50 | new Factory(entity, view.connectionFont, config.connectionClasses)); 51 | } 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /src/View/Entities/ConnectionView/ConnectionView.ts: -------------------------------------------------------------------------------- 1 | import {Base} from '../../../Infrastructure/Repository'; 2 | import {SVGNS} from '../../../Infrastructure/SVGNS'; 3 | import {Connection} from '../../../Store/Connection'; 4 | import {View} from '../../View'; 5 | import {LabelView} from '../LabelView/LabelView'; 6 | import {Line} from '../Line/Line'; 7 | import {TopContext} from '../Line/TopContext/TopContext'; 8 | import {TopContextUser} from '../Line/TopContext/TopContextUser'; 9 | 10 | export namespace ConnectionView { 11 | export interface Config { 12 | readonly connectionWidthCalcMethod: 'text' | 'line', 13 | readonly connectionClasses: Array; 14 | } 15 | 16 | export class Entity extends TopContextUser { 17 | private svgElement: SVGGElement = null as any; 18 | private lineElement: SVGPathElement = null as any; 19 | 20 | constructor( 21 | private store: Connection.Entity, private contextIn: TopContext, 22 | private config: Config) { 23 | super(); 24 | } 25 | 26 | get id() { 27 | return this.store.id; 28 | } 29 | 30 | get lineIn(): Line.ValueObject { 31 | return this.contextIn.belongTo; 32 | } 33 | 34 | get view(): View { 35 | return this.lineIn.view; 36 | } 37 | 38 | get sameLineLabelView(): LabelView.Entity { 39 | return this.view.labelViewRepository.get(this.store.priorLabel.id!)!; 40 | } 41 | 42 | get mayNotSameLineLabelView(): LabelView.Entity { 43 | return this.view.labelViewRepository.get(this.store.posteriorLabel.id!)!; 44 | } 45 | 46 | get fromLabelView(): LabelView.Entity { 47 | return this.view.labelViewRepository.get(this.store.from.id!)!; 48 | } 49 | 50 | get toLabelView(): LabelView.Entity { 51 | return this.view.labelViewRepository.get(this.store.to.id!)!; 52 | } 53 | 54 | get leftLabelView(): LabelView.Entity { 55 | return this.fromLabelView.labelLeft < this.toLabelView.labelLeft ? 56 | this.fromLabelView : 57 | this.toLabelView; 58 | } 59 | 60 | get rightLabelView(): LabelView.Entity { 61 | return this.fromLabelView.labelLeft >= this.toLabelView.labelLeft ? 62 | this.fromLabelView : 63 | this.toLabelView; 64 | } 65 | 66 | get middle(): number { 67 | return (this.leftLabelView.labelLeft + this.rightLabelView.labelRight) / 68 | 2; 69 | } 70 | 71 | get textWidth(): number { 72 | return this.view.connectionFont.widthOf(this.store.category.text); 73 | } 74 | 75 | get textLeft(): number { 76 | return this.middle - this.textWidth / 2; 77 | } 78 | 79 | get lineIncludedWidth(): number { 80 | if (this.fromLabelView.labelLeft < this.toLabelView.labelLeft) { 81 | return this.toLabelView.labelRight - this.fromLabelView.labelLeft; 82 | } else { 83 | return this.fromLabelView.labelRight - this.toLabelView.labelLeft; 84 | } 85 | } 86 | 87 | get lineIncludedLeft(): number { 88 | return this.fromLabelView.labelLeft < this.toLabelView.labelLeft ? 89 | this.fromLabelView.labelLeft : 90 | this.toLabelView.labelLeft; 91 | } 92 | 93 | get width(): number { 94 | return this.config.connectionWidthCalcMethod === 'text' ? 95 | this.textWidth : 96 | this.lineIncludedWidth; 97 | } 98 | 99 | get left(): number { 100 | return this.config.connectionWidthCalcMethod === 'text' ? 101 | this.textLeft : 102 | this.lineIncludedLeft; 103 | } 104 | 105 | get globalY(): number { 106 | return this.lineIn.y - this.layer * this.view.topContextLayerHeight; 107 | } 108 | 109 | render(): SVGGElement { 110 | this.svgElement = document.createElementNS(SVGNS, 'g') as SVGGElement; 111 | const textElement = this.view.connectionCategoryElementFactoryRepository 112 | .get(this.store.category.id) 113 | .create(); 114 | this.svgElement.appendChild(textElement); 115 | this.svgElement.style.cursor = 'pointer'; 116 | this.svgElement.onclick = (event: MouseEvent) => { 117 | this.view.root.emit('connectionClicked', this.id, event); 118 | }; 119 | this.svgElement.ondblclick = (event: MouseEvent) => { 120 | this.view.root.emit('connectionDoubleClicked', this.id, event); 121 | }; 122 | // todo: lineElement's right click event (configureable) 123 | this.svgElement.oncontextmenu = (event: MouseEvent) => { 124 | this.view.root.emit('connectionRightClicked', this.id, event); 125 | event.preventDefault(); 126 | }; 127 | // todo: lineElement's hover event (configureable) 128 | this.svgElement.onmouseenter = () => { 129 | this.svgElement.classList.add('hover'); 130 | this.lineElement.classList.add('hover'); 131 | }; 132 | this.svgElement.onmouseleave = () => { 133 | this.svgElement.classList.remove('hover'); 134 | this.lineElement.classList.remove('hover'); 135 | }; 136 | this.renderLine(); 137 | return this.svgElement; 138 | } 139 | 140 | update() { 141 | this.svgElement.style.transform = 142 | `translate(${this.textLeft}px,${this.globalY}px)`; 143 | this.updateLine(); 144 | } 145 | 146 | public addHover(label: 'from' | 'to') { 147 | this.svgElement.classList.add('hover-' + label); 148 | this.lineElement.classList.add('hover-' + label); 149 | } 150 | 151 | public removeHover(label: 'from' | 'to') { 152 | this.svgElement.classList.remove('hover-' + label); 153 | this.lineElement.classList.remove('hover-' + label); 154 | } 155 | 156 | remove() { 157 | this.svgElement.remove(); 158 | this.lineElement.remove(); 159 | } 160 | 161 | private updateLine() { 162 | const thisY = this.globalY + this.view.topContextLayerHeight / 2 - 163 | this.view.labelFont.fontSize + 2; 164 | if (this.fromLabelView.labelLeft < this.toLabelView.labelLeft) { 165 | this.lineElement.setAttribute( 166 | 'd', 167 | ` 168 | M ${this.fromLabelView.labelLeft + 1} ${ 169 | this.fromLabelView.globalY + 1} 170 | C ${this.fromLabelView.labelLeft - 8} ${thisY}, 171 | ${this.fromLabelView.labelLeft - 8} ${thisY}, 172 | ${this.fromLabelView.labelLeft + 1} ${thisY} 173 | L ${ 174 | this.toLabelView.labelLeft + 175 | this.toLabelView.labelWidth} ${thisY} 176 | C ${ 177 | this.toLabelView.labelLeft + this.toLabelView.labelWidth + 178 | 8} ${thisY}, 179 | ${ 180 | this.toLabelView.labelLeft + this.toLabelView.labelWidth + 181 | 8} ${thisY}, 182 | ${ 183 | this.toLabelView.labelLeft + 184 | this.toLabelView.labelWidth} ${this.toLabelView.globalY - 1} 185 | `); 186 | } else { 187 | this.lineElement.setAttribute( 188 | 'd', 189 | ` 190 | M ${this.fromLabelView.labelRight - 1} ${ 191 | this.fromLabelView.globalY + 1} 192 | C ${this.fromLabelView.labelRight + 8} ${thisY}, 193 | ${this.fromLabelView.labelRight + 8} ${thisY}, 194 | ${this.fromLabelView.labelRight - 1} ${thisY} 195 | L ${this.toLabelView.labelLeft} ${thisY} 196 | C ${this.toLabelView.labelLeft - 8} ${thisY}, 197 | ${this.toLabelView.labelLeft - 8} ${thisY}, 198 | ${this.toLabelView.labelLeft} ${ 199 | this.toLabelView.globalY - 1} 200 | `); 201 | } 202 | } 203 | 204 | private renderLine() { 205 | this.lineElement = document.createElementNS(SVGNS, 'path'); 206 | this.lineElement.classList.add( 207 | ...this.config.connectionClasses.map(it => it + '-line')); 208 | this.lineElement.setAttribute('fill', 'none'); 209 | this.lineElement.style.markerEnd = 'url(#marker-arrow)'; 210 | this.updateLine(); 211 | this.contextIn.backgroundElement.appendChild(this.lineElement); 212 | } 213 | } 214 | 215 | export class Repository extends Base.Repository { 216 | } 217 | } 218 | -------------------------------------------------------------------------------- /src/View/Entities/ContentEditor/ContentEditor.ts: -------------------------------------------------------------------------------- 1 | import {View} from '../../View'; 2 | import {SVGNS} from '../../../Infrastructure/SVGNS'; 3 | import {Line} from "../Line/Line"; 4 | import {Font} from "../../Font"; 5 | import {LabelView} from "../LabelView/LabelView"; 6 | 7 | export class ContentEditor { 8 | private _lineIndex: number; 9 | private cursorElement: SVGPathElement = null as any; 10 | private hiddenTextAreaElement: HTMLTextAreaElement = null as any; 11 | 12 | constructor( 13 | private view: View 14 | ) { 15 | const head = document.getElementsByTagName("head")[0] as HTMLHeadElement; 16 | const style = document.createElement('style') as HTMLStyleElement; 17 | style.innerHTML = `@keyframes cursor { from { opacity: 0; } to { opacity: 1; } }`; 18 | head.appendChild(style); 19 | this._lineIndex = 0; 20 | this._characterIndex = 0; 21 | this.inComposition = false; 22 | } 23 | 24 | private parentSVGYOffset: number = null as any; 25 | private inComposition: boolean; 26 | 27 | private _characterIndex: number; 28 | 29 | get characterIndex(): number { 30 | return this._characterIndex; 31 | } 32 | 33 | set characterIndex(value: number) { 34 | if (this.view.lines[this._lineIndex].isBlank) { 35 | this._characterIndex = 0; 36 | } else { 37 | this._characterIndex = value; 38 | } 39 | } 40 | 41 | get lineIndex(): number { 42 | return this._lineIndex; 43 | } 44 | 45 | get line(): Line.ValueObject { 46 | return this.view.lines[this.lineIndex]; 47 | } 48 | 49 | set lineIndex(value: number) { 50 | this._lineIndex = value; 51 | } 52 | 53 | render(): [SVGPathElement, HTMLTextAreaElement] { 54 | this.constructCaretElement(); 55 | this.constructHiddenTextAreaElement(); 56 | this.parentSVGYOffset = this.view.svgElement.getBoundingClientRect().top - document.getElementsByTagName('html')[0].getBoundingClientRect().top; 57 | this.hiddenTextAreaElement.onkeyup = (e) => { 58 | if (!this.inComposition) { 59 | switch (e.key) { 60 | case 'ArrowLeft': 61 | if (this.characterIndex === 0) { 62 | if (this.line.last.isSome) { 63 | --this.lineIndex; 64 | this.characterIndex = this.line.content.length; 65 | } 66 | } else { 67 | --this.characterIndex; 68 | this.avoidInLabel("backward"); 69 | } 70 | break; 71 | case 'ArrowRight': 72 | if (this.characterIndex >= this.line.content.length || this.line.isBlank) { 73 | if (this.line.next.isSome) { 74 | ++this.lineIndex; 75 | this.characterIndex = 0; 76 | } 77 | } else { 78 | ++this.characterIndex; 79 | this.avoidInLabel("forward"); 80 | } 81 | break; 82 | case 'ArrowUp': 83 | if (this.line.last.isSome) 84 | --this.lineIndex; 85 | this.characterIndex = Math.min(this.characterIndex, this.line.content.length); 86 | this.avoidInLabel("forward"); 87 | break; 88 | case 'ArrowDown': 89 | if (this.line.next.isSome) 90 | ++this.lineIndex; 91 | this.characterIndex = Math.min(this.characterIndex, this.line.content.length); 92 | this.avoidInLabel("forward"); 93 | break; 94 | case 'Backspace': 95 | const position = this.line.startIndex + this.characterIndex - 1; 96 | this.view.root.emit('contentDelete', position, 1); 97 | break; 98 | default: 99 | 100 | if (this.hiddenTextAreaElement.value !== "") { 101 | Font.Service.measureMore(this.view.contentFont, this.hiddenTextAreaElement.value, this.view.config.contentClasses, this.view.textElement); 102 | const position = this.view.lines[this._lineIndex].startIndex + this.characterIndex; 103 | this.view.root.emit('contentInput', position, this.hiddenTextAreaElement.value); 104 | this.hiddenTextAreaElement.value = ""; 105 | } 106 | break; 107 | } 108 | this.update(); 109 | } 110 | }; 111 | this.hiddenTextAreaElement.addEventListener('compositionstart', () => { 112 | this.inComposition = true; 113 | }); 114 | this.hiddenTextAreaElement.addEventListener('compositionend', () => { 115 | this.inComposition = false; 116 | }); 117 | this.update(); 118 | this.hiddenTextAreaElement.style.opacity = '0'; 119 | return [this.cursorElement, this.hiddenTextAreaElement]; 120 | } 121 | 122 | public avoidInLabel(direction: "backward" | "forward") { 123 | let position = this.line.startIndex + this.characterIndex; 124 | const labels: Array = Array.from(this.line.topContext.children) 125 | .filter(it => it instanceof LabelView.Entity) as any; 126 | let overlapWith = labels.find(it => it.store.startIndex <= position - 1 && position < it.store.endIndex); 127 | while (overlapWith !== undefined) { 128 | if (direction === "forward") 129 | ++this.characterIndex; 130 | else 131 | --this.characterIndex; 132 | position = this.line.startIndex + this.characterIndex; 133 | overlapWith = labels.find(it => it.store.startIndex <= position - 1 && position < it.store.endIndex); 134 | } 135 | } 136 | 137 | update() { 138 | const x = this.view.paddingLeft + this.view.contentFont.widthOf(this.line.content.slice(0, this.characterIndex)); 139 | this.cursorElement.setAttribute('d', ` 140 | M${x},${this.line.y} 141 | L${x},${this.line.y + this.view.contentFont.lineHeight} 142 | `); 143 | this.hiddenTextAreaElement.style.top = `${this.parentSVGYOffset + this.line.y}px`; 144 | this.hiddenTextAreaElement.style.left = `${this.cursorElement.getBoundingClientRect().left}px`; 145 | } 146 | 147 | caretChanged(y: number) { 148 | const selectionInfo = window.getSelection()!; 149 | if (selectionInfo.type !== "Caret") { 150 | return; 151 | } 152 | let clientRect = document.querySelector("svg")!.getClientRects()[0]; 153 | if (selectionInfo.anchorNode!.parentNode !== null) { 154 | let characterInfo = (selectionInfo.anchorNode!.parentNode as SVGTSpanElement).getExtentOfChar(0); 155 | let lineY = clientRect.top + characterInfo.y; 156 | if (lineY + this.view.contentFont.lineHeight <= y) { 157 | const lineEntity = (selectionInfo.anchorNode!.parentNode.nextSibling as any as { annotatorElement: Line.ValueObject }).annotatorElement; 158 | this._lineIndex = this.view.lines.indexOf(lineEntity); 159 | this.characterIndex = 0; 160 | this.avoidInLabel("forward"); 161 | } else { 162 | const lineEntity = (selectionInfo.anchorNode!.parentNode as any as { annotatorElement: Line.ValueObject }).annotatorElement; 163 | this._lineIndex = this.view.lines.indexOf(lineEntity); 164 | this.characterIndex = selectionInfo.anchorOffset; 165 | this.avoidInLabel("forward"); 166 | } 167 | this.update(); 168 | this.hiddenTextAreaElement.focus({preventScroll: true}); 169 | } 170 | } 171 | 172 | private constructHiddenTextAreaElement() { 173 | this.hiddenTextAreaElement = document.createElement('textarea'); 174 | this.hiddenTextAreaElement.setAttribute('autocorrect', 'off'); 175 | this.hiddenTextAreaElement.setAttribute('autocapitalize', 'off'); 176 | this.hiddenTextAreaElement.setAttribute('spellcheck', 'false'); 177 | this.hiddenTextAreaElement.classList.add(...this.view.config.contentClasses); 178 | this.hiddenTextAreaElement.style.position = 'absolute'; 179 | this.hiddenTextAreaElement.style.padding = '0'; 180 | this.hiddenTextAreaElement.style.width = '100vw'; 181 | this.hiddenTextAreaElement.style.height = '1em'; 182 | this.hiddenTextAreaElement.style.outline = 'none'; 183 | this.hiddenTextAreaElement.style.zIndex = '-1'; 184 | this.hiddenTextAreaElement.style.borderStyle = "none"; 185 | this.hiddenTextAreaElement.style.fontFamily = this.view.contentFont.fontFamily; 186 | this.hiddenTextAreaElement.style.fontSize = `${this.view.contentFont.fontSize}px`; 187 | this.hiddenTextAreaElement.style.fontWeight = this.view.contentFont.fontWeight; 188 | this.hiddenTextAreaElement.style.opacity = '0'; 189 | } 190 | 191 | private constructCaretElement() { 192 | this.cursorElement = document.createElementNS(SVGNS, 'path'); 193 | this.cursorElement.setAttribute('stroke', '#000000'); 194 | this.cursorElement.setAttribute('stroke-width', '1.5'); 195 | this.cursorElement.style.animationName = 'cursor'; 196 | this.cursorElement.style.animationDuration = '0.75s'; 197 | this.cursorElement.style.animationTimingFunction = 'ease-out'; 198 | this.cursorElement.style.animationDirection = 'alternate'; 199 | this.cursorElement.style.animationIterationCount = 'infinite'; 200 | } 201 | 202 | public hide() { 203 | this.cursorElement.style.display = "none"; 204 | } 205 | 206 | public show() { 207 | this.cursorElement.style.display = "inline"; 208 | } 209 | 210 | public remove() { 211 | this.hiddenTextAreaElement.remove(); 212 | } 213 | } 214 | -------------------------------------------------------------------------------- /src/View/Entities/LabelView/LabelCategoryElement.ts: -------------------------------------------------------------------------------- 1 | import {LabelCategory} from "../../../Store/LabelCategory"; 2 | import {Base} from "../../../Infrastructure/Repository"; 3 | import {View} from "../../View"; 4 | import {SVGNS} from "../../../Infrastructure/SVGNS"; 5 | import {Font} from "../../Font"; 6 | import {addAlpha} from "../../../Infrastructure/Color"; 7 | 8 | export namespace LabelCategoryElement { 9 | class Factory { 10 | private svgElement: SVGGElement; 11 | 12 | constructor( 13 | private store: LabelCategory.Entity, 14 | private readonly font: Font.ValueObject, 15 | private readonly padding: number, 16 | labelOpacity: number 17 | ) { 18 | this.svgElement = document.createElementNS(SVGNS, 'g') as SVGGElement; 19 | const rectElement = document.createElementNS(SVGNS, 'rect') as SVGRectElement; 20 | rectElement.setAttribute('fill', /^#/g.test(store.color) ? addAlpha(store.color, labelOpacity) : store.color); 21 | rectElement.setAttribute('stroke', store.borderColor); 22 | rectElement.setAttribute('width', this.width.toString()); 23 | rectElement.setAttribute('height', (font.lineHeight + padding * 2).toString()); 24 | rectElement.setAttribute('rx', (padding * 2).toString()); 25 | rectElement.style.cursor = "pointer"; 26 | const textElement = document.createElementNS(SVGNS, 'text') as SVGTextElement; 27 | textElement.style.userSelect = "none"; 28 | textElement.style.cursor = "pointer"; 29 | textElement.textContent = store.text; 30 | textElement.setAttribute("dx", padding.toString()); 31 | textElement.setAttribute("dy", `${font.topToBaseLine + padding}px`); 32 | this.svgElement.appendChild(rectElement); 33 | this.svgElement.appendChild(textElement); 34 | } 35 | 36 | get width() { 37 | return this.font.widthOf(this.store.text) + this.padding * 2; 38 | } 39 | 40 | public create(): SVGGElement { 41 | return this.svgElement.cloneNode(true) as SVGGElement; 42 | } 43 | 44 | get id() { 45 | return this.store.id; 46 | } 47 | } 48 | 49 | export class FactoryRepository extends Base.Repository { 50 | constructor(root: View, config: { 51 | readonly labelPadding: number, 52 | readonly labelOpacity: number 53 | }) { 54 | super(); 55 | for (let entity of root.store.labelCategoryRepo.values()) { 56 | this.add(new Factory( 57 | entity, 58 | root.labelFont, 59 | config.labelPadding, 60 | config.labelOpacity 61 | )); 62 | } 63 | } 64 | } 65 | } 66 | -------------------------------------------------------------------------------- /src/View/Entities/LabelView/LabelView.ts: -------------------------------------------------------------------------------- 1 | import {Label} from "../../../Store/Label"; 2 | import {TopContextUser} from "../Line/TopContext/TopContextUser"; 3 | import {SVGNS} from "../../../Infrastructure/SVGNS"; 4 | import {TopContext} from "../Line/TopContext/TopContext"; 5 | import {View} from "../../View"; 6 | import {Line} from "../Line/Line"; 7 | import {Base} from "../../../Infrastructure/Repository"; 8 | import {addAlpha} from "../../../Infrastructure/Color"; 9 | 10 | export namespace LabelView { 11 | export interface Config { 12 | readonly labelPadding: number, 13 | readonly bracketWidth: number, 14 | readonly labelWidthCalcMethod: "max" | "label", 15 | readonly labelClasses: Array; 16 | } 17 | 18 | export class Entity extends TopContextUser { 19 | layer: number = 0; 20 | private svgElement: SVGGElement = null as any; 21 | 22 | constructor( 23 | readonly store: Label.Entity, 24 | private contextIn: TopContext, 25 | private config: Config) { 26 | super(); 27 | } 28 | 29 | get id(): number { 30 | return this.store.id!; 31 | } 32 | 33 | get lineIn(): Line.ValueObject { 34 | return this.contextIn.belongTo; 35 | } 36 | 37 | get view(): View { 38 | return this.lineIn.view; 39 | } 40 | 41 | get highLightWidth(): number { 42 | return this.view.contentWidth(this.store.startIndex, this.store.endIndex); 43 | } 44 | 45 | get highLightLeft() { 46 | return this.view.contentWidth(this.lineIn.startIndex, this.store.startIndex) 47 | + /*text element's margin*/this.lineIn.view.paddingLeft; 48 | } 49 | 50 | get middle() { 51 | return this.highLightLeft + this.highLightWidth / 2; 52 | } 53 | 54 | get labelLeft() { 55 | return this.middle - this.labelWidth / 2; 56 | } 57 | 58 | get labelRight() { 59 | return this.middle + this.labelWidth / 2; 60 | } 61 | 62 | get labelWidth() { 63 | return this.view.labelFont.widthOf(this.store.category.text) + this.config.labelPadding + 2; 64 | } 65 | 66 | get left() { 67 | if (this.config.labelWidthCalcMethod === "max") { 68 | return this.labelWidth > this.highLightWidth ? this.labelLeft : this.highLightLeft; 69 | } else { 70 | return this.labelLeft; 71 | } 72 | } 73 | 74 | get width() { 75 | if (this.config.labelWidthCalcMethod === "max") { 76 | return this.labelWidth > (this.highLightWidth - 1) ? this.labelWidth : (this.highLightWidth - 1); 77 | } else { 78 | return this.labelWidth; 79 | } 80 | } 81 | 82 | get annotationY() { 83 | return -this.view.topContextLayerHeight * (this.layer - 1) - (this.view.labelFont.lineHeight + 2 + 2 * this.config.labelPadding + this.config.bracketWidth); 84 | } 85 | 86 | get globalY() { 87 | return this.lineIn.y + this.annotationY; 88 | } 89 | 90 | render(): SVGGElement { 91 | this.svgElement = document.createElementNS(SVGNS, 'g') as SVGGElement; 92 | this.svgElement.classList.add(...this.config.labelClasses); 93 | const highLightElement = this.createHighLightElement(); 94 | const annotationElement = this.createAnnotationElement(); 95 | const y = this.view.topContextLayerHeight * (this.layer - 1); 96 | const bracketElement = this.createBracketElement(this.highLightWidth, -y, 0, -y, this.config.bracketWidth); 97 | 98 | this.svgElement.appendChild(highLightElement); 99 | this.svgElement.appendChild(annotationElement); 100 | this.svgElement.appendChild(bracketElement); 101 | return this.svgElement; 102 | } 103 | 104 | update() { 105 | this.svgElement.style.transform = `translate(${this.highLightLeft}px,${this.lineIn.y}px)`; 106 | } 107 | 108 | remove() { 109 | this.svgElement.remove(); 110 | } 111 | 112 | private createHighLightElement() { 113 | const highLightElement = document.createElementNS(SVGNS, 'rect') as SVGRectElement; 114 | const color = this.store.category.color; 115 | highLightElement.setAttribute('height', this.lineIn.view.contentFont.lineHeight.toString()); 116 | highLightElement.setAttribute('width', this.highLightWidth.toString()); 117 | highLightElement.setAttribute('fill', /^#/g.test(color) ? addAlpha(color, 70) : color); 118 | return highLightElement; 119 | } 120 | 121 | 122 | private createBracketElement(x1: number, y1: number, x2: number, y2: number, width: number, q: number = 0.6): SVGPathElement { 123 | let dx = x1 - x2; 124 | let dy = y1 - y2; 125 | let len = Math.sqrt(dx * dx + dy * dy); 126 | dx = dx / len; 127 | dy = dy / len; 128 | 129 | let qx1 = x1 + q * width * dy; 130 | let qy1 = y1 - q * width * dx; 131 | let qx2 = (x1 - .25 * len * dx) + (1 - q) * width * dy; 132 | let qy2 = (y1 - .25 * len * dy) - (1 - q) * width * dx; 133 | let tx1 = (x1 - .5 * len * dx) + width * dy; 134 | let ty1 = (y1 - .5 * len * dy) - width * dx; 135 | let qx3 = x2 + q * width * dy; 136 | let qy3 = y2 - q * width * dx; 137 | let qx4 = (x1 - .75 * len * dx) + (1 - q) * width * dy; 138 | let qy4 = (y1 - .75 * len * dy) - (1 - q) * width * dx; 139 | const result = document.createElementNS(SVGNS, 'path'); 140 | result.setAttribute('d', `M${x1},${y1}Q${qx1},${qy1},${qx2},${qy2}T${tx1},${ty1}M${x2},${y2}Q${qx3},${qy3},${qx4},${qy4}T${tx1},${ty1}`); 141 | result.setAttribute('fill', 'none'); 142 | result.setAttribute('stroke', this.store.category.borderColor); 143 | return result; 144 | } 145 | 146 | private createAnnotationElement() { 147 | const annotationElement = this.view.labelCategoryElementFactoryRepository.get(this.store.category.id).create(); 148 | annotationElement.style.transform = `translate(${(this.highLightWidth - this.labelWidth) / 2}px,${this.annotationY}px)`; 149 | annotationElement.onclick = (event: MouseEvent) => { 150 | this.view.root.emit('labelClicked', this.id, event); 151 | }; 152 | annotationElement.ondblclick = (event: MouseEvent) => { 153 | this.view.root.emit('labelDoubleClicked', this.id, event); 154 | }; 155 | annotationElement.oncontextmenu = (event: MouseEvent) => { 156 | this.view.root.emit('labelRightClicked', this.id, event); 157 | event.preventDefault(); 158 | }; 159 | annotationElement.onmouseenter = () => { 160 | this.svgElement.classList.add("hover"); 161 | Array.from(this.store.connectionsFrom) 162 | .map(it => this.view.connectionViewRepository.get(it.id!)) 163 | .map(it => it.addHover("from")); 164 | Array.from(this.store.connectionsTo) 165 | .map(it => this.view.connectionViewRepository.get(it.id!)) 166 | .map(it => it.addHover("to")); 167 | }; 168 | annotationElement.onmouseleave = () => { 169 | this.svgElement.classList.remove("hover"); 170 | Array.from(this.store.connectionsFrom) 171 | .map(it => this.view.connectionViewRepository.get(it.id!)) 172 | .map(it => it.removeHover("from")); 173 | Array.from(this.store.connectionsTo) 174 | .map(it => this.view.connectionViewRepository.get(it.id!)) 175 | .map(it => it.removeHover("to")); 176 | }; 177 | 178 | return annotationElement; 179 | } 180 | } 181 | 182 | export class Repository extends Base.Repository { 183 | get(key: number): LabelView.Entity { 184 | return this.entities.get(key)!; 185 | } 186 | } 187 | } 188 | -------------------------------------------------------------------------------- /src/View/Entities/Line/Line.ts: -------------------------------------------------------------------------------- 1 | import {none, Option, some} from "../../../Infrastructure/Option"; 2 | import {SVGNS} from "../../../Infrastructure/SVGNS"; 3 | import {View} from "../../View"; 4 | import {TopContext} from "./TopContext/TopContext"; 5 | import {takeWhile} from "../../../Infrastructure/Array"; 6 | 7 | export namespace Line { 8 | export interface Config { 9 | readonly lineHeight: number 10 | } 11 | 12 | export class ValueObject { 13 | readonly topContext: TopContext; 14 | public svgElement: SVGTSpanElement = null as any; 15 | private readonly config: Config; 16 | 17 | constructor( 18 | startIndex: number, 19 | endIndex: number, 20 | public last: Option, 21 | public next: Option, 22 | readonly view: View 23 | ) { 24 | this._startIndex = startIndex; 25 | this._endIndex = endIndex; 26 | this.topContext = new TopContext(this); 27 | this.config = view.config; 28 | } 29 | 30 | private _startIndex: number; 31 | 32 | get startIndex(): number { 33 | return this._startIndex; 34 | } 35 | 36 | private _endIndex: number; 37 | 38 | get endIndex(): number { 39 | return this._endIndex; 40 | } 41 | 42 | move(offset: number) { 43 | this._startIndex += offset; 44 | this._endIndex += offset; 45 | } 46 | 47 | inserted(characterCount: number) { 48 | this._endIndex += characterCount; 49 | } 50 | 51 | get dy(): number { 52 | return this.last.match( 53 | this.view.contentFont.fontSize * this.config.lineHeight, 54 | this.view.contentFont.topToBaseLine 55 | ) + this.topContext.layer * this.view.topContextLayerHeight; 56 | } 57 | 58 | get height(): number { 59 | return this.topContext.layer * this.view.topContextLayerHeight + this.view.contentFont.fontSize; 60 | } 61 | 62 | get y(): number { 63 | return takeWhile(this.view.lines, (other: Line.ValueObject) => other !== this) 64 | .reduce((currentValue, line) => currentValue + line.height + this.view.contentFont.fontSize * (this.config.lineHeight - 1), 0) 65 | + this.topContext.layer * this.view.topContextLayerHeight; 66 | } 67 | 68 | get isBlank(): boolean { 69 | return this.view.store.content.slice(this.startIndex, this.endIndex - 1) === ""; 70 | } 71 | 72 | get content(): string { 73 | if (this.endWithHardLineBreak) { 74 | if (this.isBlank) { 75 | return "⮐"; 76 | } 77 | return this.view.store.content.slice(this.startIndex, this.endIndex - 1); 78 | } else { 79 | return this.view.store.content.slice(this.startIndex, this.endIndex); 80 | } 81 | } 82 | 83 | get endWithHardLineBreak(): boolean { 84 | return this.view.store.content[this.endIndex - 1] === '\n'; 85 | } 86 | 87 | update() { 88 | 89 | this.svgElement.innerHTML = this.content.replace(/ /g, " ") 90 | .replace(//g, ">"); 92 | if (this.isBlank) { 93 | this.svgElement.style.fontSize = `${this.view.contentFont.fontSize / 4}px`; 94 | } 95 | this.svgElement.setAttribute("x", this.view.paddingLeft.toString()); 96 | this.svgElement.setAttribute("dy", this.dy.toString() + 'px'); 97 | } 98 | 99 | render(): SVGTSpanElement { 100 | this.svgElement = document.createElementNS(SVGNS, 'tspan') as SVGTSpanElement; 101 | const [topContextElement, backgroundElement] = this.topContext.render(); 102 | this.view.svgElement.insertBefore(topContextElement, this.view.textElement); 103 | this.view.markerElement.insertAdjacentElement('afterend', backgroundElement); 104 | Object.assign(this.svgElement, {annotatorElement: this}); 105 | this.update(); 106 | return this.svgElement; 107 | } 108 | 109 | remove() { 110 | this.topContext.remove(); 111 | this.svgElement.remove(); 112 | } 113 | 114 | insertBefore(other: Option) { 115 | this.render(); 116 | other.map(it => { 117 | it.svgElement.parentNode!.insertBefore(this.svgElement, it.svgElement); 118 | it.topContext.svgElement.parentNode!.insertBefore(this.topContext.svgElement, it.topContext.svgElement); 119 | it.topContext.backgroundElement.parentNode!.insertBefore(this.topContext.backgroundElement, it.topContext.backgroundElement); 120 | }); 121 | } 122 | 123 | insertAfter(other: Option) { 124 | this.render(); 125 | other.map(it => { 126 | it.svgElement.insertAdjacentElement("afterend", this.svgElement); 127 | it.topContext.svgElement.insertAdjacentElement("afterend", this.topContext.svgElement); 128 | it.topContext.backgroundElement.insertAdjacentElement("afterend", this.topContext.backgroundElement); 129 | }); 130 | } 131 | 132 | insertInto(parent: SVGTextElement) { 133 | this.render(); 134 | parent.appendChild(this.svgElement); 135 | } 136 | } 137 | 138 | interface Token { 139 | readonly startIndex: number; 140 | readonly endIndex: number; 141 | } 142 | 143 | /** 144 | * warning: this class is tricky! 145 | * do NOT touch unless you're sure! 146 | * todo: more test! 147 | */ 148 | class LineDivideService { 149 | // "word" is kept in one token 150 | // English word number 151 | // vvvvvvvvvvvvvvvvvvvvvvvvvvvv vvvvvvvvvvvvvvvvvvvv 152 | static readonly wordReg = /([a-zA-z][a-zA-Z0-9'’]*[-|.]?)|([+\-]?[0-9.][0-9.%]*)/g; 153 | private result: Array = []; 154 | private tokenQueue: Array = []; 155 | 156 | constructor(private view: View) { 157 | } 158 | 159 | get store() { 160 | return this.view.store; 161 | } 162 | 163 | public divide(startIndex: number, endIndex: number): Array { 164 | this.init(); 165 | let currentTokenStart = startIndex; 166 | let currentTokenEnd = startIndex + 1; 167 | do { 168 | let tokenEndAfterLabelMerged = this.mergeLabel(currentTokenEnd); 169 | let tokenEndAfterWordsMerged = this.mergeWord(tokenEndAfterLabelMerged); 170 | const noMergePerformed = tokenEndAfterLabelMerged === currentTokenEnd && tokenEndAfterLabelMerged === tokenEndAfterWordsMerged; 171 | if (this.store.content[currentTokenEnd - 1] === '\n') { 172 | if (this.tokenQueue.length === 0) { 173 | this.reduce(currentTokenEnd - 1, currentTokenEnd); 174 | } else { 175 | this.reduce(this.tokenQueue[0].startIndex, currentTokenEnd); 176 | } 177 | currentTokenStart = currentTokenEnd; 178 | } else if (noMergePerformed) { 179 | this.shiftWithAutoReduce({startIndex: currentTokenStart, endIndex: currentTokenEnd}); 180 | currentTokenStart = currentTokenEnd; 181 | } 182 | ++currentTokenEnd; 183 | } while (currentTokenStart < endIndex); 184 | if (this.tokenQueue.length !== 0) 185 | this.reduce(this.tokenQueue[0].startIndex, this.tokenQueue[this.tokenQueue.length - 1].endIndex); 186 | let last: Option = none; 187 | for (let line of this.result) { 188 | last.map(it => it.next = some(line)); 189 | line.last = last; 190 | last = some(line); 191 | } 192 | return this.result; 193 | } 194 | 195 | 196 | private init() { 197 | this.result = []; 198 | this.tokenQueue = []; 199 | } 200 | 201 | // while currentToken ends in a label 202 | // merge the label into the token 203 | // 0123456789 204 | // token [ ]) 205 | // label [ ]) 206 | // out [ ]) 207 | private mergeLabel(currentTokenEnd: number): number { 208 | if (this.store.labelRepo.getEntitiesCross(currentTokenEnd - 1) 209 | .some(it => it.endIndex > currentTokenEnd)) { 210 | return this.store.labelRepo.getEntitiesCross(currentTokenEnd - 1) 211 | .filter(it => it.endIndex > currentTokenEnd) 212 | .sort((a, b) => b.endIndex - a.endIndex)[0] 213 | .endIndex; 214 | } 215 | return currentTokenEnd; 216 | }; 217 | 218 | // while currentToken ends in a word 219 | // merge the word into the token 220 | // 0123456789 221 | // token [ ]) 222 | // word []) 223 | // out [ ]) 224 | private mergeWord(currentTokenEnd: number): number { 225 | // part of a word is still a word 226 | LineDivideService.wordReg.lastIndex = 0; 227 | const nextWordRegTestResult = LineDivideService.wordReg.exec(this.store.contentSlice(currentTokenEnd - 1, currentTokenEnd + 1)); 228 | if (nextWordRegTestResult === null) { 229 | return currentTokenEnd; 230 | } 231 | if (nextWordRegTestResult[0].length === 2) { 232 | return currentTokenEnd + 1; 233 | } 234 | return currentTokenEnd; 235 | }; 236 | 237 | private reduce(startIndex: number, endIndex: number) { 238 | const newEntity = new Line.ValueObject(startIndex, endIndex, none, none, this.view); 239 | this.result.push(newEntity); 240 | this.tokenQueue = []; 241 | } 242 | 243 | private shiftWithAutoReduce(token: Token) { 244 | const currentQueueWidth = this.tokenQueue.length === 0 ? 0 : this.view.contentWidth(this.tokenQueue[0].startIndex, this.tokenQueue[this.tokenQueue.length - 1].endIndex); 245 | const currentTokenWidth = this.view.contentWidth(token.startIndex, token.endIndex); 246 | if (this.tokenQueue.length !== 0 && currentQueueWidth + currentTokenWidth > this.view.lineMaxWidth) { 247 | this.reduce(this.tokenQueue[0].startIndex, this.tokenQueue[this.tokenQueue.length - 1].endIndex); 248 | } 249 | if (currentTokenWidth > this.view.lineMaxWidth) { 250 | this.reduce(token.startIndex, token.endIndex); 251 | console.warn(`the token "${this.store.contentSlice(token.startIndex, token.endIndex)}" is too long for a line!`); 252 | } else { 253 | this.tokenQueue.push(token); 254 | } 255 | } 256 | } 257 | 258 | export namespace Service { 259 | export function divide(view: View, startIndex: number, endIndex: number) { 260 | return (new LineDivideService(view)).divide(startIndex, endIndex); 261 | } 262 | } 263 | } 264 | -------------------------------------------------------------------------------- /src/View/Entities/Line/TopContext/TopContext.ts: -------------------------------------------------------------------------------- 1 | import {Line} from "../Line"; 2 | import {SVGNS} from "../../../../Infrastructure/SVGNS"; 3 | import {overLaps, TopContextUser} from "./TopContextUser"; 4 | import {ConnectionView} from "../../ConnectionView/ConnectionView"; 5 | import {assert} from "../../../../Infrastructure/Assert"; 6 | import {LabelView} from "../../LabelView/LabelView"; 7 | 8 | 9 | export class TopContext { 10 | public backgroundElement: SVGGElement = null as any; 11 | readonly belongTo: Line.ValueObject; 12 | public svgElement: SVGGElement = null as any; 13 | readonly children = new Set(); 14 | 15 | constructor( 16 | belongTo: Line.ValueObject 17 | ) { 18 | this.belongTo = belongTo; 19 | } 20 | 21 | get layer(): number { 22 | return this.children.size === 0 ? 0 : 23 | Math.max(...Array.from(this.children).map(it => it.layer)); 24 | } 25 | 26 | update() { 27 | this.children.forEach(it => it.update()); 28 | 29 | Array.from(this.children) 30 | .filter(it => it instanceof LabelView.Entity) 31 | .map((labelView: LabelView.Entity) => { 32 | labelView.store.allConnections.forEach(storeConnection => { 33 | if (this.belongTo.view.connectionViewRepository.has(storeConnection.id!)) { 34 | const connectionView = this.belongTo.view.connectionViewRepository.get(storeConnection.id!); 35 | if (connectionView.mayNotSameLineLabelView === labelView) { 36 | connectionView.update(); 37 | } 38 | } 39 | }); 40 | }); 41 | } 42 | 43 | render(): [SVGGElement, SVGGElement] { 44 | this.svgElement = document.createElementNS(SVGNS, 'g') as SVGGElement; 45 | this.backgroundElement = document.createElementNS(SVGNS, 'g') as SVGGElement; 46 | this.children.forEach(it => { 47 | this.renderChild(it); 48 | }); 49 | this.update(); 50 | return [this.svgElement, this.backgroundElement]; 51 | } 52 | 53 | addChild(child: TopContextUser): number { 54 | const oldLayer = this.layer; 55 | let hasOverlapping = false; 56 | if (child instanceof ConnectionView.Entity) { 57 | child.layer = child.sameLineLabelView.layer; 58 | } 59 | do { 60 | ++child.layer; 61 | hasOverlapping = false; 62 | for (let otherEntity of this.children) { 63 | if (overLaps(child, otherEntity)) { 64 | hasOverlapping = true; 65 | break; 66 | } 67 | } 68 | } while (hasOverlapping); 69 | this.children.add(child); 70 | const newLayer = this.layer; 71 | return newLayer - oldLayer; 72 | } 73 | 74 | renderChild(child: TopContextUser) { 75 | assert(this.children.has(child)); 76 | const childRenderResult = child.render(); 77 | this.svgElement.appendChild(childRenderResult); 78 | } 79 | 80 | removeChild(child: TopContextUser) { 81 | assert(this.children.has(child)); 82 | this.children.delete(child); 83 | } 84 | 85 | remove() { 86 | this.svgElement.remove(); 87 | this.backgroundElement.remove(); 88 | } 89 | } 90 | -------------------------------------------------------------------------------- /src/View/Entities/Line/TopContext/TopContextUser.ts: -------------------------------------------------------------------------------- 1 | export abstract class TopContextUser { 2 | readonly left!: number; 3 | readonly width!: number; 4 | layer: number = 0; 5 | 6 | abstract render(): SVGElement; 7 | 8 | abstract update(): void; 9 | } 10 | 11 | export function overLaps(user1: TopContextUser, user2: TopContextUser): boolean { 12 | if (user1.layer !== user2.layer) { 13 | return false; 14 | } else if (user1.left > user2.left) { 15 | return overLaps(user2, user1); 16 | } else if (user1.left + user1.width < user2.left) { 17 | return false; 18 | } 19 | return true; 20 | } 21 | -------------------------------------------------------------------------------- /src/View/EventHandler/TextSelectionHandler.ts: -------------------------------------------------------------------------------- 1 | import {Annotator} from "../../Annotator"; 2 | import {Line} from "../Entities/Line/Line"; 3 | import {fromNullable} from "../../Infrastructure/Option"; 4 | import {assert} from "../../Infrastructure/Assert"; 5 | 6 | export interface Config { 7 | readonly selectingAreaStrip: RegExp | null | undefined 8 | } 9 | 10 | export class TextSelectionHandler { 11 | constructor(public root: Annotator, 12 | private config: Config) { 13 | } 14 | 15 | //处理选中的文字 16 | //生成range 17 | getSelectionInfo() { 18 | const selection = window.getSelection(); 19 | assert(selection!.type === "Range"); 20 | let startElement = null; 21 | let endElement = null; 22 | try { 23 | startElement = selection!.anchorNode!.parentNode; 24 | endElement = selection!.focusNode!.parentNode; 25 | } catch (e) { 26 | return null; 27 | } 28 | let startLine: Line.ValueObject; 29 | let endLine: Line.ValueObject; 30 | let startIndex: number; 31 | let endIndex: number; 32 | try { 33 | startLine = (startElement as any as { annotatorElement: Line.ValueObject }).annotatorElement; 34 | endLine = (endElement as any as { annotatorElement: Line.ValueObject }).annotatorElement; 35 | if (startLine.view !== this.root.view || endLine.view !== this.root.view) { 36 | return null; 37 | } 38 | startIndex = startLine.startIndex + selection!.anchorOffset; 39 | endIndex = endLine.startIndex + selection!.focusOffset; 40 | } catch (e) { 41 | return null; 42 | } 43 | if (startIndex > endIndex) { 44 | [startIndex, endIndex] = [endIndex, startIndex]; 45 | } 46 | fromNullable(this.config.selectingAreaStrip) 47 | .map(regex => { 48 | while (regex.test(this.root.store.content[startIndex])) { 49 | ++startIndex; 50 | } 51 | while (regex.test(this.root.store.content[endIndex - 1])) { 52 | --endIndex; 53 | } 54 | }); 55 | if (startIndex >= endIndex) { 56 | return null; 57 | } 58 | return { 59 | startIndex: startIndex, 60 | endIndex: endIndex 61 | } 62 | } 63 | 64 | textSelected() { 65 | let selectionInfo = this.getSelectionInfo(); 66 | if (selectionInfo) { 67 | // 向外抛出"textSelected"事件 68 | this.root.emit('textSelected', selectionInfo.startIndex, selectionInfo.endIndex); 69 | } 70 | window.getSelection()?.removeAllRanges(); 71 | } 72 | } 73 | -------------------------------------------------------------------------------- /src/View/EventHandler/TwoLabelsClickedHandler.ts: -------------------------------------------------------------------------------- 1 | import {Annotator} from "../../Annotator"; 2 | import {SVGNS} from "../../Infrastructure/SVGNS"; 3 | import {LabelView} from "../Entities/LabelView/LabelView"; 4 | import {none, Option, some} from "../../Infrastructure/Option"; 5 | 6 | export interface Config { 7 | readonly unconnectedLineStyle: "none" | "straight" | "curve"; 8 | } 9 | 10 | export class TwoLabelsClickedHandler { 11 | lastSelection: Option = none; 12 | svgElement: SVGPathElement; 13 | 14 | constructor(public root: Annotator, private config: Config) { 15 | this.svgElement = document.createElementNS(SVGNS, 'path'); 16 | this.svgElement.classList.add(...root.view.config.connectionClasses.map(it => it + "-line")); 17 | this.svgElement.setAttribute("fill", "none"); 18 | this.svgElement.style.markerEnd = "url(#marker-arrow)"; 19 | //监听lable的点击事件 20 | this.root.on('labelClicked', (labelId: number) => { 21 | if (this.lastSelection.isSome) { 22 | this.root.emit('twoLabelsClicked', this.lastSelection.toNullable()!.id, labelId); 23 | this.svgElement.remove(); 24 | this.svgElement.setAttribute("d", ""); 25 | this.lastSelection = none; 26 | } else { 27 | this.lastSelection = some(this.root.view.labelViewRepository.get(labelId)); 28 | this.root.view.svgElement.insertBefore(this.svgElement, this.root.view.svgElement.firstChild); 29 | } 30 | }); 31 | this.root.view.svgElement.onmousemove = (e) => { 32 | this.lastSelection.map((fromLabelView: LabelView.Entity) => { 33 | const fromLeft = fromLabelView.labelLeft + 1; 34 | const fromRight = fromLabelView.labelRight - 1; 35 | const fromY = fromLabelView.globalY + 1; 36 | 37 | const toX = e.clientX - this.root.view.svgElement.getBoundingClientRect().left; 38 | const toY = e.clientY - this.root.view.svgElement.getBoundingClientRect().top; 39 | const fromX = (fromLeft + fromRight) / 2 < toX ? fromLeft : fromRight; 40 | 41 | if (config.unconnectedLineStyle === "straight") { 42 | this.svgElement.setAttribute('d', ` 43 | M${fromX},${fromY} 44 | L${toX},${toY} 45 | `); 46 | } else if (config.unconnectedLineStyle === "curve") { 47 | let dx = (fromLeft - toX) / 4; 48 | let y2 = Math.min(fromY, toY) - 20; 49 | 50 | this.svgElement.setAttribute('d', ` 51 | M${fromX},${fromY} 52 | C${fromX - dx},${y2},${toX + dx},${y2},${toX},${toY} 53 | `); 54 | } 55 | }); 56 | }; 57 | this.root.view.svgElement.oncontextmenu = (e) => { 58 | this.lastSelection.map(() => { 59 | this.svgElement.remove(); 60 | this.svgElement.setAttribute("d", ""); 61 | this.lastSelection = none; 62 | e.preventDefault(); 63 | }) 64 | }; 65 | } 66 | } 67 | -------------------------------------------------------------------------------- /src/View/Font.ts: -------------------------------------------------------------------------------- 1 | // top ___________________ 2 | // /\ | | 3 | // / \ | | 4 | // /____\ topToBaseLine | 5 | // / \ \ / | fontSize 6 | // / \ \ / baseLine _|_ | 7 | // / | 8 | // / bottom _________________|_ 9 | // |--width--| |width| 10 | // 11 | import {SVGNS} from "../Infrastructure/SVGNS"; 12 | 13 | export namespace Font { 14 | export class ValueObject { 15 | constructor(readonly fontFamily: string, 16 | readonly fontSize: number, 17 | readonly fontWeight: string, 18 | readonly lineHeight: number, 19 | readonly topToBaseLine: number, 20 | readonly width: Map) { 21 | } 22 | 23 | widthOf(text: Array | string): number { 24 | if (typeof text === "string") { 25 | return this.widthOf(Array.from(text)); 26 | } else { 27 | return text.map(it => this.width.get(it)!) 28 | .reduce((a: number, b: number) => a + b, 0) 29 | } 30 | } 31 | } 32 | 33 | export namespace Factory { 34 | export function create( 35 | characters: string, 36 | testRenderElement: SVGTSpanElement, 37 | baseLineReferenceElement: SVGRectElement 38 | ): ValueObject { 39 | const width = new Map(); 40 | const characterSet = new Set(characters); 41 | characterSet.delete('\n'); 42 | const characterArray = Array.from(characterSet); 43 | testRenderElement.textContent = characterArray.join(''); 44 | testRenderElement.parentNode!.parentNode!.insertBefore(baseLineReferenceElement, testRenderElement.parentNode); 45 | characterArray.forEach((ch: string, index: number) => { 46 | width.set(ch, testRenderElement.getExtentOfChar(index).width); 47 | }); 48 | const topToBaseLine = baseLineReferenceElement.getBoundingClientRect().top - testRenderElement.getBoundingClientRect().top; 49 | const fontSize = parseFloat(window.getComputedStyle(testRenderElement).fontSize); 50 | const fontFamily = window.getComputedStyle(testRenderElement).fontFamily; 51 | const fontWeight = window.getComputedStyle(testRenderElement).fontWeight; 52 | const lineHeight = testRenderElement.getBoundingClientRect().height; 53 | return new ValueObject(fontFamily, fontSize, fontWeight, lineHeight, topToBaseLine, width); 54 | } 55 | 56 | class BatchMeasurer { 57 | private readonly baseLineReferenceElement: SVGRectElement; 58 | private readonly measuringElement: SVGTSpanElement; 59 | private readonly result: Array; 60 | 61 | constructor( 62 | private svgElement: SVGSVGElement, 63 | private textElement: SVGTextElement 64 | ) { 65 | this.baseLineReferenceElement = document.createElementNS(SVGNS, 'rect') as SVGRectElement; 66 | this.baseLineReferenceElement.setAttribute('width', '1px'); 67 | this.baseLineReferenceElement.setAttribute('height', '1px'); 68 | this.svgElement.appendChild(this.baseLineReferenceElement); 69 | 70 | this.measuringElement = document.createElementNS(SVGNS, 'tspan') as SVGTSpanElement; 71 | this.textElement.appendChild(this.measuringElement); 72 | this.result = []; 73 | } 74 | 75 | public thanCreate(classNames: Array, 76 | text: string): this { 77 | this.measuringElement.classList.add(...classNames); 78 | const font = create(text, this.measuringElement, this.baseLineReferenceElement); 79 | this.measuringElement.classList.remove(...classNames); 80 | this.result.push(font); 81 | return this; 82 | } 83 | 84 | endBatch(): Array { 85 | this.baseLineReferenceElement.remove(); 86 | this.measuringElement.remove(); 87 | return this.result; 88 | } 89 | } 90 | 91 | export function startBatch( 92 | svgElement: SVGSVGElement, 93 | textElement: SVGTextElement, 94 | ): BatchMeasurer { 95 | return new BatchMeasurer(svgElement, textElement); 96 | } 97 | } 98 | 99 | export namespace Service { 100 | export function measureMore(font: ValueObject, 101 | text: string, 102 | classes: Array, 103 | textElement: SVGTextElement 104 | ): ValueObject { 105 | const characterSet = new Set(text); 106 | characterSet.delete('\n'); 107 | const characterArray = Array.from(characterSet) 108 | .filter(it => !font.width.has(it)); 109 | if (characterArray.length > 0) { 110 | const testRenderElement = document.createElementNS(SVGNS, 'tspan'); 111 | testRenderElement.classList.add(...classes); 112 | testRenderElement.textContent = characterArray.join(''); 113 | textElement.appendChild(testRenderElement); 114 | characterArray.forEach((ch: string, index: number) => { 115 | font.width.set(ch, testRenderElement.getExtentOfChar(index).width); 116 | }); 117 | testRenderElement.remove(); 118 | } 119 | return font; 120 | } 121 | } 122 | } 123 | -------------------------------------------------------------------------------- /src/View/View.ts: -------------------------------------------------------------------------------- 1 | import {Store} from "../Store/Store"; 2 | import {SVGNS} from "../Infrastructure/SVGNS"; 3 | import {Line} from "./Entities/Line/Line"; 4 | import {Font} from "./Font"; 5 | import {LabelCategoryElement} from "./Entities/LabelView/LabelCategoryElement"; 6 | import {LabelView} from "./Entities/LabelView/LabelView"; 7 | import {ConnectionView} from "./Entities/ConnectionView/ConnectionView"; 8 | import {ConnectionCategoryElement} from "./Entities/ConnectionView/ConnectionCategoryElement"; 9 | import {Annotator} from "../Annotator"; 10 | import {Label} from "../Store/Label"; 11 | import {Connection} from "../Store/Connection"; 12 | import {ContentEditor} from "./Entities/ContentEditor/ContentEditor"; 13 | import {some} from "../Infrastructure/Option"; 14 | 15 | export interface Config extends LabelView.Config, ConnectionView.Config { 16 | readonly contentClasses: Array; 17 | readonly lineHeight: number; 18 | readonly topContextMargin: number; 19 | readonly labelOpacity: number; 20 | readonly contentEditable: boolean; 21 | } 22 | 23 | export class View { 24 | readonly contentFont: Font.ValueObject; 25 | readonly labelFont: Font.ValueObject; 26 | readonly connectionFont: Font.ValueObject; 27 | 28 | readonly topContextLayerHeight: number; 29 | readonly textElement: SVGTextElement; 30 | readonly lines: Array; 31 | readonly lineMaxWidth: number; 32 | 33 | readonly labelCategoryElementFactoryRepository: LabelCategoryElement.FactoryRepository; 34 | readonly connectionCategoryElementFactoryRepository: ConnectionCategoryElement.FactoryRepository; 35 | readonly labelViewRepository: LabelView.Repository; 36 | readonly connectionViewRepository: ConnectionView.Repository; 37 | 38 | readonly markerElement: SVGMarkerElement; 39 | readonly store: Store; 40 | 41 | readonly contentEditor: ContentEditor = null as any; 42 | 43 | constructor( 44 | readonly root: Annotator, 45 | readonly svgElement: SVGSVGElement, 46 | readonly config: Config 47 | ) { 48 | this.store = root.store; 49 | this.labelViewRepository = new LabelView.Repository(); 50 | this.connectionViewRepository = new ConnectionView.Repository(); 51 | this.markerElement = View.createMarkerElement(); 52 | this.svgElement.appendChild(this.markerElement); 53 | this.textElement = document.createElementNS(SVGNS, 'text') as SVGTextElement; 54 | this.textElement.style.whiteSpace = "pre"; 55 | this.textElement.style.wordWrap = "normal"; 56 | this.svgElement.appendChild(this.textElement); 57 | 58 | const labelText = Array.from(this.store.labelCategoryRepo.values()).map(it => it.text).join(''); 59 | const connectionText = Array.from(this.store.connectionCategoryRepo.values()).map(it => it.text).join(''); 60 | [this.contentFont, this.labelFont, this.connectionFont] = Font.Factory.startBatch(svgElement, this.textElement) 61 | .thanCreate(config.contentClasses, this.store.content) 62 | .thanCreate(config.labelClasses, labelText) 63 | .thanCreate(config.connectionClasses, connectionText) 64 | .endBatch(); 65 | 66 | const labelElementHeight = this.labelFont.lineHeight + 2 /*stroke*/ + 2 * config.labelPadding + config.bracketWidth; 67 | this.topContextLayerHeight = config.topContextMargin * 2 + 68 | Math.max(labelElementHeight, this.connectionFont.lineHeight); 69 | 70 | this.textElement.classList.add(...config.contentClasses); 71 | 72 | this.labelCategoryElementFactoryRepository = new LabelCategoryElement.FactoryRepository(this, config); 73 | this.connectionCategoryElementFactoryRepository = new ConnectionCategoryElement.FactoryRepository(this, config); 74 | 75 | this.lineMaxWidth = svgElement.width.baseVal.value - 2 * this.paddingLeft; 76 | this.lines = Line.Service.divide(this, 0, this.store.content.length); 77 | this.lines.map(this.constructLabelViewsForLine.bind(this)); 78 | this.lines.map(this.constructConnectionsForLine.bind(this)); 79 | const tspans = this.lines.map(it => it.render()); 80 | this.textElement.append(...tspans); 81 | this.svgElement.style.height = this.height.toString() + 'px'; 82 | this.registerEventHandlers(); 83 | if (this.config.contentEditable) { 84 | this.contentEditor = new ContentEditor(this); 85 | let [cursor, textArea] = this.contentEditor.render(); 86 | this.svgElement.appendChild(cursor); 87 | this.svgElement.parentNode!.insertBefore(textArea, this.svgElement); 88 | } 89 | this.svgElement.appendChild(this.collectStyle()); 90 | } 91 | 92 | private static layoutTopContextsAfter(currentLine: Line.ValueObject) { 93 | while (currentLine.next.isSome) { 94 | currentLine.topContext.update(); 95 | currentLine = currentLine.next.toNullable()!; 96 | } 97 | currentLine.topContext.update(); 98 | } 99 | 100 | private constructLabelViewsForLine(line: Line.ValueObject): Array { 101 | const labels = this.store.labelRepo.getEntitiesInRange(line.startIndex, line.endIndex); 102 | const labelViews = labels.map(it => new LabelView.Entity(it, line.topContext, this.config)); 103 | labelViews.map(it => this.labelViewRepository.add(it)); 104 | labelViews.map(it => line.topContext.addChild(it)); 105 | return labelViews; 106 | } 107 | 108 | private constructConnectionsForLine(line: Line.ValueObject): Array { 109 | const labels = this.store.labelRepo.getEntitiesInRange(line.startIndex, line.endIndex); 110 | return labels.map(label => { 111 | const connections = label.sameLineConnections.filter(it => !this.connectionViewRepository.has(it.id!)); 112 | const connectionViews = connections.map(it => new ConnectionView.Entity(it, line.topContext, this.config)); 113 | connectionViews.map(it => this.connectionViewRepository.add(it)); 114 | connectionViews.map(it => line.topContext.addChild(it)); 115 | return connectionViews; 116 | }).reduce((a, b) => a.concat(b), []); 117 | } 118 | 119 | private get height() { 120 | return this.lines.reduce((currentValue, line) => currentValue + line.height + this.contentFont.fontSize * (this.config.lineHeight - 1), 20); 121 | } 122 | 123 | static createMarkerElement(): SVGMarkerElement { 124 | const markerArrow = document.createElementNS(SVGNS, 'path'); 125 | markerArrow.setAttribute('d', "M0,4 L0,8 L6,6 L0,4 L0,8"); 126 | markerArrow.setAttribute("stroke", "#000000"); 127 | markerArrow.setAttribute("fill", "#000000"); 128 | const markerElement = document.createElementNS(SVGNS, 'marker'); 129 | markerElement.setAttribute('id', 'marker-arrow'); 130 | markerElement.setAttribute('markerWidth', '8'); 131 | markerElement.setAttribute('markerHeight', '10'); 132 | markerElement.setAttribute('orient', 'auto'); 133 | markerElement.setAttribute('refX', '5'); 134 | markerElement.setAttribute('refY', '6'); 135 | markerElement.appendChild(markerArrow); 136 | return markerElement; 137 | }; 138 | 139 | public contentWidth(startIndex: number, endIndex: number): number { 140 | return this.contentFont.widthOf(this.store.contentSlice(startIndex, endIndex)); 141 | } 142 | 143 | private removeLine(line: Line.ValueObject) { 144 | line.remove(); 145 | line.topContext.children.forEach(it => { 146 | if (it instanceof LabelView.Entity) { 147 | this.labelViewRepository.delete(it); 148 | } else if (it instanceof ConnectionView.Entity) { 149 | this.connectionViewRepository.delete(it); 150 | } 151 | }); 152 | } 153 | 154 | private registerEventHandlers() { 155 | this.textElement.onmouseup = (e) => { 156 | if (window.getSelection()!.type === "Range") { 157 | this.root.textSelectionHandler.textSelected(); 158 | } else { 159 | if (this.config.contentEditable) 160 | this.contentEditor.caretChanged(e.clientY); 161 | } 162 | }; 163 | this.store.labelRepo.on('created', this.onLabelCreated.bind(this)); 164 | this.store.labelRepo.on('removed', (label: Label.Entity) => { 165 | let viewEntity = this.labelViewRepository.get(label.id!); 166 | viewEntity.lineIn.topContext.removeChild(viewEntity); 167 | viewEntity.remove(); 168 | this.labelViewRepository.delete(viewEntity); 169 | viewEntity.lineIn.topContext.update(); 170 | viewEntity.lineIn.update(); 171 | View.layoutTopContextsAfter(viewEntity.lineIn); 172 | if (this.config.contentEditable) 173 | this.contentEditor.update(); 174 | }); 175 | this.store.connectionRepo.on('created', this.onConnectionCreated.bind(this)); 176 | this.store.connectionRepo.on('removed', (connection: ConnectionView.Entity) => { 177 | let viewEntity = this.connectionViewRepository.get(connection.id!); 178 | viewEntity.lineIn.topContext.removeChild(viewEntity); 179 | viewEntity.remove(); 180 | this.connectionViewRepository.delete(viewEntity); 181 | viewEntity.lineIn.topContext.update(); 182 | viewEntity.lineIn.update(); 183 | View.layoutTopContextsAfter(viewEntity.lineIn); 184 | if (this.config.contentEditable) 185 | this.contentEditor.update(); 186 | }); 187 | if (this.config.contentEditable) { 188 | this.store.on('contentSpliced', this.onContentSpliced.bind(this)); 189 | } 190 | } 191 | 192 | private rerenderLines(beginLineIndex: number, endInLineIndex: number) { 193 | const parent = this.lines[0].svgElement.parentElement as any as SVGTextElement; 194 | for (let i = beginLineIndex; i <= endInLineIndex; ++i) { 195 | this.removeLine(this.lines[i]); 196 | } 197 | const begin = this.lines[beginLineIndex]; 198 | const endIn = this.lines[endInLineIndex]; 199 | const newDividedLines = Line.Service.divide(this, begin.startIndex, endIn.endIndex); 200 | if (newDividedLines.length !== 0) { 201 | newDividedLines[0].last = begin.last; 202 | begin.last.map(it => it.next = some(newDividedLines[0])); 203 | newDividedLines[newDividedLines.length - 1].next = endIn.next; 204 | endIn.next.map(it => it.last = some(newDividedLines[newDividedLines.length - 1])); 205 | this.lines.splice(beginLineIndex, endInLineIndex - beginLineIndex + 1, ...newDividedLines); 206 | if (beginLineIndex === 0) { 207 | if (!endIn.next.isSome) { 208 | newDividedLines[0].insertInto(parent); 209 | } else { 210 | newDividedLines[0].insertBefore(endIn.next); 211 | } 212 | } else { 213 | newDividedLines[0].insertAfter(begin.last); 214 | } 215 | } 216 | for (let i = 1; i < newDividedLines.length; ++i) { 217 | newDividedLines[i].insertAfter(some(newDividedLines[i - 1])); 218 | } 219 | for (let line of newDividedLines) { 220 | let labelViews = this.constructLabelViewsForLine(line); 221 | labelViews.map(it => line.topContext.renderChild(it)); 222 | } 223 | for (let line of newDividedLines) { 224 | let connectionViews = this.constructConnectionsForLine(line); 225 | connectionViews.map(it => line.topContext.renderChild(it)); 226 | } 227 | for (let line of newDividedLines) { 228 | line.update(); 229 | line.topContext.update(); 230 | } 231 | } 232 | 233 | private onLabelCreated(label: Label.Entity) { 234 | let [startInLineIndex, endInLineIndex] = this.findRangeInLines(label.startIndex, label.endIndex); 235 | if (endInLineIndex === startInLineIndex + 1) { 236 | const line = this.lines[startInLineIndex]; 237 | const labelView = new LabelView.Entity(label, line.topContext, this.config); 238 | this.labelViewRepository.add(labelView); 239 | line.topContext.addChild(labelView); 240 | line.topContext.renderChild(labelView); 241 | line.topContext.update(); 242 | line.update(); 243 | } else { 244 | let hardLineEndInIndex = this.findHardLineEndsInIndex(startInLineIndex); 245 | this.rerenderLines(startInLineIndex, hardLineEndInIndex); 246 | } 247 | View.layoutTopContextsAfter(this.lines[startInLineIndex]); 248 | if (this.config.contentEditable) 249 | this.contentEditor.update(); 250 | this.svgElement.style.height = this.height.toString() + 'px'; 251 | } 252 | 253 | private findRangeInLines(startIndex: number, endIndex: number) { 254 | let startInLineIndex: number = 0; 255 | let endInLineIndex: number = 0; 256 | this.lines.forEach((line: Line.ValueObject, index: number) => { 257 | if (line.startIndex <= startIndex && startIndex < line.endIndex) { 258 | startInLineIndex = index; 259 | } 260 | if (line.startIndex <= endIndex - 1 && endIndex - 1 < line.endIndex) { 261 | endInLineIndex = index + 1; 262 | } 263 | }); 264 | return [startInLineIndex, endInLineIndex]; 265 | } 266 | 267 | private onConnectionCreated(connection: Connection.Entity) { 268 | const sameLineLabelView = this.labelViewRepository.get(connection.priorLabel.id!); 269 | const context = sameLineLabelView.lineIn.topContext; 270 | const connectionView = new ConnectionView.Entity(connection, context, this.config); 271 | this.connectionViewRepository.add(connectionView); 272 | context.addChild(connectionView); 273 | context.renderChild(connectionView); 274 | context.update(); 275 | sameLineLabelView.lineIn.update(); 276 | View.layoutTopContextsAfter(sameLineLabelView.lineIn); 277 | if (this.config.contentEditable) 278 | this.contentEditor.update(); 279 | this.svgElement.style.height = this.height.toString() + 'px'; 280 | } 281 | 282 | private onContentSpliced(startIndex: number, removed: string, inserted: string) { 283 | if (removed !== "") 284 | this.onRemoved(startIndex, removed); 285 | if (inserted !== "") 286 | this.onInserted(startIndex, inserted); 287 | } 288 | 289 | private onRemoved(startIndex: number, removed: string) { 290 | let [startInLineIndex, _] = this.findRangeInLines(startIndex, startIndex + 1); 291 | if (this.lines[startInLineIndex].startIndex === startIndex - removed.length) { 292 | this.lines[startInLineIndex].move(-removed.length); 293 | } else { 294 | this.lines[startInLineIndex].inserted(-removed.length); 295 | } 296 | let currentLineIndex = startInLineIndex + 1; 297 | while (currentLineIndex < this.lines.length) { 298 | this.lines[currentLineIndex].move(-removed.length); 299 | ++currentLineIndex; 300 | } 301 | let hardLineEndInIndex = this.findHardLineEndsInIndex(startInLineIndex); 302 | 303 | if (removed === "\n" && this.lines[startInLineIndex].isBlank) { 304 | let last = this.lines[startInLineIndex].last; 305 | let next = this.lines[startInLineIndex].next; 306 | this.lines[startInLineIndex].remove(); 307 | this.lines.splice(startInLineIndex, 1); 308 | last.map(it => it.next = next); 309 | next.map(it => it.last = last); 310 | } else { 311 | this.rerenderLines(startInLineIndex, hardLineEndInIndex); 312 | } 313 | View.layoutTopContextsAfter(this.lines[hardLineEndInIndex - 1]); 314 | const asArray = Array.from(removed); 315 | const removedLineCount = asArray.filter(it => it === "\n").length; 316 | if (removedLineCount === 0) { 317 | this.contentEditor.characterIndex -= removed.length; 318 | this.contentEditor.avoidInLabel("forward"); 319 | } else { 320 | if (this.contentEditor.lineIndex - removedLineCount >= 0) { 321 | this.contentEditor.lineIndex -= removedLineCount; 322 | this.contentEditor.characterIndex = this.contentEditor.line.content.length; 323 | this.contentEditor.avoidInLabel("forward"); 324 | } 325 | } 326 | this.contentEditor.update(); 327 | this.svgElement.style.height = this.height.toString() + 'px'; 328 | } 329 | 330 | private onInserted(startIndex: number, inserted: string) { 331 | let [startInLineIndex, _] = this.findRangeInLines(startIndex, startIndex + 1); 332 | if (this.lines[startInLineIndex].startIndex === startIndex + inserted.length) { 333 | this.lines[startInLineIndex].move(inserted.length); 334 | } else { 335 | this.lines[startInLineIndex].inserted(inserted.length); 336 | } 337 | let currentLineIndex = startInLineIndex + 1; 338 | while (currentLineIndex < this.lines.length) { 339 | this.lines[currentLineIndex].move(inserted.length); 340 | ++currentLineIndex; 341 | } 342 | let hardLineEndInIndex = this.findHardLineEndsInIndex(startInLineIndex); 343 | this.rerenderLines(startInLineIndex, hardLineEndInIndex); 344 | View.layoutTopContextsAfter(this.lines[hardLineEndInIndex]); 345 | const asArray = Array.from(inserted); 346 | const newLineCount = asArray.filter(it => it === "\n").length; 347 | const lastNewLineIndex = asArray.lastIndexOf("\n"); 348 | const afterLastNewLine = inserted.length - lastNewLineIndex; 349 | if (newLineCount === 0) { 350 | this.contentEditor.characterIndex += inserted.length; 351 | this.contentEditor.avoidInLabel("forward"); 352 | } else { 353 | this.contentEditor.lineIndex += newLineCount; 354 | this.contentEditor.characterIndex = afterLastNewLine - 1; 355 | this.contentEditor.avoidInLabel("forward"); 356 | } 357 | this.contentEditor.update(); 358 | this.svgElement.style.height = this.height.toString() + 'px'; 359 | } 360 | 361 | private findHardLineEndsInIndex(startInLineIndex: number) { 362 | let hardLineEndInIndex: number; 363 | for (hardLineEndInIndex = startInLineIndex; 364 | hardLineEndInIndex < this.lines.length - 1 && !this.lines[hardLineEndInIndex].endWithHardLineBreak; 365 | ++hardLineEndInIndex) { 366 | } 367 | return hardLineEndInIndex; 368 | } 369 | 370 | private collectStyle(): SVGStyleElement { 371 | const element = document.createElementNS(SVGNS, "style"); 372 | const textClassSelector = this.config.contentClasses.map(it => "." + it) 373 | .join(','); 374 | const textStyle = ` 375 | ${textClassSelector} { 376 | font-family: ${this.contentFont.fontFamily}; 377 | font-weight: ${this.contentFont.fontWeight}; 378 | font-size: ${this.contentFont.fontSize}px; 379 | line-height: ${this.contentFont.lineHeight}px; 380 | } 381 | `; 382 | const labelClassSelector = this.config.labelClasses.map(it => "." + it) 383 | .join(','); 384 | const labelStyle = ` 385 | ${labelClassSelector} { 386 | font-family: ${this.labelFont.fontFamily}; 387 | font-weight: ${this.labelFont.fontWeight}; 388 | font-size: ${this.labelFont.fontSize}px; 389 | } 390 | `; 391 | const connectionClassSelector = this.config.connectionClasses.map(it => "." + it) 392 | .join(','); 393 | const connectionLineClassSelector = this.config.connectionClasses.map(it => "." + it + '-line') 394 | .join(','); 395 | const connectionStyle = ` 396 | ${connectionClassSelector} { 397 | font-family: ${this.connectionFont.fontFamily}; 398 | font-weight: ${this.connectionFont.fontWeight}; 399 | font-size: ${this.connectionFont.fontSize}px; 400 | } 401 | ${connectionLineClassSelector} { 402 | stroke: #000; 403 | } 404 | `; 405 | element.innerHTML = textStyle + labelStyle + connectionStyle; 406 | return element; 407 | } 408 | 409 | get paddingLeft(): number { 410 | return Math.max(...Array.from(this.store.labelCategoryRepo.values()) 411 | .map(it => this.labelFont.widthOf(it.text))) / 2 + 1/* stroke */; 412 | } 413 | } 414 | -------------------------------------------------------------------------------- /src/index.ts: -------------------------------------------------------------------------------- 1 | import {Annotator} from "./Annotator"; 2 | import {Connection} from "./Action/Connection"; 3 | import {Label} from "./Action/Label"; 4 | import {Content} from "./Action/Content"; 5 | 6 | const Action = {Connection, Label, Content}; 7 | export {Annotator, Action}; 8 | -------------------------------------------------------------------------------- /tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "strict": true, 4 | "target": "es5", 5 | "lib": [ 6 | "es6", 7 | "dom" 8 | ], 9 | "downlevelIteration": true, 10 | "sourceMap": true, 11 | "declaration": true, 12 | "outDir": "dist", 13 | "resolveJsonModule": true 14 | }, 15 | "exclude": [ 16 | "src/Demo" 17 | ] 18 | } 19 | -------------------------------------------------------------------------------- /webpack.anal.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const BundleAnalyzerPlugin = require('webpack-bundle-analyzer').BundleAnalyzerPlugin; 3 | 4 | module.exports = { 5 | mode: 'production', 6 | entry: './src/index.ts', 7 | module: { 8 | rules: [{ 9 | test: /\.ts$/, 10 | loader: 'ts-loader', 11 | exclude: [/node_modules/, /src\/Demo/] 12 | }] 13 | }, 14 | plugins: [ 15 | new BundleAnalyzerPlugin({ 16 | analyzerMode: 'server', 17 | analyzerHost: '127.0.0.1', 18 | analyzerPort: 8888, 19 | reportFilename: 'report.html', 20 | defaultSizes: 'parsed', 21 | openAnalyzer: false, 22 | generateStatsFile: false, 23 | statsFilename: 'stats.json', 24 | statsOptions: null, 25 | logLevel: 'info' 26 | }) 27 | ], 28 | resolve: { 29 | extensions: [".ts", ".js"] 30 | }, 31 | output: { 32 | path: path.resolve(__dirname, 'dist'), 33 | filename: 'index.js', 34 | library: 'Poplar', 35 | libraryTarget: "umd" 36 | } 37 | }; 38 | -------------------------------------------------------------------------------- /webpack.dev.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | module.exports = { 4 | mode: 'development', 5 | entry: './src/Develop/dev.ts', 6 | devtool: 'inline-source-map', 7 | devServer: { 8 | contentBase: './dist' 9 | }, 10 | module: { 11 | rules: [{ 12 | test: /\.html$/, 13 | loader: 'html-loader' 14 | }, { 15 | test: /\.ts$/, 16 | loader: 'ts-loader', 17 | exclude: [/node_modules/, /src\/Demo/], 18 | }] 19 | }, 20 | plugins: [ 21 | new HtmlWebpackPlugin({ 22 | template: 'src/Develop/index.html' 23 | }) 24 | ], 25 | resolve: { 26 | extensions: [".ts", ".js"] 27 | }, 28 | output: { 29 | path: path.resolve(__dirname, 'dist'), 30 | filename: 'index.js', 31 | library: 'Poplar', 32 | libraryTarget: "umd" 33 | } 34 | }; 35 | -------------------------------------------------------------------------------- /webpack.prod.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const UglifyJsPlugin = require('uglifyjs-webpack-plugin'); 3 | 4 | module.exports = { 5 | mode: 'production', 6 | entry: './src/index.ts', 7 | module: { 8 | rules: [ 9 | { 10 | test: /\.ts$/, 11 | loader: 'ts-loader', 12 | exclude: [/node_modules/, /Demo/] 13 | }, 14 | ] 15 | }, 16 | resolve: { 17 | extensions: [".ts", ".js"] 18 | }, 19 | output: { 20 | path: path.resolve(__dirname, 'dist'), 21 | filename: 'index.js', 22 | library: 'Poplar', 23 | libraryTarget: "umd" 24 | }, 25 | optimization: { 26 | minimizer: [new UglifyJsPlugin()] 27 | } 28 | }; 29 | -------------------------------------------------------------------------------- /webpack.test.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 3 | module.exports = { 4 | mode: 'development', 5 | entry: './src/Test/test.ts', 6 | devtool: 'inline-source-map', 7 | devServer: { 8 | contentBase: './dist' 9 | }, 10 | module: { 11 | rules: [{ 12 | test: /\.html$/, 13 | loader: 'html-loader' 14 | }, { 15 | test: /\.ts$/, 16 | loader: 'ts-loader', 17 | exclude: [/node_modules/, /src\/Demo/], 18 | }] 19 | }, 20 | plugins: [ 21 | new HtmlWebpackPlugin({ 22 | template: 'src/Test/index.html' 23 | }) 24 | ], 25 | resolve: { 26 | extensions: [".ts", ".js"] 27 | }, 28 | output: { 29 | path: path.resolve(__dirname, 'dist'), 30 | filename: 'index.js', 31 | library: 'Poplar', 32 | libraryTarget: "umd" 33 | } 34 | }; 35 | --------------------------------------------------------------------------------