feat(web): improve manual edit UX with focus mode, uploads and remove-element patch (#1516)

* feat(web): improve manual edit UX with focus mode, uploads and remove-element patch

* fix: address review feedback on manual edit UX

- Remove unused panel width persistence (leftPanelRef, rightPanelRef props, constants, useEffects)
- Fix image upload to use project-relative URLs instead of daemon API URLs for deploy/share
- Add i18n keys for new UI strings (deleteElement, uploadImage, focusSlides, etc)
- Improve delete guard to prevent removing last document element
- Use translated strings instead of hardcoded English in all new buttons/labels/dialogs
- Fix upload error message to use i18n

* fix: add onPickImage prop and delete-element UI to ManualEditPanel

- Declare onPickImage?: (file: File) => Promise<string | null> in prop interface
- Destructure onApplyPatch and onPickImage from props
- Add image upload section with file input, uploads via onPickImage,
  applies set-image patch on success, shows uploadingImage state
- Add delete element button with two-step confirmation that emits
  remove-element patch via onApplyPatch
- All new strings routed through t() using existing i18n keys

* fix(web): harden manual edit delete/upload flows

* fix(web): gate image upload to image targets and normalize relative asset refs

---------

Co-authored-by: Jose Herrera <nombreregular@gmail.com>
This commit is contained in:
Jose 2026-05-19 03:25:39 -06:00 committed by GitHub
parent 555bc5e7ed
commit 726a9c921a
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 171 additions and 0 deletions

View file

@ -29,6 +29,7 @@ import {
fetchProjectDeployments,
fetchProjectFilePreview,
fetchProjectFileText,
uploadProjectFiles,
liveArtifactPreviewUrl,
projectFileUrl,
projectRawUrl,
@ -5955,6 +5956,16 @@ function HtmlViewer({
onRedo={() => {
void redoManualEdit();
}}
onPickImage={async (pickedFile) => {
const result = await uploadProjectFiles(projectId, [pickedFile]);
const uploaded = result.uploaded[0];
if (!uploaded?.path) {
setManualEditError(result.error ?? t('manualEdit.uploadImageFailed'));
return null;
}
setManualEditError(null);
return toOwnerRelativePath(file.name, uploaded.path);
}}
/>
) : null}
<div className={manualEditMode ? 'manual-edit-canvas' : 'comment-frame-clip'}>
@ -6603,6 +6614,40 @@ function baseDirFor(fileName: string): string {
return idx >= 0 ? fileName.slice(0, idx + 1) : '';
}
function toOwnerRelativePath(ownerFileName: string, targetPath: string): string {
const normalize = (value: string) => decodeURIComponent(value).replace(/^\/+/, '');
const squash = (parts: string[]) => {
const out: string[] = [];
for (const part of parts) {
if (!part || part === '.') continue;
if (part === '..') {
if (out.length > 0) out.pop();
continue;
}
out.push(part);
}
return out;
};
const ownerDirPath = normalize(baseDirFor(ownerFileName));
const targetFilePath = normalize(targetPath);
const ownerParts = squash(ownerDirPath.split('/'));
const targetParts = squash(targetFilePath.split('/'));
let common = 0;
while (
common < ownerParts.length &&
common < targetParts.length &&
ownerParts[common] === targetParts[common]
) {
common += 1;
}
const up = new Array(ownerParts.length - common).fill('..');
const down = targetParts.slice(common);
const rel = [...up, ...down].join('/');
return rel || '.';
}
function hasRelativeAssetRefs(html: string): boolean {
const attr = /\s(?:src|href)\s*=\s*["']([^"']+)["']/gi;
let match: RegExpExecArray | null;

View file

@ -1,4 +1,5 @@
import { useEffect, useRef, useState } from 'react';
import { useT } from '../i18n';
import { emptyManualEditStyles, type ManualEditHistoryEntry, type ManualEditPatch, type ManualEditStyles, type ManualEditTarget } from '../edit-mode/types';
export interface ManualEditDraft {
@ -24,11 +25,14 @@ export function ManualEditPanel({
selectedTarget,
draft,
error,
canUndo,
onDraftChange,
onStyleChange,
onInvalidStyle,
onError,
onClearSelection,
onApplyPatch,
onPickImage,
pageStylesEnabled = true,
}: {
targets: ManualEditTarget[];
@ -45,13 +49,23 @@ export function ManualEditPanel({
onStyleChange?: (id: string, styles: Partial<ManualEditStyles>, label: string) => void;
onInvalidStyle?: (id: string, keys: Array<keyof ManualEditStyles>) => void;
onApplyPatch: (patch: ManualEditPatch, label: string) => void;
onPickImage?: (file: File) => Promise<string | null>;
onError: (message: string) => void;
onClearSelection: () => void;
onCancelDraft: () => void;
onUndo: () => void;
onRedo: () => void;
}) {
const t = useT();
const [confirmDelete, setConfirmDelete] = useState(false);
const [uploadingImage, setUploadingImage] = useState(false);
const selectedTargetRef = useRef<ManualEditTarget | null>(selectedTarget);
const fileInputRef = useRef<HTMLInputElement | null>(null);
const targetForInspector = selectedTarget;
useEffect(() => {
selectedTargetRef.current = selectedTarget;
}, [selectedTarget]);
const changeTargetStyle = (key: keyof ManualEditStyles, value: string) => {
const nextStyles = { ...draft.styles, [key]: value };
onDraftChange({ ...draft, styles: nextStyles });
@ -94,6 +108,88 @@ export function ManualEditPanel({
/>
) : null}
{targetForInspector?.kind === 'image' && onPickImage ? (
<div className="cc-section">
<header className="cc-section-head">IMAGE</header>
<div className="cc-section-body">
<button
type="button"
className="cc-action-btn"
disabled={uploadingImage}
onClick={() => fileInputRef.current?.click()}
>
{uploadingImage ? t('manualEdit.uploadingImage') : t('manualEdit.uploadImage')}
</button>
<input
ref={fileInputRef}
type="file"
accept="image/*"
style={{ display: 'none' }}
onChange={async (e) => {
const file = e.currentTarget.files?.[0];
if (!file) return;
e.currentTarget.value = '';
setUploadingImage(true);
try {
const src = await onPickImage(file);
if (src) {
const activeTargetId = selectedTargetRef.current?.id ?? targetForInspector.id;
onApplyPatch(
{ id: activeTargetId, kind: 'set-image', src, alt: draft.alt },
t('manualEdit.uploadImage'),
);
} else {
onError(t('manualEdit.uploadImageFailed'));
}
} finally {
setUploadingImage(false);
}
}}
/>
</div>
</div>
) : null}
{targetForInspector ? (
<div className="cc-section">
<div className="cc-section-body">
{confirmDelete ? (
<>
<p className="cc-delete-confirm">{canUndo ? t('manualEdit.deleteElementConfirm') : t('manualEdit.deleteElement')}</p>
<button
type="button"
className="cc-action-btn cc-action-danger"
onClick={() => {
setConfirmDelete(false);
onApplyPatch(
{ id: targetForInspector.id, kind: 'remove-element' },
t('manualEdit.deleteElement'),
);
}}
>
{t('manualEdit.deleteElement')}
</button>
<button
type="button"
className="cc-action-btn"
onClick={() => setConfirmDelete(false)}
>
{t('common.cancel')}
</button>
</>
) : (
<button
type="button"
className="cc-action-btn cc-action-danger"
onClick={() => setConfirmDelete(true)}
>
{t('manualEdit.deleteElement')}
</button>
)}
</div>
</div>
) : null}
{error ? <div className="manual-edit-error">{error}</div> : null}
</section>
</aside>

View file

@ -47,6 +47,14 @@ export function applyManualEditPatch(source: string, patch: ManualEditPatch): Ma
} else if (patch.kind === 'set-outer-html') {
const replaced = replaceOuterHtml(doc, el, patch.html);
if (!replaced.ok) return { ok: false, source, error: replaced.error };
} else if (patch.kind === 'remove-element') {
if (!el.parentElement) {
return { ok: false, source, error: 'Cannot remove the root element.' };
}
if (el.parentElement === doc.body && doc.body.children.length === 1) {
return { ok: false, source, error: 'Cannot remove the last element in the document.' };
}
el.remove();
}
return { ok: true, source: serializeSource(doc, source) };

View file

@ -70,6 +70,7 @@ export type ManualEditPatch =
| { id: string; kind: 'set-text'; value: string }
| { id: string; kind: 'set-link'; text: string; href: string }
| { id: string; kind: 'set-image'; src: string; alt: string }
| { id: string; kind: 'remove-element' }
| { kind: 'set-token'; token: string; value: string }
| { id: string; kind: 'set-style'; styles: Partial<ManualEditStyles> }
| { id: string; kind: 'set-attributes'; attributes: Record<string, string> }

View file

@ -1349,6 +1349,13 @@ export const en: Dict = {
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'manualEdit.deleteElement': "Delete element",
'manualEdit.deleteElementConfirm': "Delete selected element? This can be undone with Undo.",
'manualEdit.uploadImage': "Upload image",
'manualEdit.uploadingImage': "Uploading image…",
'manualEdit.uploadImageFailed': "Could not upload image.",
'manualEdit.focusSlides': "Focus slides",
'manualEdit.showPanels': "Show panels",
'fileViewer.zoomOut': 'Zoom out',
'fileViewer.zoomIn': 'Zoom in',
'fileViewer.resetZoom': 'Reset zoom',

View file

@ -1339,6 +1339,13 @@ export const zhCN: Dict = {
'manualEdit.border': "Border",
'manualEdit.width': "Width",
'manualEdit.minHeight': "Min height",
'manualEdit.deleteElement': "删除元素",
'manualEdit.deleteElementConfirm': "删除选中元素?可以使用撤销恢复。",
'manualEdit.uploadImage': "上传图片",
'manualEdit.uploadingImage': "正在上传图片…",
'manualEdit.uploadImageFailed': "无法上传图片。",
'manualEdit.focusSlides': "聚焦幻灯片",
'manualEdit.showPanels': "显示面板",
'fileViewer.zoomOut': '缩小',
'fileViewer.zoomIn': '放大',
'fileViewer.resetZoom': '重置缩放',

View file

@ -1619,6 +1619,13 @@ export interface Dict {
'manualEdit.border': string;
'manualEdit.width': string;
'manualEdit.minHeight': string;
'manualEdit.deleteElement': string;
'manualEdit.deleteElementConfirm': string;
'manualEdit.uploadImage': string;
'manualEdit.uploadingImage': string;
'manualEdit.uploadImageFailed': string;
'manualEdit.focusSlides': string;
'manualEdit.showPanels': string;
'fileViewer.zoomOut': string;
'fileViewer.zoomIn': string;
'fileViewer.resetZoom': string;