mirror of
https://github.com/toeverything/AFFiNE.git
synced 2026-09-22 19:53:48 +08:00
feat(editor): support triangle and diamond shape in shape dom renderer (#12331)
 <!-- This is an auto-generated comment: release notes by coderabbit.ai --> ## Summary by CodeRabbit - **New Features** - Improved rendering for diamond and triangle shapes using SVG, resulting in more accurate stroke and fill display. - **Bug Fixes** - Ensured background and border styles do not interfere with SVG-based shapes. - **Tests** - Added tests to verify correct DOM rendering for diamond and triangle shapes. - **Refactor** - Streamlined and clarified the rendering logic for polygonal shapes, separating SVG and CSS rendering paths. <!-- end of auto-generated comment: release notes by coderabbit.ai -->
This commit is contained in:
@@ -9,18 +9,35 @@ function applyShapeSpecificStyles(
|
|||||||
element: HTMLElement,
|
element: HTMLElement,
|
||||||
zoom: number
|
zoom: number
|
||||||
) {
|
) {
|
||||||
if (model.shapeType === 'rect') {
|
// Reset properties that might be set by different shape types
|
||||||
const w = model.w * zoom;
|
element.style.removeProperty('clip-path');
|
||||||
const h = model.h * zoom;
|
element.style.removeProperty('border-radius');
|
||||||
const r = model.radius ?? 0;
|
// Clear DOM for shapes that don't use SVG, or if type changes from SVG-based to non-SVG-based
|
||||||
const borderRadius =
|
if (model.shapeType !== 'diamond' && model.shapeType !== 'triangle') {
|
||||||
r < 1 ? `${Math.min(w * r, h * r)}px` : `${r * zoom}px`;
|
while (element.firstChild) element.firstChild.remove();
|
||||||
element.style.borderRadius = borderRadius;
|
|
||||||
} else if (model.shapeType === 'ellipse') {
|
|
||||||
element.style.borderRadius = '50%';
|
|
||||||
} else {
|
|
||||||
element.style.borderRadius = '';
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
switch (model.shapeType) {
|
||||||
|
case 'rect': {
|
||||||
|
const w = model.w * zoom;
|
||||||
|
const h = model.h * zoom;
|
||||||
|
const r = model.radius ?? 0;
|
||||||
|
const borderRadius =
|
||||||
|
r < 1 ? `${Math.min(w * r, h * r)}px` : `${r * zoom}px`;
|
||||||
|
element.style.borderRadius = borderRadius;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
case 'ellipse':
|
||||||
|
element.style.borderRadius = '50%';
|
||||||
|
break;
|
||||||
|
case 'diamond':
|
||||||
|
element.style.clipPath = 'polygon(50% 0%, 100% 50%, 50% 100%, 0% 50%)';
|
||||||
|
break;
|
||||||
|
case 'triangle':
|
||||||
|
element.style.clipPath = 'polygon(50% 0%, 100% 100%, 0% 100%)';
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
// No 'else' needed to clear styles, as they are reset at the beginning of the function.
|
||||||
}
|
}
|
||||||
|
|
||||||
function applyBorderStyles(
|
function applyBorderStyles(
|
||||||
@@ -78,6 +95,9 @@ export const shapeDomRenderer = (
|
|||||||
renderer: DomRenderer
|
renderer: DomRenderer
|
||||||
): void => {
|
): void => {
|
||||||
const { zoom } = renderer.viewport;
|
const { zoom } = renderer.viewport;
|
||||||
|
const unscaledWidth = model.w;
|
||||||
|
const unscaledHeight = model.h;
|
||||||
|
|
||||||
const fillColor = renderer.getColorValue(
|
const fillColor = renderer.getColorValue(
|
||||||
model.fillColor,
|
model.fillColor,
|
||||||
DefaultTheme.shapeFillColor,
|
DefaultTheme.shapeFillColor,
|
||||||
@@ -89,17 +109,80 @@ export const shapeDomRenderer = (
|
|||||||
true
|
true
|
||||||
);
|
);
|
||||||
|
|
||||||
element.style.width = `${model.w * zoom}px`;
|
element.style.width = `${unscaledWidth * zoom}px`;
|
||||||
element.style.height = `${model.h * zoom}px`;
|
element.style.height = `${unscaledHeight * zoom}px`;
|
||||||
|
element.style.boxSizing = 'border-box';
|
||||||
|
|
||||||
|
// Apply shape-specific clipping, border-radius, and potentially clear innerHTML
|
||||||
applyShapeSpecificStyles(model, element, zoom);
|
applyShapeSpecificStyles(model, element, zoom);
|
||||||
|
|
||||||
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
|
if (model.shapeType === 'diamond' || model.shapeType === 'triangle') {
|
||||||
|
// For diamond and triangle, fill and border are handled by inline SVG
|
||||||
|
element.style.border = 'none'; // Ensure no standard CSS border interferes
|
||||||
|
element.style.backgroundColor = 'transparent'; // Host element is transparent
|
||||||
|
|
||||||
|
const strokeW = model.strokeWidth;
|
||||||
|
const halfStroke = strokeW / 2; // Calculate half stroke width for point adjustment
|
||||||
|
|
||||||
|
let svgPoints = '';
|
||||||
|
if (model.shapeType === 'diamond') {
|
||||||
|
// Adjusted points for diamond
|
||||||
|
svgPoints = [
|
||||||
|
`${unscaledWidth / 2},${halfStroke}`,
|
||||||
|
`${unscaledWidth - halfStroke},${unscaledHeight / 2}`,
|
||||||
|
`${unscaledWidth / 2},${unscaledHeight - halfStroke}`,
|
||||||
|
`${halfStroke},${unscaledHeight / 2}`,
|
||||||
|
].join(' ');
|
||||||
|
} else {
|
||||||
|
// triangle
|
||||||
|
// Adjusted points for triangle
|
||||||
|
svgPoints = [
|
||||||
|
`${unscaledWidth / 2},${halfStroke}`,
|
||||||
|
`${unscaledWidth - halfStroke},${unscaledHeight - halfStroke}`,
|
||||||
|
`${halfStroke},${unscaledHeight - halfStroke}`,
|
||||||
|
].join(' ');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Determine if stroke should be visible and its color
|
||||||
|
const finalStrokeColor =
|
||||||
|
model.strokeStyle !== 'none' && strokeW > 0 ? strokeColor : 'transparent';
|
||||||
|
// Determine dash array, only if stroke is visible and style is 'dash'
|
||||||
|
const finalStrokeDasharray =
|
||||||
|
model.strokeStyle === 'dash' && finalStrokeColor !== 'transparent'
|
||||||
|
? '12, 12'
|
||||||
|
: 'none';
|
||||||
|
// Determine fill color
|
||||||
|
const finalFillColor = model.filled ? fillColor : 'transparent';
|
||||||
|
|
||||||
|
// Build SVG safely with DOM-API
|
||||||
|
const SVG_NS = 'http://www.w3.org/2000/svg';
|
||||||
|
const svg = document.createElementNS(SVG_NS, 'svg');
|
||||||
|
svg.setAttribute('width', '100%');
|
||||||
|
svg.setAttribute('height', '100%');
|
||||||
|
svg.setAttribute('viewBox', `0 0 ${unscaledWidth} ${unscaledHeight}`);
|
||||||
|
svg.setAttribute('preserveAspectRatio', 'none');
|
||||||
|
|
||||||
|
const polygon = document.createElementNS(SVG_NS, 'polygon');
|
||||||
|
polygon.setAttribute('points', svgPoints);
|
||||||
|
polygon.setAttribute('fill', finalFillColor);
|
||||||
|
polygon.setAttribute('stroke', finalStrokeColor);
|
||||||
|
polygon.setAttribute('stroke-width', String(strokeW));
|
||||||
|
if (finalStrokeDasharray !== 'none') {
|
||||||
|
polygon.setAttribute('stroke-dasharray', finalStrokeDasharray);
|
||||||
|
}
|
||||||
|
svg.append(polygon);
|
||||||
|
|
||||||
|
// Replace existing children to avoid memory leaks
|
||||||
|
element.replaceChildren(svg);
|
||||||
|
} else {
|
||||||
|
// Standard rendering for other shapes (e.g., rect, ellipse)
|
||||||
|
// innerHTML was already cleared by applyShapeSpecificStyles if necessary
|
||||||
|
element.style.backgroundColor = model.filled ? fillColor : 'transparent';
|
||||||
|
applyBorderStyles(model, element, strokeColor, zoom); // Uses standard CSS border
|
||||||
|
}
|
||||||
|
|
||||||
applyBorderStyles(model, element, strokeColor, zoom);
|
|
||||||
applyTransformStyles(model, element);
|
applyTransformStyles(model, element);
|
||||||
|
|
||||||
element.style.boxSizing = 'border-box';
|
|
||||||
element.style.zIndex = renderer.layerManager.getZIndex(model).toString();
|
element.style.zIndex = renderer.layerManager.getZIndex(model).toString();
|
||||||
|
|
||||||
manageClassNames(model, element);
|
manageClassNames(model, element);
|
||||||
|
|||||||
@@ -30,7 +30,7 @@ describe('Shape rendering with DOM renderer', () => {
|
|||||||
fill: '#ff0000',
|
fill: '#ff0000',
|
||||||
stroke: '#000000',
|
stroke: '#000000',
|
||||||
};
|
};
|
||||||
const shapeId = surfaceModel.addElement(shapeProps as any);
|
const shapeId = surfaceModel.addElement(shapeProps);
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
const shapeElement = surfaceView?.renderRoot.querySelector(
|
const shapeElement = surfaceView?.renderRoot.querySelector(
|
||||||
@@ -73,7 +73,7 @@ describe('Shape rendering with DOM renderer', () => {
|
|||||||
subType: 'ellipse',
|
subType: 'ellipse',
|
||||||
xywh: '[200, 200, 50, 50]',
|
xywh: '[200, 200, 50, 50]',
|
||||||
};
|
};
|
||||||
const shapeId = surfaceModel.addElement(shapeProps as any);
|
const shapeId = surfaceModel.addElement(shapeProps);
|
||||||
|
|
||||||
await new Promise(resolve => setTimeout(resolve, 100));
|
await new Promise(resolve => setTimeout(resolve, 100));
|
||||||
|
|
||||||
@@ -91,4 +91,48 @@ describe('Shape rendering with DOM renderer', () => {
|
|||||||
);
|
);
|
||||||
expect(shapeElement).toBeNull();
|
expect(shapeElement).toBeNull();
|
||||||
});
|
});
|
||||||
|
|
||||||
|
test('should correctly render diamond shape', async () => {
|
||||||
|
const surfaceView = getSurface(window.doc, window.editor);
|
||||||
|
const surfaceModel = surfaceView.model;
|
||||||
|
const shapeProps = {
|
||||||
|
type: 'shape',
|
||||||
|
subType: 'diamond',
|
||||||
|
xywh: '[150, 150, 80, 60]',
|
||||||
|
fillColor: '#ff0000',
|
||||||
|
strokeColor: '#000000',
|
||||||
|
filled: true,
|
||||||
|
};
|
||||||
|
const shapeId = surfaceModel.addElement(shapeProps);
|
||||||
|
await wait(100);
|
||||||
|
const shapeElement = surfaceView?.renderRoot.querySelector<HTMLElement>(
|
||||||
|
`[data-element-id="${shapeId}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shapeElement).not.toBeNull();
|
||||||
|
expect(shapeElement?.style.width).toBe('80px');
|
||||||
|
expect(shapeElement?.style.height).toBe('60px');
|
||||||
|
});
|
||||||
|
|
||||||
|
test('should correctly render triangle shape', async () => {
|
||||||
|
const surfaceView = getSurface(window.doc, window.editor);
|
||||||
|
const surfaceModel = surfaceView.model;
|
||||||
|
const shapeProps = {
|
||||||
|
type: 'shape',
|
||||||
|
subType: 'triangle',
|
||||||
|
xywh: '[150, 150, 80, 60]',
|
||||||
|
fillColor: '#ff0000',
|
||||||
|
strokeColor: '#000000',
|
||||||
|
filled: true,
|
||||||
|
};
|
||||||
|
const shapeId = surfaceModel.addElement(shapeProps);
|
||||||
|
await wait(100);
|
||||||
|
const shapeElement = surfaceView?.renderRoot.querySelector<HTMLElement>(
|
||||||
|
`[data-element-id="${shapeId}"]`
|
||||||
|
);
|
||||||
|
|
||||||
|
expect(shapeElement).not.toBeNull();
|
||||||
|
expect(shapeElement?.style.width).toBe('80px');
|
||||||
|
expect(shapeElement?.style.height).toBe('60px');
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
Reference in New Issue
Block a user