@seorii/tiptap 0.4.2 → 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;
@@ -242,46 +275,228 @@ function buildResizeAttrs(kind, node, height, imageRatio, aspectRatio = normaliz
242
275
  // Keep image responsive by storing width only; fixed height causes ratio distortion on narrow layouts.
243
276
  return { ...attrs, width: roundedWidth, height: null };
244
277
  }
245
- if (kind === 'iframe' || kind === 'embed') {
246
- return { ...attrs, width: attrs.width || '100%', height: roundedHeight, aspectRatio };
278
+ if (kind === 'iframe') {
279
+ if (aspectRatio) {
280
+ // A fixed height prevents CSS aspect-ratio from taking effect.
281
+ return { ...attrs, width: attrs.width || '100%', height: null, aspectRatio };
282
+ }
283
+ return { ...attrs, width: attrs.width || '100%', height: roundedHeight, aspectRatio: null };
284
+ }
285
+ if (kind === 'embed') {
286
+ if (aspectRatio) {
287
+ // Keep PDF/embed responsive with CSS aspect-ratio when a preset is selected.
288
+ return { ...attrs, width: attrs.width || '100%', height: null, aspectRatio };
289
+ }
290
+ return { ...attrs, width: attrs.width || '100%', height: roundedHeight, aspectRatio: null };
247
291
  }
248
- return { ...attrs, height: roundedHeight, aspectRatio };
292
+ return { ...attrs, height: roundedHeight, aspectRatio: null };
293
+ }
294
+ function canUseAspectRatioPreset(kind) {
295
+ return kind === 'iframe' || kind === 'embed';
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;
249
306
  }
250
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';
251
311
  return Decoration.widget(widgetPos, () => {
252
312
  const anchor = document.createElement('div');
253
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
+ }
254
389
  const button = document.createElement('button');
255
390
  button.type = 'button';
256
391
  button.className = 'tiptap-media-resize-handle';
257
392
  button.dataset.resizePos = String(nodePos);
258
393
  button.dataset.resizeKind = resizeMeta.kind;
259
- button.setAttribute('aria-label', 'Resize media height (click for aspect ratio)');
260
- anchor.append(button);
261
- if (resizeMeta.kind !== 'image') {
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)) {
262
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
+ }
263
413
  const toolbar = document.createElement('div');
264
414
  toolbar.className = 'tiptap-media-aspect-ratio-toolbar';
265
415
  toolbar.setAttribute('role', 'toolbar');
266
- toolbar.setAttribute('aria-label', 'Aspect ratio presets');
416
+ toolbar.setAttribute('aria-label', 'Media resize options');
267
417
  toolbar.dataset.resizePos = String(nodePos);
268
- 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) {
269
444
  const optionButton = document.createElement('button');
270
445
  optionButton.type = 'button';
271
- optionButton.className = 'tiptap-media-aspect-ratio-option';
446
+ optionButton.className = 'tiptap-media-horizontal-align-option';
272
447
  optionButton.dataset.resizePos = String(nodePos);
273
- optionButton.dataset.aspectRatio = option.value ?? 'auto';
448
+ optionButton.dataset.horizontalAlign = option.value ?? 'auto';
274
449
  optionButton.textContent = option.label;
275
450
  const isActive = option.value
276
- ? sameAspectRatio(option.value, selectedAspectRatio)
277
- : !selectedAspectRatio;
451
+ ? option.value === selectedHorizontalAlign
452
+ : !selectedHorizontalAlign;
278
453
  optionButton.setAttribute('aria-pressed', isActive ? 'true' : 'false');
279
454
  toolbar.append(optionButton);
280
455
  }
281
- 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
+ }
282
494
  }
283
495
  return anchor;
284
- }, { 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
+ });
285
500
  }
