Creating a custom image composer or editor with Vue.js

- Andrés Cruz - ES En español

Video thumbnail

I am going to show you how to build a simple custom image editor or compositor. The main goal of this tool is to maintain a consistent graphic line for video thumbnails and visual brand content: clean backgrounds (usually white), high-contrast text, containers with gradients, brush stroke fragments, and representative logos.

Although it is possible to use traditional editing software like GIMP, having a pre-configured web tool greatly speeds up the workflow. This utility is part of a broader "digital brain" ecosystem, which integrates assistants for generating video templates, hyperframe structures, and multimedia asset automation.

From static Python scripts to dynamic interfaces with Vue and HTML5

Initially, the generation of these images was done through a Python script using visual processing libraries. The idea was to automate canvas creation, place text dynamically, and overlay logos.

def generate_clean_text_thumbnail(
    raw_text: str,
    brush_text: str = "",
    brush_color: str = "#E52B20",
    brush_text_color: str = "#0F0F0F",
    border_color: str = "#5A00E0",
    logo_path: str | None = None,
    start_margin_y: int = 120,
    right_half_start_x: int = 960,
    logo_max_width: int = 1300,
    logo_max_height: int = 1300,
    logo_resize_width: int = 900,
    font_size: int = 260,
    word_spacing: int = 10,
    line_spacing: int = 0,
    show_opening_quote: bool = True,
    show_closing_quote: bool = True,
) -> str:
    """Generates the thumbnail by positioning a logo on the right half and the text
    overlaid on top of it on the left side."""

    # --------------------------------------------------------------------------
    # ⚙️ CONSTANTS AND DESIGN PARAMETERS
    # --------------------------------------------------------------------------
    canvas_width = 1920
    canvas_height = 1080

    # Canvas margins
    outer_border_margin = 45

    # ️ Logo Parameters (Right Half)

    # Typography
    font_path = "app/static/fonts/SUSE-ExtraBold.ttf"

    # Badges and Spacing
    badge_padding_x = 25
    badge_padding_y = 12
    badge_corner_radius = 12

    line_rotation_angle = 3  # text inclination

    output_dir = "app/static/outputs"

    # --------------------------------------------------------------------------
    #  PROCESSING
    # --------------------------------------------------------------------------
    # 1. Create the base canvas with the color frame
    canvas = Image.new("RGBA", (canvas_width, canvas_height), border_color)
    draw = ImageDraw.Draw(canvas)

    # 2. Draw the inner white canvas
    draw.rectangle(
        [
            outer_border_margin,
            outer_border_margin,
            canvas_width - outer_border_margin,
            canvas_height - outer_border_margin,
        ],
        fill=(255, 255, 255, 255),
    )

    # --- Usage in your main function ---
    # Generate the accent burst in the desired color (e.g., red or yellow)
    # burst = draw_emphasis_burst(color="#E52B20")

    # Paste it in the upper left corner of the main box
    # canvas.paste(burst, (start_margin_x - 80, start_margin_y - 100), burst)

    # --------------------------------------------------------------------------
    # ️ 3. PASTE THE LOGO ON THE RIGHT HALF (Underneath the text)
    # --------------------------------------------------------------------------

    logo_file = f"app/static/imgs/logos/{logo_path.lower()}.png" if logo_path else None

    if logo_file and os.path.exists(logo_file):
        try:
            logo_img = Image.open(logo_file).convert("RGBA")

            if logo_resize_width > 0:
                # Manual mode: fixed width, proportional height. Authoritative,
                # not rescaled afterwards (user controls the size).
                ratio = logo_resize_width / logo_img.width
                logo_resize_height = max(1, int(logo_img.height * ratio))
                logo_img = logo_img.resize(
                    (logo_resize_width, logo_resize_height), Image.Resampling.LANCZOS
                )
            else:
                # Automatic mode: fit to real area of right half
                logo_img.thumbnail((logo_max_width, logo_max_height), Image.Resampling.LANCZOS)
                right_area_width = canvas_width - right_half_start_x - outer_border_margin
                right_area_height = canvas_height - (outer_border_margin * 2)
                if logo_img.width > right_area_width or logo_img.height > right_area_height:
                    logo_img.thumbnail(
                        (right_area_width, right_area_height), Image.Resampling.LANCZOS
                    )

            right_area_width = canvas_width - right_half_start_x - outer_border_margin
            right_area_height = canvas_height - (outer_border_margin * 2)

            logo_x = right_half_start_x + (right_area_width - logo_img.width) // 2
            logo_y = outer_border_margin + (right_area_height - logo_img.height) // 2

            # Paste logo using its own alpha channel as mask
            canvas.paste(logo_img, (logo_x, logo_y), logo_img)
        except Exception as e:
            print(f"Error loading logo {logo_file}: {e}")
            
            ***

