hero-editor 1.8.5 → 1.9.2

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "hero-editor",
3
- "version": "1.8.5",
3
+ "version": "1.9.2",
4
4
  "description": "",
5
5
  "main": "dist/lib.js",
6
6
  "scripts": {
@@ -29,8 +29,8 @@
29
29
  },
30
30
  "dependencies": {
31
31
  "is-url": "^1.2.4",
32
- "slate": "^0.58.3",
33
- "slate-react": "^0.58.3"
32
+ "slate": "^0.70.0",
33
+ "slate-react": "^0.70.0"
34
34
  },
35
35
  "peerDependencies": {
36
36
  "react": "^16.13.1",
package/src/app.js CHANGED
@@ -18,7 +18,6 @@ import HeroEditor, {
18
18
  const placeholder = window.__editorConfigs?.placeholder;
19
19
  const initialValue = window.__editorConfigs?.initialValue;
20
20
  const autoFocus = window.__editorConfigs?.autoFocus;
21
- const isAndroid = window.__editorConfigs?.isAndroid;
22
21
  const style = window.__editorConfigs?.style;
23
22
 
24
23
  const App = () => {
@@ -45,7 +44,6 @@ const App = () => {
45
44
  <HeroEditor
46
45
  id="webview"
47
46
  showToolbar={false}
48
- isAndroid={isAndroid}
49
47
  autoFocus={autoFocus}
50
48
  plugins={plugins}
51
49
  value={value}
@@ -15,6 +15,8 @@ const svgIcons = {
15
15
  '<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-5 14h-2V9h-2V7h4v10z"/></svg>',
16
16
  looks_two:
17
17
  '<svg xmlns="http://www.w3.org/2000/svg" height="24" viewBox="0 0 24 24" width="24"><path d="M0 0h24v24H0z" fill="none"/><path d="M19 3H5c-1.1 0-2 .9-2 2v14c0 1.1.9 2 2 2h14c1.1 0 2-.9 2-2V5c0-1.1-.9-2-2-2zm-4 8c0 1.11-.9 2-2 2h-2v2h4v2H9v-4c0-1.11.9-2 2-2h2V9H9V7h4c1.1 0 2 .89 2 2v2z"/></svg>',
18
+ link:
19
+ '<svg xmlns="http://www.w3.org/2000/svg" width="24" height="24" viewBox="0 0 24 24"><path d="M13.723 18.654l-3.61 3.609c-2.316 2.315-6.063 2.315-8.378 0-1.12-1.118-1.735-2.606-1.735-4.188 0-1.582.615-3.07 1.734-4.189l4.866-4.865c2.355-2.355 6.114-2.262 8.377 0 .453.453.81.973 1.089 1.527l-1.593 1.592c-.18-.613-.5-1.189-.964-1.652-1.448-1.448-3.93-1.51-5.439-.001l-.001.002-4.867 4.865c-1.5 1.499-1.5 3.941 0 5.44 1.517 1.517 3.958 1.488 5.442 0l2.425-2.424c.993.284 1.791.335 2.654.284zm.161-16.918l-3.574 3.576c.847-.05 1.655 0 2.653.283l2.393-2.389c1.498-1.502 3.94-1.5 5.44-.001 1.517 1.518 1.486 3.959 0 5.442l-4.831 4.831-.003.002c-1.438 1.437-3.886 1.552-5.439-.002-.473-.474-.785-1.042-.956-1.643l-.084.068-1.517 1.515c.28.556.635 1.075 1.088 1.528 2.245 2.245 6.004 2.374 8.378 0l4.832-4.831c2.314-2.316 2.316-6.062-.001-8.377-2.317-2.321-6.067-2.313-8.379-.002z"/></svg>',
18
20
  };
19
21
 
20
22
  const styles = {
@@ -1,7 +1,7 @@
1
1
  import React from 'react';
2
2
  import { Editor } from 'slate';
3
3
  import compose from './compose';
4
- import { NUMBERED_LIST, BULLETED_LIST } from '../constants';
4
+ import { NUMBERED_LIST, BULLETED_LIST, LINK } from '../constants';
5
5
 
6
6
  const isBlockActive = (editor, format) => {
7
7
  const [match] = Editor.nodes(editor, {
@@ -12,6 +12,8 @@ const isBlockActive = (editor, format) => {
12
12
 
13
13
  const isList = (node) => [BULLETED_LIST, NUMBERED_LIST].includes(node.type);
14
14
 
15
+ const isLink = (node) => node.type === LINK;
16
+
15
17
  const DefaultElement = React.forwardRef((props, ref) => (
16
18
  <p ref={ref} style={{ margin: 0, lineHeight: 1.5 }} {...props} />
17
19
  ));
@@ -35,6 +37,7 @@ const composeRenderElement = (plugins) =>
35
37
  export {
36
38
  isBlockActive,
37
39
  isList,
40
+ isLink,
38
41
  renderDefaultElement,
39
42
  makeRenderElement,
40
43
  composeRenderElement,
@@ -12,7 +12,6 @@ export { default as getUrl } from './getUrl';
12
12
  export { default as isEmptyContent } from './isEmptyContent';
13
13
 
14
14
  export { default as useForceUpdate } from './useForceUpdate';
15
- export { default as useAndroidHack } from './useAndroidHack';
16
15
 
17
16
  export * from './leafHelpers';
18
17
  export * from './elementHelpers';
package/src/lib.js CHANGED
@@ -10,7 +10,6 @@ import {
10
10
  get,
11
11
  apply,
12
12
  flow,
13
- useAndroidHack,
14
13
  } from './helpers';
15
14
  import Toolbar from './components/Toolbar';
16
15
  import {
@@ -59,7 +58,6 @@ const HeroEditor = ({
59
58
  onLayout = noop,
60
59
  wrapperStyle,
61
60
  editableStyle,
62
- isAndroid = false,
63
61
  }) => {
64
62
  const wrapper = useRef(null);
65
63
  const wrapperLayout = useRef({ width: 0, height: 0 });
@@ -106,13 +104,6 @@ const HeroEditor = ({
106
104
  }
107
105
  }, [value]);
108
106
 
109
- // Hack for Slate 0.58 on Android
110
- let onCompositionEnd, onDOMBeforeInput;
111
-
112
- if (isAndroid) {
113
- ({ onCompositionEnd, onDOMBeforeInput } = useAndroidHack(editor));
114
- }
115
-
116
107
  return (
117
108
  <div
118
109
  ref={wrapper}
@@ -133,8 +124,6 @@ const HeroEditor = ({
133
124
  className="hero-editor--editable"
134
125
  placeholder={placeholder}
135
126
  spellCheck
136
- onCompositionEnd={onCompositionEnd}
137
- onDOMBeforeInput={onDOMBeforeInput}
138
127
  autoFocus={autoFocus}
139
128
  renderLeaf={renderLeaf}
140
129
  renderElement={renderElement}
@@ -158,6 +147,7 @@ const HeroEditor = ({
158
147
  export * from './plugins';
159
148
  export { default as JsonViewer } from './components/JsonViewer';
160
149
  export { default as Toolbar } from './components/Toolbar';
150
+ export { default as Icon } from './components/Icon';
161
151
  export { default as plainSerializer } from './serializers/plain';
162
152
  export { default as makeReactTransformer } from './transformers/react';
163
153
  export { defaultReactTransformer } from './transformers/react';
@@ -0,0 +1,11 @@
1
+ // Jest Snapshot v1, https://goo.gl/fbAQLP
2
+
3
+ exports[`link plugin renders a link element 1`] = `
4
+ <body>
5
+ <div>
6
+ <a
7
+ href="Link example"
8
+ />
9
+ </div>
10
+ </body>
11
+ `;
@@ -0,0 +1,140 @@
1
+ import { createEditor, Transforms } from 'slate';
2
+ import { render, waitFor } from '@testing-library/react';
3
+ import link from '../link';
4
+ import {
5
+ createPlugin,
6
+ renderDefaultElement,
7
+ postMessage,
8
+ withId,
9
+ } from '../../helpers';
10
+
11
+ describe('link plugin', () => {
12
+ it('is a plugin object', () => {
13
+ for (const prop in createPlugin()) {
14
+ expect(link()[prop]).toBeDefined();
15
+ }
16
+ });
17
+
18
+ it('renders a link element', () => {
19
+ const { baseElement } = render(
20
+ link().renderElement(renderDefaultElement)({
21
+ element: {
22
+ type: 'link',
23
+ data: { url: 'https://www.example.com' },
24
+ children: [
25
+ {
26
+ text: 'Link example',
27
+ },
28
+ ],
29
+ },
30
+ }),
31
+ );
32
+ expect(baseElement).toMatchSnapshot();
33
+ });
34
+
35
+ describe('handle add link message properly', () => {
36
+ it('add new text with url', async () => {
37
+ const editor = withId('link-sample')(createEditor());
38
+ link().handleMessage(editor);
39
+
40
+ editor.insertNode({
41
+ type: 'paragraph',
42
+ children: [
43
+ {
44
+ text: '',
45
+ },
46
+ ],
47
+ });
48
+
49
+ postMessage(
50
+ 'link',
51
+ { text: 'Link example', url: 'https://www.example.com' },
52
+ editor,
53
+ );
54
+
55
+ await waitFor(() => {
56
+ expect(editor.children).toEqual([
57
+ {
58
+ type: 'paragraph',
59
+ children: [
60
+ {
61
+ text: '',
62
+ },
63
+ ],
64
+ },
65
+ {
66
+ type: 'link',
67
+ children: [
68
+ {
69
+ text: 'Link example',
70
+ },
71
+ ],
72
+ data: {
73
+ url: 'https://www.example.com',
74
+ },
75
+ },
76
+ ]);
77
+ });
78
+ });
79
+
80
+ it('add url to an existing text', async () => {
81
+ const editor = withId('link-sample')(createEditor());
82
+ link().handleMessage(editor);
83
+
84
+ editor.insertNode({
85
+ type: 'paragraph',
86
+ children: [
87
+ {
88
+ text: 'this is an url',
89
+ },
90
+ ],
91
+ });
92
+
93
+ Transforms.select(editor, {
94
+ anchor: {
95
+ path: [0, 0],
96
+ offset: 14,
97
+ },
98
+ focus: {
99
+ path: [0, 0],
100
+ offset: 11,
101
+ },
102
+ });
103
+
104
+ postMessage(
105
+ 'link',
106
+ { text: 'url', url: 'https://www.example.com' },
107
+ editor,
108
+ );
109
+
110
+ await waitFor(() => {
111
+ expect(editor.children).toEqual([
112
+ {
113
+ type: 'paragraph',
114
+ children: [
115
+ {
116
+ text: 'this is an ',
117
+ },
118
+ ],
119
+ },
120
+ {
121
+ type: 'link',
122
+ children: [
123
+ {
124
+ children: [
125
+ {
126
+ text: 'url',
127
+ },
128
+ ],
129
+ type: 'paragraph',
130
+ },
131
+ ],
132
+ data: {
133
+ url: 'https://www.example.com',
134
+ },
135
+ },
136
+ ]);
137
+ });
138
+ });
139
+ });
140
+ });
@@ -1,17 +1,35 @@
1
1
  import React from 'react';
2
+ import { useSlate } from 'slate-react';
2
3
  import isUrl from 'is-url';
3
- import { Transforms } from 'slate';
4
+ import { Transforms, Editor, Range } from 'slate';
4
5
  import {
5
6
  createPlugin,
6
7
  makeRenderElement,
7
8
  addMessageListener,
8
9
  postMessage,
10
+ isBlockActive,
11
+ isLink,
9
12
  } from '../helpers';
10
13
  import { LINK } from '../constants';
11
14
 
12
15
  const getUrl = (element) =>
13
16
  element.children.reduce((url, node) => url + node.text, '');
14
17
 
18
+ const getUrlFromNodes = (editor) => {
19
+ const [...linkNodes] = Editor.nodes(editor, { match: isLink });
20
+
21
+ if (linkNodes.length === 1) {
22
+ const [nodes] = linkNodes[0];
23
+ return nodes?.data.url;
24
+ }
25
+ return '';
26
+ };
27
+
28
+ const getSelectedData = (editor) => ({
29
+ text: Editor.string(editor, editor.selection),
30
+ url: getUrlFromNodes(editor),
31
+ });
32
+
15
33
  const LinkElement = ({ attributes, children, element }) => (
16
34
  <a {...attributes} href={getUrl(element)}>
17
35
  {children}
@@ -20,13 +38,36 @@ const LinkElement = ({ attributes, children, element }) => (
20
38
 
21
39
  const renderElement = makeRenderElement(LINK, LinkElement);
22
40
 
23
- const handleMessage = addMessageListener(LINK, ({ editor, data: { url } }) => {
24
- Transforms.insertNodes(editor, {
25
- type: LINK,
26
- data: { url },
27
- children: [{ text: url }],
28
- });
29
- });
41
+ const handleMessage = addMessageListener(
42
+ LINK,
43
+ ({ editor, data: { text, url } }) => {
44
+ if (!text || !url || !isUrl(url)) return;
45
+
46
+ if (isBlockActive(editor, LINK)) {
47
+ Transforms.unwrapNodes(editor, {
48
+ match: isLink,
49
+ });
50
+ }
51
+
52
+ const { selection } = editor;
53
+ const isCollapsed = selection && Range.isCollapsed(selection);
54
+
55
+ const linkNode = {
56
+ type: LINK,
57
+ data: { url },
58
+ children: isCollapsed ? [{ text: text ?? url }] : [],
59
+ };
60
+
61
+ if (isCollapsed) {
62
+ Transforms.insertNodes(editor, linkNode);
63
+ } else {
64
+ Transforms.wrapNodes(editor, linkNode, { split: true });
65
+ Transforms.collapse(editor, { edge: 'end' });
66
+ }
67
+
68
+ Transforms.move(editor, { unit: 'offset' });
69
+ },
70
+ );
30
71
 
31
72
  const enhanceEditor = (editor) => {
32
73
  const { isInline, insertData } = editor;
@@ -48,10 +89,30 @@ const enhanceEditor = (editor) => {
48
89
  return editor;
49
90
  };
50
91
 
51
- export default () =>
92
+ const ToolbarButton = ({ renderToolbarButton }) => {
93
+ const editor = useSlate();
94
+
95
+ const handleAddLink = (text, url) => {
96
+ postMessage(LINK, { text, url }, editor);
97
+ };
98
+
99
+ return (
100
+ <>
101
+ {renderToolbarButton?.({
102
+ handleAddLink,
103
+ getSelectedData: () => getSelectedData(editor),
104
+ })}
105
+ </>
106
+ );
107
+ };
108
+
109
+ export default ({ renderToolbarButton } = {}) =>
52
110
  createPlugin({
53
111
  name: LINK,
54
112
  renderElement,
55
113
  handleMessage,
114
+ ToolbarButton: () => (
115
+ <ToolbarButton renderToolbarButton={renderToolbarButton} />
116
+ ),
56
117
  enhanceEditor,
57
118
  });
@@ -1,148 +0,0 @@
1
- // https://github.com/ianstormtaylor/slate/issues/3470#issuecomment-639431390
2
- import { useRef, useCallback } from 'react';
3
- import { Transforms, Editor as SlateEditor } from 'slate';
4
- import { ReactEditor } from 'slate-react';
5
-
6
- const useAndroidHack = (editor) => {
7
- const lastComposeData = useRef(null);
8
-
9
- const onDOMBeforeInput = useCallback(
10
- (e) => {
11
- switch (e.inputType) {
12
- case 'insertCompositionText': {
13
- // We need to push each composition event so we can apply it when finished
14
- if (!lastComposeData.current) {
15
- lastComposeData.current = [];
16
- }
17
- try {
18
- // We need to convert the selection right away, as the window.getSelection() will change with the window
19
- const selection = window.getSelection();
20
- const sel = ReactEditor.toSlateRange(editor, selection);
21
-
22
- // The offsets may not match because the composition text might be far ahead etc...
23
- sel.anchor.offset = selection.anchorOffset;
24
- sel.focus.offset = selection.focusOffset;
25
-
26
- lastComposeData.current.push({
27
- selection: sel,
28
- value: e.data,
29
- node: selection?.anchorNode,
30
- elementNode: selection?.anchorNode?.parentElement.closest(
31
- '[data-slate-node="element"]',
32
- ),
33
- });
34
- } catch {
35
- lastComposeData.current = null;
36
- }
37
- break;
38
- }
39
- case 'insertFromComposition':
40
- case 'deleteByComposition':
41
- // If we get this event we don't need to apply any sort of fix, this is the correct event to handle things
42
- lastComposeData.current = null;
43
- break;
44
- default:
45
- break;
46
- }
47
- },
48
- [editor],
49
- );
50
-
51
- const onCompositionEnd = useCallback(
52
- (e) => {
53
- const { current } = lastComposeData;
54
- if (current) {
55
- // Store the current selection so we can move back once we have finished applying queued changes
56
- // Convert to slate range straight away as the selection can get messed up due to element fixing code
57
- const {
58
- anchorNode,
59
- anchorOffset,
60
- focusNode,
61
- focusOffset,
62
- isCollapsed,
63
- } = window.getSelection();
64
-
65
- // Apply each of the changes
66
- for (const c of current) {
67
- const { selection, value, node, elementNode } = c;
68
- Transforms.select(editor, selection);
69
- SlateEditor.insertText(editor, value);
70
-
71
- if (value) {
72
- // HACK #1 - when a new line is created slate creates a zero-width
73
- // but actually it will be filled in, this causes slate crashes
74
- // Have to recreate a full element instead of an empty element in slate
75
- const el = node.parentElement;
76
- if (el && el.hasAttribute('data-slate-zero-width')) {
77
- el.removeAttribute('data-slate-length');
78
- el.removeAttribute('data-slate-zero-width');
79
- el.setAttribute('data-slate-string', 'true');
80
- el.innerText = value;
81
- const { path, offset } = editor.selection.anchor;
82
- const p = { path, offset: offset - 1 };
83
- Transforms.select(editor, { anchor: p, focus: p });
84
- }
85
- } else {
86
- // HACK #2 - when an element is made empty during compose
87
- // the element is removed from dom, need to add it back
88
- // as a zero-width element to match
89
- const textNode = node.parentElement.closest(
90
- '[data-slate-node="text"]',
91
- );
92
- const el = node.parentElement;
93
- if (
94
- elementNode &&
95
- textNode &&
96
- el &&
97
- elementNode.children.length === 1 &&
98
- elementNode.children[0].nodeName === 'BR'
99
- ) {
100
- el.innerHTML = '&#65279;';
101
- el.setAttribute('data-slate-length', '0');
102
- el.setAttribute('data-slate-zero-width', 'n');
103
- el.removeAttribute('data-slate-string');
104
- elementNode.replaceChild(textNode, elementNode.children[0]);
105
- }
106
- }
107
- }
108
-
109
- lastComposeData.current = null;
110
-
111
- // Move back to existing selection
112
- try {
113
- const ss = ReactEditor.toSlateRange(editor, {
114
- startContainer: anchorNode,
115
- startOffset: anchorOffset,
116
- endContainer: focusNode,
117
- endOffset: focusOffset,
118
- collapsed: isCollapsed,
119
- });
120
- // Small fixup again
121
- ss.anchor.offset = anchorOffset;
122
- ss.focus.offset = focusOffset;
123
- Transforms.select(editor, ss);
124
-
125
- // Reset the dom selection as well... this can get out of alignment
126
- const sel = window.getSelection();
127
- sel.removeAllRanges();
128
- const newDomRange = ReactEditor.toDOMRange(editor, ss);
129
- if (newDomRange) {
130
- sel.addRange(newDomRange);
131
- }
132
- } catch {}
133
- }
134
-
135
- // Prevent slate from doing anything
136
- // This prevents the hack that is currently in onCompositionEnd that doesn't work
137
- e.data = null;
138
- },
139
- [editor],
140
- );
141
-
142
- return {
143
- onCompositionEnd,
144
- onDOMBeforeInput,
145
- };
146
- };
147
-
148
- export default useAndroidHack;