عرض محتوى لوحة الرسم لميزات المتصفّح

ميزات الويب: HTML في لوحة الرسم

لا يتم عرض محتوى <canvas> العادي لميزات المتصفّح، مثل برامج قراءة الشاشة والفهرسة وأدوات الترجمة وأدوات تسهيل الاستخدام والبحث في الصفحة والطباعة وما إلى ذلك. باستخدام HTML in canvas، يمكنك عرض نموذج DOM الحقيقي مباشرةً في عنصر لوحة الرسم. تؤدي إضافة السمة content="drawable" إلى عنصر HTML ‏<canvas> إلى تخطيط عناصر HTML التابعة له، وتضع السمة drawable علامة على العناصر التي يمكنك عرضها ضمن سياق عرض اللوحة. يمكنك استخدامها لتنسيق النص وتخطيطه في لوحة الرسم، وعرض محتوى اللوحة لميزات المتصفّح (مثل أدوات تسهيل الاستخدام أو الترجمة أو البحث في الصفحة)، وتطبيق تأثيرات ثنائية وثلاثية الأبعاد على HTML.

كيفية الإعداد

  1. تحقَّق مما إذا كان المتصفّح يتيح استخدام HTML-in-Canvas:

    if ('content' in HTMLCanvasElement.prototype) {
      // Use HTML in Canvas API
    } else {
      // Use fallback strategy
    }
    
  2. أضِف السمة content="drawable" إلى عنصر HTML‏ <canvas>.

  3. أضِف محتوى HTML داخل العنصر <canvas>، وأضِف السمة drawable إلى كل عنصر ترسمه. يلتقط العنصر الذي يتضمّن السمة drawable شجرته الفرعية، باستثناء العناصر التابعة المدمجة التي تتضمّن أيضًا السمة drawable.

    <canvas id="canvas" content="drawable">
      <div id="html-content" drawable></div>
    </canvas>
    
  4. اضبط شبكة لوحة الرسم لتتطابق مع عامل قياس الجهاز من أجل منع التمويه:

    const observer = new ResizeObserver(([entry]) => {
      const dpc = entry.devicePixelContentBoxSize;
      canvas.width = dpc
        ? dpc[0].inlineSize
        : Math.round(entry.contentRect.width * window.devicePixelRatio);
      canvas.height = dpc
        ? dpc[0].blockSize
        : Math.round(entry.contentRect.height * window.devicePixelRatio);
    });
    
    const supportsDevicePixelContentBox =
      typeof ResizeObserverEntry !== "undefined" &&
      "devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
    const options = supportsDevicePixelContentBox
      ? { box: "device-pixel-content-box" }
      : {};
    observer.observe(canvas, options);
    
  5. اعرض محتوى HTML على اللوحة داخل معالج الأحداث canvas.onpaint. لا يمكن رسم عنصر إلا بعد أن ترسمه اللوحة: يمكنك استدعاء canvas.requestPaint() لطلب حدث paint.

  • في سياق ثنائي الأبعاد، استخدِم طريقة drawElementImage. يرسم آخر لقطة لعنصر drawable وشجرة فرعية قابلة للرسم، بدءًا من مربّع حدود العنصر، قبل تحويلات CSS:
canvas.onpaint = () => {
  ctx.reset();
  // Draw the form element at x:0, y:0
  ctx.drawElementImage(form_element, 0, 0);
};
  • في سياقات WebGL وWebGPU، يمكنك نسخ العنصر إلى مادة. يمكنك تحديد حجم الزخرفة من canvas.captureElementImage(element). تكون السمتان width وheight بوحدات بكسل شبكة لوحة الرسم (مخزن احتياطي)، لذا يجب تقريبهما باستخدام Math.ceil(). يتم عرض captureElementImage() إذا لم يتوفّر للعنصر سجلّ رسم بعد: يمكنك استدعاء requestPaint() وإعادة المحاولة في حدث paint التالي.