However, the Python approach presented serious limitations:

  • Lack of interactivity: To adjust a font size, move an element, or fix a line break (\n), it was necessary to re-run the script and generate a physical file on disk.
  • Layout complexity: Calculating fixed coordinates, random rotations, margins, and text alignments on a static canvas is tedious and inefficient.

Because of this, the solution was to migrate toward a web approach using HTML5, CSS3, JavaScript, and Vue.js to manage interactivity and application state.

Architecture and technologies of the web compositor

The web version acts as an interactive compositor on top of a Canvas/DOM, offering full control over design layers. The stack used consists of:

  • Vue.js: For reactive state management, canvas properties, layer listing, and interactivity.
  • Tailwind CSS: For overall interface layout and applying native visual filters (brightness, contrast, shadows).
  • Interact.js: To quickly and fluidly implement drag-and-drop functionality and element repositioning.
  • html2canvas / html-to-image: To render the graphic composition and export the final result directly to an image file (PNG or JPEG).

Advantages of using dynamic SVG files

One of the greatest advantages of using the web stack over raster images in Python is manipulating SVG vectors.

When loading an SVG file (such as brush strokes or text frames), it is not simply linked as a static image; its internal XML content is decoded. This allows changing the fill color (fill) or stroke (stroke) dynamically from the interface using a single base file, without needing to store multiple versions of the same asset.

