├── .eslintignore ├── .gitignore ├── src ├── core │ ├── index.js │ ├── node-views │ │ ├── index.js │ │ ├── table.js │ │ ├── citation.js │ │ ├── highlight.js │ │ └── image.js │ ├── plugins │ │ ├── read-only.js │ │ ├── schema-transform.js │ │ ├── node-id.js │ │ ├── placeholder.js │ │ ├── pull-item-data.js │ │ ├── math.js │ │ ├── trailing-paragraph.js │ │ ├── text-color.js │ │ ├── drag.js │ │ ├── image.js │ │ ├── highlight-color.js │ │ ├── link.js │ │ └── underline-color.js │ ├── schema │ │ ├── colors.js │ │ ├── index.js │ │ ├── transformer.js │ │ ├── marks.js │ │ ├── README.md │ │ └── metadata.js │ ├── provider.js │ ├── math.js │ ├── input-rules.js │ ├── keymap.js │ └── helpers.js ├── ui │ ├── noticebar.js │ ├── toolbar-elements │ │ ├── align-dropdown.js │ │ ├── button.js │ │ ├── insert-dropdown.js │ │ ├── text-color-dropdown.js │ │ ├── highlight-color-dropdown.js │ │ ├── text-dropdown.js │ │ └── dropdown.js │ ├── popups │ │ ├── image-popup.js │ │ ├── highlight-popup.js │ │ ├── popup.js │ │ ├── citation-popup.js │ │ ├── table-popup.js │ │ └── link-popup.js │ ├── custom-icons.js │ ├── editor.js │ └── findbar.js ├── stylesheets │ ├── components │ │ ├── ui │ │ │ ├── _noticebar.scss │ │ │ ├── _popup.scss │ │ │ ├── _findbar.scss │ │ │ ├── _editor.scss │ │ │ └── _toolbar.scss │ │ └── core │ │ │ ├── _math.scss │ │ │ ├── _image.scss │ │ │ └── _prosemirror-math.scss │ ├── main.scss │ ├── abstracts │ │ └── _mixins.scss │ ├── base │ │ └── _base.scss │ └── themes │ │ ├── _dark.scss │ │ └── _light.scss ├── fluent.js ├── index.web.js └── index.android.js ├── postcss.config.js ├── .editorconfig ├── res └── icons │ ├── 16 │ ├── page.svg │ ├── insert-row-above.svg │ ├── insert-row-below.svg │ ├── show-item.svg │ ├── delete-row.svg │ ├── insert-column-right.svg │ ├── cite.svg │ ├── insert-column-left.svg │ ├── delete-table.svg │ ├── checkmark.svg │ ├── remove-color.svg │ ├── delete-column.svg │ ├── edit.svg │ ├── hide.svg │ └── unlink.svg │ └── 20 │ ├── chevron-left.svg │ ├── chevron-down.svg │ ├── chevron-up.svg │ ├── image.svg │ ├── italic.svg │ ├── plus.svg │ ├── align-left.svg │ ├── align-right.svg │ ├── align-center.svg │ ├── underline.svg │ ├── monospaced-1.25.svg │ ├── text-color.svg │ ├── table.svg │ ├── bold.svg │ ├── magnifier.svg │ ├── sidebar.svg │ ├── cite.svg │ ├── sidebar-bottom.svg │ ├── options.svg │ ├── highlight.svg │ ├── clear-format.svg │ ├── superscript.svg │ ├── subscript.svg │ ├── format-text.svg │ ├── math.svg │ ├── strikethrough.svg │ └── link.svg ├── babel.config.js ├── html ├── editor.web.html ├── editor.android.html ├── editor.ios.html ├── editor.zotero.html └── editor.dev.html ├── scripts └── upload ├── README.md ├── .eslintrc ├── .github └── workflows │ └── ci.yml ├── package.json ├── webpack.zotero-locale-plugin.js └── webpack.config.js /.eslintignore: -------------------------------------------------------------------------------- 1 | build/* 2 | -------------------------------------------------------------------------------- /.gitignore: -------------------------------------------------------------------------------- 1 | .* 2 | !.gitignore 3 | !.editorconfig 4 | node_modules 5 | build 6 | locales 7 | -------------------------------------------------------------------------------- /src/core/index.js: -------------------------------------------------------------------------------- 1 | import EditorCore from './editor-core'; 2 | 3 | export { EditorCore }; 4 | -------------------------------------------------------------------------------- /postcss.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | plugins: { 3 | 'autoprefixer': {}, 4 | 'postcss-rtlcss': {} 5 | } 6 | }; 7 | -------------------------------------------------------------------------------- /.editorconfig: -------------------------------------------------------------------------------- 1 | [*] 2 | end_of_line = lf 3 | insert_final_newline = true 4 | 5 | [*.{js, json, scss, yml, html}] 6 | charset = utf-8 7 | indent_style = tab 8 | trim_trailing_whitespace = true 9 | -------------------------------------------------------------------------------- /res/icons/20/chevron-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/core/node-views/index.js: -------------------------------------------------------------------------------- 1 | import citation from './citation'; 2 | import image from './image'; 3 | import highlight from './highlight'; 4 | import table from './table'; 5 | 6 | export default { 7 | citation, 8 | image, 9 | highlight, 10 | table 11 | }; 12 | -------------------------------------------------------------------------------- /res/icons/16/page.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/20/chevron-down.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /res/icons/20/chevron-up.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /res/icons/20/image.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/ui/noticebar.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import cx from 'classnames'; 4 | import React from 'react'; 5 | 6 | function Noticebar({ message, children }) { 7 | return ( 8 |
9 | {children} 10 |
11 | ); 12 | } 13 | 14 | export default Noticebar; 15 | -------------------------------------------------------------------------------- /res/icons/16/insert-row-above.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/20/italic.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/20/plus.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /res/icons/16/insert-row-below.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/stylesheets/components/ui/_noticebar.scss: -------------------------------------------------------------------------------- 1 | .noticebar { 2 | border-bottom: 1px solid #d9d9d9; 3 | justify-content: center; 4 | text-align: center; 5 | background: #fff86e; 6 | color: black; 7 | font-size: 12px; 8 | padding: 4px 10px; 9 | line-height: 1.4; 10 | cursor: default; 11 | } 12 | -------------------------------------------------------------------------------- /res/icons/20/align-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/20/align-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /babel.config.js: -------------------------------------------------------------------------------- 1 | module.exports = { 2 | presets: [ 3 | '@babel/preset-react', 4 | [ 5 | '@babel/preset-env', 6 | { 7 | modules: false, 8 | useBuiltIns: 'usage', 9 | corejs: { version: '3.24', proposals: true }, 10 | }, 11 | ], 12 | ], 13 | plugins: ['@babel/plugin-transform-runtime'], 14 | }; 15 | -------------------------------------------------------------------------------- /res/icons/20/align-center.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/editor.web.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zotero Note Editor 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /html/editor.android.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zotero Note Editor 6 | 7 | 8 |
9 | 10 | 11 | -------------------------------------------------------------------------------- /scripts/upload: -------------------------------------------------------------------------------- 1 | #!/bin/bash 2 | S3_URI=s3://zotero-download/ci/client-note-editor/ 3 | SCRIPT_DIR="$( cd "$( dirname "${BASH_SOURCE[0]}" )" && pwd )" 4 | cd $SCRIPT_DIR/../build/zotero 5 | HASH=$(git rev-parse HEAD) 6 | FILENAME=$HASH.zip 7 | zip -r ../$FILENAME . 8 | cd .. 9 | aws s3 cp $FILENAME $S3_URI$FILENAME 10 | rm $FILENAME 11 | -------------------------------------------------------------------------------- /src/core/plugins/read-only.js: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginKey } from 'prosemirror-state'; 2 | 3 | export function readOnly(options) { 4 | return new Plugin({ 5 | filterTransaction(tr) { 6 | if (options.enable) { 7 | if (tr.docChanged) { 8 | return false; 9 | } 10 | } 11 | return true; 12 | } 13 | }); 14 | } 15 | -------------------------------------------------------------------------------- /src/core/plugins/schema-transform.js: -------------------------------------------------------------------------------- 1 | import { Plugin } from 'prosemirror-state'; 2 | import { schemaTransform } from '../schema/transformer'; 3 | 4 | export function transform(options) { 5 | return new Plugin({ 6 | appendTransaction(transactions, oldState, newState) { 7 | return schemaTransform(newState); 8 | } 9 | }); 10 | } 11 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Zotero Note Editor 2 | 3 | ## Build 4 | 5 | Clone the repository: 6 | 7 | ``` 8 | git clone https://github.com/zotero/zotero-note-editor 9 | ``` 10 | 11 | Run `npm run build` to produce `build/web` and `build/zotero` 12 | 13 | ## Development 14 | 15 | Run `npm start` to open the automatically refreshing development window 16 | -------------------------------------------------------------------------------- /res/icons/20/underline.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/20/monospaced-1.25.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/20/text-color.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/16/show-item.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/20/table.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /html/editor.ios.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Zotero Note Editor 7 | 8 | 9 |
10 | 11 | 12 | -------------------------------------------------------------------------------- /res/icons/16/delete-row.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | -------------------------------------------------------------------------------- /html/editor.zotero.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zotero Note Editor 6 | 7 | 8 | 9 | 10 | 11 | 12 |
13 | 14 | 15 | -------------------------------------------------------------------------------- /res/icons/20/bold.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/16/insert-column-right.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /res/icons/16/cite.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /res/icons/20/magnifier.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/16/insert-column-left.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/core/schema/colors.js: -------------------------------------------------------------------------------- 1 | 2 | export const TEXT_COLORS = [ 3 | ['red', '#ff2020'], 4 | ['orange', '#ff7700'], 5 | ['yellow', '#ffcb00'], 6 | ['green', '#4eb31c'], 7 | ['purple', '#7953e3'], 8 | ['magenta', '#eb52f7'], 9 | ['blue', '#05a2ef'], 10 | ['gray', '#7e8386'], 11 | ]; 12 | 13 | export const HIGHLIGHT_COLORS = [ 14 | ['red', '#ff666680'], 15 | ['orange', '#f1983780'], 16 | ['yellow', '#ffd40080'], 17 | ['green', '#5fb23680'], 18 | ['purple', '#a28ae580'], 19 | ['magenta', '#e56eee80'], 20 | ['blue', '#2ea8e580'], 21 | ['gray', '#aaaaaa80'], 22 | ]; 23 | -------------------------------------------------------------------------------- /res/icons/20/sidebar.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | -------------------------------------------------------------------------------- /res/icons/20/cite.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /res/icons/20/sidebar-bottom.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /.eslintrc: -------------------------------------------------------------------------------- 1 | { 2 | "env": { 3 | "browser": true, 4 | "es6": true, 5 | "node": true 6 | }, 7 | "globals": { 8 | }, 9 | "extends": [ 10 | "@zotero", 11 | "plugin:react/recommended" 12 | ], 13 | "parser": "@babel/eslint-parser", 14 | "parserOptions": { 15 | "ecmaVersion": 2018, 16 | "ecmaFeatures": { 17 | "jsx": true 18 | }, 19 | "sourceType": "module", 20 | "babelOptions": { 21 | "configFile": "./babel.config.js" 22 | } 23 | }, 24 | "plugins": [ 25 | "react" 26 | ], 27 | "settings": { 28 | "react": { 29 | "version": "16.14" 30 | } 31 | }, 32 | "rules": {} 33 | } 34 | -------------------------------------------------------------------------------- /res/icons/20/options.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/16/delete-table.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/16/checkmark.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /res/icons/16/remove-color.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | -------------------------------------------------------------------------------- /res/icons/20/highlight.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/16/delete-column.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | 12 | -------------------------------------------------------------------------------- /res/icons/20/clear-format.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /src/stylesheets/main.scss: -------------------------------------------------------------------------------- 1 | 2 | $mobile: false; 3 | @if $platform == 'ios' or $platform == 'android' { 4 | $mobile: true; 5 | } 6 | 7 | // 8 | // Abstracts 9 | // 10 | 11 | @import "abstracts/mixins"; 12 | 13 | // 14 | // Themes 15 | // 16 | 17 | @import "themes/light"; 18 | @import "themes/dark"; 19 | 20 | // 21 | // Base 22 | // 23 | 24 | @import "base/base"; 25 | 26 | // 27 | // Components 28 | // 29 | 30 | @import "components/core/core"; 31 | @import "components/core/image"; 32 | @import "components/core/math"; 33 | 34 | @import "components/ui/popup"; 35 | @import "components/ui/editor"; 36 | @import "components/ui/findbar"; 37 | @import "components/ui/noticebar"; 38 | @import "components/ui/toolbar"; 39 | -------------------------------------------------------------------------------- /src/core/node-views/table.js: -------------------------------------------------------------------------------- 1 | 2 | class TableView { 3 | constructor(node) { 4 | this.node = node 5 | this.dom = document.createElement("div") 6 | this.dom.className = "tableWrapper" 7 | this.table = this.dom.appendChild(document.createElement("table")) 8 | this.colgroup = this.table.appendChild(document.createElement("colgroup")) 9 | this.contentDOM = this.table.appendChild(document.createElement("tbody")) 10 | } 11 | 12 | update(node) { 13 | if (node.type != this.node.type) return false 14 | this.node = node 15 | return true 16 | } 17 | } 18 | 19 | export default function (options) { 20 | return function (node, view, getPos) { 21 | return new TableView(node, view, getPos, options); 22 | }; 23 | } 24 | -------------------------------------------------------------------------------- /res/icons/20/superscript.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/20/subscript.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | -------------------------------------------------------------------------------- /res/icons/16/edit.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/core/plugins/node-id.js: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginKey } from 'prosemirror-state'; 2 | import { randomString } from '../utils'; 3 | 4 | function addOrDeduplicateIDs(state) { 5 | let nodeIDs = []; 6 | let tr = state.tr; 7 | let updated = false; 8 | state.doc.descendants((node, pos) => { 9 | if (node.type.attrs.nodeID) { 10 | let nodeID = node.attrs.nodeID; 11 | if (!nodeID || nodeIDs.includes(nodeID)) { 12 | nodeID = randomString(); 13 | tr.setNodeMarkup(pos, null, { 14 | ...node.attrs, 15 | nodeID 16 | }); 17 | updated = true; 18 | } 19 | } 20 | }); 21 | 22 | return updated && tr || null; 23 | } 24 | 25 | export function nodeID(options) { 26 | return new Plugin({ 27 | appendTransaction(transactions, oldState, newState) { 28 | return addOrDeduplicateIDs(newState); 29 | } 30 | }); 31 | } 32 | -------------------------------------------------------------------------------- /src/core/schema/index.js: -------------------------------------------------------------------------------- 1 | import { Schema } from 'prosemirror-model'; 2 | import nodes from './nodes'; 3 | import marks from './marks'; 4 | import { buildToHTML, buildFromHTML, buildClipboardSerializer } from './utils'; 5 | import { TEXT_COLORS, HIGHLIGHT_COLORS } from './colors'; 6 | 7 | const schema = new Schema({ nodes, marks }); 8 | // Update in Zotero 'editorInstance.js' as well! 9 | schema.version = 10; 10 | 11 | const toHTML = buildToHTML(schema); 12 | const fromHTML = buildFromHTML(schema); 13 | 14 | // Note: Upgrade schema version if introducing new quotation marks 15 | const QUOTATION_MARKS = ["'",'"', '“', '”', '‘', '’', '„','«','»']; 16 | 17 | export { 18 | nodes, 19 | marks, 20 | schema, 21 | toHTML, 22 | fromHTML, 23 | buildClipboardSerializer, 24 | QUOTATION_MARKS, 25 | TEXT_COLORS, 26 | HIGHLIGHT_COLORS 27 | }; 28 | -------------------------------------------------------------------------------- /res/icons/20/format-text.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/core/plugins/placeholder.js: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginKey } from 'prosemirror-state'; 2 | import { Decoration, DecorationSet } from 'prosemirror-view'; 3 | 4 | export function placeholder(options) { 5 | return new Plugin({ 6 | props: { 7 | decorations: (state) => { 8 | const decorations = []; 9 | if (options.text && state.doc.content.childCount === 1) { 10 | state.doc.descendants((node, pos) => { 11 | if (node.type.isBlock && node.childCount === 0 /*&& state.selection.$anchor.parent !== node*/) { 12 | decorations.push( 13 | Decoration.node(pos, pos + node.nodeSize, { 14 | class: 'empty-node', 15 | 'data-placeholder': options.text 16 | }) 17 | ); 18 | } 19 | return false; 20 | }); 21 | } 22 | return DecorationSet.create(state.doc, decorations); 23 | } 24 | } 25 | }); 26 | } 27 | -------------------------------------------------------------------------------- /res/icons/20/math.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /src/fluent.js: -------------------------------------------------------------------------------- 1 | import { FluentBundle, FluentResource } from '@fluent/bundle'; 2 | 3 | import zotero from '../locales/en-US/zotero.ftl'; 4 | import reader from '../locales/en-US/note-editor.ftl'; 5 | 6 | export let bundle = new FluentBundle('en-US', { 7 | functions: { 8 | PLATFORM: () => 'web', 9 | }, 10 | }); 11 | 12 | bundle.addResource(new FluentResource(zotero)); 13 | bundle.addResource(new FluentResource(reader)); 14 | 15 | if (__BUILD__ !== 'zotero') { 16 | bundle.addResource(new FluentResource('-app-name = Zotero')); 17 | } 18 | 19 | export function getLocalizedString(key, args = {}) { 20 | const message = bundle.getMessage(key); 21 | if (message && message.value) { 22 | return bundle.formatPattern(message.value, args); 23 | } else { 24 | console.warn(`Localization key '${key}' not found`); 25 | return key; 26 | } 27 | } 28 | 29 | export function addFTL(ftl) { 30 | bundle.addResource(new FluentResource(ftl), { allowOverrides: true }); 31 | } 32 | -------------------------------------------------------------------------------- /src/stylesheets/components/core/_math.scss: -------------------------------------------------------------------------------- 1 | @import "./prosemirror-math"; 2 | @import "~katex/dist/katex.min.css"; 3 | 4 | .math-node.empty-math .math-render::before { 5 | content: "Click to insert LaTex ..."; 6 | color: black; 7 | } 8 | 9 | .math-node .math-render.parse-error::before { 10 | content: "Cannot parse LaTeX."; 11 | cursor: help; 12 | } 13 | 14 | .math-node .ProseMirror-focused { 15 | outline: none; 16 | } 17 | 18 | math-display { 19 | position: relative; 20 | 21 | &:before { 22 | position: absolute; 23 | width: 64px; 24 | height: 85%; 25 | margin-left: -64px; 26 | content: ""; 27 | } 28 | } 29 | 30 | math-inline { 31 | .math-src { 32 | div[contenteditable] { 33 | white-space: normal; 34 | word-break: break-word; 35 | } 36 | } 37 | } 38 | 39 | math-display { 40 | margin-block-start: 1em; 41 | margin-block-end: 1em; 42 | } 43 | 44 | .math-src { 45 | font-size: 0.90em; 46 | } 47 | -------------------------------------------------------------------------------- /html/editor.dev.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | Zotero Note Editor 6 | 7 | 8 | 38 | 39 | 40 |
41 |
42 |