// Returns the element's size in canvas grid pixels, or null if the element
// has no paint record yet.
function getElementImageSize(canvas, element) {
  let elementImage;
  try {
    elementImage = canvas.captureElementImage(element);
  } catch (err) {
    canvas.requestPaint(); // No paint record yet: retry on the next paint event.
    return null;
  }
  const width = Math.ceil(elementImage.width);
  const height = Math.ceil(elementImage.height);
  elementImage.close();
  return width > 0 && height > 0 ? { width, height } : null;
}
  • في سياق WebGL، يمكنك تخصيص دعم الزخرفة مسبقًا أولاً باستخدام طريقة texImage2D. تستخدم المقتطفات في هذا الدليل سياق WebGL 2 (canvas.getContext("webgl2")). يمكنك تخصيص مساحة تخزين الزخرفة من خلال استدعاء texImage2D() باستخدام null بدلاً من بيانات وحدات البكسل، ما يؤدي إلى حجز ذاكرة وحدة معالجة الرسومات بالحجم والتنسيق المحدّدَين بدون تحميل أي وحدات بكسل. اتّبِع القواعد التالية:
    • اضبط حجم الزخرفة من captureElementImage()، كما هو موضّح أعلاه.
    • يجب تخصيص المستوى 0 فقط عند التحميل الأول أو عند تغيير الحجم، لأنّ إعادة التخصيص تؤدي إلى محو الزخرفة. استخدِم مساحة تخزين قابلة للتغيير: لا يمكن تغيير حجم مساحة التخزين غير القابلة للتغيير texStorage2D() عند تغيير حجم العنصر.
// Binds the texture, and allocates its storage on the first upload, and when
// the element size changes. `state` stores the allocated size.
function allocateElementTexture(gl, texture, state, width, height) {
  gl.bindTexture(gl.TEXTURE_2D, texture);
  if (state.width === width && state.height === height) return;

  gl.texImage2D(
    gl.TEXTURE_2D,
    0,                // Mipmap level
    gl.RGBA8,         // Internal format: 8 bits per RGBA channel
    width,            // Size in canvas grid pixels
    height,
    0,                // Border: must be 0
    gl.RGBA,          // Format and type that match RGBA8
    gl.UNSIGNED_BYTE,
    null              // null reserves GPU memory without uploading pixels
  );
  gl.texParameteri(gl.TEXTURE_2D, gl.TEXTURE_MIN_FILTER, gl.LINEAR);
  state.width = width;
  state.height = height;
}

const texture = gl.createTexture();
const textureState = { width: 0, height: 0 };
  • بعد ذلك، في سياق WebGL، اعرض محتوى HTML على النسيج باستخدام طريقة texElementSubImage2D. اتّبِع القواعد التالية:
    • مرِّر { width, height } في الإعدادات. ويؤدي ذلك إلى ملء النسخة بالبنية المخصّصة بغض النظر عن كيفية تغيير حجم لوحة الرسم.
    • أعِد تخصيص النسيج إذا تغيّر الحجم، كما هو موضّح أعلاه.
// Uploads the element into the texture. Returns false if the element has no
// paint record yet. In that case, it's uploaded on the next paint event.
function uploadElementWebGL(gl, canvas, texture, element, state) {
  const size = getElementImageSize(canvas, element);
  if (!size) return false;
  const { width, height } = size;

  // Bind the texture, and reallocate it if the size changed (see above).
  allocateElementTexture(gl, texture, state, width, height);
  gl.texElementSubImage2D(gl.TEXTURE_2D, 0, 0, 0, element, { width, height });
  return true;
}

canvas.onpaint = () => {
  try {
    uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
  } catch (err) {
    console.error('texElementSubImage2D copy failed:', err);
  }
};
  • في سياق WebGPU، استخدِم طريقة drawElementImageToTexture. اتّبِع القواعد التالية:
    • أنشئ المادة باستخدام COPY_DST وRENDER_ATTACHMENT، وأضِف TEXTURE_BINDING لأخذ عيّنات منها في أدوات التظليل. يتم الإبلاغ عن الاستخدام الخاطئ كخطأ في التحقّق من WebGPU، وليس كاستثناء.
    • يجب ضبط حجم الزخرفة على size. بدون size، يتم اقتصاص عنصر أكبر حجمًا ليتناسب مع الهيئة. باستخدام size، يتم تغيير حجم العنصر لملئه.
    • لا يمكن تغيير حجم زخارف WebGPU. أعِد إنشاء الزخرفة عند تغيير حجم العنصر، وأعِد إنشاء مجموعات الربط التي تشير إليها.