286
501
  function tryCreateNodeSelection(doc, pos) {
287
502
  if (pos < 0 || pos > doc.content.size)
@@ -342,8 +557,17 @@ export default Extension.create({
342
557
  },
343
558
  height: {
344
559
  default: '600',
345
- parseHTML: (element) => normalizeNumericAttr(element.getAttribute('height') || element.style.height) || '600',
560
+ parseHTML: (element) => {
561
+ const aspectRatio = normalizeAspectRatioAttr(element.getAttribute('data-resize-aspect-ratio'));
562
+ if (aspectRatio)
563
+ return null;
564
+ return (normalizeNumericAttr(element.getAttribute('height') || element.style.height) ||
565
+ '600');
566
+ },
346
567
  renderHTML: (attributes) => {
568
+ const aspectRatio = normalizeAspectRatioAttr(attributes.aspectRatio);
569
+ if (aspectRatio)
570
+ return {};
347
571
  const height = normalizeNumericAttr(attributes.height) || '600';
348
572
  return { height };
349
573
  }
@@ -390,6 +614,14 @@ export default Extension.create({
390
614
  const aspectRatio = normalizeAspectRatioAttr(attributes.aspectRatio);
391
615
  return aspectRatio ? { 'data-resize-aspect-ratio': aspectRatio } : {};
392
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
+ }
393
625
  }
394
626
  }
395
627
  }
@@ -399,11 +631,11 @@ export default Extension.create({
399
631
  let removeDragListeners = null;
400
632
  const closeOpenToolbars = (view, except = null) => {
401
633
  view.dom
402
- .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')
403
635
  .forEach((anchor) => {
404
636
  if (except && anchor === except)
405
637
  return;
406
- anchor.classList.remove('is-toolbar-open');
638
+ anchor.classList.remove('is-toolbar-open', 'is-width-toolbar-open');
407
639
  });
408
640
  };
409
641
  return [
@@ -467,6 +699,60 @@ export default Extension.create({
467
699
  return false;
468
700
  if (!(event.target instanceof HTMLElement))
469
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
+ }
470
756
  const ratioOption = event.target.closest('.tiptap-media-aspect-ratio-option');
471
757
  if (ratioOption) {
472
758
  event.preventDefault();
@@ -478,7 +764,7 @@ export default Extension.create({
478
764
  if (!node)
479
765
  return true;
480
766
  const resizeMeta = resolveResizeMeta(node);
481
- if (!resizeMeta || resizeMeta.kind === 'image')
767
+ if (!resizeMeta || !canUseAspectRatioPreset(resizeMeta.kind))
482
768
  return true;
483
769
  const target = resolveTargetElement(view, pos, resizeMeta, node);
484
770
  if (!target)
@@ -506,6 +792,151 @@ export default Extension.create({
506
792
  closeOpenToolbars(view);
507
793
  return true;
508
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
+ }
509
940
  const handle = event.target.closest('.tiptap-media-resize-handle');
510
941
  if (!handle) {
511
942
  closeOpenToolbars(view);
@@ -533,7 +964,9 @@ export default Extension.create({
533
964
  const startY = event.clientY;
534
965
  const startHeight = resolveStartHeight(resizeMeta.kind, node, target);
535
966
  const imageRatio = resizeMeta.kind === 'image' ? resolveImageRatio(node, target) : 1;
536
- const shouldShowProxy = resizeMeta.kind !== 'image';
967
+ const shouldShowProxy = true;
968
+ const currentHorizontalAlign = normalizeHorizontalAlignAttr(node.attrs.horizontalAlign);
969
+ const startWidth = resolveElementWidth(node, target);
537
970
  let resizeProxy = null;
538
971
  let restoreTarget = null;
539
972
  let frame = 0;
@@ -573,6 +1006,21 @@ export default Extension.create({
573
1006
  resizeProxy = document.createElement('div');
574
1007
  resizeProxy.className = 'tiptap-media-resize-proxy';
575
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
+ }
576
1024
  target.parentElement.insertBefore(resizeProxy, targetElement);
577
1025
  targetElement.style.display = 'none';
578
1026
  restoreTarget = () => {
@@ -619,6 +1067,8 @@ export default Extension.create({
619
1067
  if (!isDragging) {
620
1068
  const shouldOpen = Boolean(anchor) && !(anchor?.classList.contains('is-toolbar-open') ?? false);
621
1069
  closeOpenToolbars(view, shouldOpen && anchor ? anchor : null);
1070
+ if (shouldOpen)
1071
+ anchor?.classList.remove('is-width-toolbar-open');
622
1072
  anchor?.classList.toggle('is-toolbar-open', shouldOpen);
623
1073
  cleanup();
624
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.2",
3
+ "version": "0.4.4",
4
4
  "scripts": {
5
5
  "dev": "vite dev",
6
6
  "build": "svelte-kit sync && svelte-package",