初始化 Photoshop AI 编辑面板
This commit is contained in:
@@ -0,0 +1,6 @@
|
||||
node_modules/
|
||||
dist/
|
||||
.debug
|
||||
*.log
|
||||
.DS_Store
|
||||
Thumbs.db
|
||||
@@ -0,0 +1,201 @@
|
||||
Apache License
|
||||
Version 2.0, January 2004
|
||||
http://www.apache.org/licenses/
|
||||
|
||||
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||
|
||||
1. Definitions.
|
||||
|
||||
"License" shall mean the terms and conditions for use, reproduction,
|
||||
and distribution as defined by Sections 1 through 9 of this document.
|
||||
|
||||
"Licensor" shall mean the copyright owner or entity authorized by
|
||||
the copyright owner that is granting the License.
|
||||
|
||||
"Legal Entity" shall mean the union of the acting entity and all
|
||||
other entities that control, are controlled by, or are under common
|
||||
control with that entity. For the purposes of this definition,
|
||||
"control" means (i) the power, direct or indirect, to cause the
|
||||
direction or management of such entity, whether by contract or
|
||||
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||
|
||||
"You" (or "Your") shall mean an individual or Legal Entity
|
||||
exercising permissions granted by this License.
|
||||
|
||||
"Source" form shall mean the preferred form for making modifications,
|
||||
including but not limited to software source code, documentation
|
||||
source, and configuration files.
|
||||
|
||||
"Object" form shall mean any form resulting from mechanical
|
||||
transformation or translation of a Source form, including but
|
||||
not limited to compiled object code, generated documentation,
|
||||
and conversions to other media types.
|
||||
|
||||
"Work" shall mean the work of authorship, whether in Source or
|
||||
Object form, made available under the License, as indicated by a
|
||||
copyright notice that is included in or attached to the work
|
||||
(an example is provided in the Appendix below).
|
||||
|
||||
"Derivative Works" shall mean any work, whether in Source or Object
|
||||
form, that is based on (or derived from) the Work and for which the
|
||||
editorial revisions, annotations, elaborations, or other modifications
|
||||
represent, as a whole, an original work of authorship. For the purposes
|
||||
of this License, Derivative Works shall not include works that remain
|
||||
separable from, or merely link (or bind by name) to the interfaces of,
|
||||
the Work and Derivative Works thereof.
|
||||
|
||||
"Contribution" shall mean any work of authorship, including
|
||||
the original version of the Work and any modifications or additions
|
||||
to that Work or Derivative Works thereof, that is intentionally
|
||||
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||
or by an individual or Legal Entity authorized to submit on behalf of
|
||||
the copyright owner. For the purposes of this definition, "submitted"
|
||||
means any form of electronic, verbal, or written communication sent
|
||||
to the Licensor or its representatives, including but not limited to
|
||||
communication on electronic mailing lists, source code control systems,
|
||||
and issue tracking systems that are managed by, or on behalf of, the
|
||||
Licensor for the purpose of discussing and improving the Work, but
|
||||
excluding communication that is conspicuously marked or otherwise
|
||||
designated in writing by the copyright owner as "Not a Contribution."
|
||||
|
||||
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||
on behalf of whom a Contribution has been received by Licensor and
|
||||
subsequently incorporated within the Work.
|
||||
|
||||
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
copyright license to reproduce, prepare Derivative Works of,
|
||||
publicly display, publicly perform, sublicense, and distribute the
|
||||
Work and such Derivative Works in Source or Object form.
|
||||
|
||||
3. Grant of Patent License. Subject to the terms and conditions of
|
||||
this License, each Contributor hereby grants to You a perpetual,
|
||||
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||
(except as stated in this section) patent license to make, have made,
|
||||
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||
where such license applies only to those patent claims licensable
|
||||
by such Contributor that are necessarily infringed by their
|
||||
Contribution(s) alone or by combination of their Contribution(s)
|
||||
with the Work to which such Contribution(s) was submitted. If You
|
||||
institute patent litigation against any entity (including a
|
||||
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||
or a Contribution incorporated within the Work constitutes direct
|
||||
or contributory patent infringement, then any patent licenses
|
||||
granted to You under this License for that Work shall terminate
|
||||
as of the date such litigation is filed.
|
||||
|
||||
4. Redistribution. You may reproduce and distribute copies of the
|
||||
Work or Derivative Works thereof in any medium, with or without
|
||||
modifications, and in Source or Object form, provided that You
|
||||
meet the following conditions:
|
||||
|
||||
(a) You must give any other recipients of the Work or
|
||||
Derivative Works a copy of this License; and
|
||||
|
||||
(b) You must cause any modified files to carry prominent notices
|
||||
stating that You changed the files; and
|
||||
|
||||
(c) You must retain, in the Source form of any Derivative Works
|
||||
that You distribute, all copyright, patent, trademark, and
|
||||
attribution notices from the Source form of the Work,
|
||||
excluding those notices that do not pertain to any part of
|
||||
the Derivative Works; and
|
||||
|
||||
(d) If the Work includes a "NOTICE" text file as part of its
|
||||
distribution, then any Derivative Works that You distribute must
|
||||
include a readable copy of the attribution notices contained
|
||||
within such NOTICE file, excluding those notices that do not
|
||||
pertain to any part of the Derivative Works, in at least one
|
||||
of the following places: within a NOTICE text file distributed
|
||||
as part of the Derivative Works; within the Source form or
|
||||
documentation, if provided along with the Derivative Works; or,
|
||||
within a display generated by the Derivative Works, if and
|
||||
wherever such third-party notices normally appear. The contents
|
||||
of the NOTICE file are for informational purposes only and
|
||||
do not modify the License. You may add Your own attribution
|
||||
notices within Derivative Works that You distribute, alongside
|
||||
or as an addendum to the NOTICE text from the Work, provided
|
||||
that such additional attribution notices cannot be construed
|
||||
as modifying the License.
|
||||
|
||||
You may add Your own copyright statement to Your modifications and
|
||||
may provide additional or different license terms and conditions
|
||||
for use, reproduction, or distribution of Your modifications, or
|
||||
for any such Derivative Works as a whole, provided Your use,
|
||||
reproduction, and distribution of the Work otherwise complies with
|
||||
the conditions stated in this License.
|
||||
|
||||
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||
any Contribution intentionally submitted for inclusion in the Work
|
||||
by You to the Licensor shall be under the terms and conditions of
|
||||
this License, without any additional terms or conditions.
|
||||
Notwithstanding the above, nothing herein shall supersede or modify
|
||||
the terms of any separate license agreement you may have executed
|
||||
with Licensor regarding such Contributions.
|
||||
|
||||
6. Trademarks. This License does not grant permission to use the trade
|
||||
names, trademarks, service marks, or product names of the Licensor,
|
||||
except as required for reasonable and customary use in describing the
|
||||
origin of the Work and reproducing the content of the NOTICE file.
|
||||
|
||||
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||
agreed to in writing, Licensor provides the Work (and each
|
||||
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||
implied, including, without limitation, any warranties or conditions
|
||||
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||
appropriateness of using or redistributing the Work and assume any
|
||||
risks associated with Your exercise of permissions under this License.
|
||||
|
||||
8. Limitation of Liability. In no event and under no legal theory,
|
||||
whether in tort (including negligence), contract, or otherwise,
|
||||
unless required by applicable law (such as deliberate and grossly
|
||||
negligent acts) or agreed to in writing, shall any Contributor be
|
||||
liable to You for damages, including any direct, indirect, special,
|
||||
incidental, or consequential damages of any character arising as a
|
||||
result of this License or out of the use or inability to use the
|
||||
Work (including but not limited to damages for loss of goodwill,
|
||||
work stoppage, computer failure or malfunction, or any and all
|
||||
other commercial damages or losses), even if such Contributor
|
||||
has been advised of the possibility of such damages.
|
||||
|
||||
9. Accepting Warranty or Additional Liability. While redistributing
|
||||
the Work or Derivative Works thereof, You may choose to offer,
|
||||
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||
or other liability obligations and/or rights consistent with this
|
||||
License. However, in accepting such obligations, You may act only
|
||||
on Your own behalf and on Your sole responsibility, not on behalf
|
||||
of any other Contributor, and only if You agree to indemnify,
|
||||
defend, and hold each Contributor harmless for any liability
|
||||
incurred by, or claims asserted against, such Contributor by reason
|
||||
of your accepting any such warranty or additional liability.
|
||||
|
||||
END OF TERMS AND CONDITIONS
|
||||
|
||||
APPENDIX: How to apply the Apache License to your work.
|
||||
|
||||
To apply the Apache License to your work, attach the following
|
||||
boilerplate notice, with the fields enclosed by brackets "[]"
|
||||
replaced with your own identifying information. (Don't include
|
||||
the brackets!) The text should be enclosed in the appropriate
|
||||
comment syntax for the file format. We also recommend that a
|
||||
file or class name and description of purpose be included on the
|
||||
same "printed page" as the copyright notice for easier
|
||||
identification within third-party archives.
|
||||
|
||||
Copyright [yyyy] [name of copyright owner]
|
||||
|
||||
Licensed under the Apache License, Version 2.0 (the "License");
|
||||
you may not use this file except in compliance with the License.
|
||||
You may obtain a copy of the License at
|
||||
|
||||
http://www.apache.org/licenses/LICENSE-2.0
|
||||
|
||||
Unless required by applicable law or agreed to in writing, software
|
||||
distributed under the License is distributed on an "AS IS" BASIS,
|
||||
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
@@ -0,0 +1,107 @@
|
||||
# AI Edit
|
||||
|
||||
Photoshop CEP panel for image generation. It sends text prompts to your own image API, previews the result, and places generated PNGs into the active document as new masked layers.
|
||||
|
||||
## Architecture
|
||||
|
||||
| Layer | File | Role |
|
||||
| --- | --- | --- |
|
||||
| Panel UI | `src/panels/ImageGenerator.jsx` | React + Tailwind, runs in the CEP CEF panel |
|
||||
| Bridge | `src/cep.js` | Node file/network access and `evalScript` calls |
|
||||
| Host | `cep/host/host.jsx` | ExtendScript: masked layer placement, file and prompt dialogs |
|
||||
| Manifest | `cep/CSXS/manifest.xml` | CEP extension declaration |
|
||||
|
||||
Node runs in the panel (`--enable-nodejs --mixed-context`), so temp files and HTTP
|
||||
both go through Node rather than UXP storage or `fetch`.
|
||||
|
||||
## Styling
|
||||
|
||||
Tailwind CSS 3, compiled by PostCSS into `dist/styles/panel.css`. Utility classes
|
||||
carry the exact pixel values from the original design, so arbitrary values
|
||||
(`h-[47px]`, `bg-[#2f2f2f]`) are used throughout rather than the default scale.
|
||||
|
||||
- `tailwind.config.js` — accent color, the rounded font stack, and the `narrow` (max 340px) breakpoint
|
||||
- `src/index.css` — Tailwind entry plus base rules for `html`, `body`, and `#root`
|
||||
|
||||
`src/styles.css` and `src/panels/ImageGenerator.css` are the pre-Tailwind
|
||||
originals. Nothing imports them; they are kept as the reference for the
|
||||
original pixel values.
|
||||
|
||||
## Prompt Input
|
||||
|
||||
The prompt box is a `contentEditable` div. CEF does not reliably receive IME
|
||||
composition events, so the button above 词库 opens a native Photoshop
|
||||
ScriptUI window for typing Chinese. Text crosses `evalScript` as
|
||||
`escape()`-encoded ASCII in both directions.
|
||||
|
||||
## API Shape
|
||||
|
||||
The default request is compatible with OpenAI-style image endpoints:
|
||||
|
||||
```json
|
||||
{
|
||||
"model": "gpt-image-2",
|
||||
"prompt": "your prompt",
|
||||
"n": 1,
|
||||
"size": "1024x1024",
|
||||
"response_format": "b64_json"
|
||||
}
|
||||
```
|
||||
|
||||
Accepted response shapes:
|
||||
|
||||
```json
|
||||
{ "data": [{ "b64_json": "..." }] }
|
||||
```
|
||||
|
||||
```json
|
||||
{ "data": [{ "url": "https://..." }] }
|
||||
```
|
||||
|
||||
`output` and `images` arrays are also tolerated for gateway variants.
|
||||
|
||||
## Photoshop Placement
|
||||
|
||||
Generated images first appear only in the panel's thumbnail list. Clicking a
|
||||
thumbnail places it as an embedded smart-object layer with a new layer mask.
|
||||
The selection that existed when generation started is saved as a temporary
|
||||
alpha channel, so the mask still uses the original selection even if the user
|
||||
changes the selection before clicking. Without a selection, the layer gets a
|
||||
reveal-all mask.
|
||||
|
||||
When a selection exists, its merged visible pixels are exported as the first
|
||||
reference image. Uploaded reference files follow it. The panel sends these
|
||||
images as data URLs in the request body's `images` array.
|
||||
|
||||
The request size is read from Photoshop: the full canvas dimensions are used
|
||||
without a selection, and the selection bounds are used when a selection exists.
|
||||
|
||||
## Build
|
||||
|
||||
```bash
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
Output goes to `dist`, which is the extension folder itself (`dist/CSXS/manifest.xml`).
|
||||
|
||||
## Install
|
||||
|
||||
```bash
|
||||
npm run cep:setup
|
||||
```
|
||||
|
||||
This enables `PlayerDebugMode` for CSXS 9–12 and links `dist` into the Adobe
|
||||
extensions folder. On Windows it writes to `HKCU\Software\Adobe\CSXS.*` and
|
||||
creates a directory junction; on macOS it uses `defaults write` and a symlink.
|
||||
|
||||
Restart Photoshop, then open the panel from `Plugins`.
|
||||
|
||||
## Development
|
||||
|
||||
```bash
|
||||
npm run watch
|
||||
```
|
||||
|
||||
Reload the panel from its flyout menu, or open `http://localhost:8088` for
|
||||
Chrome DevTools (the port comes from `cep/.debug`).
|
||||
@@ -0,0 +1,61 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<ExtensionManifest Version="9.0"
|
||||
ExtensionBundleId="com.aiedit.panel"
|
||||
ExtensionBundleName="AI Edit"
|
||||
ExtensionBundleVersion="1.0.0"
|
||||
xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance">
|
||||
<ExtensionList>
|
||||
<Extension Id="com.aiedit.panel.main" Version="1.0.0" />
|
||||
</ExtensionList>
|
||||
<ExecutionEnvironment>
|
||||
<HostList>
|
||||
<Host Name="PHXS" Version="[20.0,99.9]" />
|
||||
<Host Name="PHSP" Version="[20.0,99.9]" />
|
||||
</HostList>
|
||||
<LocaleList>
|
||||
<Locale Code="All" />
|
||||
</LocaleList>
|
||||
<RequiredRuntimeList>
|
||||
<RequiredRuntime Name="CSXS" Version="9.0" />
|
||||
</RequiredRuntimeList>
|
||||
</ExecutionEnvironment>
|
||||
<DispatchInfoList>
|
||||
<Extension Id="com.aiedit.panel.main">
|
||||
<DispatchInfo>
|
||||
<Resources>
|
||||
<MainPath>./index.html</MainPath>
|
||||
<ScriptPath>./host/host.jsx</ScriptPath>
|
||||
<CEFCommandLine>
|
||||
<Parameter>--enable-nodejs</Parameter>
|
||||
<Parameter>--mixed-context</Parameter>
|
||||
</CEFCommandLine>
|
||||
</Resources>
|
||||
<Lifecycle>
|
||||
<AutoVisible>true</AutoVisible>
|
||||
</Lifecycle>
|
||||
<UI>
|
||||
<Type>Panel</Type>
|
||||
<Menu>AI 生图</Menu>
|
||||
<Geometry>
|
||||
<Size>
|
||||
<Height>876</Height>
|
||||
<Width>390</Width>
|
||||
</Size>
|
||||
<MinSize>
|
||||
<Height>420</Height>
|
||||
<Width>300</Width>
|
||||
</MinSize>
|
||||
<MaxSize>
|
||||
<Height>2000</Height>
|
||||
<Width>2000</Width>
|
||||
</MaxSize>
|
||||
</Geometry>
|
||||
<Icons>
|
||||
<Icon Type="Normal">./icons/icon_N.png</Icon>
|
||||
<Icon Type="DarkNormal">./icons/icon_D.png</Icon>
|
||||
</Icons>
|
||||
</UI>
|
||||
</DispatchInfo>
|
||||
</Extension>
|
||||
</DispatchInfoList>
|
||||
</ExtensionManifest>
|
||||
@@ -0,0 +1,281 @@
|
||||
// ExtendScript host for the AI Edit CEP panel.
|
||||
// ASCII only: ExtendScript guesses this file's encoding, so all localized
|
||||
// strings are passed in from the panel instead of written here.
|
||||
|
||||
function aiEditHasDocument() {
|
||||
try {
|
||||
return app.documents.length > 0 ? "yes" : "no";
|
||||
} catch (e) {
|
||||
return "no";
|
||||
}
|
||||
}
|
||||
|
||||
function aiEditShowComposite(doc) {
|
||||
try {
|
||||
doc.activeChannels = doc.componentChannels;
|
||||
} catch (e) {
|
||||
try {
|
||||
doc.activeChannels = [doc.channels[0]];
|
||||
} catch (fallbackError) {}
|
||||
}
|
||||
}
|
||||
|
||||
function aiEditPickImages(title, filter) {
|
||||
try {
|
||||
var picked = File.openDialog(title, filter, true);
|
||||
if (!picked) return "";
|
||||
if (!(picked instanceof Array)) picked = [picked];
|
||||
|
||||
var paths = [];
|
||||
for (var i = 0; i < picked.length; i++) paths.push(picked[i].fsName);
|
||||
return paths.join("\n");
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
// A native window is the reliable way to type CJK: the CEF panel does not
|
||||
// always receive IME composition events.
|
||||
function aiEditPromptDialog(title, label, okText, cancelText, initial) {
|
||||
try {
|
||||
var dialog = new Window("dialog", title);
|
||||
dialog.alignChildren = "fill";
|
||||
dialog.margins = 14;
|
||||
dialog.spacing = 10;
|
||||
|
||||
dialog.add("statictext", undefined, label);
|
||||
|
||||
var field = dialog.add("edittext", undefined, initial || "", {
|
||||
multiline: true,
|
||||
wantReturn: true
|
||||
});
|
||||
field.preferredSize = [420, 150];
|
||||
|
||||
var row = dialog.add("group");
|
||||
row.alignment = "right";
|
||||
row.spacing = 8;
|
||||
var cancel = row.add("button", undefined, cancelText, { name: "cancel" });
|
||||
var confirm = row.add("button", undefined, okText, { name: "ok" });
|
||||
|
||||
var text = null;
|
||||
confirm.onClick = function () {
|
||||
text = field.text;
|
||||
dialog.close(1);
|
||||
};
|
||||
cancel.onClick = function () {
|
||||
dialog.close(0);
|
||||
};
|
||||
|
||||
field.active = true;
|
||||
if (dialog.show() !== 1 || text === null) return "cancel";
|
||||
|
||||
// escape() keeps the result ASCII so evalScript can carry it back.
|
||||
return "ok:" + escape(text);
|
||||
} catch (e) {
|
||||
return "cancel";
|
||||
}
|
||||
}
|
||||
|
||||
function aiEditGetGenerationSize() {
|
||||
try {
|
||||
if (app.documents.length === 0) return "";
|
||||
var doc = app.activeDocument;
|
||||
var width = doc.width.as("px");
|
||||
var height = doc.height.as("px");
|
||||
try {
|
||||
var bounds = doc.selection.bounds;
|
||||
width = bounds[2].as("px") - bounds[0].as("px");
|
||||
height = bounds[3].as("px") - bounds[1].as("px");
|
||||
} catch (noSelection) {}
|
||||
return Math.max(1, Math.round(width)) + "x" + Math.max(1, Math.round(height));
|
||||
} catch (e) {
|
||||
return "";
|
||||
}
|
||||
}
|
||||
|
||||
function aiEditCaptureSelection() {
|
||||
var doc = null;
|
||||
var reference = null;
|
||||
try {
|
||||
if (app.documents.length === 0) return "none";
|
||||
doc = app.activeDocument;
|
||||
aiEditShowComposite(doc);
|
||||
|
||||
var bounds;
|
||||
try {
|
||||
bounds = doc.selection.bounds;
|
||||
} catch (selectionError) {
|
||||
return "none";
|
||||
}
|
||||
|
||||
var token = "AI Edit Selection " + new Date().getTime();
|
||||
var channel = doc.channels.add();
|
||||
channel.name = token;
|
||||
doc.selection.store(channel);
|
||||
aiEditShowComposite(doc);
|
||||
|
||||
var width = Math.max(1, Math.round(bounds[2].as("px") - bounds[0].as("px")));
|
||||
var height = Math.max(1, Math.round(bounds[3].as("px") - bounds[1].as("px")));
|
||||
var boundsText = [
|
||||
Math.round(bounds[0].as("px")),
|
||||
Math.round(bounds[1].as("px")),
|
||||
width,
|
||||
height
|
||||
].join(",");
|
||||
|
||||
// Avoid copy/copyMerged: Photoshop reports that command as unavailable
|
||||
// for some active layers and documents. A duplicate keeps the merged
|
||||
// visible pixels and can be cropped without using the clipboard.
|
||||
reference = doc.duplicate("AI Edit Reference", false);
|
||||
reference.flatten();
|
||||
// Do not call selection.clear here. Photoshop rejects that command
|
||||
// when the duplicated document resolves to a locked background layer.
|
||||
// Cropping to the captured bounds keeps the selected image usable as
|
||||
// a reference without changing pixels through the clipboard/delete UI.
|
||||
reference.crop(bounds);
|
||||
|
||||
var file = new File(Folder.temp.fsName + "/ai-edit-selection-" + new Date().getTime() + ".png");
|
||||
var options = new PNGSaveOptions();
|
||||
reference.saveAs(file, options, true, Extension.LOWERCASE);
|
||||
reference.close(SaveOptions.DONOTSAVECHANGES);
|
||||
app.activeDocument = doc;
|
||||
aiEditShowComposite(doc);
|
||||
return "ok:" + token + "|" + file.fsName + "|" + boundsText;
|
||||
} catch (e) {
|
||||
try {
|
||||
if (reference) reference.close(SaveOptions.DONOTSAVECHANGES);
|
||||
if (doc) {
|
||||
app.activeDocument = doc;
|
||||
aiEditShowComposite(doc);
|
||||
}
|
||||
} catch (closeError) {}
|
||||
return "error:" + e.toString();
|
||||
}
|
||||
}
|
||||
|
||||
function aiEditAddLayerMask(hasSelection) {
|
||||
var classicMask = new ActionDescriptor();
|
||||
classicMask.putClass(charIDToTypeID("Nw "), charIDToTypeID("Chnl"));
|
||||
|
||||
var classicTarget = new ActionReference();
|
||||
classicTarget.putEnumerated(
|
||||
charIDToTypeID("Chnl"),
|
||||
charIDToTypeID("Chnl"),
|
||||
charIDToTypeID("Msk ")
|
||||
);
|
||||
classicMask.putReference(charIDToTypeID("At "), classicTarget);
|
||||
classicMask.putEnumerated(
|
||||
charIDToTypeID("Usng"),
|
||||
charIDToTypeID("UsrM"),
|
||||
charIDToTypeID(hasSelection ? "RvlS" : "RvlA")
|
||||
);
|
||||
|
||||
try {
|
||||
executeAction(charIDToTypeID("Mk "), classicMask, DialogModes.NO);
|
||||
return;
|
||||
} catch (classicError) {}
|
||||
|
||||
var modernMask = new ActionDescriptor();
|
||||
var newChannel = new ActionReference();
|
||||
newChannel.putClass(stringIDToTypeID("channel"));
|
||||
modernMask.putReference(charIDToTypeID("null"), newChannel);
|
||||
|
||||
var currentLayer = new ActionReference();
|
||||
currentLayer.putEnumerated(
|
||||
stringIDToTypeID("layer"),
|
||||
stringIDToTypeID("ordinal"),
|
||||
stringIDToTypeID("targetEnum")
|
||||
);
|
||||
modernMask.putReference(charIDToTypeID("At "), currentLayer);
|
||||
modernMask.putEnumerated(
|
||||
stringIDToTypeID("using"),
|
||||
stringIDToTypeID("userMaskEnabled"),
|
||||
stringIDToTypeID(hasSelection ? "revealSelection" : "revealAll")
|
||||
);
|
||||
executeAction(charIDToTypeID("Mk "), modernMask, DialogModes.NO);
|
||||
}
|
||||
|
||||
function aiEditPlaceImage(filePath, layerName, channelName, boundsText) {
|
||||
try {
|
||||
if (app.documents.length === 0) return "error:missing-document";
|
||||
|
||||
var file = new File(filePath);
|
||||
if (!file.exists) return "error:missing-file";
|
||||
|
||||
var doc = app.activeDocument;
|
||||
var selectionChannel = null;
|
||||
var restoreChannel = null;
|
||||
var hadCurrentSelection = false;
|
||||
var hasSelection = false;
|
||||
if (channelName) {
|
||||
try {
|
||||
try {
|
||||
doc.selection.bounds;
|
||||
hadCurrentSelection = true;
|
||||
restoreChannel = doc.channels.add();
|
||||
restoreChannel.name = "AI Edit Restore " + new Date().getTime();
|
||||
doc.selection.store(restoreChannel);
|
||||
} catch (noCurrentSelection) {}
|
||||
|
||||
selectionChannel = doc.channels.getByName(channelName);
|
||||
doc.selection.load(selectionChannel);
|
||||
hasSelection = true;
|
||||
} catch (channelError) {}
|
||||
}
|
||||
|
||||
var place = new ActionDescriptor();
|
||||
place.putPath(charIDToTypeID("null"), file);
|
||||
place.putEnumerated(charIDToTypeID("FTcs"), charIDToTypeID("QCSt"), charIDToTypeID("Qcsa"));
|
||||
|
||||
var offset = new ActionDescriptor();
|
||||
offset.putUnitDouble(charIDToTypeID("Hrzn"), charIDToTypeID("#Pxl"), 0);
|
||||
offset.putUnitDouble(charIDToTypeID("Vrtc"), charIDToTypeID("#Pxl"), 0);
|
||||
place.putObject(charIDToTypeID("Ofst"), charIDToTypeID("Ofst"), offset);
|
||||
|
||||
place.putBoolean(stringIDToTypeID("linked"), false);
|
||||
executeAction(charIDToTypeID("Plc "), place, DialogModes.NO);
|
||||
|
||||
var placedLayer = app.activeDocument.activeLayer;
|
||||
if (layerName) placedLayer.name = layerName;
|
||||
|
||||
if (boundsText) {
|
||||
var boundsValues = boundsText.split(",");
|
||||
if (boundsValues.length === 4) {
|
||||
var targetLeft = Number(boundsValues[0]);
|
||||
var targetTop = Number(boundsValues[1]);
|
||||
var targetWidth = Number(boundsValues[2]);
|
||||
var targetHeight = Number(boundsValues[3]);
|
||||
var placedBounds = placedLayer.bounds;
|
||||
var placedWidth = Math.max(1, placedBounds[2].as("px") - placedBounds[0].as("px"));
|
||||
var placedHeight = Math.max(1, placedBounds[3].as("px") - placedBounds[1].as("px"));
|
||||
placedLayer.resize(
|
||||
targetWidth / placedWidth * 100,
|
||||
targetHeight / placedHeight * 100,
|
||||
AnchorPosition.TOPLEFT
|
||||
);
|
||||
placedBounds = placedLayer.bounds;
|
||||
placedLayer.translate(
|
||||
targetLeft - placedBounds[0].as("px"),
|
||||
targetTop - placedBounds[1].as("px")
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
app.activeDocument.activeLayer = placedLayer;
|
||||
aiEditAddLayerMask(hasSelection);
|
||||
|
||||
if (selectionChannel) {
|
||||
if (hadCurrentSelection && restoreChannel) doc.selection.load(restoreChannel);
|
||||
else doc.selection.deselect();
|
||||
if (restoreChannel) restoreChannel.remove();
|
||||
}
|
||||
aiEditShowComposite(doc);
|
||||
|
||||
return "ok";
|
||||
} catch (e) {
|
||||
try {
|
||||
aiEditShowComposite(app.activeDocument);
|
||||
} catch (restoreViewError) {}
|
||||
return "error:" + e.toString();
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 568 B |
Binary file not shown.
|
After Width: | Height: | Size: 1.0 KiB |
Binary file not shown.
|
After Width: | Height: | Size: 535 B |
Binary file not shown.
|
After Width: | Height: | Size: 995 B |
@@ -0,0 +1,12 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<link rel="stylesheet" href="styles/panel.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script src="index.js"></script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,6 @@
|
||||
# Direction Approval
|
||||
|
||||
- User instruction: "请不要修改样式,必须严格按照我的设置还原"
|
||||
- Approved source: `C:/Users/Administrator/AppData/Local/Temp/codex-clipboard-fd114ed3-645d-43e5-9cba-821120aa02cf.png`
|
||||
- Decision: Skip design variations and reproduce the supplied two-page UI directly.
|
||||
- Scope: Photoshop UXP panel in `D:/Software/Adobe/uxp/ai-edit`.
|
||||
@@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "com.aiedit.panel",
|
||||
"version": "1.0.0",
|
||||
"scripts": {
|
||||
"watch": "nodemon -w src -e js,jsx,json,css,html -w webpack.config.js -w cep -x npm run build",
|
||||
"build": "webpack --mode development",
|
||||
"cep:setup": "node scripts/setup-cep.js"
|
||||
},
|
||||
"author": "Adobe Inc",
|
||||
"license": "Apache-2.0",
|
||||
"devDependencies": {
|
||||
"@babel/core": "^7.8.7",
|
||||
"@babel/plugin-proposal-object-rest-spread": "^7.8.3",
|
||||
"@babel/plugin-syntax-class-properties": "^7.10.4",
|
||||
"@babel/plugin-transform-react-jsx": "^7.8.3",
|
||||
"autoprefixer": "^10.5.4",
|
||||
"babel-loader": "^8.0.6",
|
||||
"copy-webpack-plugin": "^5.0.3",
|
||||
"css-loader": "^6.11.0",
|
||||
"mini-css-extract-plugin": "^2.10.2",
|
||||
"nodemon": "^2.0.7",
|
||||
"postcss": "^8.5.25",
|
||||
"postcss-loader": "^8.2.1",
|
||||
"tailwindcss": "^3.4.19",
|
||||
"webpack": "^5.88.1",
|
||||
"webpack-cli": "^5.1.4"
|
||||
},
|
||||
"dependencies": {
|
||||
"react": "^16.8.6",
|
||||
"react-dom": "^16.8.6"
|
||||
}
|
||||
}
|
||||
Generated
+3540
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,2 @@
|
||||
allowBuilds:
|
||||
nodemon: true
|
||||
@@ -0,0 +1,6 @@
|
||||
module.exports = {
|
||||
plugins: {
|
||||
tailwindcss: {},
|
||||
autoprefixer: {}
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,63 @@
|
||||
// Enables CEP debug mode and links dist/ into the Adobe extensions folder.
|
||||
// Run once per machine: npm run cep:setup
|
||||
|
||||
const { execFileSync } = require("child_process");
|
||||
const fs = require("fs");
|
||||
const os = require("os");
|
||||
const path = require("path");
|
||||
|
||||
const CSXS_VERSIONS = [9, 10, 11, 12];
|
||||
const EXTENSION_NAME = "com.aiedit.panel";
|
||||
const dist = path.resolve(__dirname, "..", "dist");
|
||||
|
||||
function enableDebugMode() {
|
||||
if (process.platform === "win32") {
|
||||
for (const version of CSXS_VERSIONS) {
|
||||
execFileSync("reg", [
|
||||
"add", `HKCU\\Software\\Adobe\\CSXS.${version}`,
|
||||
"/v", "PlayerDebugMode", "/t", "REG_SZ", "/d", "1", "/f"
|
||||
], { stdio: "ignore" });
|
||||
}
|
||||
} else {
|
||||
for (const version of CSXS_VERSIONS) {
|
||||
execFileSync("defaults", [
|
||||
"write", `com.adobe.CSXS.${version}`, "PlayerDebugMode", "1"
|
||||
], { stdio: "ignore" });
|
||||
}
|
||||
}
|
||||
console.log(`PlayerDebugMode enabled for CSXS ${CSXS_VERSIONS.join(", ")}`);
|
||||
}
|
||||
|
||||
function extensionsFolder() {
|
||||
return process.platform === "win32"
|
||||
? path.join(process.env.APPDATA, "Adobe", "CEP", "extensions")
|
||||
: path.join(os.homedir(), "Library", "Application Support", "Adobe", "CEP", "extensions");
|
||||
}
|
||||
|
||||
function linkExtension() {
|
||||
const root = extensionsFolder();
|
||||
fs.mkdirSync(root, { recursive: true });
|
||||
|
||||
const target = path.join(root, EXTENSION_NAME);
|
||||
const existing = fs.lstatSync(target, { throwIfNoEntry: false });
|
||||
if (existing) {
|
||||
if (!existing.isSymbolicLink()) {
|
||||
console.error(`${target} exists and is not a link. Remove it manually, then rerun.`);
|
||||
process.exit(1);
|
||||
}
|
||||
fs.unlinkSync(target);
|
||||
}
|
||||
|
||||
// Junctions avoid needing administrator rights on Windows.
|
||||
fs.symlinkSync(dist, target, process.platform === "win32" ? "junction" : "dir");
|
||||
console.log(`Linked ${target} -> ${dist}`);
|
||||
}
|
||||
|
||||
if (!fs.existsSync(dist)) {
|
||||
console.error("dist/ not found. Run `npm run build` first.");
|
||||
process.exit(1);
|
||||
}
|
||||
|
||||
enableDebugMode();
|
||||
linkExtension();
|
||||
console.log("Restart Photoshop, then open Plugins > AI Edit.");
|
||||
+255
@@ -0,0 +1,255 @@
|
||||
// Bridge between the panel UI and the Photoshop host / Node runtime.
|
||||
|
||||
const HOST_ERRORS = {
|
||||
"missing-file": "临时文件不存在",
|
||||
"missing-document": "请先打开一个 Photoshop 文档"
|
||||
};
|
||||
|
||||
function nodeRequire(id) {
|
||||
const resolver = window.cep_node ? window.cep_node.require : window.require;
|
||||
if (!resolver) throw new Error("Node 运行时不可用,请确认面板启用了 --enable-nodejs");
|
||||
return resolver(id);
|
||||
}
|
||||
|
||||
// evalScript only transports ASCII reliably, and host.jsx is ASCII-only, so any
|
||||
// localized string has to cross the bridge as a \uXXXX escape.
|
||||
function toExtendScriptString(value) {
|
||||
return JSON.stringify(String(value)).replace(
|
||||
/[^\x00-\x7F]/g,
|
||||
(char) => `\\u${char.charCodeAt(0).toString(16).padStart(4, "0")}`
|
||||
);
|
||||
}
|
||||
|
||||
function evalScript(script) {
|
||||
return new Promise((resolve, reject) => {
|
||||
const cep = window.__adobe_cep__;
|
||||
if (!cep) {
|
||||
reject(new Error("CEP 环境不可用"));
|
||||
return;
|
||||
}
|
||||
cep.evalScript(script, (result) => {
|
||||
if (result === "EvalScript error.") reject(new Error("ExtendScript 执行失败"));
|
||||
else resolve(result);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
function call(fn, ...args) {
|
||||
return evalScript(`${fn}(${args.map(toExtendScriptString).join(", ")})`);
|
||||
}
|
||||
|
||||
export async function hasActiveDocument() {
|
||||
try {
|
||||
return (await evalScript("aiEditHasDocument()")) === "yes";
|
||||
} catch (error) {
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
export async function getGenerationSize() {
|
||||
const result = await evalScript("aiEditGetGenerationSize()");
|
||||
return result && /^\d+x\d+$/.test(result) ? result : "";
|
||||
}
|
||||
|
||||
export async function captureSelectionReference() {
|
||||
const result = await evalScript("aiEditCaptureSelection()");
|
||||
if (!result || result === "none") return null;
|
||||
if (result.indexOf("error:") === 0) {
|
||||
throw new Error(HOST_ERRORS[result.slice(6)] || result.slice(6));
|
||||
}
|
||||
|
||||
const parts = result.slice(3).split("|");
|
||||
if (parts.length < 3) throw new Error("无法读取 Photoshop 选区");
|
||||
return {
|
||||
channelName: parts[0],
|
||||
filePath: parts[1],
|
||||
bounds: parts[2]
|
||||
};
|
||||
}
|
||||
|
||||
export async function pickReferenceImages() {
|
||||
const result = await call("aiEditPickImages", "选择参考图", "图片:*.png;*.jpg;*.jpeg;*.webp");
|
||||
return result ? result.split("\n").filter(Boolean) : [];
|
||||
}
|
||||
|
||||
// Returns null when the user cancels, so an empty string stays a real answer.
|
||||
export async function openPromptDialog(initial) {
|
||||
const result = await call(
|
||||
"aiEditPromptDialog",
|
||||
"输入提示词",
|
||||
"在此输入描述提示词,支持中文输入法:",
|
||||
"确定",
|
||||
"取消",
|
||||
initial || ""
|
||||
);
|
||||
if (!result || !result.startsWith("ok:")) return null;
|
||||
|
||||
// ExtendScript sends escape()-encoded text; unescape is the only decoder
|
||||
// that understands its %uXXXX form.
|
||||
return unescape(result.slice(3));
|
||||
}
|
||||
|
||||
// Requests go through Node rather than fetch: the panel talks to whatever
|
||||
// endpoint the user configures, and no CORS headers are expected back.
|
||||
function request(url, { method = "GET", headers = {}, body } = {}, redirects = 5) {
|
||||
const { Buffer } = nodeRequire("buffer");
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
let target;
|
||||
try {
|
||||
target = new URL(url);
|
||||
} catch (error) {
|
||||
reject(new Error(`地址无效:${url}`));
|
||||
return;
|
||||
}
|
||||
if (target.protocol !== "http:" && target.protocol !== "https:") {
|
||||
reject(new Error(`不支持的协议:${target.protocol}`));
|
||||
return;
|
||||
}
|
||||
|
||||
const client = nodeRequire(target.protocol === "https:" ? "https" : "http");
|
||||
// Older CEP runtimes ship a Node that cannot take a URL object here.
|
||||
const options = {
|
||||
protocol: target.protocol,
|
||||
hostname: target.hostname,
|
||||
port: target.port || undefined,
|
||||
path: `${target.pathname}${target.search}`,
|
||||
method,
|
||||
headers
|
||||
};
|
||||
|
||||
const req = client.request(options, (res) => {
|
||||
const { statusCode, headers: resHeaders } = res;
|
||||
|
||||
if (statusCode >= 300 && statusCode < 400 && resHeaders.location) {
|
||||
res.resume();
|
||||
if (redirects <= 0) {
|
||||
reject(new Error("重定向次数过多"));
|
||||
return;
|
||||
}
|
||||
const next = new URL(resHeaders.location, target);
|
||||
// Never carry the API key to a host the user did not configure.
|
||||
const nextHeaders = { ...headers };
|
||||
if (next.origin !== target.origin) delete nextHeaders.Authorization;
|
||||
|
||||
request(next.toString(), { method, headers: nextHeaders, body }, redirects - 1)
|
||||
.then(resolve, reject);
|
||||
return;
|
||||
}
|
||||
|
||||
const chunks = [];
|
||||
res.on("data", (chunk) => chunks.push(chunk));
|
||||
res.on("end", () => resolve({ statusCode, buffer: Buffer.concat(chunks) }));
|
||||
});
|
||||
|
||||
req.on("error", (error) => reject(new Error(error.message || "网络请求失败")));
|
||||
if (body) req.write(body);
|
||||
req.end();
|
||||
});
|
||||
}
|
||||
|
||||
export async function postJson(url, { apiKey, body }) {
|
||||
const { Buffer } = nodeRequire("buffer");
|
||||
const payload = JSON.stringify(body);
|
||||
const { statusCode, buffer } = await request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"Content-Length": Buffer.byteLength(payload),
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
body: payload
|
||||
});
|
||||
return { statusCode, text: buffer.toString("utf8") };
|
||||
}
|
||||
|
||||
export async function postMultipart(url, { apiKey, fields, files }) {
|
||||
const { Buffer } = nodeRequire("buffer");
|
||||
const boundary = `----AIEditBoundary${Date.now().toString(16)}`;
|
||||
const chunks = [];
|
||||
const addText = (value) => chunks.push(Buffer.from(value, "utf8"));
|
||||
|
||||
Object.keys(fields || {}).forEach((name) => {
|
||||
addText(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="${name}"\r\n\r\n` +
|
||||
`${String(fields[name])}\r\n`
|
||||
);
|
||||
});
|
||||
|
||||
(files || []).forEach((file) => {
|
||||
const match = String(file.dataUrl).match(/^data:([^;]+);base64,(.*)$/);
|
||||
if (!match) throw new Error("参考图数据格式无效");
|
||||
|
||||
addText(
|
||||
`--${boundary}\r\n` +
|
||||
`Content-Disposition: form-data; name="${file.fieldName}"; filename="${file.fileName}"\r\n` +
|
||||
`Content-Type: ${match[1]}\r\n\r\n`
|
||||
);
|
||||
chunks.push(Buffer.from(match[2], "base64"));
|
||||
addText("\r\n");
|
||||
});
|
||||
|
||||
addText(`--${boundary}--\r\n`);
|
||||
const body = Buffer.concat(chunks);
|
||||
const { statusCode, buffer } = await request(url, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": `multipart/form-data; boundary=${boundary}`,
|
||||
"Content-Length": body.length,
|
||||
Authorization: `Bearer ${apiKey}`
|
||||
},
|
||||
body
|
||||
});
|
||||
return { statusCode, text: buffer.toString("utf8") };
|
||||
}
|
||||
|
||||
export async function downloadImage(url) {
|
||||
const { statusCode, buffer } = await request(url);
|
||||
if (statusCode < 200 || statusCode >= 300) throw new Error(`图片下载失败:${statusCode}`);
|
||||
return buffer;
|
||||
}
|
||||
|
||||
export function fileName(filePath) {
|
||||
return String(filePath).split(/[\\/]/).pop();
|
||||
}
|
||||
|
||||
export function writeTempImage(buffer, index) {
|
||||
const fs = nodeRequire("fs");
|
||||
const os = nodeRequire("os");
|
||||
const path = nodeRequire("path");
|
||||
const { Buffer } = nodeRequire("buffer");
|
||||
|
||||
const folder = path.join(os.tmpdir(), "ai-edit");
|
||||
if (!fs.existsSync(folder)) fs.mkdirSync(folder, { recursive: true });
|
||||
|
||||
const target = path.join(folder, `ai-edit-${Date.now()}-${index}.png`);
|
||||
fs.writeFileSync(target, Buffer.from(buffer));
|
||||
return target;
|
||||
}
|
||||
|
||||
export function readFileAsDataUrl(filePath) {
|
||||
const fs = nodeRequire("fs");
|
||||
const buffer = fs.readFileSync(filePath);
|
||||
const extension = String(filePath).split(".").pop().toLowerCase();
|
||||
const mime = {
|
||||
jpg: "image/jpeg",
|
||||
jpeg: "image/jpeg",
|
||||
webp: "image/webp"
|
||||
}[extension] || "image/png";
|
||||
return `data:${mime};base64,${buffer.toString("base64")}`;
|
||||
}
|
||||
|
||||
export async function placeImageFile(filePath, layerName, channelName, bounds) {
|
||||
const result = await call(
|
||||
"aiEditPlaceImage",
|
||||
filePath,
|
||||
layerName,
|
||||
channelName || "",
|
||||
bounds || ""
|
||||
);
|
||||
if (result === "ok") return;
|
||||
|
||||
const detail = String(result || "").replace(/^error:/, "");
|
||||
throw new Error(HOST_ERRORS[detail] || detail || "图片置入失败");
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
@tailwind base;
|
||||
@tailwind components;
|
||||
@tailwind utilities;
|
||||
|
||||
/* Element-level styles Tailwind classes cannot reach (html/body/#root). */
|
||||
@layer base {
|
||||
html,
|
||||
body {
|
||||
@apply h-full w-full min-w-0 overflow-hidden bg-[#202124] p-0;
|
||||
}
|
||||
|
||||
body {
|
||||
@apply m-0 text-[12px] text-[#f2f3f5];
|
||||
}
|
||||
|
||||
#root {
|
||||
@apply h-full w-full;
|
||||
}
|
||||
|
||||
button {
|
||||
@apply cursor-pointer;
|
||||
}
|
||||
|
||||
button:focus-visible,
|
||||
input:focus-visible,
|
||||
textarea:focus-visible,
|
||||
select:focus-visible,
|
||||
[contenteditable]:focus-visible {
|
||||
@apply outline outline-2 outline-offset-1 outline-[#6ba5ff];
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
/* ===================================
|
||||
全局隐藏滚动条(保留滚动能力)
|
||||
CEP / Chromium
|
||||
=================================== */
|
||||
|
||||
/* Chrome / Chromium */
|
||||
::-webkit-scrollbar {
|
||||
width: 0px;
|
||||
height: 0px;
|
||||
}
|
||||
|
||||
|
||||
/* Firefox */
|
||||
* {
|
||||
scrollbar-width: none;
|
||||
}
|
||||
|
||||
|
||||
/* IE / Edge旧版 */
|
||||
* {
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
import React from "react";
|
||||
import ReactDOM from "react-dom";
|
||||
|
||||
import "./index.css";
|
||||
import { ImageGenerator } from "./panels/ImageGenerator.jsx";
|
||||
|
||||
ReactDOM.render(<ImageGenerator />, document.getElementById("root"));
|
||||
@@ -0,0 +1,564 @@
|
||||
import React, { useEffect, useMemo, useRef, useState } from "react";
|
||||
|
||||
import {
|
||||
downloadImage,
|
||||
captureSelectionReference,
|
||||
fileName,
|
||||
getGenerationSize,
|
||||
hasActiveDocument,
|
||||
openPromptDialog,
|
||||
pickReferenceImages,
|
||||
placeImageFile,
|
||||
postMultipart,
|
||||
postJson,
|
||||
readFileAsDataUrl,
|
||||
writeTempImage,
|
||||
} from "../cep.js";
|
||||
|
||||
const SETTINGS_KEY = "ai-edit.settings.v3";
|
||||
|
||||
const DEFAULT_SETTINGS = {
|
||||
endpoint: "",
|
||||
apiKey: "",
|
||||
model: "gpt-image-2",
|
||||
size: "1024x1024",
|
||||
quality: "low",
|
||||
background: "auto",
|
||||
count: 1,
|
||||
taskMode: "default",
|
||||
};
|
||||
|
||||
const MODEL_OPTIONS = [
|
||||
"gpt-image-2",
|
||||
"gpt-image-1",
|
||||
"gpt-image-1.5",
|
||||
"gemini-3.1-flash-lite-image",
|
||||
].map((model) => ({ label: model, value: model }));
|
||||
const QUALITY_OPTIONS = [
|
||||
{ label: "1K", value: "low" },
|
||||
{ label: "2K", value: "medium" },
|
||||
{ label: "3K", value: "high" },
|
||||
];
|
||||
const ICON_PATHS = {
|
||||
help: [
|
||||
"M9.1 9a3 3 0 1 1 5.8 1c0 2-3 2-3 4",
|
||||
"M12 18h.01",
|
||||
"M12 22a10 10 0 1 0 0-20 10 10 0 0 0 0 20Z",
|
||||
],
|
||||
settings: [
|
||||
"M12 15.5a3.5 3.5 0 1 0 0-7 3.5 3.5 0 0 0 0 7Z",
|
||||
"M19.4 15a1.7 1.7 0 0 0 .34 1.88l.06.06-2.83 2.83-.06-.06a1.7 1.7 0 0 0-1.88-.34A1.7 1.7 0 0 0 14 20.92V21h-4v-.08A1.7 1.7 0 0 0 9 19.36a1.7 1.7 0 0 0-1.88.34l-.06.06-2.83-2.83.06-.06A1.7 1.7 0 0 0 4.63 15 1.7 1.7 0 0 0 3.08 14H3v-4h.08A1.7 1.7 0 0 0 4.64 9a1.7 1.7 0 0 0-.34-1.88l-.06-.06 2.83-2.83.06.06A1.7 1.7 0 0 0 9 4.63 1.7 1.7 0 0 0 10 3.08V3h4v.08A1.7 1.7 0 0 0 15 4.64a1.7 1.7 0 0 0 1.88-.34l.06-.06 2.83 2.83-.06.06A1.7 1.7 0 0 0 19.37 9 1.7 1.7 0 0 0 20.92 10H21v4h-.08A1.7 1.7 0 0 0 19.4 15Z",
|
||||
],
|
||||
refresh: [
|
||||
"M20 7v5h-5",
|
||||
"M4 17v-5h5",
|
||||
"M6.1 9a7 7 0 0 1 11.6-2.6L20 9",
|
||||
"M4 15l2.3 2.6A7 7 0 0 0 18 15",
|
||||
],
|
||||
expand: ["M8 3H3v5", "M16 3h5v5", "M8 21H3v-5", "M16 21h5v-5"],
|
||||
upload: [
|
||||
"M12 16V5",
|
||||
"m7.5 9.5 4.5-4.5 4.5 4.5",
|
||||
"M20 16.5a4.5 4.5 0 0 1-4.5 4.5h-8A4.5 4.5 0 0 1 7 12.03 6 6 0 0 1 18.55 10 3.5 3.5 0 0 1 20 16.5Z",
|
||||
],
|
||||
};
|
||||
|
||||
const SELECT_CLASS = "block w-full min-w-0 flex-auto";
|
||||
const SELECT_FIELD =
|
||||
"h-[33px] w-full appearance-none rounded-[6px] border-0 bg-[#2f2f2f] pl-[10px] pr-[34px] text-[13px] font-semibold text-[#f4f4f4] hover:bg-[#3a3a3a]";
|
||||
const MINI_HELP =
|
||||
"ml-[3px] inline-flex h-[13px] w-[13px] items-center justify-center rounded-full border border-white text-[9px] font-bold leading-[13px] text-white";
|
||||
const GROUP_LABEL =
|
||||
"flex h-[20px] items-center text-[12px] font-bold text-[#eeeeee]";
|
||||
const PRIMARY_BUTTON =
|
||||
"flex h-[28px] w-full items-center justify-center rounded-[5px] border-0 bg-accent px-[10px] text-[12px] font-bold text-white hover:bg-[#3d88fa]";
|
||||
const TOOL_BUTTON =
|
||||
"flex w-[34px] cursor-pointer flex-col items-center justify-center border border-[#6e6e6e] bg-[#3d3d3d] p-0 text-[#ededed] hover:bg-[#474747]";
|
||||
|
||||
const Icon = ({ name, size = 18, stroke = "#f4f4f4" }) => (
|
||||
<svg
|
||||
className="pointer-events-none block flex-none"
|
||||
width={size}
|
||||
height={size}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke={stroke}
|
||||
strokeWidth="1.8"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
aria-hidden="true"
|
||||
>
|
||||
{ICON_PATHS[name].map((path, index) => (
|
||||
<path d={path} key={`${name}-${index}`} />
|
||||
))}
|
||||
</svg>
|
||||
);
|
||||
|
||||
function loadSettings() {
|
||||
try {
|
||||
const saved = localStorage.getItem(SETTINGS_KEY);
|
||||
return saved
|
||||
? { ...DEFAULT_SETTINGS, ...JSON.parse(saved) }
|
||||
: DEFAULT_SETTINGS;
|
||||
} catch (error) {
|
||||
return DEFAULT_SETTINGS;
|
||||
}
|
||||
}
|
||||
|
||||
function cleanEndpoint(endpoint, operation = "generations") {
|
||||
const trimmed = endpoint.trim();
|
||||
if (!trimmed) return "";
|
||||
const suffix = `/images/${operation}`;
|
||||
if (trimmed.endsWith("/images/generations"))
|
||||
return trimmed.replace(/\/images\/generations$/, suffix);
|
||||
if (trimmed.endsWith("/images/edits"))
|
||||
return trimmed.replace(/\/images\/edits$/, suffix);
|
||||
return `${trimmed.replace(/\/$/, "")}${suffix}`;
|
||||
}
|
||||
|
||||
function extractImagePayload(result) {
|
||||
const candidates = [];
|
||||
if (Array.isArray(result && result.data)) candidates.push(...result.data);
|
||||
if (Array.isArray(result && result.output)) candidates.push(...result.output);
|
||||
if (Array.isArray(result && result.images)) candidates.push(...result.images);
|
||||
|
||||
return candidates
|
||||
.map((item) => {
|
||||
if (!item) return null;
|
||||
if (item.b64_json) return { type: "base64", value: item.b64_json };
|
||||
if (item.image) return { type: "base64", value: item.image };
|
||||
if (item.url) return { type: "url", value: item.url };
|
||||
if (typeof item === "string" && item.startsWith("http"))
|
||||
return { type: "url", value: item };
|
||||
if (typeof item === "string") return { type: "base64", value: item };
|
||||
return null;
|
||||
})
|
||||
.filter(Boolean);
|
||||
}
|
||||
|
||||
function base64ToArrayBuffer(base64) {
|
||||
const binary = atob(base64.replace(/^data:image\/\w+;base64,/, ""));
|
||||
const bytes = new Uint8Array(binary.length);
|
||||
for (let index = 0; index < binary.length; index += 1)
|
||||
bytes[index] = binary.charCodeAt(index);
|
||||
return bytes.buffer;
|
||||
}
|
||||
|
||||
async function payloadToBuffer(payload) {
|
||||
if (payload.type === "url") return downloadImage(payload.value);
|
||||
return base64ToArrayBuffer(payload.value);
|
||||
}
|
||||
|
||||
function payloadToSrc(payload) {
|
||||
if (payload.type === "url") return payload.value;
|
||||
if (payload.value.startsWith("data:")) return payload.value;
|
||||
return `data:image/png;base64,${payload.value}`;
|
||||
}
|
||||
|
||||
function SegmentedControl({ options, value, onChange }) {
|
||||
return (
|
||||
<div className="flex h-[27px] overflow-hidden rounded-[5px] bg-[#2f2f2f]">
|
||||
{options.map((option) => (
|
||||
<button
|
||||
key={option.value}
|
||||
className={`h-[27px] min-w-0 flex-1 rounded-none border-0 px-[4px] text-[12px] font-semibold ${
|
||||
value === option.value
|
||||
? "bg-[#1688f7] text-white"
|
||||
: "bg-transparent text-[#c5c5c5]"
|
||||
}`}
|
||||
onClick={() => onChange(option.value)}
|
||||
>
|
||||
{option.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export const ImageGenerator = () => {
|
||||
const [settings, setSettings] = useState(loadSettings);
|
||||
const [prompt, setPrompt] = useState("");
|
||||
const [status, setStatus] = useState("");
|
||||
const [isBusy, setIsBusy] = useState(false);
|
||||
const [referenceFiles, setReferenceFiles] = useState([]);
|
||||
const [activeView, setActiveView] = useState("generate");
|
||||
const [isPromptFocused, setIsPromptFocused] = useState(false);
|
||||
const [results, setResults] = useState([]);
|
||||
const editorRef = useRef(null);
|
||||
|
||||
useEffect(() => {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
}, [settings]);
|
||||
|
||||
const updateSetting = (key, value) =>
|
||||
setSettings((current) => ({ ...current, [key]: value }));
|
||||
const canGenerate = useMemo(
|
||||
() =>
|
||||
Boolean(settings.endpoint && settings.apiKey && prompt.trim() && !isBusy),
|
||||
[settings, prompt, isBusy],
|
||||
);
|
||||
|
||||
// The editor is contentEditable, so the dialog result has to be written in directly.
|
||||
const editPromptInPhotoshop = async () => {
|
||||
const text = await openPromptDialog(prompt);
|
||||
if (text === null) return;
|
||||
setPrompt(text);
|
||||
if (editorRef.current) editorRef.current.textContent = text;
|
||||
};
|
||||
|
||||
const chooseReferenceImages = async () => {
|
||||
try {
|
||||
const selected = await pickReferenceImages();
|
||||
setReferenceFiles(selected);
|
||||
setStatus(selected.length ? `已选择 ${selected.length} 张参考图` : "");
|
||||
} catch (error) {
|
||||
setStatus("");
|
||||
}
|
||||
};
|
||||
|
||||
const generate = async () => {
|
||||
if (!canGenerate) {
|
||||
setStatus(
|
||||
settings.endpoint && settings.apiKey
|
||||
? "请输入描述提示词"
|
||||
: "请先在设置中填写 API 地址和 API Key",
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
setIsBusy(true);
|
||||
setStatus("正在生成...");
|
||||
try {
|
||||
const selectionReference = await captureSelectionReference();
|
||||
const isEdit = Boolean(selectionReference);
|
||||
const generationSize = await getGenerationSize();
|
||||
if (isEdit) setStatus("正在编辑选区...");
|
||||
const referenceImages = selectionReference
|
||||
? [selectionReference.filePath, ...referenceFiles].map(readFileAsDataUrl)
|
||||
: referenceFiles.map(readFileAsDataUrl);
|
||||
const requestFields = {
|
||||
model: settings.model.trim(),
|
||||
prompt: prompt.trim(),
|
||||
n: Number(settings.count) || 1,
|
||||
size: generationSize || settings.size,
|
||||
quality: settings.quality,
|
||||
response_format: "b64_json",
|
||||
...(settings.background !== "auto"
|
||||
? { background: settings.background }
|
||||
: {}),
|
||||
};
|
||||
const requestResult = isEdit
|
||||
? await postMultipart(
|
||||
cleanEndpoint(settings.endpoint, "edits"),
|
||||
{
|
||||
apiKey: settings.apiKey.trim(),
|
||||
fields: requestFields,
|
||||
files: referenceImages.map((dataUrl, index) => ({
|
||||
fieldName: referenceImages.length === 1 ? "image" : "image[]",
|
||||
fileName: `ai-edit-reference-${index + 1}.png`,
|
||||
dataUrl,
|
||||
})),
|
||||
},
|
||||
)
|
||||
: await postJson(cleanEndpoint(settings.endpoint, "generations"), {
|
||||
apiKey: settings.apiKey.trim(),
|
||||
body: {
|
||||
...requestFields,
|
||||
...(referenceImages.length
|
||||
? { images: referenceImages, image: referenceImages[0] }
|
||||
: {}),
|
||||
},
|
||||
});
|
||||
const { statusCode, text: resultText } = requestResult;
|
||||
|
||||
let result;
|
||||
try {
|
||||
result = JSON.parse(resultText);
|
||||
} catch (error) {
|
||||
throw new Error(resultText || "接口返回不是 JSON");
|
||||
}
|
||||
if (statusCode < 200 || statusCode >= 300)
|
||||
throw new Error(
|
||||
(result.error && (result.error.message || result.error.code)) ||
|
||||
`接口请求失败:${statusCode}`,
|
||||
);
|
||||
|
||||
const payloads = extractImagePayload(result);
|
||||
if (!payloads.length) throw new Error("接口响应里没有找到图片数据");
|
||||
|
||||
setResults((current) =>
|
||||
[
|
||||
...payloads.map((payload) => ({
|
||||
src: payloadToSrc(payload),
|
||||
channelName: selectionReference && selectionReference.channelName,
|
||||
bounds: selectionReference && selectionReference.bounds,
|
||||
})),
|
||||
...current,
|
||||
].slice(0, 12),
|
||||
);
|
||||
setStatus(`完成:已生成 ${payloads.length} 张,请点击缩略图置入`);
|
||||
} catch (error) {
|
||||
setStatus(error.message || String(error));
|
||||
} finally {
|
||||
setIsBusy(false);
|
||||
}
|
||||
};
|
||||
|
||||
const placeResult = async (result, index) => {
|
||||
if (!(await hasActiveDocument())) {
|
||||
setStatus("请先在 Photoshop 中打开一个文档");
|
||||
return;
|
||||
}
|
||||
setStatus("正在置入...");
|
||||
try {
|
||||
const payload = result.src.startsWith("http")
|
||||
? { type: "url", value: result.src }
|
||||
: { type: "base64", value: result.src };
|
||||
const buffer = await payloadToBuffer(payload);
|
||||
const file = await writeTempImage(buffer, index + 1);
|
||||
await placeImageFile(
|
||||
file,
|
||||
`AI 生图 - ${index + 1}`,
|
||||
result.channelName,
|
||||
result.bounds,
|
||||
);
|
||||
setStatus("已置入当前文档");
|
||||
} catch (error) {
|
||||
setStatus(error.message || String(error));
|
||||
}
|
||||
};
|
||||
|
||||
const saveSettings = () => {
|
||||
localStorage.setItem(SETTINGS_KEY, JSON.stringify(settings));
|
||||
setStatus("设置已保存");
|
||||
setActiveView("generate");
|
||||
};
|
||||
|
||||
return (
|
||||
<main className="relative h-screen min-h-[520px] w-full min-w-[300px] overflow-y-auto overflow-x-hidden bg-[#4a4a4a] px-[19px] pb-[28px] pt-[15px] text-[#eeeeee] narrow:px-[14px]">
|
||||
<header className="flex h-[47px] items-start justify-between">
|
||||
<div className="flex items-center pt-[1px] text-white">
|
||||
<span
|
||||
className="relative mr-[8px] block h-[17px] w-[17px] rounded-t-[9px] rounded-b-[3px] border-2 border-b-0 border-white before:absolute before:bottom-[-2px] before:left-[-4px] before:right-[-4px] before:h-[2px] before:rounded-[2px] before:bg-white before:content-[''] after:absolute after:bottom-[-6px] after:left-[5px] after:h-[3px] after:w-[5px] after:rounded-b-[4px] after:bg-white after:content-['']"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<strong className="text-[14px] font-bold leading-[25px]">
|
||||
AI-Edite
|
||||
</strong>
|
||||
</div>
|
||||
<div className="flex items-center pt-[6px]">
|
||||
<div
|
||||
className="ml-[7px] flex h-[24px] w-[26px] cursor-pointer items-center justify-center rounded-[4px] bg-transparent text-[#f4f4f4] hover:bg-white/10"
|
||||
title="帮助"
|
||||
onClick={() => setStatus("请先配置 API,再输入提示词生成图片")}
|
||||
>
|
||||
<Icon name="help" size={17} />
|
||||
</div>
|
||||
<div
|
||||
className="ml-[7px] flex h-[24px] w-[26px] cursor-pointer items-center justify-center rounded-[4px] bg-transparent text-[#f4f4f4] hover:bg-white/10"
|
||||
title="设置"
|
||||
onClick={() =>
|
||||
setActiveView(activeView === "settings" ? "generate" : "settings")
|
||||
}
|
||||
>
|
||||
<Icon name="settings" size={17} />
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{activeView === "settings" ? (
|
||||
<section className="w-full pt-[5px]">
|
||||
<label className="mb-[16px] block">
|
||||
<span className="flex h-[24px] items-center text-[13px] text-[#eeeeee]">
|
||||
API地址
|
||||
</span>
|
||||
<input
|
||||
className="h-[34px] w-full rounded-[6px] border-0 bg-[#2f2f2f] px-[10px] text-[12px] text-[#eeeeee] placeholder:text-[#898989]"
|
||||
value={settings.endpoint}
|
||||
onChange={(event) =>
|
||||
updateSetting("endpoint", event.target.value)
|
||||
}
|
||||
placeholder="请输入API地址"
|
||||
/>
|
||||
</label>
|
||||
<label className="mb-[16px] block">
|
||||
<span className="flex h-[24px] items-center text-[13px] text-[#eeeeee]">
|
||||
API Key
|
||||
</span>
|
||||
<input
|
||||
className="h-[34px] w-full rounded-[6px] border-0 bg-[#2f2f2f] px-[10px] text-[12px] text-[#eeeeee] placeholder:text-[#898989]"
|
||||
type="password"
|
||||
value={settings.apiKey}
|
||||
onChange={(event) => updateSetting("apiKey", event.target.value)}
|
||||
placeholder="请输入API-Key"
|
||||
/>
|
||||
</label>
|
||||
<label className="mb-[16px] block">
|
||||
<span className="flex h-[24px] items-center text-[13px] text-[#eeeeee]">
|
||||
模型
|
||||
</span>
|
||||
<span className="relative block w-full">
|
||||
<select
|
||||
className="h-[34px] w-full appearance-none rounded-[6px] border-0 bg-[#2f2f2f] px-[10px] text-[12px] text-[#eeeeee]"
|
||||
value={settings.model}
|
||||
onChange={(event) => updateSetting("model", event.target.value)}
|
||||
>
|
||||
{MODEL_OPTIONS.map((model) => (
|
||||
<option key={model.value} value={model.value}>
|
||||
{model.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
</label>
|
||||
<button
|
||||
className={`${PRIMARY_BUTTON} mt-[22px]`}
|
||||
onClick={saveSettings}
|
||||
>
|
||||
保存设置
|
||||
</button>
|
||||
</section>
|
||||
) : (
|
||||
<section className="w-full">
|
||||
<div className="mx-[2px] mb-[10px] flex h-[36px] items-center">
|
||||
<span className="flex-[0_0_39px] text-[13px] font-bold">模型</span>
|
||||
<span className={`relative ${SELECT_CLASS}`}>
|
||||
<select
|
||||
className={SELECT_FIELD}
|
||||
value={settings.model}
|
||||
onChange={(event) => updateSetting("model", event.target.value)}
|
||||
>
|
||||
{MODEL_OPTIONS.map((model) => (
|
||||
<option key={model.value} value={model.value}>
|
||||
{model.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</span>
|
||||
<div
|
||||
className="ml-[9px] flex h-[33px] flex-[0_0_41px] cursor-pointer items-center justify-center rounded-[5px] border-0 bg-[#2f2f2f] p-0 text-white hover:bg-[#3a3a3a]"
|
||||
title="刷新模型"
|
||||
onClick={() => setStatus("模型列表已刷新")}
|
||||
>
|
||||
<Icon name="refresh" size={17} />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="relative mb-[17px] h-[116px] overflow-y-auto rounded-[5px] bg-[#2f2f2f] p-[5px]"
|
||||
ref={editorRef}
|
||||
contentEditable
|
||||
onInput={(event) =>
|
||||
setPrompt(event.currentTarget.textContent || "")
|
||||
}
|
||||
onFocus={() => setIsPromptFocused(true)}
|
||||
onBlur={() => setIsPromptFocused(false)}
|
||||
suppressContentEditableWarning
|
||||
>
|
||||
{!prompt && !isPromptFocused && (
|
||||
<span className="pointer-events-none absolute left-[9px] top-[8px] text-[13px] font-semibold leading-[18px] text-[#a8a8a8]">
|
||||
描述提示词...
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="mb-[13px] flex h-[135px] w-full cursor-pointer flex-col items-center justify-center rounded-[5px] border-0 bg-[#2f2f2f] p-[12px] text-center text-[#e7e7e7] hover:bg-[#3a3a3a]"
|
||||
onClick={chooseReferenceImages}
|
||||
>
|
||||
<div className="mb-[10px] flex h-[30px] w-full items-center justify-center">
|
||||
<Icon name="upload" size={30} stroke="#b0b0b0" />
|
||||
</div>
|
||||
<div className="w-full overflow-hidden text-ellipsis whitespace-nowrap text-center text-[13px] font-bold leading-[19px] text-[#ececec]">
|
||||
{referenceFiles.length
|
||||
? `已上传 ${referenceFiles.length} 张参考图`
|
||||
: "点击或者拖拽上传参考图"}
|
||||
</div>
|
||||
<div className="mt-[8px] w-full overflow-hidden text-ellipsis whitespace-nowrap text-center text-[10px] leading-[14px] text-[#b6b6b6]">
|
||||
{referenceFiles.length
|
||||
? referenceFiles.map(fileName).join("、")
|
||||
: "(画布选区为图1,上传图依次为图2、图3...)"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mb-[4px] -mr-[15px] flex flex-wrap narrow:-mr-[9px]">
|
||||
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
|
||||
<span className={GROUP_LABEL}>数量</span>
|
||||
<SegmentedControl
|
||||
value={settings.count}
|
||||
onChange={(value) => updateSetting("count", value)}
|
||||
options={[
|
||||
{ label: "1张", value: 1 },
|
||||
{ label: "2张", value: 2 },
|
||||
{ label: "3张", value: 3 },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
|
||||
<span className={GROUP_LABEL}>画质</span>
|
||||
<SegmentedControl
|
||||
value={settings.quality}
|
||||
onChange={(value) => updateSetting("quality", value)}
|
||||
options={QUALITY_OPTIONS}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
|
||||
<span className={GROUP_LABEL}>背景</span>
|
||||
<SegmentedControl
|
||||
value={settings.background}
|
||||
onChange={(value) => updateSetting("background", value)}
|
||||
options={[
|
||||
{ label: "默认", value: "auto" },
|
||||
{ label: "透明底", value: "transparent" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex flex-col mb-[9px] mr-[15px] min-w-0 flex-[0_0_calc(50%_-_15px)] narrow:mr-[9px] narrow:flex-[0_0_calc(50%_-_9px)] gap-[5px]">
|
||||
<span className={GROUP_LABEL}>
|
||||
生图模式 <span className={MINI_HELP}>?</span>
|
||||
</span>
|
||||
<SegmentedControl
|
||||
value={settings.taskMode}
|
||||
onChange={(value) => updateSetting("taskMode", value)}
|
||||
options={[
|
||||
{ label: "默认", value: "default" },
|
||||
{ label: "多任务", value: "multi" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<button className={PRIMARY_BUTTON} onClick={generate}>
|
||||
{isBusy ? "生成中..." : "立即生成"}
|
||||
</button>
|
||||
|
||||
{results.length > 0 && (
|
||||
<div className="-mr-[6px] mt-[14px] flex flex-wrap">
|
||||
{results.map((result, index) => (
|
||||
<button
|
||||
className="mb-[6px] mr-[6px] block h-[84px] min-w-0 flex-[0_0_calc(25%_-_6px)] overflow-hidden rounded-[3px] border-0 bg-[#2f2f2f] p-0"
|
||||
key={`${index}-${result.src.slice(-16)}`}
|
||||
title="点击置入当前文档"
|
||||
onClick={() => placeResult(result, index)}
|
||||
>
|
||||
<img
|
||||
className="block h-full w-full object-cover"
|
||||
src={result.src}
|
||||
alt={`生成结果 ${index + 1}`}
|
||||
/>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
)}
|
||||
|
||||
<div
|
||||
className="mt-[7px] min-h-[14px] overflow-hidden text-ellipsis whitespace-nowrap text-center text-[9px] leading-[14px] text-[#e1e1e1]"
|
||||
aria-live="polite"
|
||||
>
|
||||
{status}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
};
|
||||
@@ -0,0 +1,22 @@
|
||||
module.exports = {
|
||||
content: ["./src/**/*.{js,jsx}"],
|
||||
theme: {
|
||||
extend: {
|
||||
colors: {
|
||||
accent: "#2f7cf6"
|
||||
},
|
||||
fontFamily: {
|
||||
// Rounded stack: first installed font wins, falling back to the
|
||||
// locally available Maple Mono NF CN for Chinese.
|
||||
sans: [
|
||||
"MiSans", "HarmonyOS Sans SC", "Nunito", "Quicksand", "Varela Round",
|
||||
"Maple Mono NF CN", "Microsoft YaHei UI", "Microsoft YaHei"
|
||||
]
|
||||
},
|
||||
screens: {
|
||||
narrow: { max: "340px" }
|
||||
}
|
||||
}
|
||||
},
|
||||
plugins: []
|
||||
};
|
||||
@@ -0,0 +1,44 @@
|
||||
const path = require("path");
|
||||
const CopyPlugin = require("copy-webpack-plugin");
|
||||
const MiniCssExtractPlugin = require("mini-css-extract-plugin");
|
||||
|
||||
module.exports = {
|
||||
entry: './src/index.jsx',
|
||||
output: {
|
||||
path: path.resolve(__dirname, 'dist'),
|
||||
filename: 'index.js'
|
||||
},
|
||||
target: 'web',
|
||||
devtool: 'eval-cheap-source-map',
|
||||
resolve: {
|
||||
extensions: [".js", ".jsx"]
|
||||
},
|
||||
module: {
|
||||
rules: [
|
||||
{
|
||||
test: /\.jsx?$/,
|
||||
exclude: /node_modules/,
|
||||
loader: "babel-loader",
|
||||
options: {
|
||||
plugins: [
|
||||
"@babel/transform-react-jsx",
|
||||
"@babel/proposal-object-rest-spread",
|
||||
"@babel/plugin-syntax-class-properties",
|
||||
]
|
||||
}
|
||||
},
|
||||
{
|
||||
test: /\.css$/,
|
||||
use: [MiniCssExtractPlugin.loader, "css-loader", "postcss-loader"]
|
||||
}
|
||||
]
|
||||
},
|
||||
plugins: [
|
||||
new MiniCssExtractPlugin({ filename: 'styles/panel.css' }),
|
||||
new CopyPlugin([
|
||||
'cep'
|
||||
], {
|
||||
copyUnmodified: true
|
||||
})
|
||||
]
|
||||
};
|
||||
Reference in New Issue
Block a user