// WebGPU textures can't be resized. Create the texture once, and recreate it
// when the element size changes.
function ensureElementTexture(device, state, width, height) {
  if (state.texture?.width === width && state.texture?.height === height) {
    return state.texture;
  }
  state.texture?.destroy();
  state.texture = device.createTexture({
    size: { width, height },
    format: "rgba8unorm",
    usage:
      GPUTextureUsage.TEXTURE_BINDING |
      GPUTextureUsage.COPY_DST |
      GPUTextureUsage.RENDER_ATTACHMENT,
  });
  return state.texture; // Recreate the bind groups that used the previous texture.
}

const textureState = { texture: null };

canvas.onpaint = () => {
  const size = getElementImageSize(canvas, uiElement);
  if (!size) return;
  const previousTexture = textureState.texture;
  const texture = ensureElementTexture(device, textureState, size.width, size.height);
  if (texture !== previousTexture) {
    // New texture: create, or rebuild, the bind groups that reference it.
  }
  try {
    device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
  } catch (err) {
    console.error('drawElementImageToTexture copy failed:', err);
  }
};

عند استخدام حلقة تكرارية requestAnimationFrame لعرض المشهد، يمكنك استدعاء canvas.requestPaint() داخل الحلقة لضمان عرض محتوى HTML على اللوحة. تعرض السمة event.changedElements العناصر التي تغيّر عرضها. تأكَّد من إعادة رسم العنصر فقط أو إعادة تحميل بنيته في حال تم تعديل عناصر HTML التابعة:

function render() {
  // Request to update the canvas
  canvas.requestPaint();
  requestAnimationFrame(render);
}
requestAnimationFrame(render);

canvas.onpaint = (event) => {
  if (event.changedElements && event.changedElements.length > 0) {
    // Redraw the element, or update its texture, as shown above
  }
  // In WebGL and WebGPU, render the scene, and sync the element geometry as shown in step 6
};
  1. مزامنة هندسة العناصر