43 | 
44 | 
45 | 


--------------------------------------------------------------------------------
/src/core/provider.js:
--------------------------------------------------------------------------------
 1 | import { randomString } from './utils';
 2 | 
 3 | class Provider {
 4 | 	constructor(options) {
 5 | 		this.subscriptions = [];
 6 | 		this.onSubscribe = options.onSubscribe;
 7 | 		this.onUnsubscribe = options.onUnsubscribe;
 8 | 	}
 9 | 
10 | 	subscribe(subscription) {
11 | 		subscription.id = randomString();
12 | 		this.subscriptions.push(subscription);
13 | 		this.onSubscribe(subscription);
14 | 	}
15 | 
16 | 	unsubscribe(listener) {
17 | 		let subscription = this.subscriptions.find(s => s.listener === listener);
18 | 		this.subscriptions.splice(this.subscriptions.indexOf(subscription), 1);
19 | 		this.onUnsubscribe(subscription);
20 | 	}
21 | 
22 | 	notify(id, data) {
23 | 		this.subscriptions.forEach((subscription) => {
24 | 			if (subscription.id === id) {
25 | 				subscription.listener(data);
26 | 				subscription.cachedData = data;
27 | 			}
28 | 		});
29 | 	}
30 | 
31 | 	getCachedData(nodeID, type) {
32 | 		let subscription = this.subscriptions.find(s => s.nodeID === nodeID);
33 | 		return subscription && subscription.cachedData || null;
34 | 	}
35 | }
36 | 
37 | export default Provider;
38 | 


--------------------------------------------------------------------------------
/src/stylesheets/components/core/_image.scss:
--------------------------------------------------------------------------------
 1 | .regular-image {
 2 | 	width: 100%;
 3 | 	display: inline-block;
 4 | 
 5 | 	.resized-wrapper {
 6 | 		max-width: 100%;
 7 | 		margin-left: auto;
 8 | 		margin-right: auto;
 9 | 
10 | 		.image {
11 | 			outline: 1px solid var(--fill-quarternary);
12 | 
13 | 			&.annotation:hover {
14 | 				border-color: var(--fill-tertiary);
15 | 			}
16 | 		}
17 | 	}
18 | }
19 | 
20 | .external-image {
21 | 	width: 100%;
22 | 	display: inline-block;
23 | 
24 | 	.resized-wrapper {
25 | 		padding: 10px;
26 | 		border: 1px solid #d9d9d9;
27 | 		display: flex;
28 | 		align-items: center;
29 | 		flex-direction: column;
30 | 		max-width: 100%;
31 | 		margin-left: auto;
32 | 		margin-right: auto;
33 | 
34 | 		.image {
35 | 			max-width: 100px;
36 | 			max-height: 100px;
37 | 			overflow-y: hidden;
38 | 		}
39 | 	}
40 | }
41 | 
42 | .import-placeholder-image {
43 | 	width: 100%;
44 | 	display: flex;
45 | 	align-items: center;
46 | 	flex-direction: column;
47 | 
48 | 	.image {
49 | 		max-width: 100%;
50 | 		width: 200px;
51 | 		height: 200px;
52 | 		background-color: var(--fill-quarternary);
53 | 	}
54 | }
55 | 


--------------------------------------------------------------------------------
/res/icons/16/hide.svg:
--------------------------------------------------------------------------------
1 | 
2 | 
3 | 
4 | 
5 | 
6 | 
7 | 
8 | 


--------------------------------------------------------------------------------
/res/icons/20/strikethrough.svg:
--------------------------------------------------------------------------------
1 | 
2 | 
3 | 
4 | 


--------------------------------------------------------------------------------
/src/ui/toolbar-elements/align-dropdown.js:
--------------------------------------------------------------------------------
 1 | 'use strict';
 2 | 
 3 | import React from 'react';
 4 | import { useLocalization } from '@fluent/react';
 5 | 
 6 | import Dropdown from './dropdown';
 7 | import { StateButton } from './button';
 8 | 
 9 | import IconAlignLeft from '../../../res/icons/20/align-left.svg';
10 | import IconAlignCenter from '../../../res/icons/20/align-center.svg';
11 | import IconAlignRight from '../../../res/icons/20/align-right.svg';
12 | 
13 | export default function AlignDropdown({ menuState }) {
14 | 	const { l10n } = useLocalization();
15 | 
16 | 	let icon = menuState.alignCenter.isActive && 
17 | 		|| menuState.alignRight.isActive && 
18 | 		|| 
19 | 
20 | 	return (
21 | 		
26 | 			}
29 | 				title={l10n.getString('note-editor-align-left')}
30 | 			/>
31 | 			}
34 | 				title={l10n.getString('note-editor-align-center')}
35 | 			/>
36 | 			}
39 | 				title={l10n.getString('note-editor-align-right')}
40 | 			/>
41 | 		
42 | 	);
43 | }
44 | 


--------------------------------------------------------------------------------
/src/core/node-views/citation.js:
--------------------------------------------------------------------------------
 1 | import { formatCitation } from '../utils';
 2 | 
 3 | // Note: Node view is only updated when document or decoration is updated at specific position
 4 | // https://discuss.prosemirror.net/t/force-nodes-of-specific-type-to-re-render/2480/2
 5 | 
 6 | class CitationView {
 7 | 	constructor(node, view, getPos, options) {
 8 | 		this.dom = document.createElement('span');
 9 | 		this.dom.className = 'citation';
10 | 
11 | 		let formattedCitation = '{citation}';
12 | 		try {
13 | 			let citation = JSON.parse(JSON.stringify(node.attrs.citation));
14 | 			options.metadata.fillCitationItemsWithData(citation.citationItems);
15 | 			let missingItemData = citation.citationItems.find(x => !x.itemData);
16 | 			if (missingItemData) {
17 | 				formattedCitation = node.textContent;
18 | 			}
19 | 			else {
20 | 				let text = formatCitation(citation);
21 | 				if (text) {
22 | 					formattedCitation = '(' + text + ')';
23 | 				}
24 | 			}
25 | 		}
26 | 		catch (e) {
27 | 			console.log(e);
28 | 		}
29 | 		this.dom.innerHTML = formattedCitation;
30 | 	}
31 | 
32 | 	selectNode() {
33 | 		this.dom.classList.add('selected');
34 | 	}
35 | 
36 | 	deselectNode() {
37 | 		this.dom.classList.remove('selected');
38 | 	}
39 | 
40 | 	destroy() {
41 | 	}
42 | }
43 | 
44 | export default function (options) {
45 | 	return function (node, view, getPos) {
46 | 		return new CitationView(node, view, getPos, options);
47 | 	};
48 | }
49 | 


--------------------------------------------------------------------------------
/src/stylesheets/components/ui/_popup.scss:
--------------------------------------------------------------------------------
 1 | .popup-container .popup {
 2 | 	position: absolute;
 3 | 	display: flex;
 4 | 	z-index: 100;
 5 | 	font-size: 12px;
 6 | 	background: var(--material-toolbar);
 7 | 	border: var(--material-panedivider);
 8 | 	padding: 5px;
 9 | 	border-radius: 5px;
10 | 	box-shadow: 0 0 24px 0 rgba(0, 0, 0, 0.12);
11 | 	@include popover-pointer($width: 10px, $height: 5px);
12 | 
13 | 	button {
14 | 		user-select: none;
15 | 		padding: 4px;
16 | 		border-radius: 5px;
17 | 		cursor: default;
18 | 		display: flex;
19 | 		align-items: center;
20 | 		text-align: center;
21 | 		color: var(--fill-secondary);
22 | 
23 | 		&:not(:first-child) {
24 | 			margin-left: 5px;
25 | 		}
26 | 
27 | 		&:hover {
28 | 			background: var(--fill-quinary);
29 | 		}
30 | 
31 | 		.icon {
32 | 			display: flex;
33 | 			align-items: center;
34 | 			justify-content: space-around;
35 | 			margin-inline-end: 7px;
36 | 		}
37 | 
38 | 		.title {
39 | 			color: var(--fill-primary);
40 | 		}
41 | 	}
42 | }
43 | 
44 | .link-popup {
45 | 	.link {
46 | 		width: 200px;
47 | 		display: flex;
48 | 		flex: 1;
49 | 		overflow: hidden;
50 | 		align-items: center;
51 | 
52 | 		a {
53 | 			text-overflow: ellipsis;
54 | 			overflow: hidden;
55 | 			white-space: nowrap;
56 | 			margin-left: 2px;
57 | 		}
58 | 
59 | 		input {
60 | 			width: 100%;
61 | 			border: none;
62 | 			outline: none;
63 | 			color: inherit;
64 | 			background-color: transparent;
65 | 		}
66 | 	}
67 | }
68 | 


--------------------------------------------------------------------------------
/src/ui/toolbar-elements/button.js:
--------------------------------------------------------------------------------
 1 | 'use strict';
 2 | 
 3 | import React, { forwardRef } from 'react';
 4 | import cx from 'classnames';
 5 | 
 6 | export const Button = forwardRef(({ icon, title, active, enableFocus, className, triggerOnMouseDown, onClick, ...rest }, ref) => {
 7 | 	return (
 8 | 		
36 | 	);
37 | });
38 | 
39 | export function StateButton({ icon, title, state, ...rest }) {
40 | 	return (
41 | 		
33 | 			
37 | 			{citationState.canAddCitationAfter() && }
41 | 		
42 | 	);
43 | }
44 | 
45 | export default ImagePopup;
46 | 


--------------------------------------------------------------------------------
/src/core/plugins/pull-item-data.js:
--------------------------------------------------------------------------------
 1 | import { Plugin, PluginKey } from 'prosemirror-state';
 2 | 
 3 | function extract(state) {
 4 | 	let items = [];
 5 | 	let tr = state.tr;
 6 | 	let updated = false;
 7 | 	state.doc.descendants((node, pos) => {
 8 | 		try {
 9 | 			let citationItems;
10 | 			if (node.attrs.citation) {
11 | 				citationItems = node.attrs.citation.citationItems
12 | 			}
13 | 			else if (node.attrs.annotation
14 | 				&& node.attrs.annotation.citationItem) {
15 | 				citationItems = [node.attrs.annotation.citationItem];
16 | 			}
17 | 
18 | 			if (citationItems) {
19 | 				for (let citationItem of citationItems) {
20 | 					if (citationItem.itemData) {
21 | 						let { uris, itemData } = citationItem;
22 | 						let item = { uris, itemData };
23 | 						items.push(item);
24 | 						delete citationItem.itemData;
25 | 						updated = true;
26 | 					}
27 | 				}
28 | 
29 | 				if (updated) {
30 | 					tr.setNodeMarkup(pos, null, node.attrs);
31 | 				}
32 | 			}
33 | 		}
34 | 		catch(e) {
35 | 			console.log(e);
36 | 		}
37 | 	});
38 | 
39 | 	return updated && { tr, items } || null;
40 | }
41 | 
42 | export function pullItemData(options) {
43 | 	return new Plugin({
44 | 		appendTransaction(transactions, oldState, newState) {
45 | 			let changed = transactions.some(tr => tr.docChanged);
46 | 			if (!changed) return;
47 | 			let res = extract(newState);
48 | 			if (!res) {
49 | 				return null;
50 | 			}
51 | 			let { tr, items } = res;
52 | 			options.onPull(items);
53 | 			return tr;
54 | 		}
55 | 	});
56 | }
57 | 


