base.js 8.3 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314
  1. import merge from 'lodash.merge';
  2. import Emitter from '../core/emitter';
  3. import Theme from '../core/theme';
  4. import ColorPicker from '../ui/color-picker';
  5. import IconPicker from '../ui/icon-picker';
  6. import Picker from '../ui/picker';
  7. import Tooltip from '../ui/tooltip';
  8. const ALIGNS = [false, 'center', 'right', 'justify'];
  9. const COLORS = [
  10. '#000000',
  11. '#e60000',
  12. '#ff9900',
  13. '#ffff00',
  14. '#008a00',
  15. '#0066cc',
  16. '#9933ff',
  17. '#ffffff',
  18. '#facccc',
  19. '#ffebcc',
  20. '#ffffcc',
  21. '#cce8cc',
  22. '#cce0f5',
  23. '#ebd6ff',
  24. '#bbbbbb',
  25. '#f06666',
  26. '#ffc266',
  27. '#ffff66',
  28. '#66b966',
  29. '#66a3e0',
  30. '#c285ff',
  31. '#888888',
  32. '#a10000',
  33. '#b26b00',
  34. '#b2b200',
  35. '#006100',
  36. '#0047b2',
  37. '#6b24b2',
  38. '#444444',
  39. '#5c0000',
  40. '#663d00',
  41. '#666600',
  42. '#003700',
  43. '#002966',
  44. '#3d1466',
  45. ];
  46. const FONTS = [false, 'serif', 'monospace'];
  47. const HEADERS = ['1', '2', '3', false];
  48. const SIZES = ['small', false, 'large', 'huge'];
  49. class BaseTheme extends Theme {
  50. constructor(quill, options) {
  51. super(quill, options);
  52. const listener = e => {
  53. if (!document.body.contains(quill.root)) {
  54. document.body.removeEventListener('click', listener);
  55. return;
  56. }
  57. if (
  58. this.tooltip != null &&
  59. !this.tooltip.root.contains(e.target) &&
  60. document.activeElement !== this.tooltip.textbox &&
  61. !this.quill.hasFocus()
  62. ) {
  63. this.tooltip.hide();
  64. }
  65. if (this.pickers != null) {
  66. this.pickers.forEach(picker => {
  67. if (!picker.container.contains(e.target)) {
  68. picker.close();
  69. }
  70. });
  71. }
  72. };
  73. quill.emitter.listenDOM('click', document.body, listener);
  74. }
  75. addModule(name) {
  76. const module = super.addModule(name);
  77. if (name === 'toolbar') {
  78. this.extendToolbar(module);
  79. }
  80. return module;
  81. }
  82. buildButtons(buttons, icons) {
  83. Array.from(buttons).forEach(button => {
  84. const className = button.getAttribute('class') || '';
  85. className.split(/\s+/).forEach(name => {
  86. if (!name.startsWith('ql-')) return;
  87. name = name.slice('ql-'.length);
  88. if (icons[name] == null) return;
  89. if (name === 'direction') {
  90. button.innerHTML = icons[name][''] + icons[name].rtl;
  91. } else if (typeof icons[name] === 'string') {
  92. button.innerHTML = icons[name];
  93. } else {
  94. const value = button.value || '';
  95. if (value != null && icons[name][value]) {
  96. button.innerHTML = icons[name][value];
  97. }
  98. }
  99. });
  100. });
  101. }
  102. buildPickers(selects, icons) {
  103. this.pickers = Array.from(selects).map(select => {
  104. if (select.classList.contains('ql-align')) {
  105. if (select.querySelector('option') == null) {
  106. fillSelect(select, ALIGNS);
  107. }
  108. return new IconPicker(select, icons.align);
  109. }
  110. if (
  111. select.classList.contains('ql-background') ||
  112. select.classList.contains('ql-color')
  113. ) {
  114. const format = select.classList.contains('ql-background')
  115. ? 'background'
  116. : 'color';
  117. if (select.querySelector('option') == null) {
  118. fillSelect(
  119. select,
  120. COLORS,
  121. format === 'background' ? '#ffffff' : '#000000',
  122. );
  123. }
  124. return new ColorPicker(select, icons[format]);
  125. }
  126. if (select.querySelector('option') == null) {
  127. if (select.classList.contains('ql-font')) {
  128. fillSelect(select, FONTS);
  129. } else if (select.classList.contains('ql-header')) {
  130. fillSelect(select, HEADERS);
  131. } else if (select.classList.contains('ql-size')) {
  132. fillSelect(select, SIZES);
  133. }
  134. }
  135. return new Picker(select);
  136. });
  137. const update = () => {
  138. this.pickers.forEach(picker => {
  139. picker.update();
  140. });
  141. };
  142. this.quill.on(Emitter.events.EDITOR_CHANGE, update);
  143. }
  144. }
  145. BaseTheme.DEFAULTS = merge({}, Theme.DEFAULTS, {
  146. modules: {
  147. toolbar: {
  148. handlers: {
  149. formula() {
  150. this.quill.theme.tooltip.edit('formula');
  151. },
  152. image() {
  153. let fileInput = this.container.querySelector(
  154. 'input.ql-image[type=file]',
  155. );
  156. if (fileInput == null) {
  157. fileInput = document.createElement('input');
  158. fileInput.setAttribute('type', 'file');
  159. fileInput.setAttribute(
  160. 'accept',
  161. this.quill.uploader.options.mimetypes.join(', '),
  162. );
  163. fileInput.classList.add('ql-image');
  164. fileInput.addEventListener('change', () => {
  165. const range = this.quill.getSelection(true);
  166. this.quill.uploader.upload(range, fileInput.files);
  167. fileInput.value = '';
  168. });
  169. this.container.appendChild(fileInput);
  170. }
  171. fileInput.click();
  172. },
  173. video() {
  174. this.quill.theme.tooltip.edit('video');
  175. },
  176. },
  177. },
  178. },
  179. });
  180. class BaseTooltip extends Tooltip {
  181. constructor(quill, boundsContainer) {
  182. super(quill, boundsContainer);
  183. this.textbox = this.root.querySelector('input[type="text"]');
  184. this.listen();
  185. }
  186. listen() {
  187. this.textbox.addEventListener('keydown', event => {
  188. if (event.key === 'Enter') {
  189. this.save();
  190. event.preventDefault();
  191. } else if (event.key === 'Escape') {
  192. this.cancel();
  193. event.preventDefault();
  194. }
  195. });
  196. }
  197. cancel() {
  198. this.hide();
  199. }
  200. edit(mode = 'link', preview = null) {
  201. this.root.classList.remove('ql-hidden');
  202. this.root.classList.add('ql-editing');
  203. if (preview != null) {
  204. this.textbox.value = preview;
  205. } else if (mode !== this.root.getAttribute('data-mode')) {
  206. this.textbox.value = '';
  207. }
  208. this.position(this.quill.getBounds(this.quill.selection.savedRange));
  209. this.textbox.select();
  210. this.textbox.setAttribute(
  211. 'placeholder',
  212. this.textbox.getAttribute(`data-${mode}`) || '',
  213. );
  214. this.root.setAttribute('data-mode', mode);
  215. }
  216. restoreFocus() {
  217. const { scrollTop } = this.quill.scrollingContainer;
  218. this.quill.focus();
  219. this.quill.scrollingContainer.scrollTop = scrollTop;
  220. }
  221. save() {
  222. let { value } = this.textbox;
  223. switch (this.root.getAttribute('data-mode')) {
  224. case 'link': {
  225. const { scrollTop } = this.quill.root;
  226. if (this.linkRange) {
  227. this.quill.formatText(
  228. this.linkRange,
  229. 'link',
  230. value,
  231. Emitter.sources.USER,
  232. );
  233. delete this.linkRange;
  234. } else {
  235. this.restoreFocus();
  236. this.quill.format('link', value, Emitter.sources.USER);
  237. }
  238. this.quill.root.scrollTop = scrollTop;
  239. break;
  240. }
  241. case 'video': {
  242. value = extractVideoUrl(value);
  243. } // eslint-disable-next-line no-fallthrough
  244. case 'formula': {
  245. if (!value) break;
  246. const range = this.quill.getSelection(true);
  247. if (range != null) {
  248. const index = range.index + range.length;
  249. this.quill.insertEmbed(
  250. index,
  251. this.root.getAttribute('data-mode'),
  252. value,
  253. Emitter.sources.USER,
  254. );
  255. if (this.root.getAttribute('data-mode') === 'formula') {
  256. this.quill.insertText(index + 1, ' ', Emitter.sources.USER);
  257. }
  258. this.quill.setSelection(index + 2, Emitter.sources.USER);
  259. }
  260. break;
  261. }
  262. default:
  263. }
  264. this.textbox.value = '';
  265. this.hide();
  266. }
  267. }
  268. function extractVideoUrl(url) {
  269. let match =
  270. url.match(
  271. /^(?:(https?):\/\/)?(?:(?:www|m)\.)?youtube\.com\/watch.*v=([a-zA-Z0-9_-]+)/,
  272. ) ||
  273. url.match(/^(?:(https?):\/\/)?(?:(?:www|m)\.)?youtu\.be\/([a-zA-Z0-9_-]+)/);
  274. if (match) {
  275. return `${match[1] || 'https'}://www.youtube.com/embed/${
  276. match[2]
  277. }?showinfo=0`;
  278. }
  279. // eslint-disable-next-line no-cond-assign
  280. if ((match = url.match(/^(?:(https?):\/\/)?(?:www\.)?vimeo\.com\/(\d+)/))) {
  281. return `${match[1] || 'https'}://player.vimeo.com/video/${match[2]}/`;
  282. }
  283. return url;
  284. }
  285. function fillSelect(select, values, defaultValue = false) {
  286. values.forEach(value => {
  287. const option = document.createElement('option');
  288. if (value === defaultValue) {
  289. option.setAttribute('selected', 'selected');
  290. } else {
  291. option.setAttribute('value', value);
  292. }
  293. select.appendChild(option);
  294. });
  295. }
  296. export { BaseTooltip, BaseTheme as default };