feat: add image width height adaption and rotation
This commit is contained in:
1 parent
20eb94d7c0
commit
f79be768f9
4 files changed
+289
-74
No files matched your search
+197
-60
@@ -4,25 +4,50 @@ import type { Node as PMNode } from '@tiptap/pm/model';
|
||||
import { dropCursor } from '@tiptap/pm/dropcursor';
|
||||
|
||||
// Inline, as-character image (Word's "in line with text"). width/height are
|
||||
// unscaled doc px @96dpi (like tableRow.ts rowHeight); export converts to cm and
|
||||
// back, so size round-trips exactly. The node view adds aspect-locked resize handles.
|
||||
// unscaled doc px @96dpi (like tableRow.ts rowHeight); rotation is CW degrees.
|
||||
// Export converts to cm + an ODF draw:transform, so size/rotation round-trip exactly.
|
||||
|
||||
declare module '@tiptap/core' {
|
||||
interface Commands<ReturnType> {
|
||||
image: {
|
||||
setImage: (attrs: { src: string; alt?: string; width?: number | null; height?: number | null }) => ReturnType;
|
||||
setImage: (attrs: { src: string; alt?: string; width?: number | null; height?: number | null; rotation?: number }) => ReturnType;
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
const MIN_SIZE_PX = 24;
|
||||
|
||||
// Corners keep aspect (Word-style); edges (n/s/e/w) change one dimension only.
|
||||
const HANDLES: { k: string; x: -1 | 0 | 1; y: -1 | 0 | 1; aspect: boolean }[] = [
|
||||
{ k: 'nw', x: -1, y: -1, aspect: true },
|
||||
{ k: 'n', x: 0, y: -1, aspect: false },
|
||||
{ k: 'ne', x: 1, y: -1, aspect: true },
|
||||
{ k: 'e', x: 1, y: 0, aspect: false },
|
||||
{ k: 'se', x: 1, y: 1, aspect: true },
|
||||
{ k: 's', x: 0, y: 1, aspect: false },
|
||||
{ k: 'sw', x: -1, y: 1, aspect: true },
|
||||
{ k: 'w', x: -1, y: 0, aspect: false },
|
||||
];
|
||||
|
||||
function parsePx(value: string | null): number | null {
|
||||
if (!value) return null;
|
||||
const n = parseFloat(value);
|
||||
return Number.isFinite(n) ? Math.round(n) : null;
|
||||
}
|
||||
|
||||
const clamp = (v: number, lo: number, hi: number) => Math.max(lo, Math.min(hi, v));
|
||||
|
||||
// The page text height in px, capping how tall an image can be stretched. Read live
|
||||
// from the :root vars the editor maintains (orientation/margins change them).
|
||||
function pageContentHeightPx(): number {
|
||||
const cs = getComputedStyle(document.documentElement);
|
||||
const h =
|
||||
parseFloat(cs.getPropertyValue('--user-page-height')) -
|
||||
parseFloat(cs.getPropertyValue('--user-margin-top')) -
|
||||
parseFloat(cs.getPropertyValue('--user-margin-bottom'));
|
||||
return h > 0 ? h : 4000;
|
||||
}
|
||||
|
||||
export const Image = Node.create({
|
||||
name: 'image',
|
||||
group: 'inline',
|
||||
@@ -35,8 +60,8 @@ export const Image = Node.create({
|
||||
return {
|
||||
src: { default: null },
|
||||
alt: { default: '' },
|
||||
// px @96dpi; rendered as inline style (not width/height attrs) so the
|
||||
// value stays in one place for the node view to drive.
|
||||
// px @96dpi and CW degrees; kept off the width/height attrs (rendered via
|
||||
// inline style / data-rotation) so one place drives the node view.
|
||||
width: {
|
||||
default: null,
|
||||
parseHTML: el => parsePx(el.getAttribute('width') ?? (el as HTMLElement).style.width),
|
||||
@@ -47,6 +72,11 @@ export const Image = Node.create({
|
||||
parseHTML: el => parsePx(el.getAttribute('height') ?? (el as HTMLElement).style.height),
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
rotation: {
|
||||
default: 0,
|
||||
parseHTML: el => parsePx((el as HTMLElement).getAttribute('data-rotation')) ?? 0,
|
||||
renderHTML: () => ({}),
|
||||
},
|
||||
};
|
||||
},
|
||||
|
||||
@@ -57,8 +87,13 @@ export const Image = Node.create({
|
||||
renderHTML({ HTMLAttributes, node }) {
|
||||
const w = node.attrs.width as number | null;
|
||||
const h = node.attrs.height as number | null;
|
||||
const style = [w ? `width:${w}px` : '', h ? `height:${h}px` : ''].filter(Boolean).join(';');
|
||||
return ['img', mergeAttributes(HTMLAttributes, style ? { style } : {})];
|
||||
const rot = (node.attrs.rotation as number) || 0;
|
||||
const style = [
|
||||
w ? `width:${w}px` : '',
|
||||
h ? `height:${h}px` : '',
|
||||
rot ? `transform:rotate(${rot}deg)` : '',
|
||||
].filter(Boolean).join(';');
|
||||
return ['img', mergeAttributes(HTMLAttributes, { ...(style ? { style } : {}), ...(rot ? { 'data-rotation': String(rot) } : {}) })];
|
||||
},
|
||||
|
||||
addCommands() {
|
||||
@@ -81,12 +116,14 @@ export const Image = Node.create({
|
||||
},
|
||||
});
|
||||
|
||||
// Node view: <img> wrapped in an inline-block span with four corner handles shown
|
||||
// while the node is selected. Mirrors the zoom trick from tableRowResize.ts
|
||||
// (offset size is unscaled, getBoundingClientRect is scaled → ratio is the zoom).
|
||||
// Node view: a bounding-box wrapper (reserves the rotated footprint for text flow)
|
||||
// holds a centered, rotated "rotor" with the <img>, eight resize handles (corners
|
||||
// aspect-locked, edges single-axis) and a rotate grip — all rotating with the image.
|
||||
class ImageView {
|
||||
dom: HTMLElement;
|
||||
private rotor: HTMLElement;
|
||||
private img: HTMLImageElement;
|
||||
private badge: HTMLElement;
|
||||
private node: PMNode;
|
||||
private editor: Editor;
|
||||
private getPos: () => number;
|
||||
@@ -99,79 +136,180 @@ class ImageView {
|
||||
this.dom = document.createElement('span');
|
||||
this.dom.className = 'image-node';
|
||||
|
||||
this.rotor = document.createElement('span');
|
||||
this.rotor.className = 'image-rotor';
|
||||
|
||||
this.img = document.createElement('img');
|
||||
this.img.src = (node.attrs.src as string) ?? '';
|
||||
this.img.alt = (node.attrs.alt as string) ?? '';
|
||||
this.applySize();
|
||||
this.dom.appendChild(this.img);
|
||||
// Pasted/HTML images may arrive without dimensions; adopt their natural size
|
||||
// (capped to the text column) so every image is explicitly sized.
|
||||
this.img.onload = () => this.adoptNaturalSize();
|
||||
this.rotor.appendChild(this.img);
|
||||
|
||||
for (const corner of ['nw', 'ne', 'sw', 'se'] as const) {
|
||||
const handle = document.createElement('span');
|
||||
handle.className = `image-resize-handle image-resize-${corner}`;
|
||||
handle.addEventListener('mousedown', e => this.startResize(e as MouseEvent, corner));
|
||||
this.dom.appendChild(handle);
|
||||
for (const cfg of HANDLES) {
|
||||
const h = document.createElement('span');
|
||||
h.className = `image-resize-handle image-resize-${cfg.k}`;
|
||||
h.addEventListener('mousedown', e => this.startResize(e as MouseEvent, cfg));
|
||||
this.rotor.appendChild(h);
|
||||
}
|
||||
const rot = document.createElement('span');
|
||||
rot.className = 'image-rotate-handle';
|
||||
rot.addEventListener('mousedown', e => this.startRotate(e as MouseEvent));
|
||||
this.rotor.appendChild(rot);
|
||||
|
||||
this.dom.appendChild(this.rotor);
|
||||
|
||||
// Live "Width … × Height …" readout shown while resizing (on the axis-aligned
|
||||
// wrapper so it stays upright even when the image is rotated).
|
||||
this.badge = document.createElement('span');
|
||||
this.badge.className = 'image-size-badge';
|
||||
this.dom.appendChild(this.badge);
|
||||
|
||||
this.applyLayout(this.attrW(), this.attrH(), this.attrRot());
|
||||
}
|
||||
|
||||
private applySize(): void {
|
||||
const w = this.node.attrs.width as number | null;
|
||||
const h = this.node.attrs.height as number | null;
|
||||
this.img.style.width = w ? `${w}px` : '';
|
||||
this.img.style.height = h ? `${h}px` : '';
|
||||
private showBadge(w: number, h: number): void {
|
||||
const cm = (px: number) => ((px * 2.54) / 96).toFixed(2);
|
||||
this.badge.textContent = `Width ${cm(w)} cm × Height ${cm(h)} cm`;
|
||||
this.badge.style.display = 'block';
|
||||
}
|
||||
|
||||
private startResize(event: MouseEvent, corner: 'nw' | 'ne' | 'sw' | 'se'): void {
|
||||
private attrW(): number | null { const w = this.node.attrs.width; return typeof w === 'number' ? w : null; }
|
||||
private attrH(): number | null { const h = this.node.attrs.height; return typeof h === 'number' ? h : null; }
|
||||
private attrRot(): number { return (this.node.attrs.rotation as number) || 0; }
|
||||
|
||||
// Size the rotor to w×h, rotate it about its centre, and grow the axis-aligned
|
||||
// wrapper to the rotated bounding box so the line reserves the right space.
|
||||
private applyLayout(w: number | null, h: number | null, deg: number): void {
|
||||
if (w && h) {
|
||||
this.rotor.style.width = `${w}px`;
|
||||
this.rotor.style.height = `${h}px`;
|
||||
const rad = (deg * Math.PI) / 180;
|
||||
const bw = Math.abs(w * Math.cos(rad)) + Math.abs(h * Math.sin(rad));
|
||||
const bh = Math.abs(w * Math.sin(rad)) + Math.abs(h * Math.cos(rad));
|
||||
this.dom.style.width = `${bw}px`;
|
||||
this.dom.style.height = `${bh}px`;
|
||||
} else {
|
||||
this.rotor.style.width = '';
|
||||
this.rotor.style.height = '';
|
||||
this.dom.style.width = '';
|
||||
this.dom.style.height = '';
|
||||
}
|
||||
this.rotor.style.transform = `translate(-50%, -50%) rotate(${deg}deg)`;
|
||||
}
|
||||
|
||||
private adoptNaturalSize(): void {
|
||||
if (this.attrW() != null && this.attrH() != null) return;
|
||||
const nw = this.img.naturalWidth;
|
||||
const nh = this.img.naturalHeight;
|
||||
if (!nw || !nh) return;
|
||||
const maxW = this.boxMaxWidth();
|
||||
let w = nw;
|
||||
let h = nh;
|
||||
if (w > maxW) { h = Math.round((h * maxW) / w); w = maxW; }
|
||||
this.commit({ width: Math.round(w), height: Math.round(h) });
|
||||
}
|
||||
|
||||
// Largest width an image may take: the containing cell, else the page text column.
|
||||
private boxMaxWidth(): number {
|
||||
const box = (this.dom.closest('td,th') ?? this.dom.closest('.tiptap')) as HTMLElement | null;
|
||||
if (!box) return 10000;
|
||||
const cs = getComputedStyle(box);
|
||||
const w = box.clientWidth - parseFloat(cs.paddingLeft || '0') - parseFloat(cs.paddingRight || '0');
|
||||
return Math.max(MIN_SIZE_PX, w || 10000);
|
||||
}
|
||||
|
||||
private commit(attrs: Record<string, unknown>): void {
|
||||
const pos = this.getPos();
|
||||
if (typeof pos !== 'number') return;
|
||||
this.editor.view.dispatch(this.editor.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, ...attrs }));
|
||||
}
|
||||
|
||||
private startResize(event: MouseEvent, cfg: typeof HANDLES[number]): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!this.editor.isEditable) return;
|
||||
|
||||
const startX = event.clientX;
|
||||
const startW = this.img.offsetWidth;
|
||||
const startH = this.img.offsetHeight;
|
||||
const startW = this.rotor.offsetWidth;
|
||||
const startH = this.rotor.offsetHeight;
|
||||
if (!startW || !startH) return;
|
||||
const aspect = startW / startH;
|
||||
const zoom = this.img.getBoundingClientRect().width / startW || 1;
|
||||
// Left-side corners grow when dragged left; right-side corners when dragged right.
|
||||
const dirX = corner === 'ne' || corner === 'se' ? 1 : -1;
|
||||
// Never wider than the containing cell, or the page text column outside tables.
|
||||
// clientWidth/padding are unscaled layout px, matching our doc-px sizing.
|
||||
const box = (this.dom.closest('td,th') ?? this.dom.closest('.tiptap')) as HTMLElement | null;
|
||||
let maxW = startW;
|
||||
if (box) {
|
||||
const cs = getComputedStyle(box);
|
||||
maxW = box.clientWidth - parseFloat(cs.paddingLeft || '0') - parseFloat(cs.paddingRight || '0');
|
||||
}
|
||||
maxW = Math.max(MIN_SIZE_PX, maxW || startW);
|
||||
const deg = this.attrRot();
|
||||
const th = (deg * Math.PI) / 180;
|
||||
const cosT = Math.cos(th);
|
||||
const sinT = Math.sin(th);
|
||||
// The wrapper is axis-aligned, so its scaled/unscaled width ratio is the zoom.
|
||||
const zoom = this.dom.getBoundingClientRect().width / this.dom.offsetWidth || 1;
|
||||
const maxW = this.boxMaxWidth();
|
||||
const maxH = pageContentHeightPx();
|
||||
const sx = event.clientX;
|
||||
const sy = event.clientY;
|
||||
const win = this.dom.ownerDocument.defaultView ?? window;
|
||||
|
||||
let lastW = startW;
|
||||
let lastH = startH;
|
||||
let moved = false;
|
||||
|
||||
const move = (e: MouseEvent): void => {
|
||||
if (!e.buttons) {
|
||||
finish();
|
||||
return;
|
||||
if (!e.buttons) { finish(); return; }
|
||||
// Un-rotate the screen delta onto the image's own axes so handles track the
|
||||
// pointer at any rotation.
|
||||
const dx = (e.clientX - sx) / zoom;
|
||||
const dy = (e.clientY - sy) / zoom;
|
||||
const lx = dx * cosT + dy * sinT;
|
||||
const ly = -dx * sinT + dy * cosT;
|
||||
if (cfg.aspect) {
|
||||
let w = clamp(startW + cfg.x * lx, MIN_SIZE_PX, maxW);
|
||||
let h = w / aspect;
|
||||
if (h > maxH) { h = maxH; w = h * aspect; }
|
||||
lastW = Math.round(w);
|
||||
lastH = Math.round(h);
|
||||
} else {
|
||||
if (cfg.x) lastW = Math.round(clamp(startW + cfg.x * lx, MIN_SIZE_PX, maxW));
|
||||
if (cfg.y) lastH = Math.round(clamp(startH + cfg.y * ly, MIN_SIZE_PX, maxH));
|
||||
}
|
||||
const dx = ((e.clientX - startX) / zoom) * dirX;
|
||||
lastW = Math.max(MIN_SIZE_PX, Math.min(maxW, Math.round(startW + dx)));
|
||||
lastH = Math.max(MIN_SIZE_PX, Math.round(lastW / aspect));
|
||||
moved = true;
|
||||
this.img.style.width = `${lastW}px`;
|
||||
this.img.style.height = `${lastH}px`;
|
||||
this.applyLayout(lastW, lastH, deg);
|
||||
this.showBadge(lastW, lastH);
|
||||
};
|
||||
|
||||
const finish = (): void => {
|
||||
win.removeEventListener('mousemove', move);
|
||||
win.removeEventListener('mouseup', finish);
|
||||
if (!moved) return;
|
||||
const pos = this.getPos();
|
||||
if (typeof pos !== 'number') return;
|
||||
this.editor.view.dispatch(
|
||||
this.editor.state.tr.setNodeMarkup(pos, undefined, { ...this.node.attrs, width: lastW, height: lastH }),
|
||||
);
|
||||
this.badge.style.display = 'none';
|
||||
if (moved) this.commit({ width: lastW, height: lastH });
|
||||
};
|
||||
win.addEventListener('mousemove', move);
|
||||
win.addEventListener('mouseup', finish);
|
||||
}
|
||||
|
||||
private startRotate(event: MouseEvent): void {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (!this.editor.isEditable) return;
|
||||
|
||||
const w = this.rotor.offsetWidth;
|
||||
const h = this.rotor.offsetHeight;
|
||||
const rect = this.dom.getBoundingClientRect();
|
||||
const cx = rect.left + rect.width / 2;
|
||||
const cy = rect.top + rect.height / 2;
|
||||
const win = this.dom.ownerDocument.defaultView ?? window;
|
||||
let lastDeg = this.attrRot();
|
||||
let moved = false;
|
||||
|
||||
const move = (e: MouseEvent): void => {
|
||||
if (!e.buttons) { finish(); return; }
|
||||
// Handle sits above centre, so 0° is straight up; Shift snaps to 15°.
|
||||
let ang = (Math.atan2(e.clientY - cy, e.clientX - cx) * 180) / Math.PI + 90;
|
||||
if (e.shiftKey) ang = Math.round(ang / 15) * 15;
|
||||
lastDeg = ((Math.round(ang) % 360) + 360) % 360;
|
||||
moved = true;
|
||||
this.applyLayout(w, h, lastDeg);
|
||||
};
|
||||
const finish = (): void => {
|
||||
win.removeEventListener('mousemove', move);
|
||||
win.removeEventListener('mouseup', finish);
|
||||
if (moved) this.commit({ rotation: lastDeg });
|
||||
};
|
||||
win.addEventListener('mousemove', move);
|
||||
win.addEventListener('mouseup', finish);
|
||||
}
|
||||
@@ -182,7 +320,7 @@ class ImageView {
|
||||
const src = (node.attrs.src as string) ?? '';
|
||||
if (this.img.getAttribute('src') !== src) this.img.src = src;
|
||||
this.img.alt = (node.attrs.alt as string) ?? '';
|
||||
this.applySize();
|
||||
this.applyLayout(this.attrW(), this.attrH(), this.attrRot());
|
||||
return true;
|
||||
}
|
||||
|
||||
@@ -194,16 +332,15 @@ class ImageView {
|
||||
this.dom.classList.remove('image-selected');
|
||||
}
|
||||
|
||||
// Atom node: no ProseMirror-managed content. Ignore the <img> load and our own
|
||||
// size/handle pokes so PM doesn't try to re-read the DOM.
|
||||
ignoreMutation(): boolean {
|
||||
return true;
|
||||
}
|
||||
|
||||
// Keep ProseMirror out of resize-handle interactions so the drag isn't hijacked
|
||||
// into a node drag or selection change.
|
||||
// Keep ProseMirror out of handle interactions so a drag isn't hijacked into a
|
||||
// node drag or selection change.
|
||||
stopEvent(event: Event): boolean {
|
||||
const t = event.target as HTMLElement | null;
|
||||
return event.type.startsWith('mouse') && !!t?.classList?.contains('image-resize-handle');
|
||||
if (!event.type.startsWith('mouse')) return false;
|
||||
return !!t?.classList?.contains('image-resize-handle') || !!t?.classList?.contains('image-rotate-handle');
|
||||
}
|
||||
}
|
||||
+21
-5
@@ -152,8 +152,8 @@ function replaceTabs(node: TiptapNode): TiptapNode {
|
||||
}
|
||||
|
||||
// One embedded picture, collected by replaceImages and emitted by applyImages.
|
||||
// bytes is ArrayBuffer-backed to match fflate's zip entry map.
|
||||
type ImageExport = { path: string; bytes: Uint8Array<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string };
|
||||
// bytes is ArrayBuffer-backed to match fflate's zip entry map. rotationDeg is CW.
|
||||
type ImageExport = { path: string; bytes: Uint8Array<ArrayBuffer>; mimeType: string; widthCm: number; heightCm: number; alt: string; rotationDeg: number };
|
||||
|
||||
function base64ToBytes(b64: string): Uint8Array<ArrayBuffer> {
|
||||
const bin = atob(b64);
|
||||
@@ -189,6 +189,7 @@ function imageDescriptor(node: TiptapNode, index: number): ImageExport | null {
|
||||
widthCm: w > 0 ? round3((w * 2.54) / 96) : 0,
|
||||
heightCm: h > 0 ? round3((h * 2.54) / 96) : 0,
|
||||
alt: typeof node.attrs?.alt === 'string' ? node.attrs.alt : '',
|
||||
rotationDeg: typeof node.attrs?.rotation === 'number' ? node.attrs.rotation : 0,
|
||||
};
|
||||
}
|
||||
|
||||
@@ -1016,15 +1017,30 @@ function applyInlineSentinels(odtBytes: Uint8Array): Uint8Array {
|
||||
return rezipOdt(files);
|
||||
}
|
||||
|
||||
// One <draw:frame> for an as-character image. Position is purely the text-flow
|
||||
// anchor; size is the exact svg geometry, so it round-trips unchanged.
|
||||
// ODF draw:transform for a rotated frame. ODF rotate() is CCW radians (ours is CW
|
||||
// degrees) about the origin, so the translate re-centres it on the unrotated box.
|
||||
function imageTransform(img: ImageExport): string {
|
||||
if (!img.rotationDeg || !img.widthCm || !img.heightCm) return '';
|
||||
const a = (-img.rotationDeg * Math.PI) / 180;
|
||||
const cw = img.widthCm / 2;
|
||||
const ch = img.heightCm / 2;
|
||||
const cos = Math.cos(a);
|
||||
const sin = Math.sin(a);
|
||||
const r3 = (v: number) => Math.round(v * 1000) / 1000;
|
||||
const tx = r3(cos * cw - sin * ch - cw);
|
||||
const ty = r3(sin * cw + cos * ch - ch);
|
||||
return ` draw:transform="rotate (${a.toFixed(6)}) translate (${tx}cm ${ty}cm)"`;
|
||||
}
|
||||
|
||||
// One <draw:frame> for an as-character image. Position is the text-flow anchor; size
|
||||
// is the exact svg geometry and rotation the draw:transform, so both round-trip.
|
||||
function imageFrameXml(img: ImageExport, index: number): string {
|
||||
const dims =
|
||||
(img.widthCm ? ` svg:width="${img.widthCm}cm"` : '') +
|
||||
(img.heightCm ? ` svg:height="${img.heightCm}cm"` : '');
|
||||
const title = img.alt ? `<svg:title>${escapeXml(img.alt)}</svg:title>` : '';
|
||||
return (
|
||||
`<draw:frame draw:name="Image${index + 1}" text:anchor-type="as-char" draw:z-index="${index}"${dims}>` +
|
||||
`<draw:frame draw:name="Image${index + 1}" text:anchor-type="as-char" draw:z-index="${index}"${dims}${imageTransform(img)}>` +
|
||||
`<draw:image xlink:href="${img.path}"/>${title}</draw:frame>`
|
||||
);
|
||||
}
|
||||
|
||||
@@ -102,6 +102,13 @@ function convertFrame(frame: Element, ctx: Ctx): Node | null {
|
||||
if (hCm != null) attrs.height = Math.round(cmToPx(hCm));
|
||||
const title = frame.getElementsByTagNameNS(NS.svg, 'title')[0]?.textContent;
|
||||
if (title) attrs.alt = title;
|
||||
// draw:transform rotate() is CCW radians; the editor stores CW degrees.
|
||||
const transform = frame.getAttributeNS(NS.draw, 'transform');
|
||||
const rot = transform && /rotate\s*\(\s*(-?[\d.eE+]+)\s*\)/.exec(transform);
|
||||
if (rot) {
|
||||
const deg = ((Math.round((-parseFloat(rot[1]) * 180) / Math.PI) % 360) + 360) % 360;
|
||||
if (deg) attrs.rotation = deg;
|
||||
}
|
||||
return { type: 'image', attrs };
|
||||
}
|
||||
|
||||
|
||||
+64
-9
@@ -147,20 +147,26 @@
|
||||
margin-bottom: 0.212cm;
|
||||
}
|
||||
|
||||
/* Inline, as-character images (image.ts). The span is the node view wrapper; it
|
||||
carries selection chrome and positions the corner resize handles. */
|
||||
/* Inline, as-character images (image.ts). The wrapper reserves the rotated bounding
|
||||
box; the rotor holds the <img>, resize handles and rotate grip, rotating together. */
|
||||
.paper .tiptap .image-node {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
/* sit on the text baseline like a tall character, no descender gap */
|
||||
vertical-align: bottom;
|
||||
line-height: 0;
|
||||
}
|
||||
.paper .tiptap .image-node img {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
.paper .tiptap .image-rotor {
|
||||
position: absolute;
|
||||
left: 50%;
|
||||
top: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
.paper .tiptap .image-node.image-selected img {
|
||||
.paper .tiptap .image-rotor img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
display: block;
|
||||
}
|
||||
.paper .tiptap .image-node.image-selected .image-rotor img {
|
||||
outline: 2px solid var(--accent, #3b82f6);
|
||||
outline-offset: 1px;
|
||||
}
|
||||
@@ -174,13 +180,62 @@
|
||||
border-radius: 50%;
|
||||
z-index: 30;
|
||||
}
|
||||
.paper .tiptap .image-node.image-selected .image-resize-handle {
|
||||
.paper .tiptap .image-node.image-selected .image-resize-handle,
|
||||
.paper .tiptap .image-node.image-selected .image-rotate-handle {
|
||||
display: block;
|
||||
}
|
||||
.paper .tiptap .image-resize-nw { top: -5px; left: -5px; cursor: nwse-resize; }
|
||||
.paper .tiptap .image-resize-n { top: -5px; left: 50%; margin-left: -5px; cursor: ns-resize; }
|
||||
.paper .tiptap .image-resize-ne { top: -5px; right: -5px; cursor: nesw-resize; }
|
||||
.paper .tiptap .image-resize-sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
|
||||
.paper .tiptap .image-resize-e { top: 50%; right: -5px; margin-top: -5px; cursor: ew-resize; }
|
||||
.paper .tiptap .image-resize-se { bottom: -5px; right: -5px; cursor: nwse-resize; }
|
||||
.paper .tiptap .image-resize-s { bottom: -5px; left: 50%; margin-left: -5px; cursor: ns-resize; }
|
||||
.paper .tiptap .image-resize-sw { bottom: -5px; left: -5px; cursor: nesw-resize; }
|
||||
.paper .tiptap .image-resize-w { top: 50%; left: -5px; margin-top: -5px; cursor: ew-resize; }
|
||||
/* Rotate grip floats above top-centre with a short connector line; the icon is a
|
||||
circular rotation arrow (like Word). */
|
||||
.paper .tiptap .image-rotate-handle {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: -28px;
|
||||
left: 50%;
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
margin-left: -8px;
|
||||
background: #fff center / 11px 11px no-repeat
|
||||
url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 24 24' fill='none' stroke='%233b82f6' stroke-width='2.5' stroke-linecap='round' stroke-linejoin='round'%3E%3Cpath d='M21 12a9 9 0 1 1-2.6-6.4'/%3E%3Cpath d='M21 4v5h-5'/%3E%3C/svg%3E");
|
||||
border: 1px solid var(--accent, #3b82f6);
|
||||
border-radius: 50%;
|
||||
cursor: grab;
|
||||
z-index: 31;
|
||||
}
|
||||
.paper .tiptap .image-rotate-handle::before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
top: 15px;
|
||||
left: 50%;
|
||||
width: 1px;
|
||||
height: 13px;
|
||||
margin-left: -0.5px;
|
||||
background: var(--accent, #3b82f6);
|
||||
}
|
||||
/* Live size readout while resizing. */
|
||||
.paper .tiptap .image-size-badge {
|
||||
display: none;
|
||||
position: absolute;
|
||||
top: 6px;
|
||||
left: 50%;
|
||||
transform: translateX(-50%);
|
||||
padding: 2px 7px;
|
||||
background: rgba(0, 0, 0, 0.75);
|
||||
color: #fff;
|
||||
font-size: 11px;
|
||||
line-height: 1.4;
|
||||
white-space: nowrap;
|
||||
border-radius: 4px;
|
||||
pointer-events: none;
|
||||
z-index: 32;
|
||||
}
|
||||
|
||||
/* Tab stops every 1.25cm, matching ODF's default tab interval. */
|
||||
.paper .tiptap {
|
||||
|
||||
Reference in new issue
Block a user