يحتاج المتصفّح إلى معرفة مكان رسم عنصر HTML، لكي يتطابق موضع DOM مع وحدات البكسل المرسومة. يستخدم اختبار مؤشر الماوس، على سبيل المثال، للنقرات واختيار النص، وأدوات تسهيل الاستخدام هذه الهندسة: لا يزال عنصر drawable الذي لم يتم تعديل هندسته معروضًا في شجرة تسهيل الاستخدام، ولكن بدون معلومات هندسية. يختبر عنصر canvas مؤشر الماوس فقط على العناصر التي يتم رسمها باستخدام طريقة drawElementImage() ثنائية الأبعاد أو المسجّلة باستخدام updateElementGeometry().

  • بالنسبة إلى حالة السياق الثنائي الأبعاد، يزامن المتصفّح العنصر تلقائيًا. تتم محاذاة حدود اختبار مؤشر الماوس في نموذج DOM وحلقات تركيز قارئ الشاشة مع الإحداثيات المرسومة في drawElementImage()، لذا لا حاجة إلى ضبط style.transform. لإدارة الشكل الهندسي يدويًا، على سبيل المثال، عند رسم العنصر نفسه أكثر من مرة، يمكنك تمرير { preserveElementGeometry: true } إلى drawElementImage()، وتطبيق عمليات التحويل باستخدام canvas.updateElementGeometry().

  • بالنسبة إلى الحالة الثلاثية الأبعاد مع WebGL أو WebGPU، لا يمكن للوحة الرسم معرفة مكان رسم عنصر HTML، لذا عليك إخبارها بذلك. احتسِب DOMMatrix يربط مربّع حدود العنصر، بوحدات بكسل CSS، بلوحة العرض، بوحدات بكسل CSS. يحتاج المتصفّح إلى الربط بين مساحة الإحداثيات الثلاثية الأبعاد ومساحة إحداثيات CSS باستخدام تحويل إطار العرض. لتسهيل ذلك، اتّبِع الخطوات التالية:

    • تطبيع عنصر HTML. يتم تحديد حجم عناصر HTML بالبكسل (على سبيل المثال، عرض 200 بكسل). ومع ذلك، يتعامل WebGL عادةً مع العناصر على أنّها "مربّعات وحدات"، على سبيل المثال، تتراوح من -0.5 إلى 0.5. إذا لم تقم بالتسوية، فسيظهر الزر الذي يبلغ حجمه 200 بكسل أكبر بمقدار 200 مرة. تعمل هذه الخطوة أيضًا على قلب المحور Y، لأنّه في CSS، يكون الاتجاه لأسفل موجبًا، ولكن في WebGL، يكون الاتجاه لأعلى موجبًا.
    • تحويل مصفوفة WebGL MVP إلى مصفوفة DOM
    • الربط بإطار عرض اللوحة هذه الخطوة هي مرحلة "إعادة تغيير الحجم": فهي تمدد حسابات مساحة الوحدة مرة أخرى لتتطابق مع أبعاد بكسل CSS لعنصر <canvas> على الشاشة، وتقلب المحور Y مرة أخرى.
    • احسب التحويل النهائي. اضرب المصفوفات بالترتيب: إطار العرض * MVP * التسوية. وعند دمجها في تحويل نهائي واحد، يتم إنشاء "خريطة" تخبر المتصفّح بالمكان الذي يجب أن يظهر فيه عنصر HTML بالضبط ليتوافق مع الرسم الثلاثي الأبعاد. ينفّذ المتصفّح عملية القسمة المنظورية.
    // Maps the element's border box (CSS pixels) to the canvas (CSS pixels), for a
    // quad that spans -0.5 to 0.5 in model space and is drawn with the `mvp` matrix.
    function computeCanvasTransform(canvas, element, mvp) {
      const width = element.offsetWidth;
      const height = element.offsetHeight;
    
      // 1. Normalize the HTML element (CSS pixels -> WebGL Model Space)
      const toGLModel = new DOMMatrix()
        // Scale pixels to 1 unit, flip Y (as in CSS it points down, and in WebGL it points up)
        .scale(1 / width, -1 / height, 1 / height)
        // Center the origin: (0,0) becomes (-width/2, -height/2) before scaling
        .translate(-width / 2, -height / 2);
    
      // 2. Convert WebGL MVP Matrix to DOM Matrix
      const mvpDOM = new DOMMatrix(Array.from(mvp));
    
      // 3. Map to the canvas viewport, in CSS pixels
      const clipToCanvasViewport = new DOMMatrix()
        // Move center (0,0) to center of canvas
        .translate(canvas.clientWidth / 2, canvas.clientHeight / 2)
        // Scale normalized clip (-1..1) to viewport size, and flip Y back
        .scale(canvas.clientWidth / 2, -canvas.clientHeight / 2, canvas.clientHeight / 2);
    
      // 4. Multiply: (Clip -> Pixels) * (MVP) * (pixels -> unit square)
      return clipToCanvasViewport.multiply(mvpDOM).multiply(toGLModel);
    }
    

بعد ذلك، مرِّر التحويل إلى canvas.updateElementGeometry() على أنّه canvasTransform. يؤدي ذلك إلى نقل عنصر HTML ليكون فوق وحدات البكسل المعروضة مباشرةً. يضمن ذلك أنّه عندما ينقر المستخدم على زر أو يختار نصًا، فإنّه ينقر فعليًا على عنصر HTML الحقيقي. يستخدم المتصفّح أيضًا التحويل لحدود تسهيل الاستخدام للعنصر، على سبيل المثال، لحلقات تركيز قارئ الشاشة:

  canvas.updateElementGeometry(targetHTMLElement, {
    canvasTransform: computeCanvasTransform(canvas, targetHTMLElement, htmlElementMVP),
  });

استخدِم هذه الخيارات للتحكّم في اختبار مؤشر الماوس:

  • يؤدي كل استدعاء لـ updateElementGeometry() إلى نقل العنصر إلى أعلى ترتيب اختبار مؤشر الماوس. تجاوز "{ preserveHitTestOrder: true }" للحفاظ على مركزه.
  • يمكنك استدعاء canvas.clearElementGeometry(element) لإزالة عنصر من اختبار مؤشر الماوس، على سبيل المثال، عند إخفائه.

