بناء عنصر تحكم مخصص
الهدف
كتابة عنصر تحكم مخصص — وهو مكوّن React قابل لإعادة الاستخدام يحل محل المحرّر المدمج لحقل في نموذج مبني على البيانات — بدءًا من مجلد فارغ وصولًا إلى عنصر تحكم يستطيع المصمم ربطه بحقل.
عنصر التحكم المخصص هو الأداة الصحيحة حين يحتاج حقل إلى محرّر لا تقدّمه المنصة: لوحة توقيع، أو منتقي ألوان، أو تقييم بالنجوم، أو خريطة، أو قارئ باركود. وهو ليس الأداة المناسبة لشاشة كاملة — فتلك مهمة تطبيق React مخصص.
المتطلبات المسبقة
- Node.js 18 أو أحدث وnpm.
- إلمام بـ React 18 وTypeScript.
- وجود ناشر وحل في البيئة التي سيُثبَّت فيها عنصر التحكم. بادئة الناشر (مثل
par) جزء من الاسم المنطقي لعنصر التحكم. - قراءة بناء تطبيق React مخصص تفيد هنا: فعنصر التحكم يُستضاف بالطريقة نفسها التي يُستضاف بها التطبيق، والاختلاف في العقد فقط.
عقد عنصر ا لتحكم
عنصر التحكم المخصص هو مكوّن React واحد خصائصه ثابتة تحددها المنصة. ويعرضه Model-Driven App Player بدلًا من محرّر الحقل الأصلي، ويزوّده بالخصائص الأربع كلها:
interface ParswareControlProps {
/** The bound field's current value. Its type follows the field's data type. */
value: unknown;
/** Commit a new value back to the form. */
onChange: (next: unknown) => void;
/** The bound field's metadata — type, label, required flag, constraints. */
fieldMetadata: FieldMetadata;
/** The same auth/data/logging context a Custom React App receives. */
context: RuntimeContext;
}
تنبثق من هذا الشكل أربع قواعد، وعنصر التحكم الذي يخالف أيًا منها يُعدّ ناقصًا:
- عنصر التحكم مُدار من الخارج. فهو يعرض
valueويبلّغ عن التعديلات عبرonChange. ولا يحتفظ بالقيمة المعتمَدة في حالته الداخلية بوصفها مصدر الحقيقة، ولا يحفظ السجل — فالنموذج هو مالك عملية الحفظ. - يقرأ عنصر التحكم بيانات وصف حقله بدل افتراضها. فالتسمية، وعلامة الإلزام، والطول
الأقصى، والجدول الهدف لحقل البحث، واسم مجموعة الخيارات، كلها تصل في
fieldMetadata. وعنصر التحكم الذي يكتب التسمية داخل شيفرته هو عنصر تحكم يصلح للاستخدام مرة واحدة فقط. - لا يصل عنصر التحكم إلى المنصة إلا عبر
context. وهو الواجهةParswareRuntimeنفسها التي يحصل عليها التطبيق —context.dataللقراءة، وcontext.logللتسجيل. ولا استدعاءات HTTP مباشرة. - يتدهور عنصر التحكم بلطف بدل أن يطلق استثناءً. فهو مُركَّب داخل نموذج يخص جهة أخرى؛ والاستثناء يُسقط السجل بأكمله معه. احترس من واجهة متصفح مفقودة، ومن نوع قيمة غير متوقع، ومن قيمة فارغة.
الخطوات
1. إنشاء المشروع
يُهيَّأ المشروع تمامًا كمشروع تطبيق React مخصص — package.json وtsconfig.json وindex.html
وsrc/vite-env.d.ts وvite.config.ts — انظر الخطوة 1 من
بناء تطبيق React مخصص. أما Runtime SDK وأداة
التحزيم فتنزيلان من موقع المساعدة هذا، ويُثبَّتان بعنوانهما:
{
"name": "signature-pad",
"type": "module",
"scripts": {
"dev": "vite",
"build": "tsc && vite build",
"test": "vitest run",
"pack": "node node_modules/@parsware/pack/src/cli.mjs"
},
"dependencies": {
"@parsware/runtime-sdk": "https://docs.parsware.com/sdk/parsware-runtime-sdk-1.0.0.tgz",
"react": "^18.3.1",
"react-dom": "^18.3.1"
},
"devDependencies": {
"@parsware/pack": "https://docs.parsware.com/sdk/parsware-pack-1.0.0.tgz"
}
}
سكربت pack هو ما تشغّله الخطوة ٨. تعريفه هنا يعني أن أداة التحزيم تُستدعى بالطريقة نفسها في
كل مشروع وعلى كل جهاز — npm run pack — بدل أن يعتمد ذلك على مكان تثبيت الأداة. وهو يستدعي
ملف الأداة مباشرةً بدل غلاف التنفيذ parsware-pack، لأن تشغيل الغلاف عملية exec، وقد
تُرفض لأسباب لا علاقة لها بمشروعك — وأشيعها Gatekeeper في macOS، الذي يعزل الملفات المستخرجة
من أرشيف مُنزَّل ثم يبلّغ bad interpreter: Operation not permitted.
2. ضبط Module Federation
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
import { federation } from '@module-federation/vite';
// The control reuses the Shell's React and Runtime SDK instances; its bundle ships
// no copy of either. Without `singleton: true`, a second React would break hooks.
const sharedSingletons = {
react: { singleton: true, requiredVersion: '^18.3.1' },
'react-dom': { singleton: true, requiredVersion: '^18.3.1' },
'@parsware/runtime-sdk': { singleton: true },
};
export default defineConfig({
plugins: [
react(),
federation({
name: 'par_signature_pad', // the container's name: unique per environment
filename: 'remoteEntry.js',
exposes: { './Control': './src/Control.tsx' },
shared: sharedSingletons,
manifest: true, // writes dist/mf-manifest.json, which the packer reads
}),
],
build: { target: 'esnext' },
});
بحسب العرف يعرض عنصر التحكم ./Control حيث يعرض التطبيق ./App. ولا يكتبه أحد في أي مكان:
تقرؤه أداة التحزيم من mf-manifest.json، ولهذا فإن manifest: true ليس اختياريًا.
3. كتابة عنصر التحكم
import { useEffect, useRef, useState, type PointerEvent } from 'react';
import type { ParswareControlProps } from '@parsware/runtime-sdk';
import './index.css';
/**
* A signature pad bound to a text field. The committed value is a PNG data URL, so
* the field it binds to is an ordinary text field — the control decides what the
* text means, the platform stores it unchanged.
*/
export default function Control({ value, onChange, fieldMetadata, context }: ParswareControlProps) {
const canvasRef = useRef<HTMLCanvasElement | null>(null);
const drawing = useRef(false);
const [hasInk, setHasInk] = useState(false);
// 1. Announce the mount through the platform's log — a control reaches platform
// services only through `context`.
useEffect(() => {
context.log('debug', 'Signature pad mounted', { field: fieldMetadata.logicalName });
}, [context, fieldMetadata.logicalName]);
// 2. Begin a stroke. A missing 2D context is a no-op rather than a throw, so the
// control degrades on a canvas-less environment instead of breaking the form.
const startStroke = (e: PointerEvent<HTMLCanvasElement>) => {
const ctx = canvasRef.current?.getContext('2d');
if (!ctx) return;
drawing.current = true;
const rect = e.currentTarget.getBoundingClientRect();
ctx.beginPath();
ctx.moveTo(e.clientX - rect.left, e.clientY - rect.top);
};
// 3. Commit the drawing to the form when the stroke ends. `onChange` is the only
// way a value reaches the record — the control never saves anything itself.
const endStroke = () => {
if (!drawing.current) return;
drawing.current = false;
const dataUrl = canvasRef.current?.toDataURL('image/png');
if (dataUrl) onChange(dataUrl);
};
// 4. Clearing is an edit like any other: wipe the canvas, commit an empty value.
const clear = () => {
const canvas = canvasRef.current;
const ctx = canvas?.getContext('2d');
if (canvas && ctx) ctx.clearRect(0, 0, canvas.width, canvas.height);
setHasInk(false);
onChange('');
};
const signed = typeof value === 'string' && value.length > 0;
return (
<div className="signature-pad">
<canvas
ref={canvasRef}
width={320}
height={120}
role="img"
aria-label={`${fieldMetadata.displayName} signature canvas`}
onPointerDown={startStroke}
onPointerUp={endStroke}
onPointerLeave={endStroke}
/>
<div className="signature-pad__row">
<span>
{signed || hasInk ? 'Signed' : 'Not signed'}
{/* The required marker comes from metadata, not from a hard-coded assumption. */}
{fieldMetadata.isRequired && !signed && !hasInk ? ' — required' : ''}
</span>
<button type="button" onClick={clear}>Clear</button>
</div>
{/* Re-opening a saved record must show the stored signature. */}
{signed && <img src={value as string} alt={`${fieldMetadata.displayName} signature`} />}
</div>
);
}