The HTML:

  <div id="app" class="flex h-[calc(100vh-6rem)]">
    
    <!-- ── SIDE PANEL ─────────────────────────────────────────── -->
    <aside class="w-80 bg-gray-800 p-4 flex flex-col gap-4 border-r border-gray-700 overflow-y-auto">
      <h2 class="text-xl font-bold">Canvas Editor (CDN)</h2>

      <!-- Action Buttons -->
      <div class="flex gap-2">
        <button @click="addTextLayer" class="flex-1 bg-blue-600 hover:bg-blue-500 py-2 rounded text-sm font-semibold">
          + Container Text
        </button>
        <button @click="addNormalTextLayer" class="flex-1 bg-cyan-600 hover:bg-cyan-500 py-2 rounded text-sm font-semibold">
          + Text
        </button>
      </div>
      <div class="flex flex-col gap-2">
        <label class="text-xs text-gray-400">Brush Text (brush style)</label>
        <div class="flex gap-2">
          <select v-model="brushSelection" class="flex-1 bg-gray-700 px-2 py-1 rounded text-sm">
            <option value="">— Select —</option>
            <option v-for="file in brushMasks" :key="file" :value="file">{{ file }}</option>
          </select>
          <button @click="addBrushTextLayer" class="bg-orange-600 hover:bg-orange-500 px-3 py-1 rounded text-sm font-semibold whitespace-nowrap">
            + Create
          </button>
        </div>
      </div>
      <div class="flex gap-2">
        <label class="flex-1 bg-green-600 hover:bg-green-500 py-2 rounded text-sm font-semibold text-center cursor-pointer">
          + Image
          <input type="file" accept="image/*" class="hidden" @change="handleImageUpload" />
        </label>
      </div>

      <!-- Add project images -->
      <div class="flex flex-col gap-2">
        <label class="text-xs text-gray-400">Add Logo (project)</label>
        <select v-model="selectedLogo" @change="addProjectImage('logos', selectedLogo, 300)"
          class="w-full bg-gray-700 px-2 py-1 rounded text-sm">
          <option value="">— Select logo —</option>
          <option v-for="f in logoFiles" :key="f" :value="f">{{ f }}</option>
        </select>
      </div>

      <div class="flex flex-col gap-2">
        <label class="text-xs text-gray-400">Add Fragment (project)</label>
        <select v-model="selectedFragment" @change="addProjectImage('fragments', selectedFragment, 600)"
          class="w-full bg-gray-700 px-2 py-1 rounded text-sm">
          <option value="">— Select fragment —</option>
          <option v-for="f in fragmentFiles" :key="f" :value="f">{{ f }}</option>
        </select>
      </div>

      <!-- Canvas Format -->
      <div class="border-t border-gray-700 pt-3">
        <label class="block text-xs text-gray-400 mb-1">Canvas Format</label>
        <select v-model="selectedFormat" @change="handleFormatChange"
          class="w-full bg-gray-700 px-2 py-1 rounded text-sm">
          <option v-for="fmt in canvasFormats" :key="fmt.id" :value="fmt.id">{{ fmt.label }}</option>
        </select>
      </div>

      <!-- Background Color -->
      <div class="border-t border-gray-700 pt-3">
        <label class="block text-xs text-gray-400 mb-1">Canvas Background Color</label>
        <input type="color" v-model="canvasConfig.bgColor" class="w-full h-8 cursor-pointer rounded bg-transparent" />
      </div>

      <!-- Border -->
      <div class="border-t border-gray-700 pt-3 flex items-center justify-between">
        <label for="border-toggle" class="text-xs text-gray-400">Border (#5A00E0, 80px)</label>
        <input id="border-toggle" type="checkbox" v-model="canvasConfig.showBorder" class="w-5 h-5" />
      </div>

      <!-- Selected Layer Controls -->
      <div v-if="selectedLayer" class="border-t border-gray-700 pt-3 flex flex-col gap-3">
        <h3 class="text-sm font-semibold text-gray-300">Layer Properties</h3>

        <template v-if="selectedLayer.type === 'text'">
          <div>
            <label class="block text-xs text-gray-400">Text</label>
            <input v-model="selectedLayer.text" type="text" class="w-full bg-gray-700 px-2 py-1 rounded text-sm" />
          </div>

          <div class="flex gap-2">
            <div class="flex-1">
              <label class="block text-xs text-gray-400">Text Color</label>
              <input type="color" v-model="selectedLayer.color" class="w-full h-8" />
            </div>
            <div class="flex-1">
              <label class="block text-xs text-gray-400">Background/Banner</label>
              <input type="color" v-model="selectedLayer.bgColor" class="w-full h-8" />
            </div>
          </div>

          <div>
            <label class="block text-xs text-gray-400">Typography</label>
            <select v-model="selectedLayer.fontFamily"
              class="w-full bg-gray-700 px-2 py-1 rounded text-sm">
              <option v-for="fam in fontFamilies" :key="fam" :value="fam">{{ fam }}</option>
            </select>
          </div>

          <div>
            <label class="block text-xs text-gray-400">Font Size ({{ selectedLayer.fontSize }}px)</label>
            <input type="range" min="120" max="350" v-model.number="selectedLayer.fontSize" class="w-full" />
          </div>

          <div class="flex items-center justify-between">
            <label for="italic-toggle" class="text-xs text-gray-400">Italic</label>
            <input id="italic-toggle" type="checkbox" v-model="selectedLayer.italic" class="w-5 h-5" />
          </div>

          <div class="flex items-center justify-between">
            <label for="outline-toggle" class="text-xs text-gray-400">Word Outline</label>
            <input id="outline-toggle" type="checkbox" v-model="selectedLayer.outlineEnabled" class="w-5 h-5" />
          </div>

          <div v-if="selectedLayer.outlineEnabled">
            <label class="block text-xs text-gray-400">Outline Color</label>
            <input type="color" v-model="selectedLayer.outlineColor" class="w-full h-8" />
          </div>

          <div v-if="selectedLayer.brush">
            <label class="block text-xs text-gray-400">Brush Style</label>
            <select v-model="selectedLayer.brushMask"
              class="w-full bg-gray-700 px-2 py-1 rounded text-sm">
              <option v-for="file in brushMasks" :key="file" :value="file">{{ file }}</option>
            </select>
          </div>
        </template>

        <!-- Layer Filters and Effects -->
        <div class="border-t border-gray-700 pt-3 flex flex-col gap-3">
          <h3 class="text-sm font-semibold text-gray-300">Filters and Effects</h3>

          <!-- Box Shadow -->
          <div class="flex items-center justify-between">
            <label for="fx-boxshadow-toggle" class="text-xs text-gray-400">Box Shadow</label>
            <input id="fx-boxshadow-toggle" type="checkbox" v-model="selectedLayer.fx.boxShadow" class="w-5 h-5" />
          </div>
          <template v-if="selectedLayer.fx.boxShadow">
            <div class="flex gap-2">
              <div class="flex-1">
                <label class="block text-xs text-gray-400">Color</label>
                <input type="color" v-model="selectedLayer.fx.shadowColor" class="w-full h-8" />
              </div>
              <div class="flex-1">
                <label class="block text-xs text-gray-400">Blur ({{ selectedLayer.fx.shadowBlur }}px)</label>
                <input type="range" min="0" max="80" v-model.number="selectedLayer.fx.shadowBlur" class="w-full" />
              </div>
            </div>
            <div>
              <label class="block text-xs text-gray-400">Offset ({{ selectedLayer.fx.shadowOffset }}px)</label>
              <input type="range" min="-40" max="40" v-model.number="selectedLayer.fx.shadowOffset" class="w-full" />
            </div>
          </template>

          <!-- Text Shadow (text only) -->
          <template v-if="selectedLayer.type === 'text'">
            <div class="flex items-center justify-between">
              <label for="fx-textshadow-toggle" class="text-xs text-gray-400">Text Shadow</label>
              <input id="fx-textshadow-toggle" type="checkbox" v-model="selectedLayer.fx.textShadow" class="w-5 h-5" />
            </div>
            <template v-if="selectedLayer.fx.textShadow">
              <div class="flex gap-2">
                <div class="flex-1">
                  <label class="block text-xs text-gray-400">Color</label>
                  <input type="color" v-model="selectedLayer.fx.textShadowColor" class="w-full h-8" />
                </div>
                <div class="flex-1">
                  <label class="block text-xs text-gray-400">Blur ({{ selectedLayer.fx.textShadowBlur }}px)</label>
                  <input type="range" min="0" max="40" v-model.number="selectedLayer.fx.textShadowBlur" class="w-full" />
                </div>
              </div>
            </template>
          </template>

          <!-- Border -->
          <div class="flex items-center justify-between">
            <label for="fx-border-toggle" class="text-xs text-gray-400">Border</label>
            <input id="fx-border-toggle" type="checkbox" v-model="selectedLayer.fx.border" class="w-5 h-5" />
          </div>
          <template v-if="selectedLayer.fx.border">
            <div class="flex gap-2">
              <div class="flex-1">
                <label class="block text-xs text-gray-400">Color</label>
                <input type="color" v-model="selectedLayer.fx.borderColor" class="w-full h-8" />
              </div>
              <div class="flex-1">
                <label class="block text-xs text-gray-400">Thickness ({{ selectedLayer.fx.borderWidth }}px)</label>
                <input type="range" min="1" max="40" v-model.number="selectedLayer.fx.borderWidth" class="w-full" />
              </div>
            </div>
          </template>

          <!-- Rounded Corners -->
          <div>
            <label class="block text-xs text-gray-400">Rounded Corners ({{ selectedLayer.fx.borderRadius }}px)</label>
            <input type="range" min="0" max="120" v-model.number="selectedLayer.fx.borderRadius" class="w-full" />
          </div>

          <!-- CSS Filters -->
          <div>
            <label class="block text-xs text-gray-400">Blur ({{ selectedLayer.fx.blur }}px)</label>
            <input type="range" min="0" max="20" v-model.number="selectedLayer.fx.blur" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Brightness ({{ selectedLayer.fx.brightness }}%)</label>
            <input type="range" min="0" max="200" v-model.number="selectedLayer.fx.brightness" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Contrast ({{ selectedLayer.fx.contrast }}%)</label>
            <input type="range" min="0" max="200" v-model.number="selectedLayer.fx.contrast" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Saturation ({{ selectedLayer.fx.saturate }}%)</label>
            <input type="range" min="0" max="200" v-model.number="selectedLayer.fx.saturate" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Grayscale ({{ selectedLayer.fx.grayscale }}%)</label>
            <input type="range" min="0" max="100" v-model.number="selectedLayer.fx.grayscale" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Sepia ({{ selectedLayer.fx.sepia }}%)</label>
            <input type="range" min="0" max="100" v-model.number="selectedLayer.fx.sepia" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Opacity ({{ selectedLayer.fx.opacity }}%)</label>
            <input type="range" min="0" max="100" v-model.number="selectedLayer.fx.opacity" class="w-full" />
          </div>
          <div>
            <label class="block text-xs text-gray-400">Hue Rotation ({{ selectedLayer.fx.hueRotate }}°)</label>
            <input type="range" min="0" max="360" v-model.number="selectedLayer.fx.hueRotate" class="w-full" />
          </div>
        </div>

        <div>
          <label class="block text-xs text-gray-400">Rotation ({{ selectedLayer.rotation }}°)</label>
          <input type="range" min="-180" max="180" v-model.number="selectedLayer.rotation" class="w-full" />
        </div>

        <button @click="deleteSelectedLayer" class="bg-red-600 hover:bg-red-500 py-1 rounded text-xs">
          Delete Layer
        </button>
      </div>

      <!-- Save / Load Project -->
      <div class="border-t border-gray-700 pt-3 flex flex-col gap-3">
        <h3 class="text-sm font-semibold text-gray-300">Save / Load Project</h3>

        <div class="flex gap-2">
          <input v-model="projectFilename" type="text" placeholder="Project name"
            class="flex-1 bg-gray-700 px-2 py-1 rounded text-sm" />
          <button @click="saveProjectToServer" class="bg-emerald-600 hover:bg-emerald-500 px-3 py-1 rounded text-xs font-semibold whitespace-nowrap">
            Save
          </button>
        </div>
        <button @click="downloadProjectJson" class="bg-gray-600 hover:bg-gray-500 py-1 rounded text-xs">
          Download JSON
        </button>

        <div class="flex flex-col gap-2">
          <label class="text-xs text-gray-400">Saved projects ({{ jsonFiles.length }})</label>
          <div class="flex gap-2">
            <select v-model="selectedJsonFile" class="flex-1 bg-gray-700 px-2 py-1 rounded text-sm">
              <option value="">— Select project —</option>
              <option v-for="f in jsonFiles" :key="f" :value="f">{{ f }}</option>
            </select>
            <button @click="loadProjectFile(selectedJsonFile)" class="bg-blue-600 hover:bg-blue-500 px-3 py-1 rounded text-xs font-semibold whitespace-nowrap">
              Load
            </button>
          </div>
        </div>

        <div class="flex flex-col gap-2">
          <label class="text-xs text-gray-400">Import local file (.json)</label>
          <label class="bg-gray-600 hover:bg-gray-500 py-1 rounded text-xs text-center cursor-pointer">
            Select file
            <input type="file" accept=".json,application/json" class="hidden" @change="handleProjectUpload" />
          </label>
        </div>

        <div v-if="statusMsg" class="text-xs text-emerald-400 bg-gray-900 px-2 py-1 rounded">{{ statusMsg }}</div>
      </div>

      <button @click="exportImage" class="mt-auto bg-purple-600 hover:bg-purple-500 py-3 rounded font-bold">
        Export to PNG
      </button>
    </aside>

    <!-- ── MAIN CANVAS ──────────────────────────────────────── -->
    <main ref="stageRef" class="flex-1 relative overflow-hidden bg-gray-950 rounded-lg">
      <div class="absolute inset-0 flex items-center justify-center">
        <div
          class="relative"
          :style="{
            width: (canvasConfig.width * scale) + 'px',
            height: (canvasConfig.height * scale) + 'px'
          }"
        >
          <div
            ref="canvasRef"
            class="absolute top-0 left-0 shadow-2xl overflow-hidden"
            :style="{
              width: canvasConfig.width + 'px',
              height: canvasConfig.height + 'px',
              backgroundColor: canvasConfig.bgColor,
              transform: 'scale(' + scale + ')',
              transformOrigin: 'top left'
            }"
            @click.self="selectedLayerId = null"
          >
            <!-- Optional border (in the background, text/images go on top) -->
            <div
              v-if="canvasConfig.showBorder"
              class="absolute inset-0 pointer-events-none"
              :style="{ border: '80px solid #5A00E0' }"
            ></div>

            <!-- Rendered Layers -->
            <div
              v-for="layer in layers"
              :key="layer.id"
              :data-id="layer.id"
              class="draggable-layer absolute cursor-move flex items-center justify-center"
              :class="{ 'selected': selectedLayerId === layer.id && !isExporting }"
              :style="{
                left: layer.x + 'px',
                top: layer.y + 'px',
                width: layer.width + 'px',
                height: layer.height + 'px',
                transform: `rotate(${layer.rotation}deg)`,
                zIndex: layer.zIndex
              }"
              @click="selectLayer(layer.id)"
            >
              <!-- Text Content -->
              <div
                v-if="layer.type === 'text'"
                class="w-full h-full flex items-center justify-center text-center p-2 font-bold select-none relative"
                :style="{
                  color: layer.color,
                  background: layer.brush
                    ? 'transparent'
                    : (layer.gradient
                      ? `linear-gradient(to bottom, ${layer.bgColor}, ${shadeColor(layer.bgColor, -35)})`
                      : layer.bgColor),
                  borderRadius: layer.brush ? '0px' : (layer.fx.borderRadius > 0
                    ? layer.fx.borderRadius + 'px'
                    : (layer.gradient ? '14px' : '0px')),
                  fontFamily: `'${layer.fontFamily}', sans-serif`,
                  fontSize: layer.fontSize + 'px',
                  fontStyle: layer.italic ? 'italic' : 'normal',
                  lineHeight: 1,
                  WebkitTextStroke: layer.outlineEnabled
                    ? Math.max(1, Math.round(layer.fontSize / 15)) + 'px ' + layer.outlineColor
                    : '0px',
                  boxShadow: layer.brush ? 'none' : layerBoxShadow(layer),
                  textShadow: layerTextShadow(layer),
                  border: layer.brush ? 'none' : layerBorder(layer),
                  filter: layerFilter(layer)
                }"
              >
                <!-- Brush shaped banner -->
                <div
                  v-if="layer.brush"
                  class="absolute inset-0 pointer-events-none"
                  :style="{
                    backgroundColor: layer.bgColor,
                    borderRadius: layer.fx.borderRadius > 0 ? layer.fx.borderRadius + 'px' : '0px',
                    WebkitMaskImage: brushMaskUrl(layer),
                    maskImage: brushMaskUrl(layer),
                    WebkitMaskSize: '100% 100%',
                    maskSize: '100% 100%',
                    WebkitMaskRepeat: 'no-repeat',
                    maskRepeat: 'no-repeat',
                    WebkitMaskMode: 'alpha',
                    maskMode: 'alpha',
                    boxShadow: layerBoxShadow(layer),
                    border: layerBorder(layer)
                  }"
                ></div>
                <span class="relative">{{ layer.text }}</span>
              </div>

              <!-- Image Content -->
              <img
                v-else-if="layer.type === 'image'"
                :src="layer.src"
                class="w-full h-full object-contain pointer-events-none select-none"
                :style="{
                  boxShadow: layerBoxShadow(layer),
                  border: layerBorder(layer),
                  borderRadius: layer.fx.borderRadius + 'px',
                  filter: layerFilter(layer)
                }"
              />
            </div>
          </div>
        </div>
      </div>
    </main>

  </div>