مثال على الرمز

لوحة رسم ثنائية الأبعاد

<canvas id="canvas" content="drawable" style="width: 400px; height: 200px;">
  <div id="ui-element" drawable>
    <p>
      This text is rendered inside the canvas but is present in the DOM tree.
    </p>
    <input type="email" name="email" placeholder="enter your email" />
    <button type="button">Submit</button>
  </div>
</canvas>

<script>
  const canvas = document.getElementById("canvas");
  const ctx = canvas.getContext("2d");
  const uiElement = document.getElementById("ui-element");

  canvas.onpaint = () => {
    ctx.reset();
    // Draw the HTML element at x:0, y:0. The browser syncs the DOM location
    // with the drawn location.
    ctx.drawElementImage(uiElement, 0, 0);
  };

  // Handle resizing to match device pixels
  const observer = new ResizeObserver(([entry]) => {
    const dpc = entry.devicePixelContentBoxSize;
    canvas.width = dpc
      ? dpc[0].inlineSize
      : Math.round(entry.contentRect.width * window.devicePixelRatio);
    canvas.height = dpc
      ? dpc[0].blockSize
      : Math.round(entry.contentRect.height * window.devicePixelRatio);
    canvas.requestPaint();
  });

  const supportsDevicePixelContentBox =
    typeof ResizeObserverEntry !== "undefined" &&
    "devicePixelContentBoxSize" in ResizeObserverEntry.prototype;
  const options = supportsDevicePixelContentBox
    ? { box: "device-pixel-content-box" }
    : {};
  observer.observe(canvas, options);
</script>

لوحة WebGL

<canvas id="canvas" content="drawable" style="width: 400px; height: 400px;">
  <div id="ui-element" drawable>
    <p>WebGL UI Element</p>
    <button>Action</button>
  </div>
</canvas>

<script>
  const canvas = document.getElementById("canvas");
  const gl = canvas.getContext("webgl2");
  const uiElement = document.getElementById("ui-element");

  // getElementImageSize(), allocateElementTexture(), and uploadElementWebGL()
  // are defined in step 5.
  // computeCanvasTransform() is defined in step 6.

  // Setup WebGL texture...
  const texture = gl.createTexture();
  gl.bindTexture(gl.TEXTURE_2D, texture);
  const textureState = { width: 0, height: 0 };

  canvas.onpaint = () => {
    // 1. Update texture with HTML content
    try {
      uploadElementWebGL(gl, canvas, texture, uiElement, textureState);
    } catch (err) {
      console.error('texElementSubImage2D copy failed:', err);
    }

    // ... Render your 3D scene here, calculating htmlElementMVP matrix ...

    // 2. Sync DOM position, hit testing, and accessibility bounds with 3D scene
    canvas.updateElementGeometry(uiElement, {
      canvasTransform: computeCanvasTransform(canvas, uiElement, htmlElementMVP),
    });
  };
</script>

لوحة WebGPU

<canvas id="canvas" content="drawable" style="width: 400px; height: 400px;">
  <div id="ui-element" drawable>
    <p>WebGPU UI Element</p>
  </div>
</canvas>

<script>
  const canvas = document.getElementById("canvas");
  const context = canvas.getContext("webgpu");
  const uiElement = document.getElementById("ui-element");

  // getElementImageSize() and ensureElementTexture() are defined in step 5.
  // computeCanvasTransform() is defined in step 6.

  // Setup WebGPU...
  // const device = ...
  const textureState = { texture: null };

  canvas.onpaint = () => {
    // 1. Copy HTML content to a texture that matches the element size
    const size = getElementImageSize(canvas, uiElement);
    if (size) {
      const previousTexture = textureState.texture;
      const texture = ensureElementTexture(device, textureState, size.width, size.height);
      if (texture !== previousTexture) {
        // New texture: create, or rebuild, the bind groups that reference it.
      }
      try {
        device.queue.drawElementImageToTexture({ source: uiElement }, { texture, size });
      } catch (err) {
        console.error('drawElementImageToTexture copy failed:', err);
      }
    }

    // ... Render your 3D scene here, calculating htmlElementMVP matrix ...

    // 2. Sync DOM position, hit testing, and accessibility bounds (same matrix math as WebGL)
    canvas.updateElementGeometry(uiElement, {
      canvasTransform: computeCanvasTransform(canvas, uiElement, htmlElementMVP),
    });
  };