--------------------------------------------------------------------------------
/src/core/node-views/highlight.js:
--------------------------------------------------------------------------------
 1 | import { formatCitation } from '../utils';
 2 | 
 3 | class HighlightView {
 4 | 	constructor(node, view, getPos, options) {
 5 | 		this.node = node;
 6 | 		this.options = options;
 7 | 		
 8 | 		this.dom = document.createElement('span');
 9 | 		this.dom.className = 'highlight';
10 | 		this.contentDOM = this.dom;
11 | 		
12 | 		this.updateCitation();
13 | 	}
14 | 	
15 | 	updateCitation() {
16 | 		let formattedCitation = '';
17 | 		if (this.node.attrs.annotation.citationItem) {
18 | 			try {
19 | 				let citationItem = JSON.parse(JSON.stringify(this.node.attrs.annotation.citationItem));
20 | 				let citation = {
21 | 					citationItems: [citationItem],
22 | 					properties: {}
23 | 				};
24 | 
25 | 				this.options.metadata.fillCitationItemsWithData(citation.citationItems);
26 | 				let missingItemData = citation.citationItems.find(x => !x.itemData);
27 | 				if (!missingItemData) {
28 | 					formattedCitation = formatCitation(citation);
29 | 				}
30 | 			}
31 | 			catch (e) {
32 | 			}
33 | 		}
34 | 
35 | 		if (formattedCitation) {
36 | 			this.dom.title = formattedCitation;
37 | 		} else {
38 | 			this.dom.removeAttribute('title');
39 | 		}
40 | 	}
41 | 	
42 | 	update(node) {
43 | 		if (node.type !== this.node.type) {
44 | 			return false;
45 | 		}
46 | 		this.node = node;
47 | 		this.updateCitation();
48 | 		return true;
49 | 	}
50 | }
51 | 
52 | export default function (options) {
53 | 	return function (node, view, getPos) {
54 | 		return new HighlightView(node, view, getPos, options);
55 | 	};
56 | }
57 | 


--------------------------------------------------------------------------------
/src/ui/toolbar-elements/insert-dropdown.js:
--------------------------------------------------------------------------------
 1 | 'use strict';
 2 | 
 3 | import React from 'react';
 4 | import { useLocalization } from '@fluent/react';
 5 | 
 6 | import Dropdown from './dropdown';
 7 | 
 8 | import IconInsert from '../../../res/icons/20/plus.svg';
 9 | import IconImage from '../../../res/icons/20/image.svg';
10 | import IconMath from '../../../res/icons/20/math.svg';
11 | import IconTable from '../../../res/icons/20/table.svg';
12 | 
13 | export default function InsertDropdown({ isAttachmentNote, onInsertTable, onInsertMath, onInsertImage }) {
14 | 	const { l10n } = useLocalization();
15 | 
16 | 	return (
17 | 		}
20 | 			title={l10n.getString('general-insert')}
21 | 		>
22 | 			{ !isAttachmentNote &&  }
29 | 			
36 | 			
43 | 		
44 | 	);
45 | }
46 | 


--------------------------------------------------------------------------------
/src/ui/popups/highlight-popup.js:
--------------------------------------------------------------------------------
 1 | import React from 'react';
 2 | import { useLocalization } from '@fluent/react';
 3 | import Popup from './popup';
 4 | import IconBlockquote from '../../../res/icons/16/cite.svg';
 5 | import IconDocument from '../../../res/icons/16/page.svg';
 6 | import IconUnlink from '../../../res/icons/16/unlink.svg';
 7 | 
 8 | function HighlightPopup({ parentRef, highlightState, citationState, viewMode }) {
 9 | 	let { l10n } = useLocalization();
10 | 
11 | 	function handleOpen() {
12 | 		highlightState.popup.open();
13 | 	}
14 | 
15 | 	function handleUnlink() {
16 | 		highlightState.popup.unlink();
17 | 	}
18 | 
19 | 	function handleAdd() {
20 | 		citationState.addCitationAfter();
21 | 	}
22 | 
23 | 	return (
24 | 		
25 | 			
29 | 			{!['web'].includes(viewMode) && (
30 | 				
34 | 			)}
35 | 			{citationState.canAddCitationAfter() && }
39 | 		
40 | 	);
41 | }
42 | 
43 | export default HighlightPopup;
44 | 


--------------------------------------------------------------------------------
/src/stylesheets/components/ui/_findbar.scss:
--------------------------------------------------------------------------------
 1 | .findbar {
 2 | 	display: grid;
 3 | 	grid-template-columns: 1fr auto;
 4 | 	align-items: center;
 5 | 	row-gap: 6px;
 6 | 	column-gap: 8px;
 7 | 	padding: 6px 8px;
 8 | 	background: var(--material-toolbar);
 9 | 	border-bottom: var(--material-panedivider);
10 | 	font-size: 12px;
11 | 
12 | 	input[type=text] {
13 | 		padding: 0 7px;
14 | 		background: var(--material-background);
15 | 		border-radius: 5px;
16 | 		border: var(--material-border-quinary);
17 | 		width: 100%;
18 | 		outline: none;
19 | 		height: 28px;
20 | 
21 | 		&:focus {
22 | 			outline: none;
23 | 			border-color: rgba(0, 0, 0, 0);
24 | 			box-shadow: 0 0 0 var(--width-focus-border) var(--color-focus-search);
25 | 		}
26 | 	}
27 | 
28 | 	.buttons {
29 | 		display: flex;
30 | 		gap: 8px;
31 | 		align-self: center;
32 | 
33 | 		.group {
34 | 			display: flex;
35 | 			gap: 4px;
36 | 		}
37 | 	}
38 | 
39 | 	.check-button {
40 | 		display: flex;
41 | 		align-items: center;
42 | 		cursor: default;
43 | 		white-space: nowrap;
44 | 
45 | 		label {
46 | 			padding-inline-start: 6px;
47 | 		}
48 | 	}
49 | 
50 | 	.text-button {
51 | 		display: flex;
52 | 		padding: 4px;
53 | 		align-items: center;
54 | 		gap: 4px;
55 | 		height: 24px;
56 | 		border-radius: 5px;
57 | 		background: var(--material-button);
58 | 		color: var(--fill-primary);
59 | 		box-shadow: 0px 0px 0px 0.5px rgba(0, 0, 0, 0.05), 0px 0.5px 2.5px 0px rgba(0, 0, 0, 0.30);
60 | 		cursor: default;
61 | 
62 | 		&:active {
63 | 			background-color: var(--fill-senary);
64 | 		}
65 | 	}
66 | 
67 | 	@include macOS-inactive-opacity;
68 | }
69 | 


--------------------------------------------------------------------------------
/src/stylesheets/components/ui/_editor.scss:
--------------------------------------------------------------------------------
 1 | #editor-container {
 2 | 	position: absolute;
 3 | 	top: 0;
 4 | 	bottom: 0;
 5 | 	left: 0;
 6 | 	width: 100%;
 7 | 
 8 | 	.editor {
 9 | 		position: absolute;
10 | 		top: 0;
11 | 		left: 0;
12 | 		bottom: 0;
13 | 		right: 0;
14 | 
15 | 		display: flex;
16 | 		flex-direction: column;
17 | 
18 | 		.editor-core {
19 | 			display: flex;
20 | 			flex: 1;
21 | 			flex-direction: column;
22 | 			overflow: auto;
23 | 			background-color: var(--color-background);
24 | 			--editor-padding-inline: 30px;
25 | 			--editor-padding-block: 20px;
26 | 			--editor-max-inner-width: 70ch;
27 | 			--editor-max-width: calc(var(--editor-max-inner-width) + var(--editor-padding-inline) * 2);
28 | 
29 | 			@if $platform == 'zotero' {
30 | 				font-size: var(--font-size);
31 | 
32 | 				.primary-editor {
33 | 					// Need this for wide image/math nodes otherwise content overflows
34 | 					// TODO: Replace with stretch after updating to fx146
35 | 					width: -moz-available;
36 | 					max-width: var(--editor-max-width);
37 | 					min-width: min(100%, var(--editor-max-width));
38 | 					align-self: center;
39 | 				}
40 | 			}
41 | 
42 | 			.relative-container {
43 | 				position: relative;
44 | 			}
45 | 
46 | 			.primary-editor {
47 | 				flex: 1;
48 | 				// Margins must belong to editor contenteditable area to
49 | 				// allow text selection from the margin
50 | 				padding: var(--editor-padding-block) var(--editor-padding-inline);
51 | 				flex-direction: column;
52 | 
53 | 				& > *:first-child {
54 | 					margin-top: 0;
55 | 				}
56 | 
57 | 				& > *:last-child {
58 | 					margin-bottom: 0;
59 | 				}
60 | 			}
61 | 		}
62 | 	}
63 | }
64 | 


--------------------------------------------------------------------------------
/src/core/plugins/math.js:
--------------------------------------------------------------------------------
 1 | import { Plugin, PluginKey } from 'prosemirror-state';
 2 | import { schema } from '../schema';
 3 | 
 4 | class Math {
 5 | 	constructor(state, options) {
 6 | 
 7 | 	}
 8 | 
 9 | 	update(newState, oldState) {
10 | 		if (!this.view) {
11 | 			return;
12 | 		}
13 | 		let { dispatch } = this.view;
14 | 		let { tr, selection: newSelection } = newState;
15 | 		let { selection: oldSelection } = oldState;
16 | 		if (newSelection.from !== oldSelection.from && oldSelection.from < tr.doc.content.size) {
17 | 			let node = tr.doc.nodeAt(oldSelection.from);
18 | 			if (node && node.type === schema.nodes.math_display && !node.content.size) {
19 | 				dispatch(tr.replaceWith(oldSelection.from, oldSelection.from + node.nodeSize, schema.nodes.paragraph.create()));
20 | 			}
21 | 			else if (node && node.type === schema.nodes.math_inline && !node.content.size) {
22 | 				dispatch(tr.delete(oldSelection.from, oldSelection.from + node.nodeSize));
23 | 			}
24 | 		}
25 | 	}
26 | 
27 | 	destroy() {
28 | 
29 | 	}
30 | }
31 | 
32 | export let mathKey = new PluginKey('math');
33 | 
34 | export function math(options) {
35 | 	return new Plugin({
36 | 		key: mathKey,
37 | 		state: {
38 | 			init(config, state) {
39 | 				return new Math(state, options);
40 | 			},
41 | 			apply(tr, pluginState, oldState, newState) {
42 | 				return pluginState;
43 | 			}
44 | 		},
45 | 		view: (view) => {
46 | 			let pluginState = mathKey.getState(view.state);
47 | 			pluginState.view = view;
48 | 			return {
49 | 				update(view, lastState) {
50 | 					pluginState.update(view.state, lastState);
51 | 				},
52 | 				destroy() {
53 | 					pluginState.destroy();
54 | 				}
55 | 			};
56 | 		}
57 | 	});
58 | }
59 | 


--------------------------------------------------------------------------------
/src/core/plugins/trailing-paragraph.js:
--------------------------------------------------------------------------------
 1 | import { Plugin, PluginKey } from 'prosemirror-state';
 2 | import { schema } from '../schema';
 3 | 
 4 | // TODO: Do other transformations as well i.e. insert space between highlights, citations
 5 | 
 6 | function nodeEqualsType({ types, node }) {
 7 | 	return (Array.isArray(types) && types.includes(node.type)) || node.type === types;
 8 | }
 9 | 
10 | export let trailingParagraphKey = new PluginKey('trailingParagraph');
11 | 
12 | export function trailingParagraph() {
13 | 	let options = {
14 | 		node: 'paragraph',
15 | 		notAfter: [
16 | 			'paragraph'
17 | 		]
18 | 	};
19 | 
20 | 	const disabledNodes = Object.entries(schema.nodes)
21 | 	.map(([, value]) => value)
22 | 	.filter(node => options.notAfter.includes(node.name));
23 | 
24 | 	return new Plugin({
25 | 		key: trailingParagraphKey,
26 | 		view(view) {
27 | 			return {
28 | 				update(view) {
29 | 					const { state } = view;
30 | 					const insertNodeAtEnd = trailingParagraphKey.getState(state);
31 | 
32 | 					if (!insertNodeAtEnd) {
33 | 						return;
34 | 					}
35 | 
36 | 					const { doc, schema, tr } = state;
37 | 					const type = schema.nodes[options.node];
38 | 					const transaction = tr.insert(doc.content.size, type.create());
39 | 					view.dispatch(transaction);
40 | 				}
41 | 			};
42 | 		},
43 | 		state: {
44 | 			init(_, state) {
45 | 				const lastNode = state.tr.doc.lastChild;
46 | 				return !nodeEqualsType({ node: lastNode, types: disabledNodes });
47 | 			},
48 | 			apply(tr, value) {
49 | 				if (!tr.docChanged) {
50 | 					return value;
51 | 				}
52 | 
53 | 				const lastNode = tr.doc.lastChild;
54 | 				return !nodeEqualsType({ node: lastNode, types: disabledNodes });
55 | 			}
56 | 		}
57 | 	});
58 | }
59 | 


--------------------------------------------------------------------------------
/src/stylesheets/abstracts/_mixins.scss:
--------------------------------------------------------------------------------
 1 | @use "sass:math";
 2 | 
 3 | @mixin popover-pointer($width, $height, $offset: 50%, $position: null) {
 4 | 	$border-x: math.div($width, 2);
 5 | 	$border-y: $height;
 6 | 
 7 | 	&::before,
 8 | 	&::after {
 9 | 		content: "";
10 | 		position: absolute;
11 | 		left: $offset;
12 | 		border: solid transparent;
13 | 		border-width: $border-y $border-x;
14 | 		transform: translateX(-$border-x);
15 | 	}
16 | 
17 | 	#{if($position == "bottom", "&", "&[class*='bottom']")} {
18 | 		&::before,
19 | 		&::after {
20 | 			border-top-width: 0;
21 | 		}
22 | 
23 | 		&::before {
24 | 			top: (-$border-y - 1px);
25 | 			border-bottom-color: var(--color-panedivider); // Updated color
26 | 		}
27 | 
28 | 		&::after {
29 | 			top: -$border-y;
30 | 			border-bottom-color: var(--color-toolbar);
31 | 		}
32 | 	}
33 | 
34 | 	#{if($position == "top", "&", "&[class*='top']")} {
35 | 		&::before,
36 | 		&::after {
37 | 			border-bottom-width: 0;
38 | 		}
39 | 
40 | 		&::before {
41 | 			bottom: (-$border-y - 1px);
42 | 			border-top-color: var(--color-panedivider); // Updated color
43 | 		}
44 | 
45 | 		&::after {
46 | 			bottom: -$border-y;
47 | 			border-top-color: var(--color-toolbar);
48 | 		}
49 | 	}
50 | }
51 | 
52 | // An implementation of Firefox light-dark() CSS mixin, which is not supported in 102
53 | @mixin light-dark($prop, $light-color, $dark-color) {
54 | 	@media (prefers-color-scheme: light) {
55 | 		#{$prop}: $light-color;
56 | 	}
57 | 	@media (prefers-color-scheme: dark) {
58 | 		#{$prop}: $dark-color;
59 | 	}
60 | }
61 | 
62 | @mixin macOS-inactive-opacity {
63 | 	$selector: &;
64 | 	@at-root {
65 | 		@media (-moz-platform: macos) {
66 | 			#{$selector} {
67 | 				&:-moz-window-inactive {
68 | 					opacity: 0.6;
69 | 				}
70 | 			}
71 | 		}
72 | 	}
73 | }
74 | 


