mirror of
https://github.com/PR0M3TH3AN/Artstr-Studio.git
synced 2026-11-06 13:40:39 +00:00
Add on-canvas resize handles and expand the Text tool panel
- Selected layers (text / shape / image / color / QR) now show 8 drag-resize handles — 4 corners + 4 edge midpoints. Edge handles are single-axis (resize one direction only); corner handles resize toward the corner with the opposite corner anchored. Resizing is computed in the layer's own rotated frame, so rotated layers behave correctly, and the aspect-lock toggle applies to corner handles only. - Resizing a text box changes only its w/h (the text flow area) — the point size is never rescaled. - Expand #textToolOptions to match the pen/shape panels: font family, point size, color, alignment, and bold/italic — sets the default for the next text box, and restyles the selected text layer live. Co-Authored-By: Claude Opus 4.7 <[email protected]>
This commit is contained in:
1 parent
cf006ce1aa
commit
1a517c9552
1 file changed
+192
-10
+192
-10
@@ -1321,6 +1321,28 @@
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
/* Drag-resize handles on the selected layer — 4 corners + 4 edges. */
|
||||
.layerHandle {
|
||||
position: absolute;
|
||||
width: 13px; height: 13px;
|
||||
margin: -7px 0 0 -7px;
|
||||
background: #fff;
|
||||
border: 2px solid var(--accent);
|
||||
border-radius: 3px;
|
||||
box-sizing: border-box;
|
||||
z-index: 30;
|
||||
pointer-events: auto;
|
||||
}
|
||||
body.tool-drawing .layerHandle { display: none; }
|
||||
.lh-nw { left: 0; top: 0; cursor: nwse-resize; }
|
||||
.lh-n { left: 50%; top: 0; cursor: ns-resize; }
|
||||
.lh-ne { left: 100%; top: 0; cursor: nesw-resize; }
|
||||
.lh-e { left: 100%; top: 50%; cursor: ew-resize; }
|
||||
.lh-se { left: 100%; top: 100%; cursor: nwse-resize; }
|
||||
.lh-s { left: 50%; top: 100%; cursor: ns-resize; }
|
||||
.lh-sw { left: 0; top: 100%; cursor: nesw-resize; }
|
||||
.lh-w { left: 0; top: 50%; cursor: ew-resize; }
|
||||
|
||||
.freeLayer img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
@@ -3231,11 +3253,23 @@
|
||||
</div>
|
||||
<div id="textToolOptions" style="display:none">
|
||||
<h4>Text tool</h4>
|
||||
<label for="textToolFont">Font</label>
|
||||
<select id="textToolFont"></select>
|
||||
<div class="toolOptRow">
|
||||
<div><label for="textToolColor">Color</label><input type="color" id="textToolColor" value="#111827" /></div>
|
||||
<div><label for="textToolSize">Size</label><input type="number" id="textToolSize" min="6" max="200" step="1" value="24" /></div>
|
||||
<div><label for="textToolSize">Point size</label><input type="number" id="textToolSize" min="6" max="200" step="1" value="24" /></div>
|
||||
</div>
|
||||
<p class="toolOptHint">Click on the canvas to place a text box, then type.</p>
|
||||
<label for="textToolAlign">Alignment</label>
|
||||
<select id="textToolAlign">
|
||||
<option value="left">Left</option>
|
||||
<option value="center">Center</option>
|
||||
<option value="right">Right</option>
|
||||
</select>
|
||||
<div class="buttonRow">
|
||||
<button type="button" id="textToolBold" class="aspectLockBtn" aria-pressed="false" title="Bold"><b>B</b></button>
|
||||
<button type="button" id="textToolItalic" class="aspectLockBtn" aria-pressed="false" title="Italic"><i>I</i></button>
|
||||
</div>
|
||||
<p class="toolOptHint">Click the canvas to place a text box. With a text box selected, these controls restyle it.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -5437,7 +5471,7 @@
|
||||
strokeColor: '#111827',
|
||||
strokeWidth: 0
|
||||
};
|
||||
const _textToolOpts = { color: '#111827', size: 24 };
|
||||
const _textToolOpts = { color: '#111827', fontSize: 24, fontFamily: 'inherit', align: 'center', bold: false, italic: false };
|
||||
|
||||
function currentCanvasHost() {
|
||||
return state.appMode === 'designer' ? $('designerDiscWrap') : $('sheet');
|
||||
@@ -5538,8 +5572,18 @@
|
||||
set('shapeStrokeColor', _shapeToolOpts.strokeColor);
|
||||
set('shapeStrokeWidth', _shapeToolOpts.strokeWidth);
|
||||
} else if (_editorTool === 'text') {
|
||||
set('textToolColor', _textToolOpts.color);
|
||||
set('textToolSize', _textToolOpts.size);
|
||||
// With a text layer selected the panel mirrors (and edits) it;
|
||||
// otherwise it shows the defaults for the next text box.
|
||||
const tl = layer && layer.type === 'text' ? layer
|
||||
: (selectedLayer() && selectedLayer().type === 'text' ? selectedLayer() : null);
|
||||
set('textToolFont', tl ? (tl.fontFamily || 'inherit') : _textToolOpts.fontFamily);
|
||||
set('textToolColor', tl ? (tl.color || '#111827') : _textToolOpts.color);
|
||||
set('textToolSize', tl ? (tl.fontSize || _textToolOpts.fontSize) : _textToolOpts.fontSize);
|
||||
set('textToolAlign', tl ? (tl.align || 'left') : _textToolOpts.align);
|
||||
const bBtn = $('textToolBold');
|
||||
if (bBtn) bBtn.setAttribute('aria-pressed', (tl ? !!tl.bold : _textToolOpts.bold) ? 'true' : 'false');
|
||||
const iBtn = $('textToolItalic');
|
||||
if (iBtn) iBtn.setAttribute('aria-pressed', (tl ? !!tl.italic : _textToolOpts.italic) ? 'true' : 'false');
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5781,8 +5825,8 @@
|
||||
html: 'Text',
|
||||
x: p.x - w / 2, y: p.y - h / 2, w, h,
|
||||
rotate: 0, opacity: 1, z: nextLayerZ(),
|
||||
fontFamily: 'inherit', fontSize: _textToolOpts.size, color: _textToolOpts.color,
|
||||
align: 'center', bold: false, italic: false
|
||||
fontFamily: _textToolOpts.fontFamily, fontSize: _textToolOpts.fontSize, color: _textToolOpts.color,
|
||||
align: _textToolOpts.align, bold: _textToolOpts.bold, italic: _textToolOpts.italic
|
||||
});
|
||||
// Hand off to Select so the new text box is editable and movable, and
|
||||
// focus it so the user can type right away (selecting the placeholder).
|
||||
@@ -8679,6 +8723,19 @@
|
||||
}
|
||||
render();
|
||||
});
|
||||
|
||||
// Drag-resize handles — only on the selected, unlocked layer.
|
||||
if (layer.id === state.selectedLayerId && !layer.locked) {
|
||||
for (const dir of ['nw', 'n', 'ne', 'e', 'se', 's', 'sw', 'w']) {
|
||||
const handle = document.createElement('div');
|
||||
handle.className = `layerHandle lh-${dir}`;
|
||||
handle.dataset.handle = dir;
|
||||
handle.addEventListener('pointerdown', startLayerResize);
|
||||
handle.addEventListener('click', (e) => e.stopPropagation());
|
||||
el.appendChild(handle);
|
||||
}
|
||||
}
|
||||
|
||||
(isDesignerLayer ? designerOverlay : overlay).appendChild(el);
|
||||
}
|
||||
renderLayerList();
|
||||
@@ -10459,6 +10516,109 @@
|
||||
}
|
||||
}
|
||||
|
||||
// Drag-resize a layer from one of its 8 handles. Resizes in the layer's
|
||||
// own (rotated) frame and keeps the opposite edge/corner anchored.
|
||||
// Text layers reflow inside the new box — fontSize is never touched.
|
||||
function startLayerResize(event) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
const handle = event.currentTarget;
|
||||
const dir = handle.dataset.handle;
|
||||
const el = handle.parentElement;
|
||||
const layer = getLayer(el?.dataset.layerId);
|
||||
if (!layer || layer.locked) return;
|
||||
state.selectedLayerId = layer.id;
|
||||
|
||||
const isDesignerLayer = layer.target === 'designer-disc';
|
||||
const dragHost = isDesignerLayer ? $('designerDiscWrap') : $('sheet');
|
||||
const hostRect = dragHost.getBoundingClientRect();
|
||||
const unitW = isDesignerLayer ? discTemplate.discD : sheetW();
|
||||
const unitH = isDesignerLayer ? discTemplate.discD : sheetH();
|
||||
|
||||
const startX = event.clientX, startY = event.clientY;
|
||||
const w0 = Number(layer.w) || 0, h0 = Number(layer.h) || 0;
|
||||
const cx0 = (Number(layer.x) || 0) + w0 / 2;
|
||||
const cy0 = (Number(layer.y) || 0) + h0 / 2;
|
||||
const theta = (Number(layer.rotate) || 0) * Math.PI / 180;
|
||||
const cos = Math.cos(theta), sin = Math.sin(theta);
|
||||
|
||||
// Anchor sign points at the FIXED side (opposite the dragged handle).
|
||||
const sx = dir.includes('w') ? 1 : dir.includes('e') ? -1 : 0;
|
||||
const sy = dir.includes('n') ? 1 : dir.includes('s') ? -1 : 0;
|
||||
const drivesW = dir.includes('e') || dir.includes('w');
|
||||
const drivesH = dir.includes('n') || dir.includes('s');
|
||||
const MIN = 0.12;
|
||||
|
||||
// World position of the fixed anchor (must stay put through the drag).
|
||||
const axOld = sx * w0 / 2, ayOld = sy * h0 / 2;
|
||||
const anchorWX = cx0 + (axOld * cos - ayOld * sin);
|
||||
const anchorWY = cy0 + (axOld * sin + ayOld * cos);
|
||||
|
||||
let snapshotted = false;
|
||||
document.body.classList.add('dragging-layer');
|
||||
handle.setPointerCapture(event.pointerId);
|
||||
|
||||
const move = (e) => {
|
||||
if (!snapshotted) { pushHistory('resize layer'); snapshotted = true; }
|
||||
// Screen delta → inches → the layer's local (un-rotated) frame.
|
||||
const idx = (e.clientX - startX) / hostRect.width * unitW;
|
||||
const idy = (e.clientY - startY) / hostRect.height * unitH;
|
||||
const ldx = idx * cos + idy * sin;
|
||||
const ldy = -idx * sin + idy * cos;
|
||||
const dw = drivesW ? (dir.includes('e') ? ldx : -ldx) : 0;
|
||||
const dh = drivesH ? (dir.includes('s') ? ldy : -ldy) : 0;
|
||||
|
||||
// Edge handles are always single-axis — they resize in their one
|
||||
// direction only. Aspect-lock applies to corner handles alone, so
|
||||
// grabbing an edge never balloons the box on the other axis.
|
||||
const isCorner = drivesW && drivesH;
|
||||
let newW, newH;
|
||||
if (_aspectLocked && isCorner && w0 > 0 && h0 > 0) {
|
||||
const sw = dw / w0;
|
||||
const sh = dh / h0;
|
||||
const s = Math.abs(sw) >= Math.abs(sh) ? sw : sh;
|
||||
newW = Math.max(MIN, w0 * (1 + s));
|
||||
newH = Math.max(MIN, h0 * (1 + s));
|
||||
} else {
|
||||
newW = drivesW ? Math.max(MIN, w0 + dw) : w0;
|
||||
newH = drivesH ? Math.max(MIN, h0 + dh) : h0;
|
||||
}
|
||||
|
||||
// Re-place the box so the fixed anchor stays at its world position.
|
||||
const axNew = sx * newW / 2, ayNew = sy * newH / 2;
|
||||
const ncx = anchorWX - (axNew * cos - ayNew * sin);
|
||||
const ncy = anchorWY - (axNew * sin + ayNew * cos);
|
||||
layer.w = newW; layer.h = newH;
|
||||
layer.x = ncx - newW / 2; layer.y = ncy - newH / 2;
|
||||
|
||||
if (isDesignerLayer) {
|
||||
const d = discTemplate.discD;
|
||||
el.style.left = `${layer.x / d * 100}%`;
|
||||
el.style.top = `${layer.y / d * 100}%`;
|
||||
el.style.width = `${layer.w / d * 100}%`;
|
||||
el.style.height = `${layer.h / d * 100}%`;
|
||||
} else {
|
||||
el.style.left = inches(layer.x);
|
||||
el.style.top = inches(layer.y);
|
||||
el.style.width = inches(layer.w);
|
||||
el.style.height = inches(layer.h);
|
||||
}
|
||||
syncLayerInputs();
|
||||
};
|
||||
|
||||
const stop = () => {
|
||||
document.body.classList.remove('dragging-layer');
|
||||
handle.removeEventListener('pointermove', move);
|
||||
handle.removeEventListener('pointerup', stop);
|
||||
handle.removeEventListener('pointercancel', stop);
|
||||
if (snapshotted) render();
|
||||
};
|
||||
|
||||
handle.addEventListener('pointermove', move);
|
||||
handle.addEventListener('pointerup', stop);
|
||||
handle.addEventListener('pointercancel', stop);
|
||||
}
|
||||
|
||||
function readDimensionInputs() {
|
||||
state.panelW = Number($('panelW').value) || state.panelW;
|
||||
state.trimH = Number($('trimH').value) || state.trimH;
|
||||
@@ -17107,11 +17267,14 @@
|
||||
render();
|
||||
});
|
||||
}
|
||||
const fontOptionsHtml = WEB_SAFE_FONTS.map(f => `<option value="${escapeHtml(f.value)}" style="font-family:${f.value === 'inherit' ? 'inherit' : escapeHtml(f.value)}">${escapeHtml(f.label)}</option>`).join('');
|
||||
const fontFamilyEl = $('textFontFamily');
|
||||
if (fontFamilyEl) {
|
||||
fontFamilyEl.innerHTML = WEB_SAFE_FONTS.map(f => `<option value="${escapeHtml(f.value)}" style="font-family:${f.value === 'inherit' ? 'inherit' : escapeHtml(f.value)}">${escapeHtml(f.label)}</option>`).join('');
|
||||
fontFamilyEl.innerHTML = fontOptionsHtml;
|
||||
fontFamilyEl.addEventListener('change', (event) => updateSelectedLayer('fontFamily', event.target.value));
|
||||
}
|
||||
const textToolFontEl = $('textToolFont');
|
||||
if (textToolFontEl) textToolFontEl.innerHTML = fontOptionsHtml;
|
||||
const deckFontEl = $('deckThemeFont');
|
||||
if (deckFontEl) {
|
||||
deckFontEl.innerHTML = '<option value="">No font override</option>'
|
||||
@@ -17407,8 +17570,27 @@
|
||||
on('shapeFillColor', 'input', (e) => { _shapeToolOpts.fillColor = e.target.value; });
|
||||
on('shapeStrokeColor', 'input', (e) => { _shapeToolOpts.strokeColor = e.target.value; });
|
||||
on('shapeStrokeWidth', 'input', (e) => { _shapeToolOpts.strokeWidth = Math.max(0, Math.min(50, Number(e.target.value) || 0)); });
|
||||
on('textToolColor', 'input', (e) => { _textToolOpts.color = e.target.value; });
|
||||
on('textToolSize', 'input', (e) => { _textToolOpts.size = Math.max(6, Math.min(200, Number(e.target.value) || 24)); });
|
||||
// Text tool controls: set the default for the next text box, and —
|
||||
// when a text layer is selected — restyle it live.
|
||||
const applyTextToolField = (field, value) => {
|
||||
_textToolOpts[field] = value;
|
||||
const l = selectedLayer();
|
||||
if (l && l.type === 'text') updateSelectedLayer(field, value);
|
||||
};
|
||||
on('textToolFont', 'change', (e) => applyTextToolField('fontFamily', e.target.value));
|
||||
on('textToolColor', 'input', (e) => applyTextToolField('color', e.target.value));
|
||||
on('textToolSize', 'input', (e) => applyTextToolField('fontSize', Math.max(6, Math.min(200, Number(e.target.value) || 24))));
|
||||
on('textToolAlign', 'change', (e) => applyTextToolField('align', e.target.value));
|
||||
on('textToolBold', 'click', () => {
|
||||
const next = $('textToolBold').getAttribute('aria-pressed') !== 'true';
|
||||
$('textToolBold').setAttribute('aria-pressed', next ? 'true' : 'false');
|
||||
applyTextToolField('bold', next);
|
||||
});
|
||||
on('textToolItalic', 'click', () => {
|
||||
const next = $('textToolItalic').getAttribute('aria-pressed') !== 'true';
|
||||
$('textToolItalic').setAttribute('aria-pressed', next ? 'true' : 'false');
|
||||
applyTextToolField('italic', next);
|
||||
});
|
||||
syncToolUI();
|
||||
|
||||
// Global undo/redo keyboard shortcuts. Bypass when an editable
|
||||
|
||||
Reference in new issue
Block a user