mirror of
https://github.com/nexu-io/open-design.git
synced 2026-06-01 03:14:35 +07:00
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:
parent
555bc5e7ed
commit
726a9c921a
7 changed files with 171 additions and 0 deletions
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) };
|
||||
|
|
|
|||
|
|
@ -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> }
|
||||
|
|
|
|||
|
|
@ -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',
|
||||
|
|
|
|||
|
|
@ -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': '重置缩放',
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
Loading…
Reference in a new issue