</script>

أفضل الممارسات

  • إجراء إلزامي: تحقَّق من توافق المتصفّح مع واجهة HTML-in-Canvas API قبل استخدامها.
  • إلزامي: أضِف دائمًا السمة content="drawable" إلى العنصر <canvas>.
  • إجراء إلزامي: أضِف السمة drawable إلى كل عنصر ترسمه، بما في ذلك العناصر الفرعية المباشرة للّوحة.
  • إلزامي: استخدِم معالج أحداث onpaint لعرض محتوى HTML على اللوحة.
  • إجراء إلزامي: استخدِم طريقة drawElementImage في البُعد الثنائي أو drawElementImageToTexture في WebGPU لعرض محتوى HTML على اللوحة.
  • إجراء إلزامي: في WebGL، يجب تخصيص مخزن مؤقت للزخرفة مسبقًا باستخدام texImage2D() مرة واحدة (عند الإعداد أو عند تغيير حجم العنصر). يجب إعادة تخصيص الزخرفة فقط عند تغيير الحجم، وضبط TEXTURE_MIN_FILTER على LINEAR.
  • إلزامي: في WebGL، استخدِم طريقة texElementSubImage2D لعرض محتوى HTML في مادة يتم تخصيصها مسبقًا باستخدام texImage2D()، بحجم من captureElementImage().
  • إجراء إلزامي: في WebGPU، يجب تحديد حجم النسيج من captureElementImage()، وإعادة إنشائه فقط عند تغيير الحجم. أنشئ النسيج باستخدام COPY_DST وRENDER_ATTACHMENT، وأدخِل حجم النسيج على أنّه size.
  • إجراء إلزامي: يجب مزامنة هندسة عنصر HTML مع المحتوى المعروض. في الوضع الثنائي الأبعاد، يتم تنفيذ ذلك تلقائيًا من خلال "drawElementImage". في WebGL وWebGPU، يمكنك استدعاء canvas.updateElementGeometry(element, { canvasTransform }).
  • إجراء إلزامي: استخدِم ResizeObserver لمراقبة حجم الشاشة وتعديل حجم اللوحة ليتطابق مع وحدات بكسل الجهاز.
  • لا تضمِّن محتوى متعدّد المصادر في لوحة رسم، لأنّ ذلك غير متاح.
  • لا تبدأ ResizeObserver ضمن معالِج الأحداث onpaint، لأنّ ذلك قد يؤدي إلى تسرُّب الذاكرة.

استراتيجيات احتياطية

لا تتوفّر واجهة HTML-in-Canvas API حاليًا في جميع المتصفّحات الحديثة، لذا عادةً ما تكون هناك حاجة إلى استراتيجية احتياطية.

ومع ذلك، نظرًا لمزايا الأداء المحسّنة لواجهة برمجة التطبيقات هذه، يجب استخدام HTML-in-Canvas إذا كان المتصفّح يتيح ذلك.

تعتمد استراتيجية الاحتياط على حالة الاستخدام. على سبيل المثال، بالنسبة إلى محتوى HTML التفاعلي في لوحة الرسم، إذا لم يكن HTML-in-Canvas متوافقًا، يمكنك وضع محتوى HTML في أعلى لوحة الرسم باستخدام CSS.

‫HTML-in-Canvas polyfills

استخدِم نص polyfill البرمجي التالي لمحاكاة واجهة برمجة التطبيقات HTML-in-Canvas في المتصفحات التي لا تتيح استخدامها.

  1. ثبِّت المكتبة أو ضمِّنها:

    # Install
    npm install three-html-render
    
    # Embed
    <script src="https://cdn.jsdelivr.net/npm/three-html-render/dist/polyfill.js"></script>
    
  2. شغِّل طريقة installHtmlInCanvasPolyfill() لترجمة HTML-in-Canvas.