The JavaScript in Vue.js:

<script>
  const { createApp, ref, reactive, computed, nextTick, onMounted } = Vue;

  createApp({
    setup() {
      const canvasRef = ref(null);
      const stageRef = ref(null);
      const isExporting = ref(false);
      const scale = ref(1);

      const canvasConfig = reactive({
        width: 1920,
        height: 1080,
        bgColor: '#ffffff',
        showBorder: true
      });

      const canvasFormats = ref([
        { id: 'landscape', label: 'Landscape (1920×1080)', width: 1920, height: 1080 },
        { id: 'vertical', label: 'Vertical (1080×1920)', width: 1080, height: 1920 }
      ]);
      const selectedFormat = ref('landscape');

      const updateScale = () => {
        if (!stageRef.value) return;
        const availW = stageRef.value.clientWidth;
        const availH = stageRef.value.clientHeight;
        scale.value = Math.min(
          availW / canvasConfig.width,
          availH / canvasConfig.height,
          1
        );
      };

      const handleFormatChange = () => {
        const fmt = canvasFormats.value.find(f => f.id === selectedFormat.value);
        if (!fmt) return;
        const fromW = canvasConfig.width;
        const fromH = canvasConfig.height;
        const toW = fmt.width;
        const toH = fmt.height;
        if (fromW === toW && fromH === toH) {
          updateScale();
          return;
        }
        canvasConfig.width = toW;
        canvasConfig.height = toH;
        // recalculates positions on the canvas, in the layer level when changing

Learn how to generate images with HTML, CSS, and JavaScript using Vue.js, compose SVG, generate brushstroke effects, download text, load image fragments, and export in PNG.


Únete a la comunidad de desarrolladores que han decidido dejar de picar código y empezar a construir productos reales. Recibe mis mejores trucos de arquitectura cada semana:

I agree to receive announcements of interest about this Blog.