You need to enable JavaScript to run this app.
优惠活动
大模型
产品
解决方案
定价
更多

如何将手写识别Shiny应用模块化?排查模块实现问题

问题描述

我有一个基于Google Input Tools的Shiny应用,支持在Canvas上手写并转换为文本输入。现在想要把这个功能模块化,实现任意数量的画布实例。我已经掌握R端的模块化方法,但不知道怎么处理JavaScript中的命名空间问题(比如Shiny.setInputValue里的recognized_text需要加入命名空间)。模块化后出现两个问题:

  • 只有第一个画布能正常书写
  • 第一个模块的发送按钮无法把识别出的文本显示到文本框里

需要排查问题并给出正确的实现方案。


问题代码(模块化尝试版本)

模块UI与Server

library(shiny)

# 模块UI
handwritingModuleUI <- function(id) {
  ns <- NS(id)
  tagList(
    tags$script(src = "https://www.google.com/jsapi"),
    tags$script(src = "https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"),
    tags$script(HTML('
      google.load("elements", "1", {packages: "transliteration"});
      
      var transliterationControl;
      function onLoad() {
        var options = {
          sourceLanguage: google.elements.transliteration.LanguageCode.ENGLISH,
          destinationLanguage: [google.elements.transliteration.LanguageCode.URDU],
          shortcutKey: "ctrl+g",
          transliterationEnabled: true
        };
        transliterationControl = new google.elements.transliteration.TransliterationControl(options);
        transliterationControl.makeTransliteratable(["handwriting_input"]);
        
        var canvas = document.getElementById("handwriting_canvas");
        var ctx = canvas.getContext("2d");
        var isDrawing = false;
        var lastX = 0;
        var lastY = 0;
        
        canvas.addEventListener("mousedown", startDrawing);
        canvas.addEventListener("mousemove", draw);
        canvas.addEventListener("mouseup", stopDrawing);
        canvas.addEventListener("mouseout", stopDrawing);
        
        function startDrawing(e) {
          isDrawing = true;
          [lastX, lastY] = [e.offsetX, e.offsetY];
        }
        
        function draw(e) {
          if (!isDrawing) return;
          ctx.beginPath();
          ctx.moveTo(lastX, lastY);
          ctx.lineTo(e.offsetX, e.offsetY);
          ctx.strokeStyle = "black";
          ctx.lineWidth = 2;
          ctx.stroke();
          [lastX, lastY] = [e.offsetX, e.offsetY];
        }
        
        function stopDrawing() {
          isDrawing = false;
        }
        
        $("#recognize_btn").click(function() {
          var canvasData = canvas.toDataURL("image/png");
          var currentText = $("#handwriting_input").val();
          var recognizedText = currentText + "手写内容";
          Shiny.setInputValue("recognized_text", recognizedText);
        });
        
        $("#clear_btn").click(function() {
          ctx.clearRect(0, 0, canvas.width, canvas.height);
        });
      }
      
      google.setOnLoadCallback(onLoad);
    ')),
    div(
      id = ns("handwriting_container"),
      canvas(id = ns("handwriting_canvas"), width = "300", height = "200", style = "border:1px solid black;"),
      actionButton(ns("clear_btn"), "清空画布"),
      actionButton(ns("recognize_btn"), "识别并发送"),
      textInput(ns("handwriting_input"), "输入框")
    )
  )
}

# 模块Server
handwritingModuleServer <- function(id) {
  moduleServer(id, function(input, output, session) {
    observeEvent(input$recognized_text, {
      updateTextInput(session, "handwriting_input", value = input$recognized_text)
    })
  })
}

# 主应用
ui <- fluidPage(
  handwritingModuleUI("module1"),
  br(),
  handwritingModuleUI("module2")
)

server <- function(input, output, session) {
  handwritingModuleServer("module1")
  handwritingModuleServer("module2")
}

shinyApp(ui, server)

问题原因分析

  1. 重复加载JS资源:模块UI中重复引入Google Input Tools和jQuery,多次加载会导致全局变量冲突。
  2. 元素ID未命名空间化:JS中使用固定ID(如handwriting_canvas),但模块生成的实际ID是带命名空间的(如module1-handwriting_canvas),导致JS无法找到后续模块的元素。
  3. 输入值无命名空间:Shiny.setInputValue("recognized_text", ...)直接写入全局输入,模块Server无法区分不同实例的输入。
  4. 全局变量覆盖:transliterationControl是全局变量,多个模块会互相覆盖,导致只有最后一个模块的转写控件生效。

正确模块化实现方案

完整代码

library(shiny)

# 模块UI
handwritingModuleUI <- function(id) {
  ns <- NS(id)
  tagList(
    div(
      id = ns("handwriting_container"),
      canvas(id = ns("handwriting_canvas"), width = "300", height = "200", style = "border:1px solid black;"),
      actionButton(ns("clear_btn"), "清空画布"),
      actionButton(ns("recognize_btn"), "识别并发送"),
      textInput(ns("handwriting_input"), "输入框")
    ),
    # 传递命名空间给JS初始化函数
    tags$script(sprintf('initHandwritingModule("%s");', ns("")))
  )
}

# 模块Server
handwritingModuleServer <- function(id) {
  moduleServer(id, function(input, output, session) {
    # 监听当前模块命名空间下的recognized_text输入
    observeEvent(input$recognized_text, {
      updateTextInput(session, "handwriting_input", value = input$recognized_text)
    })
  })
}

# 主应用UI
ui <- fluidPage(
  # 统一加载JS资源,避免重复加载
  tags$script(src = "https://www.google.com/jsapi"),
  tags$script(src = "https://ajax.googleapis.com/ajax/libs/jquery/3.6.0/jquery.min.js"),
  # 封装通用JS逻辑,支持命名空间
  tags$script(HTML('
    google.load("elements", "1", {packages: "transliteration"});
    
    // 存储每个模块的转写控件实例,避免全局冲突
    var transliterationControls = {};
    
    function initHandwritingModule(namespace) {
      // 拼接命名空间前缀
      var nsPrefix = namespace ? namespace + "-" : "";
      
      // 初始化转写控件
      var inputId = nsPrefix + "handwriting_input";
      var options = {
        sourceLanguage: google.elements.transliteration.LanguageCode.ENGLISH,
        destinationLanguage: [google.elements.transliteration.LanguageCode.URDU],
        shortcutKey: "ctrl+g",
        transliterationEnabled: true
      };
      transliterationControls[namespace] = new google.elements.transliteration.TransliterationControl(options);
      transliterationControls[namespace].makeTransliteratable([inputId]);
      
      // 获取当前模块的画布元素
      var canvas = document.getElementById(nsPrefix + "handwriting_canvas");
      var ctx = canvas.getContext("2d");
      var isDrawing = false;
      var lastX = 0;
      var lastY = 0;
      
      // 绑定画布手写事件
      canvas.addEventListener("mousedown", startDrawing);
      canvas.addEventListener("mousemove", draw);
      canvas.addEventListener("mouseup", stopDrawing);
      canvas.addEventListener("mouseout", stopDrawing);
      
      function startDrawing(e) {
        isDrawing = true;
        [lastX, lastY] = [e.offsetX, e.offsetY];
      }
      
      function draw(e) {
        if (!isDrawing) return;
        ctx.beginPath();
        ctx.moveTo(lastX, lastY);
        ctx.lineTo(e.offsetX, e.offsetY);
        ctx.strokeStyle = "black";
        ctx.lineWidth = 2;
        ctx.stroke();
        [lastX, lastY] = [e.offsetX, e.offsetY];
      }
      
      function stopDrawing() {
        isDrawing = false;
      }
      
      // 绑定识别按钮事件,发送带命名空间的输入值
      $("#" + nsPrefix + "recognize_btn").click(function() {
        var currentText = $("#" + inputId).val();
        var recognizedText = currentText + "手写内容";
        // 给输入值添加命名空间
        Shiny.setInputValue(namespace + "recognized_text", recognizedText);
      });
      
      // 绑定清空画布按钮事件
      $("#" + nsPrefix + "clear_btn").click(function() {
        ctx.clearRect(0, 0, canvas.width, canvas.height);
      });
    }
    
    google.setOnLoadCallback(function() {
      // 页面加载完成后自动初始化已渲染的模块
    });
  ')),
  # 加载两个模块实例
  handwritingModuleUI("module1"),
  br(),
  handwritingModuleUI("module2")
)

# 主应用Server
server <- function(input, output, session) {
  handwritingModuleServer("module1")
  handwritingModuleServer("module2")
}

shinyApp(ui, server)

关键改进说明

  1. 统一JS资源加载:将Google Input Tools和jQuery移到主UI,仅加载一次,避免冲突。
  2. JS逻辑封装为可复用函数:initHandwritingModule接收命名空间参数,为每个模块独立初始化控件和事件。
  3. 元素ID命名空间化:通过nsPrefix拼接带命名空间的元素ID,确保JS能精准定位对应模块的元素。
  4. 输入值命名空间化:Shiny.setInputValue(namespace + "recognized_text", ...)确保每个模块的识别结果发送到独立的命名空间输入,模块Server自动映射对应输入。
  5. 控件实例隔离:用transliterationControls对象存储每个模块的转写控件,避免全局变量覆盖。

内容的提问来源于stack exchange,提问作者umair durrani

相关产品推荐
方舟 Agent Plan

超全模态模型 × Harness 升级,最新支持 Deepseek-V4.1-Flash、GLM-5.3 系列、Doubao-Seedream-5.0-pro、Kimi-K3 (部分), 限时 9.9 元起

最近更新时间:2026.06.30 04:21:01