--------------------------------------------------------------------------------
/src/ui/popups/popup.js:
--------------------------------------------------------------------------------
 1 | 'use strict';
 2 | 
 3 | import React, { useLayoutEffect, useRef } from 'react';
 4 | import cx from 'classnames';
 5 | 
 6 | 
 7 | function Popup({ parentRef, pluginState, className, children }) {
 8 | 	const containerRef = useRef();
 9 | 	const popupRef = useRef();
10 | 
11 | 	useLayoutEffect(() => {
12 | 		if (!pluginState.active) {
13 | 			return;
14 | 		}
15 | 
16 | 		let rect = pluginState.rect || pluginState.node.getBoundingClientRect();
17 | 
18 | 		let parentScrollTop = parentRef.current.scrollTop;
19 | 		let parentTop = parentRef.current.getBoundingClientRect().top;
20 | 		let maxWidth = containerRef.current.offsetWidth;
21 | 		let top = parentScrollTop + (rect.top - popupRef.current.offsetHeight - parentTop - 10);
22 | 
23 | 		if (top < 0) {
24 | 			top = parentScrollTop + (rect.bottom - parentTop) + 10;
25 | 			popupRef.current.classList.remove('popup-top');
26 | 			popupRef.current.classList.add('popup-bottom');
27 | 		}
28 | 		else {
29 | 			popupRef.current.classList.remove('popup-bottom');
30 | 			popupRef.current.classList.add('popup-top');
31 | 		}
32 | 
33 | 		let width = popupRef.current.offsetWidth;
34 | 		let left = rect.left + (rect.right - rect.left) / 2 - width / 2 + 1;
35 | 
36 | 		if (left + width >= maxWidth) {
37 | 			left = maxWidth - width;
38 | 		}
39 | 
40 | 		if (left < 2) {
41 | 			left = 2;
42 | 		}
43 | 
44 | 		popupRef.current.style.top = Math.round(top) + 'px';
45 | 		popupRef.current.style.left = Math.round(left) + 'px';
46 | 	}, [pluginState]);
47 | 
48 | 
49 | 	if (!pluginState.active) return null;
50 | 
51 | 	return (
52 | 		
53 |
54 | {children} 55 |
56 |
57 | ); 58 | 59 | } 60 | 61 | export default Popup; 62 | -------------------------------------------------------------------------------- /src/ui/popups/citation-popup.js: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { useLocalization } from '@fluent/react'; 3 | import Popup from './popup'; 4 | import IconBlockquote from '../../../res/icons/16/cite.svg'; 5 | import IconHide from '../../../res/icons/16/hide.svg'; 6 | import IconDocument from '../../../res/icons/16/page.svg'; 7 | import IconUndo from '../../../res/icons/16/show-item.svg'; 8 | 9 | function CitationPopup({ parentRef, citationState, viewMode }) { 10 | let { l10n } = useLocalization(); 11 | 12 | function handleOpen() { 13 | citationState.popup.open(); 14 | } 15 | 16 | function handleShowItem() { 17 | citationState.popup.showItem(); 18 | } 19 | 20 | function handleEdit() { 21 | citationState.popup.edit(); 22 | } 23 | 24 | function handleRemove() { 25 | citationState.popup.remove(); 26 | } 27 | 28 | return ( 29 | 30 | {citationState.popup.canOpen && } 34 | 38 | {!['ios', 'web'].includes(viewMode) && } 42 | {citationState.popup.canRemove && } 46 | 47 | ) 48 | } 49 | 50 | export default CitationPopup; 51 | -------------------------------------------------------------------------------- /res/icons/16/unlink.svg: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | 7 | 8 | 9 | 10 | 11 | -------------------------------------------------------------------------------- /.github/workflows/ci.yml: -------------------------------------------------------------------------------- 1 | name: CI 2 | on: [ push, pull_request ] 3 | jobs: 4 | build: 5 | name: Build, Upload, Test 6 | runs-on: ubuntu-latest 7 | steps: 8 | - uses: actions/checkout@v3 9 | with: 10 | submodules: recursive 11 | 12 | - name: Install Node 13 | uses: actions/setup-node@v3 14 | with: 15 | node-version: 20 16 | 17 | - name: Cache Node modules 18 | id: node-cache 19 | uses: actions/cache@v3 20 | with: 21 | path: node_modules 22 | key: node-modules-${{ hashFiles('package-lock.json') }} 23 | 24 | - name: Install Node modules 25 | if: steps.node-cache.outputs.cache-hit != 'true' 26 | run: npm ci 27 | # Currently necessary for Webpack 28 | env: 29 | NODE_OPTIONS: --openssl-legacy-provider 30 | 31 | - name: Build note-editor 32 | run: npm run build 33 | # Currently necessary for Webpack 34 | env: 35 | NODE_OPTIONS: --openssl-legacy-provider 36 | 37 | - uses: ruby/setup-ruby@v1 38 | with: 39 | ruby-version: '3.3' 40 | bundler-cache: true 41 | 42 | - name: Upload pre-build ZIP 43 | if: | 44 | env.ACT != 'true' 45 | && github.repository == 'zotero/note-editor' 46 | && github.event_name == 'push' 47 | && (github.ref == 'refs/heads/master' || endsWith(github.ref, '-hotfix') || github.ref == 'refs/heads/gh-actions-ci-test') 48 | env: 49 | AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} 50 | AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} 51 | run: | 52 | mkdir build-zip 53 | cd build 54 | zip -r ../build-zip/$GITHUB_SHA.zip * 55 | cd .. 56 | gem install --no-document dpl -v '>= 2.0' 57 | dpl s3 --bucket zotero-download --local_dir build-zip --upload_dir ci/note-editor --acl public_read 58 | 59 | - name: Run tests 60 | run: npm run test 61 | -------------------------------------------------------------------------------- /src/ui/custom-icons.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | 5 | export function IconHighlighter({ color }) { 6 | return ( 7 | 8 | 14 | {color && } 15 | 16 | ); 17 | } 18 | 19 | export function IconTextColor({ color }) { 20 | return ( 21 | 22 | 25 | 26 | 27 | 28 | ); 29 | } 30 | 31 | export function IconColor({ color }) { 32 | return ( 33 | 34 | 35 | 36 | 40 | 45 | 46 | 47 | 48 | ); 49 | } 50 | -------------------------------------------------------------------------------- /src/stylesheets/base/_base.scss: -------------------------------------------------------------------------------- 1 | *, 2 | ::before, 3 | ::after { 4 | box-sizing: border-box; 5 | } 6 | 7 | :root { 8 | color-scheme: light dark; 9 | background-color: Window; 10 | 11 | font-family: -apple-system, BlinkMacSystemFont, "Segoe UI", "Helvetica Neue", Helvetica, Arial, sans-serif; 12 | font-size: 12px; 13 | 14 | font-style: normal; 15 | 16 | @if $platform == 'zotero' { 17 | @media (-moz-platform: windows) { 18 | --color-accent: var(--accent-blue); 19 | @include light-dark(--color-focus-border, #000, #fff); 20 | --width-focus-border: 2px; 21 | } 22 | @media (-moz-platform: macos) { 23 | --color-accent: SelectedItem; 24 | --color-focus-border: color-mix(in srgb, var(--color-accent) 70%, transparent); 25 | --width-focus-border: 3px; 26 | } 27 | @media (-moz-platform: linux) { 28 | --color-accent: SelectedItem; 29 | --color-focus-border: var(--color-accent); 30 | --width-focus-border: 2px; 31 | } 32 | } @else if $platform == 'web' { 33 | --color-accent: SelectedItem; 34 | --color-focus-border: color-mix(in srgb, var(--color-accent) 70%, transparent); 35 | --width-focus-border: 3px; 36 | } @else if $platform == 'ios' { 37 | --color-accent: SelectedItem; 38 | --color-focus-border: color-mix(in srgb, var(--color-accent) 70%, transparent); 39 | --width-focus-border: 3px; 40 | } @else if $platform == 'android' { 41 | --color-accent: SelectedItem; 42 | --color-focus-border: color-mix(in srgb, var(--color-accent) 70%, transparent); 43 | --width-focus-border: 3px; 44 | } @else if $platform == 'dev' { 45 | --color-accent: SelectedItem; 46 | --color-focus-border: color-mix(in srgb, var(--color-accent) 70%, transparent); 47 | --width-focus-border: 3px; 48 | } 49 | 50 | --color-focus-search: color-mix(in srgb, var(--color-accent) 70%, transparent); 51 | } 52 | 53 | button { 54 | all: unset; 55 | outline: revert; 56 | display: block; 57 | box-sizing: border-box; 58 | 59 | &:focus-visible { 60 | outline: none; 61 | box-shadow: 0 0 0 var(--width-focus-border) var(--color-focus-border); 62 | } 63 | } 64 | 65 | @if $mobile { 66 | // Avoid focus ring on touch devices 67 | button:focus { 68 | outline: none; 69 | box-shadow: none; 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /src/core/math.js: -------------------------------------------------------------------------------- 1 | import { InputRule } from 'prosemirror-inputrules'; 2 | import { mathPlugin, makeBlockMathInputRule, mathBackspaceCmd } from '@benrbray/prosemirror-math'; 3 | import { schema } from './schema'; 4 | 5 | // Input rule 6 | function makeInlineMathInputRule(pattern, nodeType, getAttrs) { 7 | return new InputRule(pattern, (state, match, start, end) => { 8 | let $start = state.doc.resolve(start); 9 | let index = $start.index(); 10 | let $end = state.doc.resolve(end); 11 | 12 | let attrs = getAttrs instanceof Function ? getAttrs(match) : getAttrs; 13 | 14 | if (!$start.parent.canReplaceWith(index, $end.index(), nodeType)) { 15 | return null; 16 | } 17 | 18 | return state.tr 19 | .replaceRangeWith(start, end, nodeType.create(attrs, nodeType.schema.text(match[1]))) 20 | .insertText(match[0].slice(-1), end); 21 | }); 22 | } 23 | 24 | // Keymap 25 | function buildMathCommand(pattern, nodeType) { 26 | return (state, dispatch) => { 27 | if (!state.selection.$cursor) return false; 28 | 29 | let from = state.selection.$cursor.pos; 30 | let to = state.selection.$cursor.pos; 31 | 32 | let $from = state.doc.resolve(from) 33 | let textBefore = $from.parent.textBetween(0, $from.parentOffset, null, '\ufffc'); 34 | 35 | let match = pattern.exec(textBefore); 36 | if (!match) return false; 37 | 38 | let start = from - match[0].length; 39 | let end = to; 40 | 41 | let $start = state.doc.resolve(start); 42 | let index = $start.index(); 43 | let $end = state.doc.resolve(end); 44 | 45 | if (!$start.parent.canReplaceWith(index, $end.index(), nodeType)) return false; 46 | 47 | let { tr } = state; 48 | tr.replaceRangeWith(start,end, nodeType.create(null, nodeType.schema.text(match[1]))) 49 | .scrollIntoView(); 50 | dispatch(tr); 51 | return false; 52 | } 53 | } 54 | 55 | const INLINE_MATH_PATTERN = /\$([^\$]+)\$$/; 56 | const createInlineMath = buildMathCommand(INLINE_MATH_PATTERN, schema.nodes.math_inline); 57 | const mathKeymap = { 58 | 'Backspace': mathBackspaceCmd, 59 | 'Enter': createInlineMath, 60 | 'ArrowUp': createInlineMath, 61 | 'ArrowDown': createInlineMath, 62 | 'ArrowLeft': createInlineMath, 63 | 'ArrowRight': createInlineMath, 64 | }; 65 | 66 | export { mathPlugin, mathKeymap, makeBlockMathInputRule, makeInlineMathInputRule }; 67 | -------------------------------------------------------------------------------- /src/core/schema/transformer.js: -------------------------------------------------------------------------------- 1 | export function preprocessHTML(html) { 2 | let metadataAttributes = {}; 3 | let doc = document.implementation.createHTMLDocument(''); 4 | let container = doc.body; 5 | container.innerHTML = html; 6 | 7 | let metadataNode = doc.querySelector('body > div[data-schema-version]'); 8 | if (metadataNode) { 9 | let attrs = metadataNode.attributes; 10 | for (let i = 0; i < attrs.length; i++) { 11 | let attr = attrs[i]; 12 | // TinyMCE keeps only data attributes 13 | if (attr.name.startsWith('data-')) { 14 | metadataAttributes[attr.name] = attr.value; 15 | } 16 | } 17 | } 18 | 19 | function createLink(url) { 20 | let a = doc.createElement('a'); 21 | a.href = url; 22 | a.appendChild(doc.createTextNode(url)); 23 | return a; 24 | } 25 | 26 | function createImage(src) { 27 | let img = doc.createElement('img'); 28 | img.src = src; 29 | return img; 30 | } 31 | 32 | function walk(elm) { 33 | let node; 34 | for (node = elm.firstChild; node; node = node.nextSibling) { 35 | if (node.nodeType === Node.ELEMENT_NODE) { 36 | if (node.style) { 37 | if (node.style.backgroundImage) { 38 | let matched = node.style.backgroundImage.match(/url\(["']?([^"']*)["']?\)/); 39 | if (matched && /^(https?|data):/.test(matched[1])) { 40 | node.parentElement.insertBefore(createImage(matched[1]), node); 41 | } 42 | } 43 | } 44 | 45 | if (node.nodeName !== 'IMG' && node.getAttribute('src')) { 46 | node.parentElement.insertBefore(createLink(node.getAttribute('src')), node); 47 | } 48 | walk(node); 49 | } 50 | } 51 | } 52 | 53 | walk(container); 54 | 55 | return { html: container.innerHTML, metadataAttributes }; 56 | } 57 | 58 | // Additional transformations that can't be described with schema alone 59 | export function schemaTransform(state) { 60 | let { tr } = state; 61 | let updated = false; 62 | state.doc.descendants((node, pos) => { 63 | // Do not allow to be wrapped in any mark 64 | if (['image'].includes(node.type.name) && node.marks.length) { 65 | tr.setNodeMarkup(pos, null, node.attrs, []); 66 | updated = true; 67 | } 68 | // Force inline code to have only plain text 69 | else if (!node.isText && node.marks.find(mark => mark.type.name === 'code')) { 70 | tr.removeMark(pos, pos + 1, state.schema.marks.code); 71 | updated = true; 72 | } 73 | }); 74 | return updated && tr || null; 75 | } 76 | -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "zotero-note-editor", 3 | "version": "1.0.0", 4 | "description": "", 5 | "scripts": { 6 | "build": "webpack --node-env production", 7 | "build:web": "webpack --node-env production --config-name web", 8 | "build:zotero": "webpack --node-env production --config-name zotero", 9 | "build:ios": "webpack --node-env production --config-name ios", 10 | "start": "webpack serve --node-env development --config-name dev", 11 | "test": "mocha", 12 | "test:watch": "mocha -w" 13 | }, 14 | "author": "Martynas Bagdonas", 15 | "license": "AGPL-3.0", 16 | "browserslist": [ 17 | "ff >= 60.9.0" 18 | ], 19 | "dependencies": { 20 | "@benrbray/prosemirror-math": "github:zotero/prosemirror-math", 21 | "@fluent/bundle": "^0.19.1", 22 | "@fluent/react": "^0.15.2", 23 | "classnames": "^2.3.1", 24 | "core-js": "^3.37.1", 25 | "katex": "^0.16.22", 26 | "markdown-it": "^14.1.0", 27 | "prop-types": "^15.8.1", 28 | "prosemirror-commands": "^1.7.1", 29 | "prosemirror-dropcursor": "^1.8.2", 30 | "prosemirror-gapcursor": "^1.3.2", 31 | "prosemirror-history": "^1.4.1", 32 | "prosemirror-inputrules": "^1.5.0", 33 | "prosemirror-keymap": "^1.2.3", 34 | "prosemirror-markdown": "^1.13.2", 35 | "prosemirror-model": "^1.25.2", 36 | "prosemirror-schema-basic": "^1.2.4", 37 | "prosemirror-schema-list": "^1.5.1", 38 | "prosemirror-state": "^1.4.3", 39 | "prosemirror-tables": "^1.7.1", 40 | "prosemirror-transform": "^1.10.4", 41 | "prosemirror-utils": "^1.2.2", 42 | "prosemirror-view": "^1.40.1", 43 | "react": "^18.3.1", 44 | "react-dom": "^18.3.1" 45 | }, 46 | "devDependencies": { 47 | "@babel/core": "^7.18.9", 48 | "@babel/eslint-parser": "^7.18.9", 49 | "@babel/plugin-transform-runtime": "^7.18.9", 50 | "@babel/preset-env": "^7.18.9", 51 | "@babel/preset-react": "^7.18.6", 52 | "@babel/runtime": "^7.18.9", 53 | "@svgr/webpack": "^8.1.0", 54 | "@zotero/eslint-config": "^1.0.6", 55 | "autoprefixer": "^10.4.7", 56 | "babel-loader": "^8.2.5", 57 | "babel-runtime": "^6.26.0", 58 | "chai": "^6.2.0", 59 | "clean-webpack-plugin": "^4.0.0", 60 | "css-loader": "^6.7.1", 61 | "css-minimizer-webpack-plugin": "^4.0.0", 62 | "eslint": "^8.20.0", 63 | "eslint-plugin-react": "^7.30.1", 64 | "html-webpack-plugin": "^5.5.0", 65 | "js-beautify": "^1.14.4", 66 | "mini-css-extract-plugin": "^2.6.1", 67 | "mocha": "^11.7.4", 68 | "postcss": "^8.4.14", 69 | "postcss-loader": "^7.0.1", 70 | "postcss-rtlcss": "^3.7.2", 71 | "sass": "^1.54.0", 72 | "sass-loader": "^15.0.0", 73 | "terser-webpack-plugin": "^5.3.3", 74 | "webpack": "^5.74.0", 75 | "webpack-cli": "^5.1.4", 76 | "webpack-dev-middleware": "^7.3.0", 77 | "webpack-dev-server": "5.0.4" 78 | } 79 | } 80 | -------------------------------------------------------------------------------- /src/ui/toolbar-elements/text-color-dropdown.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import { useLocalization } from '@fluent/react'; 5 | import cx from 'classnames'; 6 | 7 | import Dropdown from './dropdown'; 8 | 9 | import { IconColor, IconTextColor } from '../custom-icons'; 10 | import IconRemoveColor from '../../../res/icons/16/remove-color.svg'; 11 | 12 | export default function TextColorDropdown({ textColorState }) { 13 | const { l10n } = useLocalization(); 14 | 15 | let colorState = textColorState; 16 | 17 | function handleColorPick(color) { 18 | textColorState.state.setColor(color) 19 | } 20 | 21 | function handleColorClear() { 22 | textColorState.state.removeColor(); 23 | } 24 | 25 | let activeColor = textColorState.state.activeColors.length === 1 26 | ? textColorState.state.activeColors[0] 27 | : null; 28 | 29 | let clear = !!textColorState.state.activeColors.length; 30 | 31 | return ( 32 | } 35 | title={l10n.getString('note-editor-text-color')} 36 | > 37 | {clear && 38 | 42 | } 43 | {clear &&
} 44 | { 45 | colorState.state.availableColors.slice(0, 8).map(([name, code], i) => { 46 | let active = colorState.state.activeColors.includes(code); 47 | return ( 48 | 62 | ) 63 | }) 64 | } 65 | {colorState.state.availableColors.length >= 8 &&
} 66 | { 67 | colorState.state.availableColors.slice(8).map(([name, code], i) => { 68 | let active = colorState.state.activeColors.includes(code); 69 | return ( 70 | 80 | ) 81 | }) 82 | } 83 | 84 | ); 85 | } 86 | -------------------------------------------------------------------------------- /src/stylesheets/components/core/_prosemirror-math.scss: -------------------------------------------------------------------------------- 1 | // Note: This is the original ~@benrbray/prosemirror-math/style/math.css, except 2 | // the global text selection background color is commented, because it was messing 3 | // up all other nodes since Zotero 7, because 60 ESR supported only -moz-selection… 4 | 5 | /*--------------------------------------------------------- 6 | * Author: Benjamin R. Bray 7 | * License: MIT (see LICENSE in project root for details) 8 | *--------------------------------------------------------*/ 9 | 10 | /* == Math Nodes ======================================== */ 11 | 12 | .math-node { 13 | min-width: 1em; 14 | min-height: 1em; 15 | font-size: 0.95em; 16 | font-family: "Consolas", "Ubuntu Mono", monospace; 17 | cursor: auto; 18 | } 19 | 20 | .math-node.empty-math .math-render::before { 21 | content: "(empty)"; 22 | color: red; 23 | } 24 | 25 | .math-node .math-render.parse-error::before { 26 | content: "(math error)"; 27 | color: red; 28 | cursor: help; 29 | } 30 | 31 | .math-node.ProseMirror-selectednode { outline: none; } 32 | 33 | .math-node .math-src { 34 | display: none; 35 | color: rgb(132, 33, 162); 36 | tab-size: 4; 37 | } 38 | 39 | .math-node.ProseMirror-selectednode .math-src { display: inline; } 40 | .math-node.ProseMirror-selectednode .math-render { display: none; } 41 | 42 | /* -- Inline Math --------------------------------------- */ 43 | 44 | math-inline { display: inline; white-space: nowrap; } 45 | 46 | math-inline .math-render { 47 | display: inline-block; 48 | cursor:pointer; 49 | } 50 | 51 | math-inline .math-src .ProseMirror { 52 | display: inline; 53 | /* Necessary to fix FireFox bug with contenteditable, https://bugzilla.mozilla.org/show_bug.cgi?id=1252108 */ 54 | border-right: 1px solid transparent; 55 | border-left: 1px solid transparent; 56 | } 57 | 58 | math-inline .math-src::after, math-inline .math-src::before { 59 | content: "$"; 60 | color: var(--fill-tertiary); 61 | } 62 | 63 | /* -- Block Math ---------------------------------------- */ 64 | 65 | math-display { display: block; } 66 | 67 | math-display .math-render { display: block; } 68 | 69 | math-display.ProseMirror-selectednode { background-color: var(--fill-quinary); } 70 | 71 | math-display .math-src .ProseMirror { 72 | width: 100%; 73 | display: block; 74 | } 75 | 76 | math-display .math-src::after, math-display .math-src::before { 77 | content: "$$"; 78 | text-align: left; 79 | color: var(--fill-tertiary); 80 | } 81 | 82 | math-display .katex-display { margin: 0; } 83 | 84 | /* -- Selection Plugin ---------------------------------- */ 85 | 86 | //p::selection, p > *::selection { background-color: #c0c0c0; } 87 | .katex-html *::selection { background-color: none !important; } 88 | 89 | .math-node.math-select .math-render { 90 | background-color: var(--fill-tertiary); 91 | } 92 | math-inline.math-select .math-render { 93 | padding-top: 2px; 94 | } 95 | -------------------------------------------------------------------------------- /src/ui/popups/table-popup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React, { useCallback } from 'react'; 4 | import { useLocalization } from '@fluent/react'; 5 | 6 | import Popup from './popup'; 7 | import IconInsertRowAbove from '../../../res/icons/16/insert-row-above.svg'; 8 | import IconInsertRowBelow from '../../../res/icons/16/insert-row-below.svg'; 9 | import IconInsertColumnRight from '../../../res/icons/16/insert-column-right.svg'; 10 | import IconInsertColumnLeft from '../../../res/icons/16/insert-column-left.svg'; 11 | import IconDeleteRow from '../../../res/icons/16/delete-row.svg'; 12 | import IconDeleteColumn from '../../../res/icons/16/delete-column.svg'; 13 | import IconDeleteTable from '../../../res/icons/16/delete-table.svg'; 14 | 15 | function TablePopup({ parentRef, tableState }) { 16 | const { l10n } = useLocalization(); 17 | 18 | const handleInsertRowBefore = useCallback(() => { 19 | tableState.insertRowBefore(); 20 | }); 21 | const handleInsertRowAfter = useCallback(() => { 22 | tableState.insertRowAfter(); 23 | }); 24 | 25 | const handleColumnBefore = useCallback(() => { 26 | tableState.insertColumnBefore(); 27 | }); 28 | 29 | const handleColumnAfter = useCallback(() => { 30 | tableState.insertColumnAfter(); 31 | }); 32 | 33 | const handleDeleteColumn = useCallback(() => { 34 | tableState.deleteColumn(); 35 | }); 36 | 37 | const handleDeleteRow = useCallback(() => { 38 | tableState.deleteRow(); 39 | }); 40 | 41 | const handleDeleteTable = useCallback(() => { 42 | tableState.deleteTable(); 43 | }); 44 | 45 | return ( 46 | 47 | 53 | 59 | 65 | 71 | 77 | 83 | 89 | 90 | ); 91 | } 92 | 93 | export default TablePopup; 94 | -------------------------------------------------------------------------------- /src/ui/popups/link-popup.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React, { useState, useEffect, useLayoutEffect, useRef, Fragment } from 'react'; 4 | import { useLocalization } from '@fluent/react'; 5 | 6 | import Popup from './popup'; 7 | 8 | import IconCheckmark from '../../../res/icons/16/checkmark.svg'; 9 | import IconEdit from '../../../res/icons/16/edit.svg'; 10 | import IconUnlink from '../../../res/icons/16/unlink.svg'; 11 | 12 | function LinkPopup({ parentRef, pluginState }) { 13 | const { l10n } = useLocalization(); 14 | const [editing, setEditing] = useState(false); 15 | const inputRef = useRef(); 16 | 17 | useEffect(() => { 18 | setEditing(!pluginState.href || pluginState.edit); 19 | }, [pluginState]); 20 | 21 | useLayoutEffect(() => { 22 | if (inputRef.current) { 23 | inputRef.current.value = pluginState.href || 'https://'; 24 | } 25 | 26 | if (editing) { 27 | setTimeout(() => { 28 | if (inputRef.current) { 29 | inputRef.current.focus(); 30 | if (pluginState.href) { 31 | inputRef.current.select(); 32 | } 33 | } 34 | }, 0); 35 | } 36 | }, [editing, pluginState]); 37 | 38 | function handleSet() { 39 | pluginState.setURL(inputRef.current.value); 40 | } 41 | 42 | function handleUnset() { 43 | pluginState.removeURL(); 44 | } 45 | 46 | function handleOpen(event) { 47 | event.preventDefault(); 48 | pluginState.open(); 49 | } 50 | 51 | function handleEdit() { 52 | setEditing(true); 53 | } 54 | 55 | function handleKeydown(event) { 56 | if (event.key === 'Enter') { 57 | pluginState.setURL(inputRef.current.value); 58 | event.preventDefault(); 59 | } 60 | else if (event.key === 'Escape') { 61 | pluginState.cancel(); 62 | event.preventDefault(); 63 | } 64 | } 65 | 66 | function handleInput(event) { 67 | event.target.value = event.target.value.replace(/^[a-z]+:\/\/([a-z]+:\/\/)(.*)/, '$1$2'); 68 | } 69 | 70 | return ( 71 | 72 | {editing 73 | ? ( 74 | 75 |
76 | 83 |
84 | 90 |
91 | ) 92 | : ( 93 | 94 | 95 | 101 | 107 | 108 | )} 109 |
110 | ); 111 | 112 | } 113 | 114 | export default LinkPopup; 115 | -------------------------------------------------------------------------------- /src/ui/toolbar-elements/highlight-color-dropdown.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import { useLocalization } from '@fluent/react'; 5 | import cx from 'classnames'; 6 | 7 | import Dropdown from './dropdown'; 8 | 9 | import { IconColor, IconHighlighter } from '../custom-icons'; 10 | import IconRemoveColor from '../../../res/icons/16/remove-color.svg'; 11 | 12 | export default function HighlightColorDropdown({ highlightColorState, underlineColorState }) { 13 | const { l10n } = useLocalization(); 14 | 15 | function handleColorPick(color) { 16 | if (underlineColorState.state.isCursorInUnderline) { 17 | underlineColorState.state.setColor(color); 18 | } 19 | else { 20 | highlightColorState.state.setColor(color); 21 | } 22 | } 23 | 24 | function handleColorClear() { 25 | if (underlineColorState.state.isCursorInUnderline) { 26 | underlineColorState.state.removeColor(); 27 | } 28 | else { 29 | highlightColorState.state.removeColor(); 30 | } 31 | } 32 | 33 | let colorState = underlineColorState.state.isCursorInUnderline ? underlineColorState : highlightColorState; 34 | 35 | let activeColor = colorState.state.activeColors.length === 1 36 | ? colorState.state.activeColors[0] 37 | : null; 38 | 39 | let clear = !!colorState.state.activeColors.length; 40 | 41 | return ( 42 | } 45 | title={l10n.getString('note-editor-highlight-text')} 46 | > 47 | {clear && 48 | 52 | } 53 | {clear &&
} 54 | { 55 | colorState.state.availableColors.slice(0, 8).map(([name, code], i) => { 56 | let active = colorState.state.activeColors.includes(code); 57 | return ( 58 | 70 | ) 71 | }) 72 | } 73 | {colorState.state.availableColors.length >= 8 &&
} 74 | { 75 | colorState.state.availableColors.slice(8).map(([name, code], i) => { 76 | let active = colorState.state.activeColors.includes(code); 77 | return ( 78 | 88 | ) 89 | }) 90 | } 91 | 92 | ); 93 | } 94 | -------------------------------------------------------------------------------- /src/core/input-rules.js: -------------------------------------------------------------------------------- 1 | import { Fragment } from 'prosemirror-model'; 2 | import { 3 | wrappingInputRule, 4 | inputRules, 5 | InputRule, 6 | textblockTypeInputRule, 7 | smartQuotes, 8 | ellipsis, 9 | emDash 10 | } from 'prosemirror-inputrules'; 11 | 12 | import { schema } from './schema'; 13 | import { TextSelection } from 'prosemirror-state'; 14 | 15 | import { makeBlockMathInputRule, makeInlineMathInputRule } from './math'; 16 | 17 | function markInputRule(regexp, markType, size) { 18 | return new InputRule(regexp, (state, match, start, end) => { 19 | let to = end; 20 | let from = match[1] ? to - match[1].length + 1 : start; 21 | if (schema.marks.code 22 | && schema.marks.code.isInSet(state.doc.resolve(from + 1).marks())) { 23 | return; 24 | } 25 | 26 | // Skip input rule if matched range includes inline math node 27 | let nodesInRange = []; 28 | state.doc.nodesBetween(from, to, (node, pos) => { 29 | nodesInRange.push({ node, pos }); 30 | }); 31 | if (nodesInRange.find(x => x.node.type.name === 'math_inline')) { 32 | return; 33 | } 34 | 35 | let tr = state.tr.addMark(from, to, markType.create()); 36 | if (size > 1) { 37 | tr = tr.delete(to - (size - 1), to); 38 | } 39 | return tr.delete(from, from + size).removeStoredMark(markType); 40 | }); 41 | } 42 | 43 | function linkInputRule() { 44 | let regexp = /(^|[^!])\[(.*?)\]\((\S+)\)$/; 45 | return new InputRule(regexp, (state, match, start, end) => { 46 | return state.tr.replaceWith( 47 | start + match[1].length, 48 | end, 49 | schema.text(match[2], [schema.mark('link', { href: match[3] })]) 50 | ); 51 | }); 52 | } 53 | 54 | export function buildInputRules({ enableSmartQuotes }) { 55 | let rules = [ 56 | ellipsis, 57 | // emDash, 58 | wrappingInputRule(/^\s*>\s$/, schema.nodes.blockquote), 59 | wrappingInputRule(/^\s*(1\.)\s$/, schema.nodes.orderedList), 60 | wrappingInputRule(/^\s*([-+*])\s$/, schema.nodes.bulletList), 61 | new InputRule(/^```$/, (state, match, start, end) => { 62 | let { tr } = state; 63 | return tr 64 | .replaceWith(start - 1, end, Fragment.from(schema.nodes.codeBlock.create())) 65 | .setSelection(new TextSelection(tr.doc.resolve(start))); 66 | }), 67 | linkInputRule(), 68 | makeBlockMathInputRule(/^\$\$\s+$/, schema.nodes.math_display), 69 | makeInlineMathInputRule(/\$([^\$]+)\$(?=[^\w\d])[\s\S]$/, schema.nodes.math_inline), 70 | markInputRule(/(?:[^`0-9A-Za-z]+)(__([^\s_][^_]+)__)$|^(__([^\s _][^_]+)__)$/, schema.marks.strong, 2), 71 | markInputRule(/^(?:[^`]+)(\*\*([^\s*][^*]+)\*\*)$|^(\*\*([^\s*][^*]+)\*\*)$/, schema.marks.strong, 2), 72 | markInputRule(/(?:[^_`0-9A-Za-z]+)(_([^\s_][^_]+?)_)$|^(_([^\s_][^_]+)_)$/, schema.marks.em, 1), 73 | markInputRule(/^(?:[^*`]+)(\*([^\s*][^*]+?)\*)$|^(\*([^\s*][^*]+)\*)$/, schema.marks.em, 1), 74 | markInputRule(/^(?:[^`]+)(~~([^\s~][^~]+)~~)$|^(~~([^\s~][^~]+)~~)$/, schema.marks.strike, 2), 75 | markInputRule(/(`[^\s`].*`)$/, schema.marks.code, 1), 76 | new InputRule(/^\-\-\-$|^\*\*\*$/, (state, match, start, end) => { 77 | return state.tr.replaceWith(start - 1, end, Fragment.from(schema.nodes.horizontalRule.create())); 78 | }), 79 | textblockTypeInputRule(new RegExp("^(#{1,6}) $"), schema.nodes.heading, match => ({level: match[1].length})), 80 | ]; 81 | 82 | if (enableSmartQuotes) { 83 | rules = [...smartQuotes, ...rules]; 84 | } 85 | 86 | return inputRules({ rules }); 87 | } 88 | -------------------------------------------------------------------------------- /src/core/plugins/text-color.js: -------------------------------------------------------------------------------- 1 | import { Plugin, PluginKey } from 'prosemirror-state'; 2 | import { Mark } from 'prosemirror-model'; 3 | import { schema, TEXT_COLORS } from '../schema'; 4 | import { removeMarkRangeAtCursor, updateMarkRangeAtCursor } from '../commands'; 5 | import { getMarkRange } from '../helpers'; 6 | 7 | const MAX_AVAILABLE_COLORS = 30; 8 | 9 | class Color { 10 | constructor(state, options) { 11 | this.state = { 12 | availableColors: TEXT_COLORS, 13 | activeColors: [] 14 | }; 15 | } 16 | 17 | update(state, oldState) { 18 | if (!this.view) { 19 | return; 20 | } 21 | 22 | let oldCurrentMarks = oldState && (oldState.storedMarks || oldState.selection.$from.marks()) || []; 23 | let newCurrentMarks = state && (state.storedMarks || state.selection.$from.marks()) || []; 24 | 25 | if (oldState 26 | && oldState.doc.eq(state.doc) 27 | && oldState.selection.eq(state.selection) 28 | && Mark.sameSet(oldCurrentMarks, newCurrentMarks) 29 | ) { 30 | return; 31 | } 32 | 33 | let availableColors = []; 34 | let activeColors = []; 35 | 36 | state.doc.descendants((node, pos) => { 37 | let mark = node.marks.find(mark => mark.type === schema.marks.textColor); 38 | if (mark) { 39 | let color = mark.attrs.color; 40 | color = color.toLowerCase(); 41 | if (!availableColors.find(x => x[1] === color) 42 | && !TEXT_COLORS.find(x => x[1] === color)) { 43 | availableColors.push(['', color]); 44 | } 45 | } 46 | }); 47 | 48 | availableColors.sort((a, b) => a[1] < b[1]); 49 | availableColors = [...TEXT_COLORS, ...availableColors]; 50 | availableColors = availableColors.slice(0, MAX_AVAILABLE_COLORS); 51 | 52 | let { from, to } = state.selection; 53 | 54 | let marks = []; 55 | state.doc.nodesBetween(from, to, node => { 56 | marks = [...marks, ...node.marks]; 57 | }); 58 | 59 | marks = [ 60 | ...marks, 61 | ...(state.storedMarks || state.selection.$from.marks()) 62 | ]; 63 | 64 | for (let mark of marks) { 65 | if (mark.type === schema.marks.textColor) { 66 | let color = mark.attrs.color; 67 | color = color.toLowerCase(); 68 | if (!activeColors.includes(color)) { 69 | activeColors.push(color); 70 | } 71 | } 72 | } 73 | 74 | this.state = { 75 | availableColors, 76 | activeColors, 77 | setColor: this.setColor.bind(this), 78 | removeColor: this.removeColor.bind(this), 79 | }; 80 | } 81 | 82 | setColor(color) { 83 | this.view.focus(); 84 | let { state, dispatch } = this.view; 85 | updateMarkRangeAtCursor(schema.marks.textColor, { color })(state, dispatch); 86 | } 87 | 88 | removeColor() { 89 | this.view.focus(); 90 | let { state, dispatch } = this.view; 91 | removeMarkRangeAtCursor(schema.marks.textColor)(state, dispatch); 92 | } 93 | } 94 | 95 | export let textColorKey = new PluginKey('text-color'); 96 | 97 | export function textColor(options) { 98 | return new Plugin({ 99 | key: textColorKey, 100 | state: { 101 | init(config, state) { 102 | return new Color(state, options); 103 | }, 104 | apply(tr, pluginState, oldState, newState) { 105 | return pluginState; 106 | } 107 | }, 108 | view: (view) => { 109 | let pluginState = textColorKey.getState(view.state); 110 | pluginState.view = view; 111 | pluginState.update(view.state); 112 | return { 113 | update(view, lastState) { 114 | pluginState.update(view.state, lastState); 115 | }, 116 | destroy() { 117 | pluginState.destroy(); 118 | } 119 | }; 120 | } 121 | }); 122 | } 123 | -------------------------------------------------------------------------------- /src/stylesheets/themes/_dark.scss: -------------------------------------------------------------------------------- 1 | // Copied form zotero/scss/_dark.scss 2 | 3 | @use 'sass:color'; 4 | @use "sass:map"; 5 | 6 | $-colors: ( 7 | accent-blue: #4072e5, 8 | accent-blue10: #4072e54d, 9 | accent-blue30: #4072e573, 10 | accent-blue50: #4072e599, 11 | accent-gold: #cc9200d9, 12 | accent-green: #39bf68d9, 13 | accent-orange: #ff794cd9, 14 | accent-red: #db2c3ae5, 15 | accent-teal: #59adc4e5, 16 | accent-white: #fff, 17 | accent-wood-dark: #996b6f, 18 | accent-wood: #cc7a52e5, 19 | accent-yellow: #faa700cc, 20 | fill-primary: #ffffffe5, 21 | fill-secondary: #ffffff8c, 22 | fill-tertiary: #ffffff4d, 23 | fill-quarternary: #ffffff1f, 24 | fill-quinary: #ffffff0f, 25 | fill-senary: #ffffff08, 26 | color-background: #1e1e1e, 27 | color-background50: #1e1e1e80, 28 | color-background70: #1e1e1eb2, 29 | color-border: #ffffff2e, 30 | color-border50: #ffffff17, 31 | color-button: #404040, 32 | color-control: #ccc, 33 | color-menu: #28282894, 34 | color-panedivider: #404040, 35 | color-sidepane: #303030, 36 | color-tabbar: #1e1e1e, 37 | color-toolbar: #272727, 38 | color-scrollbar: rgb(117, 117, 117), 39 | color-scrollbar-hover: rgb(158, 158, 158), 40 | color-scrollbar-background: transparent, 41 | ); 42 | 43 | @mixin -dark-rules() { 44 | @each $name, $color in $-colors { 45 | --#{$name}: #{$color}; 46 | } 47 | 48 | // composite (opaque) colors 49 | --color-quinary-on-background: #{color.mix( 50 | map.get($-colors, "color-background"), color.change(map.get($-colors, "fill-quinary"), $alpha: 1), 100% * (1 - color.alpha(map.get($-colors, "fill-quinary"))) 51 | )}; 52 | --color-quarternary-on-background: #{color.mix( 53 | map.get($-colors, "color-background"), color.change(map.get($-colors, "fill-quarternary"), $alpha: 1), 100% * (1 - color.alpha(map.get($-colors, "fill-quarternary"))) 54 | )}; 55 | --color-quarternary-on-sidepane: #{color.mix( 56 | map.get($-colors, "color-sidepane"), color.change(map.get($-colors, "fill-quarternary"), $alpha: 1), 100% * (1 - color.alpha(map.get($-colors, "fill-quarternary"))) 57 | )}; 58 | 59 | // background materials 60 | --material-background: var(--color-background); 61 | --material-background50: var(--color-background50); 62 | --material-background70: var(--color-background70); 63 | --material-button: var(--color-button); 64 | --material-control: var(--color-control); 65 | --material-menu: var(--color-menu); 66 | --material-sidepane: var(--color-sidepane); 67 | --material-tabbar: var(--color-tabbar); 68 | --material-toolbar: var(--color-toolbar); 69 | --material-mix-quinary: var(--color-quinary-on-background); 70 | --material-mix-quarternary: var(--color-quarternary-on-background); 71 | 72 | // border materials 73 | --material-border-transparent: 1px solid transparent; 74 | --material-border: 1px solid var(--color-border); 75 | --material-border50: 1px solid var(--color-border50); 76 | --material-panedivider: 1px solid var(--color-panedivider); 77 | --material-border-quinary: 1px solid var(--fill-quinary); 78 | --material-border-quarternary: 1px solid var(--fill-quarternary); 79 | } 80 | 81 | @if $platform == 'web' { 82 | :root[data-color-scheme=dark] { 83 | @include -dark-rules(); 84 | } 85 | 86 | @media (prefers-color-scheme: dark) { 87 | :root:not([data-color-scheme]) { 88 | @include -dark-rules(); 89 | } 90 | } 91 | } @else { 92 | @media (prefers-color-scheme: dark) { 93 | :root { 94 | @include -dark-rules(); 95 | } 96 | } 97 | } 98 | -------------------------------------------------------------------------------- /src/core/keymap.js: -------------------------------------------------------------------------------- 1 | import { 2 | wrapIn, setBlockType, chainCommands, toggleMark, exitCode, 3 | joinUp, joinDown, lift, newlineInCode, liftEmptyBlock, createParagraphNear 4 | } from 'prosemirror-commands'; 5 | import { wrapInList, splitListItem } from 'prosemirror-schema-list'; 6 | import { undo, redo } from 'prosemirror-history'; 7 | import { undoInputRule } from 'prosemirror-inputrules'; 8 | import { schema } from './schema'; 9 | import { changeIndent, removeBlockIndent, customSplitBlock } from './commands'; 10 | import { isMac } from './utils'; 11 | 12 | export function buildKeymap(options) { 13 | let keys = {}; 14 | 15 | function bind(key, cmd) { 16 | if (key.includes('Mod-')) { 17 | key = key.replace(/Mod-/g, isMac() ? 'Cmd-' : 'Ctrl-'); 18 | } 19 | keys[key] = cmd; 20 | } 21 | 22 | bind('Mod-z', customUndo); 23 | bind('Shift-Mod-z', redo); 24 | bind('Backspace', undoInputRule); 25 | bind('Backspace', removeBlockIndent()); 26 | if (!isMac()) bind('Ctrl-y', redo); 27 | 28 | bind('Alt-F10', focusToolbar); 29 | bind('Alt-ArrowUp', joinUp); 30 | bind('Alt-ArrowDown', joinDown); 31 | bind('Mod-BracketLeft', lift); 32 | 33 | bind('Mod-b', toggleMark(schema.marks.strong)); 34 | bind('Mod-B', toggleMark(schema.marks.strong)); 35 | 36 | bind('Mod-i', toggleMark(schema.marks.em)); 37 | bind('Mod-I', toggleMark(schema.marks.em)); 38 | 39 | bind('Mod-u', toggleMark(schema.marks.underline)); 40 | bind('Mod-U', toggleMark(schema.marks.underline)); 41 | 42 | bind('Mod-`', toggleMark(schema.marks.code)); 43 | 44 | bind('Mod-k', options.toggleLink); 45 | bind('Mod-K', options.toggleLink); 46 | 47 | if (isMac()) { 48 | bind('Cmd-Ctrl-c', options.insertCitation); 49 | bind('Cmd-Ctrl-C', options.insertCitation); 50 | } 51 | else { 52 | bind('Ctrl-Alt-c', options.insertCitation); 53 | bind('Ctrl-Alt-C', options.insertCitation); 54 | } 55 | 56 | bind('Shift-Ctrl-8', wrapInList(schema.nodes.bulletList)); 57 | bind('Shift-Ctrl-9', wrapInList(schema.nodes.orderedList)); 58 | bind('Ctrl->', wrapIn(schema.nodes.blockquote)); 59 | 60 | // Hard break 61 | let cmd = chainCommands(exitCode, (state, dispatch) => { 62 | dispatch(state.tr.replaceSelectionWith(schema.nodes.hardBreak.create()).scrollIntoView()); 63 | return true; 64 | }); 65 | bind('Mod-Enter', cmd); 66 | bind('Shift-Enter', cmd); 67 | if (isMac()) bind('Ctrl-Enter', cmd); 68 | 69 | bind('Shift-Tab', chainCommands( 70 | options.goToPreviousCell, 71 | changeIndent(-1, true) 72 | )); 73 | bind('Tab', chainCommands( 74 | options.goToNextCell, 75 | changeIndent(1, true) 76 | )); 77 | 78 | bind('Shift-Ctrl-0', setBlockType(schema.nodes.paragraph)); 79 | bind('Shift-Ctrl-\\', setBlockType(schema.nodes.codeBlock)); 80 | 81 | // Heading 82 | for (let i = 1; i <= 6; i++) { 83 | bind('Shift-Ctrl-' + i, setBlockType(schema.nodes.heading, { level: i })); 84 | } 85 | 86 | // Horizontal rule 87 | bind('Mod-_', (state, dispatch) => { 88 | dispatch(state.tr.replaceSelectionWith(schema.nodes.horizontalRule.create()).scrollIntoView()); 89 | return true; 90 | }); 91 | 92 | bind('Enter', chainCommands( 93 | splitListItem(schema.nodes.listItem), 94 | newlineInCode, 95 | createParagraphNear, 96 | liftEmptyBlock, 97 | customSplitBlock 98 | ),); 99 | 100 | return keys; 101 | } 102 | 103 | function customUndo(state, dispatch) { 104 | if(undoInputRule(state, dispatch)) { 105 | return true; 106 | } else { 107 | return undo(state, dispatch); 108 | } 109 | } 110 | 111 | function focusToolbar() { 112 | document.querySelector('.toolbar button').focus(); 113 | } 114 | 115 | window.addEventListener('keydown', function(event) { 116 | if (event.key === 'Escape') { 117 | document.querySelector('.primary-editor').focus(); 118 | // TODO: Close findbar 119 | } 120 | }); 121 | -------------------------------------------------------------------------------- /src/ui/toolbar-elements/text-dropdown.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React from 'react'; 4 | import { useLocalization } from '@fluent/react'; 5 | import cx from 'classnames'; 6 | 7 | import Dropdown from './dropdown'; 8 | import { StateButton } from './button'; 9 | 10 | import IconFormatText from '../../../res/icons/20/format-text.svg'; 11 | import IconBold from '../../../res/icons/20/bold.svg'; 12 | import IconItalic from '../../../res/icons/20/italic.svg'; 13 | import IconUnderline from '../../../res/icons/20/underline.svg'; 14 | import IconStrike from '../../../res/icons/20/strikethrough.svg'; 15 | import IconSubscript from '../../../res/icons/20/subscript.svg'; 16 | import IconSuperscript from '../../../res/icons/20/superscript.svg'; 17 | import IconCode from '../../../res/icons/20/monospaced-1.25.svg'; 18 | 19 | export default function TextDropdown({ menuState }) { 20 | const { l10n } = useLocalization(); 21 | 22 | const blockTypes = [ 23 | ['heading1',

{l10n.getString('note-editor-heading-1')}

], 24 | ['heading2',

{l10n.getString('note-editor-heading-2')}

], 25 | ['heading3',

{l10n.getString('note-editor-heading-3')}

], 26 | ['paragraph', {l10n.getString('note-editor-paragraph')}], 27 | ['codeBlock', {l10n.getString('note-editor-monospaced')}], 28 | ['bulletList', • {l10n.getString('note-editor-bullet-list')}], 29 | ['orderedList', 1. {l10n.getString('note-editor-ordered-list')}], 30 | ['blockquote', │ {l10n.getString('note-editor-block-quote')}], 31 | ['math_display', 𝑓 {l10n.getString('note-editor-math-block')}], 32 | ]; 33 | 34 | const handleItemPick = (type) => { 35 | menuState[type].run(); 36 | } 37 | 38 | return ( 39 | } 42 | title={l10n.getString('note-editor-format-text')} 43 | > 44 |
45 |
46 | } 49 | title={l10n.getString('note-editor-bold')} 50 | state={menuState.strong} 51 | /> 52 | } 55 | title={l10n.getString('note-editor-italic')} 56 | state={menuState.em} 57 | /> 58 | } 61 | title={l10n.getString('note-editor-underline')} 62 | state={menuState.underline} 63 | /> 64 | } 67 | title={l10n.getString('note-editor-strikethrough')} 68 | state={menuState.strike} 69 | /> 70 |
71 |
72 | } 75 | title={l10n.getString('note-editor-subscript')} 76 | state={menuState.subscript} 77 | /> 78 | } 81 | title={l10n.getString('note-editor-superscript')} 82 | state={menuState.superscript} 83 | /> 84 | } 87 | title={l10n.getString('note-editor-monospaced')} 88 | state={menuState.code} 89 | /> 90 |
91 |
92 |
93 |
94 | {blockTypes.map(([type, element], index) => ( 95 | 102 | ))} 103 |
104 | 105 | ); 106 | } 107 | -------------------------------------------------------------------------------- /src/stylesheets/themes/_light.scss: -------------------------------------------------------------------------------- 1 | // Copied form zotero/scss/_light.scss 2 | 3 | @use 'sass:color'; 4 | @use "sass:map"; 5 | 6 | $-colors: ( 7 | accent-blue: #4072e5, 8 | accent-blue10: #4072e51a, 9 | accent-blue30: #4072e54d, 10 | accent-blue50: #4072e580, 11 | accent-gold: #cc9200, 12 | accent-green: #39bf68, 13 | accent-orange: #ff794c, 14 | accent-red: #db2c3a, 15 | accent-teal: #59adc4, 16 | accent-white: #fff, 17 | accent-wood-dark: #996b6f, 18 | accent-wood: #cc7a52, 19 | accent-yellow: #faa700, 20 | fill-primary: #000000d9, 21 | fill-secondary: #00000080, 22 | fill-tertiary: #00000040, 23 | fill-quarternary: #0000001a, 24 | fill-quinary: #0000000d, 25 | fill-senary: #00000005, 26 | color-background: #fff, 27 | color-background50: #ffffff80, 28 | color-background70: #ffffffb2, 29 | color-border: #00000026, 30 | color-border50: #00000014, 31 | color-button: #fff, 32 | color-control: #fff, 33 | color-menu: #f6f6f6b8, 34 | color-panedivider: #dadada, 35 | color-sidepane: #f2f2f2, 36 | color-tabbar: #f2f2f2, 37 | color-toolbar: #f9f9f9, 38 | color-scrollbar: rgb(194, 194, 194), 39 | color-scrollbar-hover: rgb(125, 125, 125), 40 | color-scrollbar-background: transparent, 41 | ); 42 | 43 | @mixin -light-rules() { 44 | @each $name, $color in $-colors { 45 | --#{$name}: #{$color}; 46 | } 47 | 48 | // composite (opaque) colors 49 | --color-quinary-on-background: #{color.mix( 50 | map.get($-colors, "color-background"), color.change(map.get($-colors, "fill-quinary"), $alpha: 1), 100% * (1 - color.alpha(map.get($-colors, "fill-quinary"))) 51 | )}; 52 | --color-quarternary-on-background: #{color.mix( 53 | map.get($-colors, "color-background"), color.change(map.get($-colors, "fill-quarternary"), $alpha: 1), 100% * (1 - color.alpha(map.get($-colors, "fill-quarternary"))) 54 | )}; 55 | --color-quarternary-on-sidepane: #{color.mix( 56 | map.get($-colors, "color-sidepane"), color.change(map.get($-colors, "fill-quarternary"), $alpha: 1), 100% * (1 - color.alpha(map.get($-colors, "fill-quarternary"))) 57 | )}; 58 | 59 | // background materials 60 | --material-background: var(--color-background); 61 | --material-background50: var(--color-background50); 62 | --material-background70: var(--color-background70); 63 | --material-button: var(--color-button); 64 | --material-control: var(--color-control); 65 | --material-menu: var(--color-menu); 66 | --material-sidepane: var(--color-sidepane); 67 | --material-tabbar: var(--color-tabbar); 68 | --material-toolbar: var(--color-toolbar); 69 | --material-mix-quinary: var(--color-quinary-on-background); 70 | --material-mix-quarternary: var(--color-quarternary-on-background); 71 | 72 | // border materials 73 | --material-border-transparent: 1px solid transparent; 74 | --material-border: 1px solid var(--color-border); 75 | --material-border50: 1px solid var(--color-border50); 76 | --material-panedivider: 1px solid var(--color-panedivider); 77 | --material-border-quinary: 1px solid var(--fill-quinary); 78 | --material-border-quarternary: 1px solid var(--fill-quarternary); 79 | } 80 | 81 | @if $platform == 'web' { 82 | :root { 83 | // fallback for browsers that do not support color-schemes (e.g., Safari 11) 84 | @include -light-rules(); 85 | } 86 | 87 | :root[data-color-scheme=light] { 88 | @include -light-rules(); 89 | } 90 | 91 | @media (prefers-color-scheme: light) { 92 | :root:not([data-color-scheme]) { 93 | @include -light-rules(); 94 | } 95 | } 96 | } @else { 97 | @media (prefers-color-scheme: light) { 98 | :root { 99 | @include -light-rules(); 100 | } 101 | } 102 | } 103 | -------------------------------------------------------------------------------- /webpack.zotero-locale-plugin.js: -------------------------------------------------------------------------------- 1 | let https = require('https'); 2 | let fs = require('fs'); 3 | let path = require('path'); 4 | 5 | const OUTPUT_PATH = path.resolve(__dirname, './locales'); 6 | const SIGNATURE_PATH = path.join(OUTPUT_PATH, '.signature'); 7 | 8 | // Static flag to ensure the plugin executes only once 9 | let pluginActivated = false; 10 | 11 | class ZoteroLocalePlugin { 12 | constructor(options) { 13 | this.files = options.files; 14 | this.locales = options.locales; 15 | this.commitHash = options.commitHash; 16 | } 17 | 18 | getRepoURL() { 19 | return `https://raw.githubusercontent.com/zotero/zotero/${this.commitHash}/chrome/locale`; 20 | } 21 | 22 | async downloadFile(url, outputPath) { 23 | return new Promise((resolve, reject) => { 24 | let file = fs.createWriteStream(outputPath); 25 | https.get(url, (response) => { 26 | if (response.statusCode === 200) { 27 | response.pipe(file); 28 | file.on('finish', () => { 29 | file.close(resolve); 30 | }); 31 | } 32 | else { 33 | reject(new Error(`Failed to download file (${response.statusCode}): ${url}`)); 34 | } 35 | }).on('error', (err) => { 36 | fs.unlink(outputPath, () => reject(err)); 37 | }); 38 | }); 39 | } 40 | 41 | // Downloads locale files if the commit hash has changed. 42 | async processFiles() { 43 | // Load the previous commit hash from the plain text .signature file 44 | let lastCommitHash = null; 45 | try { 46 | if (fs.existsSync(SIGNATURE_PATH)) { 47 | lastCommitHash = fs.readFileSync(SIGNATURE_PATH, 'utf8').trim(); // Read as plain text 48 | } 49 | } 50 | catch (err) { 51 | console.error('Error reading .signature file:', err); 52 | } 53 | 54 | // If the commit hash has changed 55 | if (lastCommitHash !== this.commitHash) { 56 | console.log(`Detected commit hash change (was: ${lastCommitHash}, now: ${this.commitHash}). Clearing and downloading locale files...`); 57 | 58 | // Remove and recreate the output directory 59 | try { 60 | if (fs.existsSync(OUTPUT_PATH)) { 61 | fs.rmSync(OUTPUT_PATH, { recursive: true, force: true }); 62 | console.log(`Deleted existing locale directory: ${OUTPUT_PATH}`); 63 | } 64 | fs.mkdirSync(OUTPUT_PATH, { recursive: true }); 65 | console.log(`Recreated locale directory: ${OUTPUT_PATH}`); 66 | } 67 | catch (err) { 68 | console.error('Error while resetting locale directory:', err); 69 | return; 70 | } 71 | 72 | let repoUrl = this.getRepoURL(); 73 | 74 | for (let locale of this.locales) { 75 | for (let file of this.files) { 76 | let url = `${repoUrl}/${locale}/zotero/${file}`; 77 | let localeDir = path.join(OUTPUT_PATH, locale); 78 | let outputFile = path.join(localeDir, file); 79 | 80 | // Ensure the directory exists 81 | fs.mkdirSync(localeDir, { recursive: true }); 82 | 83 | // Download the file 84 | try { 85 | console.log(`Downloading ${url} -> ${outputFile}`); 86 | await this.downloadFile(url, outputFile); 87 | } 88 | catch (error) { 89 | console.error(`Failed to download ${url}:`, error.message); 90 | throw error; 91 | } 92 | } 93 | } 94 | 95 | // Save the new commit hash in the .signature file as plain text 96 | try { 97 | fs.writeFileSync(SIGNATURE_PATH, this.commitHash, 'utf8'); 98 | console.log(`Updated commit hash saved to ${SIGNATURE_PATH}`); 99 | } 100 | catch (err) { 101 | console.error('Error writing to .signature file:', err); 102 | } 103 | } 104 | else { 105 | console.log(`No changes detected (current hash: ${this.commitHash}). Skipping downloads.`); 106 | } 107 | } 108 | 109 | apply(compiler) { 110 | // Prevent plugin from running multiple times 111 | if (pluginActivated) { 112 | return; 113 | } 114 | // Mark plugin as activated 115 | pluginActivated = true; 116 | // Hook into Webpack's lifecycle 117 | compiler.hooks.beforeRun.tapPromise('ZoteroLocalePlugin', async () => { 118 | console.log('ZoteroLocalePlugin is running...'); 119 | await this.processFiles(); 120 | }); 121 | } 122 | } 123 | 124 | module.exports = ZoteroLocalePlugin; 125 | -------------------------------------------------------------------------------- /webpack.config.js: -------------------------------------------------------------------------------- 1 | const path = require('path'); 2 | const webpack = require('webpack'); // ← 1) new import 3 | const HtmlWebpackPlugin = require('html-webpack-plugin'); 4 | const MiniCssExtractPlugin = require('mini-css-extract-plugin'); 5 | const CssMinimizerPlugin = require('css-minimizer-webpack-plugin'); 6 | const TerserPlugin = require('terser-webpack-plugin'); 7 | const { CleanWebpackPlugin } = require('clean-webpack-plugin'); 8 | const ZoteroLocalePlugin = require('./webpack.zotero-locale-plugin'); 9 | 10 | const babelConfigs = new Map([ 11 | ['zotero', { 12 | presets: [ 13 | ['@babel/preset-env', { useBuiltIns: false }], 14 | ], 15 | }], 16 | ['web', { 17 | presets: [ 18 | ["@babel/preset-env", { 19 | targets: 'firefox >= 68, chrome >= 67, edge >= 79, safari >= 11, last 2 versions, not dead, not ie 11, not ie 10', 20 | corejs: { version: 3.37 }, 21 | useBuiltIns: "usage", 22 | }] 23 | ] 24 | }], 25 | ]); 26 | 27 | function generateEditorConfig(build) { 28 | let config = { 29 | name: build, 30 | mode: build === 'dev' ? 'development' : 'production', 31 | devtool: build === 'dev' ? 'source-map' : (build === 'zotero' ? false : 'source-map'), 32 | entry: { 33 | editor: [ 34 | `./src/index.${build}.js`, 35 | './src/stylesheets/main.scss' 36 | ] 37 | }, 38 | output: { 39 | path: path.resolve(__dirname, `./build/${build}`), 40 | filename: '[name].js', 41 | publicPath: '', 42 | library: { 43 | name: 'zotero-editor', 44 | type: 'umd', 45 | umdNamedDefine: true, 46 | }, 47 | }, 48 | optimization: { 49 | minimize: build !== 'dev', 50 | minimizer: build === 'dev' ? [] : [new TerserPlugin({ extractComments: false }), new CssMinimizerPlugin()], 51 | }, 52 | module: { 53 | rules: [ 54 | { 55 | test: /\.(js|jsx)$/, 56 | exclude: { 57 | and: [/node_modules/], 58 | not: build === 'web' 59 | // some dependencies need to be transpiled for web, see zotero/web-library#556 60 | ? [ 61 | /@benrbray[\\/]prosemirror-math/, 62 | ] 63 | : [] 64 | }, 65 | use: { 66 | loader: 'babel-loader', 67 | options: babelConfigs.get(build) ?? {}, 68 | }, 69 | }, 70 | { 71 | test: /\.s?css$/, 72 | use: [ 73 | MiniCssExtractPlugin.loader, 74 | { 75 | loader: 'css-loader', 76 | }, 77 | { 78 | loader: 'postcss-loader', 79 | }, 80 | { 81 | loader: 'sass-loader', 82 | options: { 83 | additionalData: `$platform: '${build}';` 84 | } 85 | }, 86 | ], 87 | }, 88 | { 89 | test: /\.svg$/i, 90 | issuer: /\.[jt]sx?$/, 91 | use: ['@svgr/webpack'], 92 | }, 93 | { 94 | test: /\.woff2$/, 95 | type: 'asset/resource', 96 | generator: { 97 | filename: 'assets/fonts/[name].[hash:8][ext]', 98 | }, 99 | }, 100 | { 101 | test: /\.(ttf|woff)$/, 102 | type: 'asset/resource', 103 | generator: { 104 | emit: false, 105 | }, 106 | }, 107 | { 108 | test: /\.ftl$/, 109 | type: 'asset/source' 110 | } 111 | ] 112 | }, 113 | plugins: [ 114 | new ZoteroLocalePlugin({ 115 | files: ['zotero.ftl', 'note-editor.ftl'], 116 | locales: ['en-US'], 117 | commitHash: 'e644df74feedd620d311da1ffca9f1aeb0c46626', 118 | }), 119 | new CleanWebpackPlugin(), 120 | new MiniCssExtractPlugin({ 121 | filename: '[name].css', 122 | }), 123 | new HtmlWebpackPlugin({ 124 | template: `./html/editor.${build}.html`, 125 | filename: './[name].html', 126 | }), 127 | new webpack.DefinePlugin({ __BUILD__: JSON.stringify(build) }) 128 | ], 129 | }; 130 | 131 | if (build === 'zotero') { 132 | config.externals = { 133 | react: 'React', 134 | 'react-dom': 'ReactDOM', 135 | 'prop-types': 'PropTypes', 136 | }; 137 | } 138 | else if (build === 'dev') { 139 | config.devServer = { 140 | static: { 141 | directory: path.resolve(__dirname, 'build/'), 142 | watch: true, 143 | }, 144 | devMiddleware: { 145 | writeToDisk: true, 146 | }, 147 | open: `/dev/editor.html`, 148 | port: 3002, 149 | }; 150 | } 151 | 152 | return config; 153 | } 154 | 155 | module.exports = ['dev', 'web', 'zotero', 'ios', 'android'].map(generateEditorConfig); 156 | -------------------------------------------------------------------------------- /src/core/schema/marks.js: -------------------------------------------------------------------------------- 1 | import { HIGHLIGHT_COLORS } from './colors'; 2 | 3 | export default { 4 | strong: { 5 | inclusive: true, 6 | parseDOM: [ 7 | { tag: 'strong' }, 8 | // From ProseMirror source: 9 | // This works around a Google Docs misbehavior where 10 | // pasted content will be inexplicably wrapped in `` 11 | // tags with a font-weight normal. 12 | { 13 | tag: 'b', 14 | getAttrs: dom => dom.style.fontWeight !== 'normal' && null 15 | }, 16 | { 17 | style: 'font-weight', 18 | getAttrs: value => /^(bold(er)?|[5-9]\d{2,})$/.test(value) && null 19 | }, 20 | { tag: 'dt' } 21 | ], 22 | toDOM: () => ['strong', 0] 23 | }, 24 | 25 | 26 | em: { 27 | inclusive: true, 28 | parseDOM: [ 29 | { tag: 'i' }, 30 | { tag: 'em' }, 31 | { style: 'font-style=italic' }, 32 | { tag: 'cite' }, 33 | { tag: 'dfn' }, 34 | { tag: 'q' } 35 | ], 36 | toDOM: () => ['em', 0] 37 | }, 38 | 39 | 40 | underline: { 41 | inclusive: true, 42 | attrs: { 43 | color: {default: ''} 44 | }, 45 | parseDOM: [ 46 | { 47 | tag: 'u', 48 | getAttrs: (node) => { 49 | let color = node.style['text-decoration-color'] || ''; 50 | return {color}; 51 | } 52 | }, 53 | { style: 'text-decoration=underline' }, 54 | { style: 'text-decoration-line=underline' } 55 | ], 56 | toDOM: (mark) => { 57 | let style; 58 | if (mark.attrs.color) { 59 | style = `text-decoration-color: ${mark.attrs.color};`; 60 | } 61 | return ['u', {style}, 0]; 62 | }, 63 | }, 64 | 65 | 66 | strike: { 67 | inclusive: true, 68 | parseDOM: [ 69 | { tag: 's' }, 70 | { tag: 'strike' }, 71 | { tag: 'del' }, 72 | { style: 'text-decoration=line-through' }, 73 | { style: 'text-decoration-line=line-through' } 74 | ], 75 | // Unfortunately Zotero TinyMCE doesn't support 76 | toDOM: () => ['span', { style: 'text-decoration: line-through' }, 0] 77 | }, 78 | 79 | 80 | subsup: { 81 | inclusive: true, 82 | attrs: { type: { default: 'sub' } }, 83 | parseDOM: [ 84 | { tag: 'sub', attrs: { type: 'sub' } }, 85 | { style: 'vertical-align=sub', attrs: { type: 'sub' } }, 86 | { tag: 'sup', attrs: { type: 'sup' } }, 87 | { style: 'vertical-align=super', attrs: { type: 'sup' } } 88 | ], 89 | toDOM: mark => [mark.attrs.type] 90 | }, 91 | 92 | 93 | textColor: { 94 | inclusive: true, 95 | attrs: { color: {} }, 96 | parseDOM: [{ 97 | style: 'color', 98 | getAttrs: value => ({ color: value }) 99 | }], 100 | toDOM: mark => ['span', { style: `color: ${mark.attrs.color}` }, 0] 101 | }, 102 | 103 | 104 | backgroundColor: { 105 | inclusive: true, 106 | attrs: { color: {} }, 107 | parseDOM: [ 108 | { 109 | style: 'background-color', 110 | getAttrs: value => { 111 | let color = value; 112 | if (color) { 113 | color = color.toLowerCase(); 114 | // Add 50% opacity if it has one of highlight colors 115 | if (HIGHLIGHT_COLORS.map(x => x[1].slice(0, 7)).includes(color)) { 116 | color += '80'; 117 | } 118 | } 119 | return { color }; 120 | } 121 | }, 122 | { 123 | style: 'background', 124 | getAttrs: value => value.split(' ').length === 1 ? { color: value } : false 125 | } 126 | ], 127 | toDOM: mark => ['span', { style: `background-color: ${mark.attrs.color}` }, 0] 128 | }, 129 | 130 | 131 | link: { 132 | // excludes: 'textColor backgroundColor', 133 | inclusive: false, 134 | attrs: { 135 | href: {}, 136 | title: { default: null } 137 | }, 138 | parseDOM: [{ 139 | tag: 'a[href]', 140 | getAttrs: dom => ({ 141 | href: dom.getAttribute('href'), 142 | title: dom.getAttribute('title') 143 | }) 144 | }], 145 | toDOM: mark => ['a', { 146 | ...mark.attrs, 147 | rel: 'noopener noreferrer nofollow' 148 | }, 0] 149 | }, 150 | 151 | 152 | // Additional constraints are applied through transformations 153 | code: { 154 | excludes: `_`, 155 | inclusive: true, 156 | parseDOM: [ 157 | { tag: 'code', preserveWhitespace: true }, 158 | { tag: 'tt', preserveWhitespace: true }, 159 | { tag: 'kbd', preserveWhitespace: true }, 160 | { tag: 'samp', preserveWhitespace: true }, 161 | { tag: 'var', preserveWhitespace: true }, 162 | { 163 | style: 'font-family', 164 | preserveWhitespace: true, 165 | getAttrs: value => (value.toLowerCase().indexOf('monospace') > -1) && null 166 | }, 167 | { style: 'white-space=pre', preserveWhitespace: true } 168 | ], 169 | toDOM: () => ['code', 0] 170 | } 171 | }; 172 | -------------------------------------------------------------------------------- /src/ui/editor.js: -------------------------------------------------------------------------------- 1 | 'use strict'; 2 | 3 | import React, { useCallback, useRef, useState, useLayoutEffect, useEffect, Fragment } from 'react'; 4 | import { LocalizationProvider, ReactLocalization, useLocalization } from '@fluent/react'; 5 | 6 | import Toolbar from './toolbar'; 7 | import Findbar from './findbar'; 8 | import LinkPopup from './popups/link-popup'; 9 | import HighlightPopup from './popups/highlight-popup'; 10 | import CitationPopup from './popups/citation-popup'; 11 | import ImagePopup from './popups/image-popup'; 12 | import TablePopup from './popups/table-popup'; 13 | import Noticebar from './noticebar'; 14 | import { bundle } from '../fluent'; 15 | 16 | function Editor(props) { 17 | const { l10n } = useLocalization(); 18 | 19 | const editorRef = useRef(null); 20 | const [editorState, setEditorState] = useState(props.editorCore.pluginState); 21 | const [contextPaneButtonMode, setContextPaneButtonMode] = useState(props.contextPaneButtonMode); 22 | 23 | const [refReady, setRefReady] = useState(false); 24 | useEffect(() => { 25 | setRefReady(true); 26 | }, []); 27 | 28 | useLayoutEffect(() => { 29 | props.editorCore.onUpdateState = (state) => { 30 | setEditorState(props.editorCore.pluginState); 31 | }; 32 | props.editorCore.setContextPaneButtonMode = setContextPaneButtonMode; 33 | editorRef.current.appendChild(props.editorCore.view.dom); 34 | }, []); 35 | 36 | const handleInsertTable = useCallback(() => { 37 | props.editorCore.view.dom.focus(); 38 | props.editorCore.pluginState.table.insertTable(2, 2); 39 | }, [props.editorCore]); 40 | 41 | const handleInsertMath = useCallback(() => { 42 | props.editorCore.view.dom.focus(); 43 | props.editorCore.insertMath() 44 | }, [props.editorCore]); 45 | 46 | const handleInsertImage = useCallback(() => { 47 | props.editorCore.view.dom.focus(); 48 | props.editorCore.pluginState.image.openFilePicker(); 49 | }, [props.editorCore]); 50 | 51 | return ( 52 |
53 | {!props.disableUI && } 76 | 77 | {props.showUpdateNotice && 78 | {l10n.getString('note-editor-update-notice') 79 | // Transform \n to
80 | .split(/\n/) 81 | .reduce((result, word) => result.length ? [...result,
, word] : [word], [])} 82 |
} 83 |
84 |
85 | {refReady && !props.disableUI && 86 | {['ios', 'web'].includes(props.viewMode) && !editorState.link?.popup.active && editorState.table.isTableSelected() && } 87 | {editorState.link && } 88 | {editorState.highlight && } 89 | {!['web'].includes(props.viewMode) && editorState.image && } 90 | {editorState.citation && } 95 | } 96 |
97 |
98 |
99 | ); 100 | } 101 | 102 | function EditorWrapper(props) { 103 | return ( 104 | 105 | 106 | 107 | ) 108 | } 109 | 110 | export default EditorWrapper; 111 | -------------------------------------------------------------------------------- /src/core/schema/README.md: -------------------------------------------------------------------------------- 1 | # Schema migration 2 | 3 | ## Rules 4 | 5 | - `div[data-schema-version]` container must never be removed, although it's a hack that allows `data-schema-version` to 6 | survive TinyMCE based editor 7 | - `zotero-note-editor` must never save the opened note without user triggered note modification 8 | - Image attachments can be affected by schema changes 9 | - As long as sync cut-off is not applied to all TinyMCE based Zotero versions, the new schema must never introduce 10 | elements or attributes not listed in TinyMCE valid elements list below 11 | - `data-schema-version` increase forces all `< data-schema-version` 12 | clients to open the note in read-only mode and prevent wiping new data introduced in the newer editor 13 | - `data-schema-version` must be increased every time the schema output is affected, although not necessary when only the 14 | importing part is modified 15 | - `data-schema-version` can be increased without doing TinyMCE based Zotero cut-off to older clients, although that 16 | means only very old and very new clients can modify the note 17 | - Any future schema must be able to import any previously produced note 18 | 19 | ## Examples 20 | 21 | | Case | Cut-off TinyMCE based Zotero | Increase `data-schema-version` (and force read-only) | Comment | 22 | | --- | --- | --- | --- | 23 | | `codeBlock` gets a new attribute called `data-language` | no | yes | | 24 | | Introduce `` element | yes | no | | 25 | | Introduce `