@seorii/tiptap 0.4.3 → 0.4.4

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.
@@ -29,33 +29,37 @@ function matchItem(item, query, compactQuery) {
29
29
  return [item.title, item.subtitle ?? '', ...(item.keywords ?? [])].some((value) => matchQuery(value, query, compactQuery));
30
30
  }
31
31
  function fixRange(editor, rawRange, split = '/') {
32
- const range = { ...rawRange };
33
- const { state } = editor.view;
34
- const { selection, doc } = state;
35
- if (selection.$to.nodeBefore?.text?.includes?.(split)) {
36
- range.from = range.to;
37
- while (range.from > 0 && doc.textBetween(range.from - 1, range.from) !== split) {
38
- try {
39
- range.from -= 1;
40
- }
41
- catch {
42
- range.from += 2;
32
+ const doc = editor.state.doc;
33
+ const docSize = doc.content.size;
34
+ const range = {
35
+ from: Math.max(0, Math.min(rawRange.from, docSize)),
36
+ to: Math.max(0, Math.min(rawRange.to, docSize))
37
+ };
38
+ if (range.to < range.from)
39
+ range.to = range.from;
40
+ if (doc.textBetween(range.from, Math.min(range.from + 1, docSize)) !== split && range.from > 0) {
41
+ const minFrom = Math.max(0, range.from - 64);
42
+ for (let cursor = range.from; cursor > minFrom; cursor -= 1) {
43
+ const char = doc.textBetween(cursor - 1, cursor);
44
+ if (!char || /\s/.test(char))
43
45
  break;
44
- }
46
+ if (char !== split)
47
+ continue;
48
+ range.from = cursor - 1;
49
+ break;
45
50
  }
46
- range.from -= 1;
47
51
  }
48
- while (range.to < selection.to && doc.textBetween(range.to, range.to + 1) !== ' ') {
49
- try {
50
- range.to += 1;
51
- }
52
- catch {
53
- range.to -= 1;
52
+ while (range.to < docSize) {
53
+ const next = doc.textBetween(range.to, range.to + 1);
54
+ if (!next || /\s/.test(next))
54
55
  break;
55
- }
56
+ range.to += 1;
56
57
  }
57
58
  return range;
58
59
  }
60
+ function clampDocPos(editor, pos) {
61
+ return Math.max(0, Math.min(pos, editor.state.doc.content.size));
62
+ }
59
63
  export function getDetail(editor, range, option) {
60
64
  slashState.selection = () => {
61
65
  editor.chain().focus().deleteRange(fixRange(editor, range)).run();
@@ -147,7 +151,18 @@ export const suggest = {
147
151
  subtitle: i18n('imageInfo'),
148
152
  keywords: createKeywords(['image', 'imageInfo']),
149
153
  command: ({ editor, range }) => {
150
- editor.chain().focus().deleteRange(fixRange(editor, range)).run();
154
+ const fixedRange = fixRange(editor, range);
155
+ editor.chain().focus().deleteRange(fixedRange).run();
156
+ let insertAt = clampDocPos(editor, fixedRange.from);
157
+ let insertParagraph = true;
158
+ const { selection } = editor.state;
159
+ if (selection.empty &&
160
+ selection.$from.depth > 0 &&
161
+ selection.$from.parent.isTextblock &&
162
+ selection.$from.parent.content.size === 0) {
163
+ insertAt = clampDocPos(editor, selection.$from.before(selection.$from.depth));
164
+ insertParagraph = false;
165
+ }
151
166
  const input = document.createElement('input');
152
167
  input.type = 'file';
153
168
  input.accept = 'image/*';
@@ -159,10 +174,13 @@ export const suggest = {
159
174
  return;
160
175
  const skeleton = insertUploadSkeleton(editor, {
161
176
  kind: 'image',
162
- height: 220
177
+ height: 220,
178
+ at: insertAt,
179
+ insertParagraph
163
180
  });
164
181
  try {
165
- const upload = window.__image_uploader ?? fallbackUpload;
182
+ const imageUploader = window.__image_uploader;
183
+ const upload = imageUploader ?? fallbackUpload;
166
184
  const src = await upload(file);
167
185
  if (skeleton) {
168
186
  skeleton.replaceWith({
@@ -171,9 +189,16 @@ export const suggest = {
171
189
  });
172
190
  }
173
191
  else {
174
- editor.chain().focus().setImage({ src }).run();
192
+ editor
193
+ .chain()
194
+ .insertContentAt(clampDocPos(editor, insertAt), insertParagraph
195
+ ? [{ type: 'image', attrs: { src } }, { type: 'paragraph' }]
196
+ : { type: 'image', attrs: { src } })
197
+ .run();
198
+ }
199
+ if (imageUploader) {
200
+ releaseObjectUrlOnImageSettled(editor.view, src);
175
201
  }
176
- releaseObjectUrlOnImageSettled(editor.view, src);
177
202
  }
178
203
  catch {
179
204
  skeleton?.remove();
@@ -67,7 +67,8 @@ export const dropImagePlugin = () => {
67
67
  props: {
68
68
  handleDOMEvents: {
69
69
  paste(view, event) {
70
- const upload = window.__image_uploader || fallbackUpload;
70
+ const imageUploader = window.__image_uploader;
71
+ const upload = imageUploader || fallbackUpload;
71
72
  const items = Array.from(event.clipboardData?.items || []);
72
73
  const { schema } = view.state;
73
74
  items.forEach((item) => {
@@ -95,7 +96,9 @@ export const dropImagePlugin = () => {
95
96
  const transaction = view.state.tr.replaceSelectionWith(node);
96
97
  view.dispatch(transaction);
97
98
  }
98
- releaseObjectUrlOnImageSettled(view, src);
99
+ if (imageUploader) {
100
+ releaseObjectUrlOnImageSettled(view, src);
101
+ }
99
102
  })
100
103
  .catch(() => {
101
104
  skeleton?.remove();
@@ -119,7 +122,8 @@ export const dropImagePlugin = () => {
119
122
  return false;
120
123
  },
121
124
  drop: (view, event) => {
122
- const upload = window.__image_uploader || fallbackUpload;
125
+ const imageUploader = window.__image_uploader;
126
+ const upload = imageUploader || fallbackUpload;
123
127
  const hasFiles = event.dataTransfer && event.dataTransfer.files && event.dataTransfer.files.length;
124
128
  if (!hasFiles) {
125
129
  return false;
@@ -160,7 +164,9 @@ export const dropImagePlugin = () => {
160
164
  const transaction = view.state.tr.insert(coordinates.pos, node);
161
165
  view.dispatch(transaction);
162
166
  }
163
- releaseObjectUrlOnImageSettled(view, src);
167
+ if (imageUploader) {
168
+ releaseObjectUrlOnImageSettled(view, src);
169
+ }
164
170
  }
165
171
  catch {
166
172
  skeleton?.remove();
@@ -28,6 +28,17 @@ const aspectRatioOptions = [
28
28
  { label: '4:3', value: '4:3' },
29
29
  { label: '9:16', value: '9:16' }
30
30
  ];
31
+ const horizontalAlignValues = ['left', 'center', 'right'];
32
+ const horizontalAlignOptions = [
33
+ { label: 'Auto', value: null },
34
+ { label: 'Left', value: 'left' },
35
+ { label: 'Center', value: 'center' },
36
+ { label: 'Right', value: 'right' }
37
+ ];
38
+ const widthPresetOptions = [
39
+ { label: '100%', value: '100%' },
40
+ { label: '50%', value: '50%' }
41
+ ];
31
42
  function isResizableType(value) {
32
43
  return typeSet.has(value);
33
44
  }
@@ -109,6 +120,28 @@ function sameAspectRatio(left, right) {
109
120
  return false;
110
121
  return Math.abs(leftRatio - rightRatio) <= 0.001;
111
122
  }
123
+ function normalizeHorizontalAlignAttr(value) {
124
+ if (typeof value !== 'string')
125
+ return null;
126
+ const normalized = value.trim().toLowerCase();
127
+ if (!normalized)
128
+ return null;
129
+ return horizontalAlignValues.includes(normalized)
130
+ ? normalized
131
+ : null;
132
+ }
133
+ function normalizeWidthPreset(value) {
134
+ if (typeof value !== 'string' && typeof value !== 'number')
135
+ return null;
136
+ const normalized = String(value).trim();
137
+ if (!normalized)
138
+ return null;
139
+ if (normalized === '100' || normalized === '100%')
140
+ return '100%';
141
+ if (normalized === '50' || normalized === '50%')
142
+ return '50%';
143
+ return null;
144
+ }
112
145
  function normalizeStringAttr(value) {
113
146
  if (typeof value !== 'string')
114
147
  return null;
@@ -261,41 +294,209 @@ function buildResizeAttrs(kind, node, height, imageRatio, aspectRatio = normaliz
261
294
  function canUseAspectRatioPreset(kind) {
262
295
  return kind === 'iframe' || kind === 'embed';
263
296
  }
297
+ function canUseLayoutOptions(kind) {
298
+ return kind === 'image' || canUseAspectRatioPreset(kind);
299
+ }
300
+ function createToolbarGroupIcon(group) {
301
+ const icon = document.createElement('span');
302
+ icon.className = 'tiptap-media-toolbar-group-icon';
303
+ icon.dataset.group = group;
304
+ icon.setAttribute('aria-hidden', 'true');
305
+ return icon;
306
+ }
264
307
  function createResizeHandleDecoration(nodePos, widgetPos, resizeMeta, node) {
308
+ const widthKey = normalizeWidthAttr(node.attrs.width) || 'auto';
309
+ const aspectRatioKey = normalizeAspectRatioAttr(node.attrs.aspectRatio) || 'auto';
310
+ const horizontalAlignKey = normalizeHorizontalAlignAttr(node.attrs.horizontalAlign) || 'auto';
265
311
  return Decoration.widget(widgetPos, () => {
266
312
  const anchor = document.createElement('div');
267
313
  anchor.className = 'tiptap-media-resize-anchor';
314
+ const isImageAnchor = resizeMeta.kind === 'image';
315
+ let controlsContainer = anchor;
316
+ if (isImageAnchor) {
317
+ anchor.classList.add('is-image-anchor');
318
+ const controls = document.createElement('div');
319
+ controls.className = 'tiptap-media-resize-controls';
320
+ controls.style.left = '0px';
321
+ controls.style.width = '100%';
322
+ anchor.append(controls);
323
+ controlsContainer = controls;
324
+ }
325
+ else {
326
+ const normalizedWidth = normalizeWidthAttr(node.attrs.width);
327
+ if (normalizedWidth) {
328
+ if (normalizedWidth.endsWith('%')) {
329
+ anchor.style.width = normalizedWidth;
330
+ }
331
+ else {
332
+ const numericWidth = parseNumericSize(normalizedWidth);
333
+ if (numericWidth !== null) {
334
+ anchor.style.width = `${Math.round(numericWidth)}px`;
335
+ }
336
+ }
337
+ anchor.style.maxWidth = '100%';
338
+ }
339
+ const horizontalAlign = normalizeHorizontalAlignAttr(node.attrs.horizontalAlign);
340
+ if (horizontalAlign === 'center') {
341
+ anchor.style.marginLeft = 'auto';
342
+ anchor.style.marginRight = 'auto';
343
+ }
344
+ else if (horizontalAlign === 'right') {
345
+ anchor.style.marginLeft = 'auto';
346
+ anchor.style.marginRight = '0';
347
+ }
348
+ else {
349
+ anchor.style.marginLeft = '0';
350
+ anchor.style.marginRight = '0';
351
+ }
352
+ }
353
+ if (isImageAnchor) {
354
+ requestAnimationFrame(() => {
355
+ if (!anchor.isConnected)
356
+ return;
357
+ let candidate = anchor.previousElementSibling;
358
+ let imageElement = null;
359
+ while (candidate && !imageElement) {
360
+ if (candidate instanceof HTMLImageElement) {
361
+ imageElement = candidate;
362
+ }
363
+ else if (candidate instanceof HTMLElement) {
364
+ imageElement = candidate.querySelector('img');
365
+ }
366
+ candidate = candidate.previousElementSibling;
367
+ }
368
+ if (!imageElement && anchor.parentElement instanceof HTMLElement) {
369
+ imageElement =
370
+ anchor.parentElement.querySelector('figure.ProseMirror-selectednode img, img.ProseMirror-selectednode') || null;
371
+ }
372
+ if (!(imageElement instanceof HTMLElement))
373
+ return;
374
+ if (!(controlsContainer instanceof HTMLElement))
375
+ return;
376
+ const rect = imageElement.getBoundingClientRect();
377
+ if (rect.width <= 0 || rect.height <= 0)
378
+ return;
379
+ const anchorRect = anchor.getBoundingClientRect();
380
+ const leftOffset = rect.left - anchorRect.left;
381
+ const maxLeft = Math.max(0, anchorRect.width - rect.width);
382
+ const clampedLeft = clamp(leftOffset, 0, maxLeft);
383
+ const topOffset = rect.bottom - anchorRect.top + 6;
384
+ controlsContainer.style.top = `${Math.round(topOffset)}px`;
385
+ controlsContainer.style.left = `${Math.round(clampedLeft)}px`;
386
+ controlsContainer.style.width = `${Math.round(rect.width)}px`;
387
+ });
388
+ }
268
389
  const button = document.createElement('button');
269
390
  button.type = 'button';
270
391
  button.className = 'tiptap-media-resize-handle';
271
392
  button.dataset.resizePos = String(nodePos);
272
393
  button.dataset.resizeKind = resizeMeta.kind;
273
- button.setAttribute('aria-label', 'Resize media height (click for aspect ratio)');
274
- anchor.append(button);
275
- if (canUseAspectRatioPreset(resizeMeta.kind)) {
394
+ button.setAttribute('aria-label', canUseLayoutOptions(resizeMeta.kind)
395
+ ? 'Resize media height (click for layout options)'
396
+ : 'Resize media height');
397
+ controlsContainer.append(button);
398
+ if (canUseLayoutOptions(resizeMeta.kind)) {
276
399
  const selectedAspectRatio = normalizeAspectRatioAttr(node.attrs.aspectRatio);
400
+ const selectedHorizontalAlign = normalizeHorizontalAlignAttr(node.attrs.horizontalAlign);
401
+ const selectedWidthPreset = normalizeWidthPreset(node.attrs.width);
402
+ const supportsAspectRatio = canUseAspectRatioPreset(resizeMeta.kind);
403
+ const supportsBottomWidthPreset = resizeMeta.kind === 'image';
404
+ if (supportsAspectRatio) {
405
+ const widthHandle = document.createElement('button');
406
+ widthHandle.type = 'button';
407
+ widthHandle.className = 'tiptap-media-width-resize-handle';
408
+ widthHandle.dataset.resizePos = String(nodePos);
409
+ widthHandle.dataset.resizeKind = resizeMeta.kind;
410
+ widthHandle.setAttribute('aria-label', 'Resize media width (click for width presets)');
411
+ controlsContainer.append(widthHandle);
412
+ }
277
413
  const toolbar = document.createElement('div');
278
414
  toolbar.className = 'tiptap-media-aspect-ratio-toolbar';
279
415
  toolbar.setAttribute('role', 'toolbar');
280
- toolbar.setAttribute('aria-label', 'Aspect ratio presets');
416
+ toolbar.setAttribute('aria-label', 'Media resize options');
281
417
  toolbar.dataset.resizePos = String(nodePos);
282
- for (const option of aspectRatioOptions) {
418
+ let hasToolbarItems = false;
419
+ if (supportsAspectRatio) {
420
+ toolbar.append(createToolbarGroupIcon('aspect'));
421
+ for (const option of aspectRatioOptions) {
422
+ const optionButton = document.createElement('button');
423
+ optionButton.type = 'button';
424
+ optionButton.className = 'tiptap-media-aspect-ratio-option';
425
+ optionButton.dataset.resizePos = String(nodePos);
426
+ optionButton.dataset.aspectRatio = option.value ?? 'auto';
427
+ optionButton.textContent = option.label;
428
+ const isActive = option.value
429
+ ? sameAspectRatio(option.value, selectedAspectRatio)
430
+ : !selectedAspectRatio;
431
+ optionButton.setAttribute('aria-pressed', isActive ? 'true' : 'false');
432
+ toolbar.append(optionButton);
433
+ }
434
+ hasToolbarItems = true;
435
+ }
436
+ if (hasToolbarItems) {
437
+ const separator = document.createElement('span');
438
+ separator.className = 'tiptap-media-toolbar-separator';
439
+ separator.setAttribute('aria-hidden', 'true');
440
+ toolbar.append(separator);
441
+ }
442
+ toolbar.append(createToolbarGroupIcon('align'));
443
+ for (const option of horizontalAlignOptions) {
283
444
  const optionButton = document.createElement('button');
284
445
  optionButton.type = 'button';
285
- optionButton.className = 'tiptap-media-aspect-ratio-option';
446
+ optionButton.className = 'tiptap-media-horizontal-align-option';
286
447
  optionButton.dataset.resizePos = String(nodePos);
287
- optionButton.dataset.aspectRatio = option.value ?? 'auto';
448
+ optionButton.dataset.horizontalAlign = option.value ?? 'auto';
288
449
  optionButton.textContent = option.label;
289
450
  const isActive = option.value
290
- ? sameAspectRatio(option.value, selectedAspectRatio)
291
- : !selectedAspectRatio;
451
+ ? option.value === selectedHorizontalAlign
452
+ : !selectedHorizontalAlign;
292
453
  optionButton.setAttribute('aria-pressed', isActive ? 'true' : 'false');
293
454
  toolbar.append(optionButton);
294
455
  }
295
- anchor.append(toolbar);
456
+ hasToolbarItems = true;
457
+ if (supportsBottomWidthPreset) {
458
+ const separator = document.createElement('span');
459
+ separator.className = 'tiptap-media-toolbar-separator';
460
+ separator.setAttribute('aria-hidden', 'true');
461
+ toolbar.append(separator);
462
+ toolbar.append(createToolbarGroupIcon('width'));
463
+ for (const option of widthPresetOptions) {
464
+ const optionButton = document.createElement('button');
465
+ optionButton.type = 'button';
466
+ optionButton.className = 'tiptap-media-width-option';
467
+ optionButton.dataset.resizePos = String(nodePos);
468
+ optionButton.dataset.widthPreset = option.value;
469
+ optionButton.textContent = option.label;
470
+ optionButton.setAttribute('aria-pressed', selectedWidthPreset === option.value ? 'true' : 'false');
471
+ toolbar.append(optionButton);
472
+ }
473
+ }
474
+ controlsContainer.append(toolbar);
475
+ if (supportsAspectRatio) {
476
+ const widthToolbar = document.createElement('div');
477
+ widthToolbar.className = 'tiptap-media-width-toolbar';
478
+ widthToolbar.setAttribute('role', 'toolbar');
479
+ widthToolbar.setAttribute('aria-label', 'Media width presets');
480
+ widthToolbar.dataset.resizePos = String(nodePos);
481
+ widthToolbar.append(createToolbarGroupIcon('width'));
482
+ for (const option of widthPresetOptions) {
483
+ const optionButton = document.createElement('button');
484
+ optionButton.type = 'button';
485
+ optionButton.className = 'tiptap-media-width-option';
486
+ optionButton.dataset.resizePos = String(nodePos);
487
+ optionButton.dataset.widthPreset = option.value;
488
+ optionButton.textContent = option.label;
489
+ optionButton.setAttribute('aria-pressed', selectedWidthPreset === option.value ? 'true' : 'false');
490
+ widthToolbar.append(optionButton);
491
+ }
492
+ controlsContainer.append(widthToolbar);
493
+ }
296
494
  }
297
495
  return anchor;
298
- }, { side: 1, key: `media-resize-${nodePos}-${resizeMeta.typeName}-${resizeMeta.kind}` });
496
+ }, {
497
+ side: 1,
498
+ key: `media-resize-${nodePos}-${resizeMeta.typeName}-${resizeMeta.kind}-${widthKey}-${aspectRatioKey}-${horizontalAlignKey}`
499
+ });
299
500
  }
300
501
  function tryCreateNodeSelection(doc, pos) {
301
502
  if (pos < 0 || pos > doc.content.size)
@@ -413,6 +614,14 @@ export default Extension.create({
413
614
  const aspectRatio = normalizeAspectRatioAttr(attributes.aspectRatio);
414
615
  return aspectRatio ? { 'data-resize-aspect-ratio': aspectRatio } : {};
415
616
  }
617
+ },
618
+ horizontalAlign: {
619
+ default: null,
620
+ parseHTML: (element) => normalizeHorizontalAlignAttr(element.getAttribute('data-resize-horizontal-align')),
621
+ renderHTML: (attributes) => {
622
+ const horizontalAlign = normalizeHorizontalAlignAttr(attributes.horizontalAlign);
623
+ return horizontalAlign ? { 'data-resize-horizontal-align': horizontalAlign } : {};
624
+ }
416
625
  }
417
626
  }
418
627
  }
@@ -422,11 +631,11 @@ export default Extension.create({
422
631
  let removeDragListeners = null;
423
632
  const closeOpenToolbars = (view, except = null) => {
424
633
  view.dom
425
- .querySelectorAll('.tiptap-media-resize-anchor.is-toolbar-open')
634
+ .querySelectorAll('.tiptap-media-resize-anchor.is-toolbar-open, .tiptap-media-resize-anchor.is-width-toolbar-open')
426
635
  .forEach((anchor) => {
427
636
  if (except && anchor === except)
428
637
  return;
429
- anchor.classList.remove('is-toolbar-open');
638
+ anchor.classList.remove('is-toolbar-open', 'is-width-toolbar-open');
430
639
  });
431
640
  };
432
641
  return [
@@ -490,6 +699,60 @@ export default Extension.create({
490
699
  return false;
491
700
  if (!(event.target instanceof HTMLElement))
492
701
  return false;
702
+ const widthOption = event.target.closest('.tiptap-media-width-option');
703
+ if (widthOption) {
704
+ event.preventDefault();
705
+ event.stopPropagation();
706
+ const pos = Number.parseInt(widthOption.dataset.resizePos || '', 10);
707
+ if (!Number.isFinite(pos))
708
+ return true;
709
+ const node = view.state.doc.nodeAt(pos);
710
+ if (!node)
711
+ return true;
712
+ const resizeMeta = resolveResizeMeta(node);
713
+ if (!resizeMeta || !canUseLayoutOptions(resizeMeta.kind))
714
+ return true;
715
+ const widthPreset = normalizeWidthPreset(widthOption.dataset.widthPreset);
716
+ if (!widthPreset)
717
+ return true;
718
+ if (node.attrs.width !== widthPreset) {
719
+ view.dispatch(view.state.tr.setNodeMarkup(pos, node.type, {
720
+ ...node.attrs,
721
+ width: widthPreset
722
+ }));
723
+ }
724
+ closeOpenToolbars(view);
725
+ return true;
726
+ }
727
+ const horizontalAlignOption = event.target.closest('.tiptap-media-horizontal-align-option');
728
+ if (horizontalAlignOption) {
729
+ event.preventDefault();
730
+ event.stopPropagation();
731
+ const pos = Number.parseInt(horizontalAlignOption.dataset.resizePos || '', 10);
732
+ if (!Number.isFinite(pos))
733
+ return true;
734
+ const node = view.state.doc.nodeAt(pos);
735
+ if (!node)
736
+ return true;
737
+ const resizeMeta = resolveResizeMeta(node);
738
+ if (!resizeMeta || !canUseLayoutOptions(resizeMeta.kind))
739
+ return true;
740
+ const selectedHorizontalAlign = horizontalAlignOption.dataset.horizontalAlign || 'auto';
741
+ const normalizedHorizontalAlign = selectedHorizontalAlign === 'auto'
742
+ ? null
743
+ : normalizeHorizontalAlignAttr(selectedHorizontalAlign);
744
+ if (selectedHorizontalAlign !== 'auto' && !normalizedHorizontalAlign)
745
+ return true;
746
+ const currentHorizontalAlign = normalizeHorizontalAlignAttr(node.attrs.horizontalAlign);
747
+ if (normalizedHorizontalAlign !== currentHorizontalAlign) {
748
+ view.dispatch(view.state.tr.setNodeMarkup(pos, node.type, {
749
+ ...node.attrs,
750
+ horizontalAlign: normalizedHorizontalAlign
751
+ }));
752
+ }
753
+ closeOpenToolbars(view);
754
+ return true;
755
+ }
493
756
  const ratioOption = event.target.closest('.tiptap-media-aspect-ratio-option');
494
757
  if (ratioOption) {
495
758
  event.preventDefault();
@@ -529,6 +792,151 @@ export default Extension.create({
529
792
  closeOpenToolbars(view);
530
793
  return true;
531
794
  }
795
+ const widthHandle = event.target.closest('.tiptap-media-width-resize-handle');
796
+ if (widthHandle) {
797
+ const pos = Number.parseInt(widthHandle.dataset.resizePos || '', 10);
798
+ if (!Number.isFinite(pos))
799
+ return false;
800
+ const node = view.state.doc.nodeAt(pos);
801
+ if (!node)
802
+ return false;
803
+ const resizeMeta = resolveResizeMeta(node);
804
+ if (!resizeMeta || !canUseAspectRatioPreset(resizeMeta.kind))
805
+ return false;
806
+ const resizeKind = widthHandle.dataset.resizeKind;
807
+ if (resizeKind && resizeMeta.kind !== resizeKind)
808
+ return false;
809
+ const target = resolveTargetElement(view, pos, resizeMeta, node);
810
+ if (!target)
811
+ return false;
812
+ event.preventDefault();
813
+ event.stopPropagation();
814
+ const anchor = widthHandle.closest('.tiptap-media-resize-anchor');
815
+ const startX = event.clientX;
816
+ const startY = event.clientY;
817
+ const startHeight = resolveStartHeight(resizeMeta.kind, node, target);
818
+ const targetParent = target.parentElement;
819
+ const shouldShowProxy = resizeMeta.kind !== 'image' && Boolean(targetParent);
820
+ const currentHorizontalAlign = normalizeHorizontalAlignAttr(node.attrs.horizontalAlign);
821
+ const startWidth = Math.max(1, resolveElementWidth(node, target) ||
822
+ targetParent?.getBoundingClientRect().width ||
823
+ 0);
824
+ let frame = 0;
825
+ let pendingWidth = startWidth;
826
+ let resizeProxy = null;
827
+ let restoreTarget = null;
828
+ let isDragging = false;
829
+ let appliedDragCursor = false;
830
+ const previousCursor = document.body.style.cursor;
831
+ const previousSelect = document.body.style.userSelect;
832
+ const dispatchWidth = (width) => {
833
+ const current = view.state.doc.nodeAt(pos);
834
+ if (!current || current.type.name !== resizeMeta.typeName)
835
+ return;
836
+ const currentMeta = resolveResizeMeta(current);
837
+ if (!currentMeta || !canUseAspectRatioPreset(currentMeta.kind))
838
+ return;
839
+ const containerWidth = target.parentElement?.getBoundingClientRect().width || 0;
840
+ const nextWidth = containerWidth > 0
841
+ ? `${Math.round(clamp((width / containerWidth) * 100, 10, 100))}%`
842
+ : String(Math.max(1, Math.round(width)));
843
+ if (nextWidth === current.attrs.width)
844
+ return;
845
+ view.dispatch(view.state.tr.setNodeMarkup(pos, current.type, {
846
+ ...current.attrs,
847
+ width: nextWidth
848
+ }));
849
+ };
850
+ const beginDrag = () => {
851
+ if (isDragging)
852
+ return;
853
+ isDragging = true;
854
+ closeOpenToolbars(view);
855
+ if (shouldShowProxy && targetParent) {
856
+ const targetElement = target;
857
+ const originalDisplay = targetElement.style.display;
858
+ resizeProxy = document.createElement('div');
859
+ resizeProxy.className = 'tiptap-media-resize-proxy';
860
+ resizeProxy.style.height = `${Math.round(startHeight)}px`;
861
+ resizeProxy.style.width = `${Math.round(startWidth)}px`;
862
+ if (currentHorizontalAlign === 'center') {
863
+ resizeProxy.style.marginLeft = 'auto';
864
+ resizeProxy.style.marginRight = 'auto';
865
+ }
866
+ else if (currentHorizontalAlign === 'right') {
867
+ resizeProxy.style.marginLeft = 'auto';
868
+ resizeProxy.style.marginRight = '0';
869
+ }
870
+ else {
871
+ resizeProxy.style.marginLeft = '0';
872
+ resizeProxy.style.marginRight = '0';
873
+ }
874
+ targetParent.insertBefore(resizeProxy, targetElement);
875
+ targetElement.style.display = 'none';
876
+ restoreTarget = () => {
877
+ targetElement.style.display = originalDisplay;
878
+ resizeProxy?.remove();
879
+ resizeProxy = null;
880
+ restoreTarget = null;
881
+ };
882
+ }
883
+ document.body.style.cursor = 'ew-resize';
884
+ document.body.style.userSelect = 'none';
885
+ appliedDragCursor = true;
886
+ };
887
+ const onMove = (moveEvent) => {
888
+ const deltaX = moveEvent.clientX - startX;
889
+ const deltaY = moveEvent.clientY - startY;
890
+ if (!isDragging && Math.max(Math.abs(deltaX), Math.abs(deltaY)) < 4)
891
+ return;
892
+ if (!isDragging)
893
+ beginDrag();
894
+ const containerWidth = target.parentElement?.getBoundingClientRect().width || 0;
895
+ const minWidth = containerWidth > 0 ? Math.max(120, containerWidth * 0.2) : 120;
896
+ const maxWidth = containerWidth > 0 ? containerWidth : maxHeight;
897
+ const nextWidth = clamp(startWidth + deltaX, minWidth, maxWidth);
898
+ pendingWidth = nextWidth;
899
+ if (resizeProxy)
900
+ resizeProxy.style.width = `${Math.round(nextWidth)}px`;
901
+ if (shouldShowProxy)
902
+ return;
903
+ if (frame)
904
+ cancelAnimationFrame(frame);
905
+ frame = requestAnimationFrame(() => dispatchWidth(nextWidth));
906
+ };
907
+ const cleanup = () => {
908
+ if (frame)
909
+ cancelAnimationFrame(frame);
910
+ window.removeEventListener('mousemove', onMove);
911
+ window.removeEventListener('mouseup', onUp);
912
+ if (appliedDragCursor) {
913
+ document.body.style.cursor = previousCursor;
914
+ document.body.style.userSelect = previousSelect;
915
+ }
916
+ restoreTarget?.();
917
+ removeDragListeners = null;
918
+ };
919
+ const onUp = () => {
920
+ if (!isDragging) {
921
+ const shouldOpen = Boolean(anchor) &&
922
+ !(anchor?.classList.contains('is-width-toolbar-open') ?? false);
923
+ closeOpenToolbars(view, shouldOpen && anchor ? anchor : null);
924
+ if (shouldOpen)
925
+ anchor?.classList.remove('is-toolbar-open');
926
+ anchor?.classList.toggle('is-width-toolbar-open', shouldOpen);
927
+ cleanup();
928
+ return;
929
+ }
930
+ if (shouldShowProxy)
931
+ dispatchWidth(pendingWidth);
932
+ cleanup();
933
+ };
934
+ removeDragListeners?.();
935
+ window.addEventListener('mousemove', onMove);
936
+ window.addEventListener('mouseup', onUp);
937
+ removeDragListeners = cleanup;
938
+ return true;
939
+ }
532
940
  const handle = event.target.closest('.tiptap-media-resize-handle');
533
941
  if (!handle) {
534
942
  closeOpenToolbars(view);
@@ -556,7 +964,9 @@ export default Extension.create({
556
964
  const startY = event.clientY;
557
965
  const startHeight = resolveStartHeight(resizeMeta.kind, node, target);
558
966
  const imageRatio = resizeMeta.kind === 'image' ? resolveImageRatio(node, target) : 1;
559
- const shouldShowProxy = resizeMeta.kind !== 'image';
967
+ const shouldShowProxy = true;
968
+ const currentHorizontalAlign = normalizeHorizontalAlignAttr(node.attrs.horizontalAlign);
969
+ const startWidth = resolveElementWidth(node, target);
560
970
  let resizeProxy = null;
561
971
  let restoreTarget = null;
562
972
  let frame = 0;
@@ -596,6 +1006,21 @@ export default Extension.create({
596
1006
  resizeProxy = document.createElement('div');
597
1007
  resizeProxy.className = 'tiptap-media-resize-proxy';
598
1008
  resizeProxy.style.height = `${Math.round(startHeight)}px`;
1009
+ if (startWidth > 0) {
1010
+ resizeProxy.style.width = `${Math.round(startWidth)}px`;
1011
+ }
1012
+ if (currentHorizontalAlign === 'center') {
1013
+ resizeProxy.style.marginLeft = 'auto';
1014
+ resizeProxy.style.marginRight = 'auto';
1015
+ }
1016
+ else if (currentHorizontalAlign === 'right') {
1017
+ resizeProxy.style.marginLeft = 'auto';
1018
+ resizeProxy.style.marginRight = '0';
1019
+ }
1020
+ else {
1021
+ resizeProxy.style.marginLeft = '0';
1022
+ resizeProxy.style.marginRight = '0';
1023
+ }
599
1024
  target.parentElement.insertBefore(resizeProxy, targetElement);
600
1025
  targetElement.style.display = 'none';
601
1026
  restoreTarget = () => {
@@ -642,6 +1067,8 @@ export default Extension.create({
642
1067
  if (!isDragging) {
643
1068
  const shouldOpen = Boolean(anchor) && !(anchor?.classList.contains('is-toolbar-open') ?? false);
644
1069
  closeOpenToolbars(view, shouldOpen && anchor ? anchor : null);
1070
+ if (shouldOpen)
1071
+ anchor?.classList.remove('is-width-toolbar-open');
645
1072
  anchor?.classList.toggle('is-toolbar-open', shouldOpen);
646
1073
  cleanup();
647
1074
  return;
@@ -12,11 +12,9 @@
12
12
  import { quartOut } from 'svelte/easing';
13
13
  import defaultI18n, { I18N_CONTEXT, type I18nTranslate } from '../i18n';
14
14
 
15
- const editor = getContext<{ v: any }>('editor');
16
15
  const i18nFromContext = getContext<I18nTranslate | undefined>(I18N_CONTEXT);
17
16
  const i18n: I18nTranslate = (...args) =>
18
17
  i18nFromContext ? i18nFromContext(...args) : defaultI18n(...args);
19
- const tiptap = $derived(editor.v);
20
18
 
21
19
  let height = $state(0);
22
20
  let input = $state(''),
@@ -32,7 +30,6 @@
32
30
  if (!editor || !range) return;
33
31
 
34
32
  item.command({ editor, range });
35
- setTimeout(() => tiptap?.commands?.focus?.());
36
33
  }
37
34
 
38
35
  function runDetailCommand() {
@@ -18,7 +18,6 @@
18
18
  type I18nTranslate
19
19
  } from '../i18n';
20
20
  import type { UploadFn } from '../plugin/image/dragdrop';
21
- import { fallbackUpload } from '../plugin/image/dragdrop';
22
21
  import MediaResize, { type ResizeOptions } from '../plugin/resize';
23
22
  import { Render } from 'nunui';
24
23
 
@@ -51,7 +50,7 @@
51
50
  ref = $bindable(null),
52
51
  options = {},
53
52
  loaded = $bindable(false),
54
- imageUpload = fallbackUpload,
53
+ imageUpload,
55
54
  style = '',
56
55
  blocks = [],
57
56
  placeholder,
@@ -82,6 +81,7 @@
82
81
  'data-resize-min-height',
83
82
  'data-resize-max-height',
84
83
  'data-resize-aspect-ratio',
84
+ 'data-resize-horizontal-align',
85
85
  'data-bubble-menu',
86
86
  'data-hide-bubble-menu'
87
87
  ];
@@ -341,6 +341,10 @@
341
341
  outline: 3px solid var(--primary);
342
342
  }
343
343
 
344
+ .editable :global(.ProseMirror) {
345
+ position: relative;
346
+ }
347
+
344
348
  .editable :global(.tiptap-media-resize-anchor) {
345
349
  width: 100%;
346
350
  display: flex;
@@ -353,6 +357,27 @@
353
357
  overflow: visible;
354
358
  }
355
359
 
360
+ .editable :global(.tiptap-media-resize-anchor.is-image-anchor) {
361
+ position: relative;
362
+ display: block;
363
+ width: 100%;
364
+ height: 0;
365
+ margin: 0;
366
+ z-index: 4;
367
+ }
368
+
369
+ .editable :global(.tiptap-media-resize-anchor.is-image-anchor .tiptap-media-resize-controls) {
370
+ position: absolute;
371
+ top: 0;
372
+ left: 0;
373
+ display: flex;
374
+ flex-direction: column;
375
+ align-items: center;
376
+ line-height: 0;
377
+ pointer-events: none;
378
+ overflow: visible;
379
+ }
380
+
356
381
  .editable :global(.tiptap-media-resize-handle) {
357
382
  appearance: none;
358
383
  -webkit-appearance: none;
@@ -383,6 +408,39 @@
383
408
  transform: translateY(1px);
384
409
  }
385
410
 
411
+ .editable :global(.tiptap-media-width-resize-handle) {
412
+ appearance: none;
413
+ -webkit-appearance: none;
414
+ position: absolute;
415
+ top: 0;
416
+ right: 0;
417
+ width: 12px;
418
+ height: 12px;
419
+ margin: 0;
420
+ padding: 0;
421
+ border: 1px solid var(--primary-light3, rgba(120, 120, 120, 0.45));
422
+ border-radius: 999px;
423
+ background: var(--primary-light6, rgba(120, 120, 120, 0.2));
424
+ cursor: ew-resize;
425
+ pointer-events: auto;
426
+ transform: translate(40%, -40%);
427
+ transition:
428
+ background-color 0.15s ease,
429
+ border-color 0.15s ease,
430
+ transform 0.15s ease;
431
+ }
432
+
433
+ .editable :global(.tiptap-media-width-resize-handle:hover),
434
+ .editable :global(.tiptap-media-width-resize-handle:focus-visible) {
435
+ background: var(--primary-light4, rgba(120, 120, 120, 0.35));
436
+ border-color: var(--primary-light2, rgba(100, 100, 100, 0.55));
437
+ outline: none;
438
+ }
439
+
440
+ .editable :global(.tiptap-media-width-resize-handle:active) {
441
+ transform: translate(40%, -40%) scale(0.95);
442
+ }
443
+
386
444
  .editable :global(.tiptap-media-aspect-ratio-toolbar) {
387
445
  display: none;
388
446
  align-items: center;
@@ -402,12 +460,81 @@
402
460
  white-space: nowrap;
403
461
  }
404
462
 
463
+ .editable :global(.tiptap-media-width-toolbar) {
464
+ display: none;
465
+ align-items: center;
466
+ gap: 4px;
467
+ padding: 4px;
468
+ border: 1px solid var(--primary-light3, rgba(120, 120, 120, 0.4));
469
+ border-radius: 999px;
470
+ background: var(--surface, #fff);
471
+ box-shadow: 0 6px 16px rgba(0, 0, 0, 0.12);
472
+ pointer-events: auto;
473
+ line-height: 1;
474
+ position: absolute;
475
+ top: calc(100% + 6px);
476
+ right: 0;
477
+ z-index: 4;
478
+ white-space: nowrap;
479
+ }
480
+
405
481
  .editable
406
482
  :global(.tiptap-media-resize-anchor.is-toolbar-open .tiptap-media-aspect-ratio-toolbar) {
407
483
  display: flex;
408
484
  }
409
485
 
410
- .editable :global(.tiptap-media-aspect-ratio-option) {
486
+ .editable :global(.tiptap-media-resize-anchor.is-width-toolbar-open .tiptap-media-width-toolbar) {
487
+ display: flex;
488
+ }
489
+
490
+ .editable :global(.tiptap-media-toolbar-separator) {
491
+ width: 1px;
492
+ height: 16px;
493
+ background: var(--primary-light2, rgba(120, 120, 120, 0.35));
494
+ opacity: 0.9;
495
+ }
496
+
497
+ .editable :global(.tiptap-media-toolbar-group-icon) {
498
+ width: 14px;
499
+ height: 14px;
500
+ border: 1px solid var(--primary-light3, rgba(120, 120, 120, 0.45));
501
+ border-radius: 4px;
502
+ color: var(--on-surface, #333);
503
+ opacity: 0.75;
504
+ flex: 0 0 auto;
505
+ pointer-events: none;
506
+ }
507
+
508
+ .editable :global(.tiptap-media-toolbar-group-icon[data-group='aspect']) {
509
+ background: linear-gradient(
510
+ 135deg,
511
+ transparent 42%,
512
+ currentColor 43%,
513
+ currentColor 57%,
514
+ transparent 58%
515
+ )
516
+ center / 100% 100% no-repeat;
517
+ }
518
+
519
+ .editable :global(.tiptap-media-toolbar-group-icon[data-group='align']) {
520
+ background:
521
+ linear-gradient(currentColor, currentColor) left 2px top 3px / 8px 1.5px no-repeat,
522
+ linear-gradient(currentColor, currentColor) center top 6px / 10px 1.5px no-repeat,
523
+ linear-gradient(currentColor, currentColor) right 2px top 9px / 8px 1.5px no-repeat;
524
+ }
525
+
526
+ .editable :global(.tiptap-media-toolbar-group-icon[data-group='width']) {
527
+ background:
528
+ linear-gradient(currentColor, currentColor) center / 8px 1.5px no-repeat,
529
+ linear-gradient(45deg, transparent 38%, currentColor 39%, currentColor 61%, transparent 62%)
530
+ left 2px center / 4px 4px no-repeat,
531
+ linear-gradient(-45deg, transparent 38%, currentColor 39%, currentColor 61%, transparent 62%)
532
+ right 2px center / 4px 4px no-repeat;
533
+ }
534
+
535
+ .editable :global(.tiptap-media-aspect-ratio-option),
536
+ .editable :global(.tiptap-media-horizontal-align-option),
537
+ .editable :global(.tiptap-media-width-option) {
411
538
  appearance: none;
412
539
  -webkit-appearance: none;
413
540
  margin: 0;
@@ -423,12 +550,18 @@
423
550
  }
424
551
 
425
552
  .editable :global(.tiptap-media-aspect-ratio-option:hover),
426
- .editable :global(.tiptap-media-aspect-ratio-option:focus-visible) {
553
+ .editable :global(.tiptap-media-aspect-ratio-option:focus-visible),
554
+ .editable :global(.tiptap-media-horizontal-align-option:hover),
555
+ .editable :global(.tiptap-media-horizontal-align-option:focus-visible),
556
+ .editable :global(.tiptap-media-width-option:hover),
557
+ .editable :global(.tiptap-media-width-option:focus-visible) {
427
558
  background: var(--primary-light1, rgba(120, 120, 120, 0.14));
428
559
  outline: none;
429
560
  }
430
561
 
431
- .editable :global(.tiptap-media-aspect-ratio-option[aria-pressed='true']) {
562
+ .editable :global(.tiptap-media-aspect-ratio-option[aria-pressed='true']),
563
+ .editable :global(.tiptap-media-horizontal-align-option[aria-pressed='true']),
564
+ .editable :global(.tiptap-media-width-option[aria-pressed='true']) {
432
565
  background: var(--primary-light4, rgba(120, 120, 120, 0.3));
433
566
  color: var(--on-primary, #000);
434
567
  }
@@ -469,6 +602,10 @@
469
602
  position: relative;
470
603
  }
471
604
 
605
+ & :global(figure[data-bubble-menu='false']) {
606
+ margin: 0;
607
+ }
608
+
472
609
  & :global(code.inline) {
473
610
  background: var(--primary-light1);
474
611
  padding: 2px 4px;
@@ -535,8 +672,38 @@
535
672
  & :global(iframe),
536
673
  & :global(embed) {
537
674
  display: block;
538
- width: 100%;
539
675
  max-width: 100%;
676
+ margin-left: var(--tiptap-media-horizontal-margin-left, 0);
677
+ margin-right: var(--tiptap-media-horizontal-margin-right, 0);
678
+ }
679
+
680
+ & :global(img[data-resize-horizontal-align]) {
681
+ display: block;
682
+ margin-left: var(--tiptap-media-horizontal-margin-left, 0);
683
+ margin-right: var(--tiptap-media-horizontal-margin-right, 0);
684
+ }
685
+
686
+ & :global(img[width='100%']) {
687
+ width: 100%;
688
+ }
689
+
690
+ & :global(img[width='50%']) {
691
+ width: 50%;
692
+ }
693
+
694
+ & :global([data-resize-horizontal-align='left']) {
695
+ --tiptap-media-horizontal-margin-left: 0;
696
+ --tiptap-media-horizontal-margin-right: auto;
697
+ }
698
+
699
+ & :global([data-resize-horizontal-align='center']) {
700
+ --tiptap-media-horizontal-margin-left: auto;
701
+ --tiptap-media-horizontal-margin-right: auto;
702
+ }
703
+
704
+ & :global([data-resize-horizontal-align='right']) {
705
+ --tiptap-media-horizontal-margin-left: auto;
706
+ --tiptap-media-horizontal-margin-right: 0;
540
707
  }
541
708
 
542
709
  & :global([data-resize-aspect-ratio='16:9']) {
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@seorii/tiptap",
3
- "version": "0.4.3",
3
+ "version": "0.4.4",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "svelte-kit sync && svelte-package",